diff --git a/sdks/community/dart/CHANGELOG.md b/sdks/community/dart/CHANGELOG.md index ace79c7841..9f2f3ac0a0 100644 --- a/sdks/community/dart/CHANGELOG.md +++ b/sdks/community/dart/CHANGELOG.md @@ -5,6 +5,535 @@ All notable changes to the AG-UI Dart SDK will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Breaking Changes (review-fix pass) +- **`StateDeltaEvent.delta` and `ActivityDeltaEvent.patch` are now + `List>` instead of `List`.** RFC 6902 JSON + Patch operations are always objects. Using `requireListField>` surfaces non-object elements as `AGUIValidationError` at the + decoder boundary with a `field: 'delta[$i]'` / `field: 'patch[$i]'` index, + rather than leaking a downstream `TypeError` at the first `op['op']` access. + Direct consumers of `event.delta[i]` who are already casting to Map are + unaffected; consumers storing the list as `List` will need a type + annotation update. +- **`SseParser.maxDataBytes` renamed to `maxDataCodeUnits`.** The field + already measured UTF-16 code units, not bytes — the rename corrects the + misleading name. `SseParser(maxDataBytes: ...)` call sites must be updated + to `SseParser(maxDataCodeUnits: ...)`. + +### Fixed (review-fix pass) +- **`ActivityMessage.fromJson` now silently strips `encryptedValue` / + `encrypted_value` instead of throwing `AGUIValidationError`.** `ActivityMessage` + is not a `BaseMessage` extension in the canonical protocol, so the field + does not apply. Dart was the only SDK that tore down the stream on encountering + the field; TS strips silently (zod default) and Python preserves it. The + change restores forward compatibility when a proxy emits the field. +- **`ReasoningEncryptedValueEvent.fromJson` no longer stores the cipher + payload in `BaseEvent.rawEvent`.** Previously `rawEvent: _readRawEvent(json)` + stored the full wire JSON (including `encryptedValue`) in the inherited + `rawEvent` field, undoing the cipher-data scrubbing in every error path. + `rawEvent` is now always `null` for this event type; proxies that need the + raw wire form should retain it before calling `fromJson`. +- **`RunStartedEvent.fromJson` rethrow now forwards the inner error's `json` + (`e.json`) instead of the full outer payload.** The outer payload can carry + `input.messages[*].encryptedValue`. Using `e.json` (the specific inner map + that failed) limits cipher-data exposure in `AGUIValidationError`, mirroring + the existing cautious default in `MessagesSnapshotEvent.fromJson`. +- **`MessagesSnapshotEvent.fromJson` rethrow now drops `json:` entirely.** + Forwarding `e.json` previously exposed the inner Message map on the outer + error; for Tool/Reasoning subtypes that map can carry `encryptedValue`. Drops + `json:` to match `AssistantMessage.fromJson`'s tool-call IIFE, which already + uses the cautious default. +- **`JsonDecoder.requireEitherField` now distinguishes "key present but null" + from "key absent".** Previously both cases produced the same + "Missing required field 'X' (or 'Y')" message, misleading consumers into + thinking the snake_case alias might work when the camelCase key was + explicitly null. Now: key-present-but-null produces "Required field 'X' is + present but null"; both-absent still produces the dual-key error. +- **`copyWith` sentinel sweep completed.** `ThinkingStartEvent.title`, + `ToolCallResultEvent.role`, `StateSnapshotEvent.snapshot`, and + `RunErrorEvent.code` now use the `kUnsetSentinel` pattern so callers can + clear these nullable fields via `copyWith(field: null)`. The "Known parity + gaps" list is now empty for payload fields. +- **`EventEncoder.acceptsProtobuf` and `EventDecoder.decodeBinary` now carry + explicit dartdoc warnings** that protobuf is not yet implemented end-to-end. + A client negotiating `application/vnd.ag-ui.event+proto` would receive a + misleading "Invalid UTF-8 data" error; the docs now direct consumers to use + SSE transport until protobuf support lands. +- **`groupRelatedEvents` dartdoc now documents the `ReasoningStart` / + `ReasoningEnd` asymmetry.** Phase-level reasoning events are emitted as + standalone singletons; only message-level `REASONING_MESSAGE_*` events are + grouped. Consumers that need to associate phase-level markers with message + groups must track phase boundaries in their own state. +- **`processChunk` resets `errorRoutedInChunk` after the for-loop.** The flag + was previously only set inside the loop; future throw sites after the loop + body could have silently swallowed unrelated errors. +- **`SseParser` error message corrected.** The OOM-guard error now says + "code-unit limit" (not "byte limit") to match what the cap actually measures. +- **`SseParser._processField` now uses `write('\\n')` instead of `writeln()`** + for the inter-`data:` separator. `writeln()` is equivalent on all Dart + platforms but the explicit form removes any ambiguity about whether a + platform line terminator is emitted. +- **`EventType.fromString` dartdoc strengthened** with an explicit contract + note: callers must not change the throw type from `ArgumentError`, because + `BaseEvent.fromJson` uses a narrow `on ArgumentError` catch to distinguish + unknown event types from factory bugs. + +### Fixed (review pass — protocol parity) +- **`encryptedValue` is now plumbed through every BaseMessage subtype** + (`DeveloperMessage`, `SystemMessage`, `AssistantMessage`, + `UserMessage`) on the base `Message` class. Mirrors canonical TS + `BaseMessageSchema.encryptedValue: z.string().optional()` and Python + `BaseMessage.encrypted_value: Optional[str]`. Previously the field + was only present on `ToolMessage` and `ReasoningMessage`, so a Dart + proxy decoding a `MESSAGES_SNAPSHOT` whose assistant or user message + carried `encryptedValue` from a TS or Python server silently dropped + the value at decode and could not re-emit it on the next hop. Decode + accepts both `encryptedValue` (TS-canonical) and `encrypted_value` + (Python-canonical); `toJson` emits camelCase; each subtype's + `copyWith` accepts an explicit-null clear via the sentinel pattern. + The `ToolMessage` and `ReasoningMessage` field declarations were + removed in favor of inheriting from the base — the wire shape is + unchanged. +- **`raw_event` (snake_case) is now preserved on every event factory.** + All ~30 `BaseEvent` subclasses now read `rawEvent` via a centralized + `_readRawEvent` helper that uses `containsKey` precedence: the + camelCase key wins when present (even when explicitly `null`), and + the snake_case key is consulted only when camelCase is absent. + Previously every factory read `json['rawEvent']` directly, silently + dropping Python-style `raw_event` payloads. `toJson` continues to + emit camelCase only. +- **`REASONING_ENCRYPTED_VALUE` no longer rejects empty + `entityId` / `encryptedValue` strings.** Canonical TS uses + `z.string()` and Python uses `str` — neither imposes a minimum + length. The Dart-only empty-string rejection (in both + `ReasoningEncryptedValueEvent.fromJson` and `EventDecoder.validate`) + was over-strict and would reject payloads that the canonical SDKs + accept. The strict subtype discriminator stays — unknown subtypes + still throw. +- **`SseParser._processField` now matches the WHATWG SSE spec for + empty leading `data:` lines and repeated `event:` lines.** The + `data:` case used `_dataBuffer.isNotEmpty` as a "have we written + data yet?" heuristic, which collapsed `data:\ndata: x\n\n` to `"x"` + instead of the spec-correct `"\nx"`. Now uses the `_hasDataField` + flag (mirroring the `inDataBlock` pattern in + `EventStreamAdapter.appendDataLine`). The `event:` case appended on + every `event:` line; per spec it must REPLACE. +- **`EventStreamAdapter.fromRawSseStream` now propagates downstream + cancellation, pause, and resume to the upstream raw SSE + subscription.** Previously the upstream `rawStream.listen(...)` + subscription was fire-and-forget — a consumer that cancelled the + adapted stream early left the upstream draining indefinitely + (a real resource leak on long-lived agent streams). +- **`SseParser.parseBytes` now flushes any final unterminated event + on stream close.** Routed through `parseLines` so the final + `_dispatchEvent()` flush in `parseLines` fires for byte-stream + sources too. A byte source that ended without a trailing blank line + previously lost its last buffered message. +- **`copyWith` sentinel sweep.** `RawEvent.source`, + `RunAgentInput.state`, `RunAgentInput.forwardedProps`, and + `Run.result` previously used the standard `?? this.field` pattern, + so a caller could not clear them via `copyWith(field: null)`. They + now use the existing sentinel pattern. The "Known parity gaps" + list below has been updated. +- **`JsonDecoder.optionalEitherField` now resolves on KEY presence, + not value-non-null.** A payload carrying both `camelKey: null` and + `snake_key: ` previously fell through to the snake_case + value; the documented contract on `requireEitherField` is that + camelCase wins when its key is present (even when explicitly + `null`). The implementation now matches the dartdoc. The inline + `forwardedProps` decode in `context.dart` was migrated to the same + `containsKey` rule for consistency. +- **`ToolMessage.fromJson` and `ToolResult.fromJson` now use + `requireEitherField`** instead of the older + `optionalEitherField + manual null-check + custom throw` pattern, + matching the migration already done for `RunAgentInput.fromJson` + and `Run.fromJson`. +- **`Validators.validateMessageContent` is now `String`-only.** The + pre-0.2.0 permissive `Map`/`List` branches were dead code (no caller + in the SDK passed those types) and disagreed with canonical + `BaseMessage.content: Optional[str]`. Multimodal `UserMessage.content` + remains a tracked parity gap. +- **`Validators.validateUrl` now rejects URLs containing C0 control + characters or DEL** (`\x00`–`\x1f`, `\x7f`). `Uri.parse` is + permissive with embedded `\n` / `\r` / `\t`, which can flow into + HTTP request lines as a header-injection vector. +- **`JsonDecoder.requireField` and `optionalField` transform-failure + paths now preserve `cause: e`** when wrapping an inner exception + in `AGUIValidationError`. The structured cause was previously + flattened into the message via `'$e'` interpolation only. + +### Documented +- `AGUIValidationError.json` dartdoc now carries an explicit + sensitive-data warning: the field captures the entire wire payload + including cipher fields. `toString()` does not emit it (safe by + default), but reflection-based serializers used by some logging + frameworks will leak. Prefer `.field` and `.value` on log lines + shipped to external sinks. +- `EventDecoder.validate` dartdoc now documents the dual-source + error class asymmetry: `validate()` raises + `client/errors.dart`'s `ValidationError`; `fromJson`-side eager + rejections raise `types/base.dart`'s `AGUIValidationError`. Both + surface uniformly as `DecodingError` through the public + `decode` / `decodeJson` boundary; both extend `AGUIError`. +- `BaseEvent.rawEvent` dartdoc now notes the round-trip emission + consequence — anything assigned to this field WILL be re-emitted + on the next `encode`. Set `rawEvent: null` on the in-flight event + if a proxy doesn't want the upstream payload echoed downstream. +- README adds a "Proxy notes: wire-spelling normalization" paragraph + documenting that the SDK accepts both camelCase and snake_case on + `fromJson` but always emits camelCase on `toJson`. The Error + Handling section is refreshed to use the current error-hierarchy + class names (`TransportError`, `DecodingError`, `ValidationError`, + `CancellationError`, all under `AGUIError`). +- `AgUiClient.runAgent` dartdoc `Throws:` list refreshed to match + the current error hierarchy. +- `EventStreamAdapter.groupRelatedEvents` dartdoc now carries an + explicit unbounded-state warning — open groups (where `*Start` was + received but `*End` has not arrived) are held in memory until the + matching end event or stream completion. Same caveat applies to + `accumulateTextMessages`. + +### Fixed (review pass — behavior) +- **`Tool.copyWith(parameters: null)` now correctly clears `parameters`.** + The previous `parameters ?? this.parameters` pattern silently kept the + existing value when `null` was passed; the field now uses the `_unsetTool` + sentinel pattern, consistent with `ToolCall.encryptedValue` and every + other nullable field in the SDK. This gap was omitted from the 0.2.0 + "Known parity gaps" list — it has been corrected here. +- **`EventStreamAdapter.fromRawSseStream` now subscribes to the upstream + lazily** (inside `controller.onListen`) rather than eagerly at call time. + A caller that obtained the returned stream but never subscribed would + previously leak the upstream SSE connection until the server closed it. + The cancellation, pause, and resume propagation added in the prior + review pass is preserved; subscription lifecycle callbacks now use + null-safe `?.` calls since the subscription is no longer `late final`. +- **`Message.fromJson` now preserves the wire JSON payload in + `AGUIValidationError`** when `MessageRole.fromString` fails. Previously + the error was thrown without `json:` set, making it impossible to + identify which message in a `MESSAGES_SNAPSHOT` had the unrecognized + role. The re-thrown error carries the originating `json` map so the + decoder pipeline can surface it as a `DecodingError` with full context. + +### Changed +- `TimeoutError` renamed to `AGUITimeoutError` to avoid shadowing the + built-in `dart:async.TimeoutError` (raised by `Future.timeout(...)` / + `Stream.timeout(...)`). The bare name is preserved as a deprecated + typedef alias for backward compat and will be removed in 1.0.0. + Internal call sites in `AgUiClient` throw the new name directly. The + README "Errors" recipe and "Migrating from 0.1.0" section call out + the rename so consumers using both `package:ag_ui/ag_ui.dart` and + `dart:async` can avoid the symbol collision. +- Empty `delta` is now accepted on `TEXT_MESSAGE_CONTENT`, + `TOOL_CALL_ARGS`, and `REASONING_MESSAGE_CONTENT`, and empty + `content` is accepted on `TOOL_CALL_RESULT`, to match the canonical + TS/Python schemas (`z.string()` / `str` with no `min(1)` constraint). + Previously the Dart SDK rejected empty values at both the `fromJson` + factory and the `EventDecoder.validate` pipeline; a Python or TS + server that legitimately emitted a deliberate empty chunk (e.g. a + noop content refresh) would fail decode in Dart but pass in the + canonical SDKs. Empty cipher payloads on `REASONING_ENCRYPTED_VALUE` + (`entityId`, `encryptedValue`) continue to be rejected — the "no + graceful default for cipher payloads" contract stays. + +### Fixed +- `ToolCall` now carries the optional `encryptedValue` field for parity + with canonical TS (`ToolCallSchema.encryptedValue: z.string().optional()`) + and Python (`ToolCall.encrypted_value: Optional[str]`). Previously a + message arriving with `toolCalls: [{..., encryptedValue: "..."}]` + silently dropped the value at decode and could not re-emit it on a + proxy hop. Decode accepts both `encryptedValue` and `encrypted_value`; + `toJson` emits the camelCase key when present; `copyWith` uses the + sentinel pattern so callers can explicitly clear it via + `copyWith(encryptedValue: null)`. +- `RunAgentInput` now carries the optional `parentRunId` field for + parity with canonical TS (`RunAgentInputSchema.parentRunId: + z.string().optional()`) and Python (`RunAgentInput.parent_run_id`). + Previously a `RUN_STARTED` payload with `input.parentRunId: '...'` + decoded with the field silently dropped, even though + `RunStartedEvent.parentRunId` itself was preserved. Decode accepts + both `parentRunId` and `parent_run_id`; `toJson` emits camelCase when + present; `copyWith` uses the sentinel pattern. +- `EventStreamAdapter.fromRawSseStream` now handles CRLF (`\r\n`) line + terminators, not just LF. Previously a CRLF-emitting SSE server + produced `"\r"` lines that never matched the empty-line event-boundary + signal, so events buffered until stream close. The line splitter now + strips a trailing `\r` after splitting on `\n`. The same fix is + applied to `EventDecoder.decodeSSE`, which now uses `LineSplitter` + (handling `\n`, `\r`, and `\r\n` per the WHATWG SSE spec). +- `JsonDecoder.optionalListField` and `requireListField` now eagerly + type-check elements (raising `AGUIValidationError(field: '$field[$i]')` + on the first wrong-typed element) instead of returning a lazy + `cast()` view that surfaced as a raw `TypeError` at access time and + was flattened to `field: 'json'` by the decoder catch-all. +- `AssistantMessage.fromJson` now uses `JsonDecoder.optionalEitherField` + on the `toolCalls` / `tool_calls` key itself, instead of a `??` chain + on the post-`.map(...).toList()` value. The previous chain only fired + on null, so an empty `toolCalls: []` short-circuited the snake_case + fallback even when `tool_calls: [...]` was populated. +- `AssistantMessage.toJson` now emits `toolCalls` whenever the in-memory + field is non-null (including empty lists), so the round-trip + `fromJson(m.toJson()) == m` is symmetric. +- Decoder pipeline now rethrows `EncoderError` / `DecodeError` / + `EncodeError` unchanged instead of re-wrapping them as a generic + "Failed to decode event" via the catch-all. +- `EventEncoder.encodeSSE` no longer strips fields whose value is `null`. + The blanket `json.removeWhere((k, v) => v == null)` was silently + dropping fields that intentionally serialize as `null` + (`ActivitySnapshotEvent.content`, `RawEvent.event`, `CustomEvent.value`, + `StateSnapshotEvent.snapshot`), breaking the encode→decode round-trip + because the matching factories require the key to be present and reject + it with `AGUIValidationError`. Each `toJson()` already uses + `if (field != null) 'field': field` for fields that opt in to omission, + so the strip pass was redundant in addition to harmful. Pinned by a + new round-trip test in `fixtures_integration_test.dart`. +- `EventStreamAdapter.fromRawSseStream` now handles WHATWG-spec lone-`\r` + line terminators in addition to `\n` and `\r\n`. The previous chunk + scanner only split on `\n`, so a producer using bare `\r` (rare in + practice but spec-valid) buffered indefinitely. The new multi-terminator + scanner defers a trailing `\r` at chunk boundaries to disambiguate from + a chunk-spanning `\r\n` and consumes it on stream close. Steady-state + emission for CRLF-encoded streams is unchanged. +- `EventStreamAdapter.fromSseStream` and `fromRawSseStream` now preserve + any `AGUIError` subtype (`AgUiError`, `AGUIValidationError`, + `EncoderError`) raised by the decoder instead of re-wrapping the + encoder-family errors as a generic `DecodingError`. Mirrors the + unified-error-surface contract that `EventDecoder.decode/decodeJson` + already honor. +- `TestHelpers.findToolCalls` (test-only helper) now uses the typed + `AssistantMessage.toolCalls` accessor. Previously it round-tripped + through `toJson` and read the snake_case key `tool_calls`, but + `AssistantMessage.toJson` emits camelCase `toolCalls` — the helper + silently always returned an empty list. Currently unreferenced by the + test suite, so this is a latent-bug fix. + +### Added +- `JsonDecoder.optionalEitherListField` helper combining the dual-key + resolution rule from `optionalEitherField` with the index-aware + element-type validation from `requireListField` / `optionalListField`. + `AssistantMessage.fromJson` now uses it so a malformed nested + `toolCalls[i]` raises `AGUIValidationError(field: 'toolCalls[$i]')` + instead of leaking a raw `TypeError` from the per-element cast. + +### Changed +- `Message` subclass `copyWith` methods (`DeveloperMessage`, + `SystemMessage`, `UserMessage`, `AssistantMessage`, `ToolMessage`, + `ReasoningMessage`) now use the `_unsetMessage` sentinel pattern for + nullable fields, matching the event-class discipline. Callers can + explicitly clear a nullable field via `copyWith(field: null)` — + previously `?? this.field` could not distinguish "argument omitted" + from "argument explicitly null". +- `JsonDecoder.optionalIntField` (new helper) accepts `int` or `num` + and coerces via `.toInt()`. Every event factory now reads + `timestamp` via this helper, so a TS server emitting a fractional + number (e.g. `Date.now() / 1000`) no longer fails decode with + `AGUIValidationError(field: 'timestamp')`. +- Error-hierarchy unification: `AgUiError` now extends `AGUIError`, + and `AGUIValidationError` now extends `AGUIError` instead of bare + `implements Exception`. Callers can `on AGUIError catch (e)` to + cover the entire SDK error surface (including direct-factory + validation, encoder-side failures, runtime/transport, and decoder + errors). `on AgUiError` still scopes to runtime/transport/decoding + as before. Added an "Errors" section to the README documenting the + recommended catch recipe. +- `AGUIValidationError` gained an optional `cause` parameter so the + `transform`-rethrow path in `JsonDecoder` can preserve structured + error info instead of flattening to `'Failed to transform field: $e'`. +- `SseParser` documented its per-connection state semantics (sticky + `_lastEventId`); a new `reset()` method clears all parser state for + callers that explicitly want to reuse an instance across independent + streams. +- `Validators.maxTimeout` exposed as `static const Duration` so callers + can introspect the limit (10 minutes). The cap value is unchanged; + raising it is deferred to a future release. +- `RunAgentInput.fromJson` and `Run.fromJson` migrated to + `JsonDecoder.requireEitherField` for consistency with every other + factory in the SDK. Behavior preserved; the + "Missing required field 'X' (or 'Y')" wording shifts slightly to match + the helper's standard error message. +- Long `@Deprecated` messages on the `THINKING_*` enum values and event + classes hoisted into top-level `const` strings (`event_type.dart`, + `events.dart`). Surfaces the planned-removal version in one place per + context and reduces drift risk if it ever changes. No behavior change. + +### Documentation +- `UserMessage` documented as a known parity gap with the canonical + multimodal schema (TS `Union[string, InputContent[]]`, Python + `Union[str, List[InputContent]]`); the Dart SDK currently only + supports the string variant. +- `Message.id` documented as nullable-by-type but required-by-convention + (every concrete subtype constructor declares it `required`); a future + major version may tighten the type to non-nullable for parity with + canonical `BaseMessageSchema.id: z.string()`. +- `EventDecoder.validate`'s `Thinking*` deprecated cases gained + comments explaining why they don't validate `messageId` (the + deprecated wire shape has no such field; the migration target + `REASONING_*` does). +- `EventDecoder.validate`'s `ActivityDeltaEvent` case gained a comment + noting that an empty `patch` is intentional per the canonical + TS/Python schemas (`z.array(...).min(0)` / list with no length floor). +- `BaseEvent.rawEvent` field gained a dartdoc note clarifying that the + field is unvalidated (typed `dynamic` because the protocol does not + constrain the shape). +- `ToolCallResultEvent.role`, `StateSnapshotEvent.snapshot`, and + `RunErrorEvent.code` field declarations gained a dartdoc note that + `copyWith(field: null)` does NOT clear the field (these three are the + remaining cases listed in "Known parity gaps"). Construct a new + instance directly to drop. +- `MessageRole.activity` and `MessageRole.reasoning` enum values gained + wire-spelling-pinning dartdoc, mirroring the + `ReasoningEncryptedValueSubtype.toolCall` style. +- `EventDecoder.validate`'s `ThinkingTextMessageContentEvent` case gained + a clarified rationale comment: the deprecated path keeps the pre-0.2.0 + stricter "non-empty `delta`" contract intentionally — sibling content + events (`TextMessageContentEvent`, `ToolCallArgsEvent`, + `ToolCallResultEvent`, `ReasoningMessageContentEvent`) were RELAXED + to accept empty strings in 0.2.0 for canonical TS/Python parity, but + loosening a deprecated contract retroactively serves no one. +- `ReasoningEncryptedValueEvent.fromJson` empty-string rejection comment + updated to reflect the post-0.2.0 sibling state — it is intentionally + stricter than the relaxed sibling content events because cipher + payloads have no defensible "empty" semantic. +- `BaseEvent.fromJson` and `Message.fromJson` switches gained an explicit + trailing comment stating the analyzer-enforced exhaustiveness so future + contributors don't add a `default` clause "to be safe." +- `EventStreamAdapter` adopted an internal `_appendDataLine` / + `flushDataBlock` decomposition to share the per-line and `onDone` + flush paths in `fromRawSseStream`. No behavior change. +- README "Migrating from 0.1.0" `TimeoutError` → `AGUITimeoutError` + section gained a paragraph clarifying the symmetric case: consumers + who previously meant `dart:async.TimeoutError` and were accidentally + catching SDK instances will see different runtime behavior after they + fix the import. + +### Known parity gaps +- **`requireNonEmpty` on `messageId`, `threadId`, and `runId` fields is + stricter than the canonical `z.string()` / `str` schemas** (which allow + empty strings). `EventDecoder.validate()` rejects empty ID strings; + a TS or Python server that legitimately emits an empty `messageId` would + fail decode in Dart. The strict behavior is intentional (empty IDs have + no valid semantic in the current protocol) and is tracked for review at + 1.0.0 alignment. + +## [0.2.0] - 2026-04-30 + +### Breaking Changes +- `ToolCallResultEvent.role` is now typed `ToolCallResultRole?` instead of + `String?`. Callers constructing the event directly must use the enum + (e.g. `ToolCallResultRole.tool`) instead of a raw string. Wire decoding + is unaffected: an unknown role string on the wire is absorbed via + `ToolCallResultRole.fromString` and falls back to `ToolCallResultRole.tool` + (forward-compatible with future canonical roles). The new `role` enum + exists for parity with the Python `Literal["tool"]` / TypeScript + `z.literal("tool")` canonical role surface. + +### Added +- Activity events for event-type parity with the Python and TypeScript SDKs + ([#1018](https://github.com/ag-ui-protocol/ag-ui/issues/1018)): + - `ActivitySnapshotEvent` (`ACTIVITY_SNAPSHOT`) + - `ActivityDeltaEvent` (`ACTIVITY_DELTA`) +- Reasoning events for event-type parity: + - `ReasoningStartEvent` (`REASONING_START`) + - `ReasoningMessageStartEvent` (`REASONING_MESSAGE_START`) + - `ReasoningMessageContentEvent` (`REASONING_MESSAGE_CONTENT`) + - `ReasoningMessageEndEvent` (`REASONING_MESSAGE_END`) + - `ReasoningMessageChunkEvent` (`REASONING_MESSAGE_CHUNK`) + - `ReasoningEndEvent` (`REASONING_END`) + - `ReasoningEncryptedValueEvent` (`REASONING_ENCRYPTED_VALUE`) +- Supporting enums: `ReasoningMessageRole`, `ReasoningEncryptedValueSubtype`. +- `ActivityMessage` and `ReasoningMessage` `Message` subtypes (with + `MessageRole.activity` / `MessageRole.reasoning`) so `MESSAGES_SNAPSHOT` + payloads carrying those roles decode in Dart with the same schema as the + canonical TypeScript and Python SDKs. The `activityType` / + `activity_type` and `encryptedValue` / `encrypted_value` keys both + decode for camelCase/snake_case parity with the wider protocol. +- Field-level parity for canonical events that previously dropped wire data + on decode: `TextMessageStartEvent.name`, `TextMessageChunkEvent.name`, + `RunStartedEvent.parentRunId`, and `RunStartedEvent.input` are now decoded + and re-emitted by `toJson` so a Dart proxy preserves upstream metadata. +- All event `fromJson` factories now accept both camelCase (TypeScript + server) and snake_case (Python server) field keys, including the + pre-existing `TextMessage*` and `ToolCall*` events that were previously + camelCase-only. +- Decoder-boundary non-empty validation extended to `ToolCallArgsEvent`, + `ToolCallEndEvent`, `ToolCallResultEvent`, `RunFinishedEvent`, + `StepStartedEvent`, `StepFinishedEvent`, `StateSnapshotEvent`, `RawEvent`, + and `CustomEvent` so wire payloads with empty required identifiers or + missing required content fail at `EventDecoder.decodeJson` instead of + reaching consumer code as a null/empty value. + +### Changed +- `REASONING_MESSAGE_START.role` is now required during decoding to match + the canonical TypeScript and Python schemas. A payload missing `role` + now raises `AGUIValidationError` (wrapped as `DecodingError` through + `EventDecoder`); an unknown role string still falls back to + `ReasoningMessageRole.reasoning` for forward-compatibility. +- `TextMessageRole.fromString` now throws `ArgumentError` on unknown + values, mirroring `ReasoningMessageRole.fromString`. Wire decoding is + unaffected: `TextMessageStartEvent.fromJson` and + `TextMessageChunkEvent.fromJson` absorb the throw and fall back to + `TextMessageRole.assistant` for forward compatibility — only direct + callers of `TextMessageRole.fromString` see the new visible failure + mode. +- `ReasoningEncryptedValueEvent.fromJson` now wraps an unknown `subtype` + as `AGUIValidationError` (matching the class-level dartdoc contract), + instead of leaking the raw `ArgumentError` from + `ReasoningEncryptedValueSubtype.fromString`. The `EventDecoder` + pipeline still surfaces it as `DecodingError`. +- `ActivitySnapshotEvent.copyWith` (`content`), `RawEvent.copyWith` + (`event`), `CustomEvent.copyWith` (`value`), and + `RunFinishedEvent.copyWith` (`result`) now use an internal sentinel + parameter so callers can intentionally clear the field to `null` + (matching each factory contract that already accepted explicit-null + payloads). Other `copyWith` methods retain the standard + `?? this.field` pattern (see Known parity gaps). +- `EventDecoder.decodeJson` now wraps `AGUIValidationError` (thrown by + `fromJson` factories) explicitly so the resulting `DecodingError` + preserves the original failing field — `role`, `messageId`, + `subtype`, etc. — instead of flattening to `field: 'json'`. Pre-fix, + the wrapper relied on the `AgUiError`-based catch path, which + `AGUIValidationError` (which only `implements Exception`) bypassed. +- `EventDecoder.validate` now rejects an empty `messageId` on + `TextMessageEndEvent`, restoring symmetry with `TextMessageStartEvent` + and `TextMessageContentEvent` (and the new reasoning-end events). + +### Deprecated +- `EventType.thinkingContent` and `ThinkingContentEvent` — not part of the + canonical AG-UI protocol. Use `EventType.thinkingTextMessageContent` / + `ThinkingTextMessageContentEvent` instead. Decoding remains supported for + backward compatibility; scheduled for removal in 1.0.0. +- `EventType.thinkingTextMessageStart` / + `EventType.thinkingTextMessageContent` / + `EventType.thinkingTextMessageEnd` (and their event classes: + `ThinkingTextMessageStartEvent`, `ThinkingTextMessageContentEvent`, + `ThinkingTextMessageEndEvent`). Mirrors the canonical TypeScript SDK's + deprecation of `THINKING_TEXT_MESSAGE_*` in favor of `REASONING_*`. Use + `ReasoningMessageStartEvent` / `ReasoningMessageContentEvent` / + `ReasoningMessageEndEvent` instead. Decoding remains supported for + backward compatibility; scheduled for removal in 1.0.0. + +### Known parity gaps (follow-up) +- `copyWith` sentinel sweep is now complete for all nullable payload fields. + The sentinel pattern (`kUnsetSentinel` / `identical` check) is in place for + `ActivitySnapshotEvent.content`, `RawEvent.event`, `RawEvent.source`, + `CustomEvent.value`, `RunFinishedEvent.result`, the optional fields of + `TextMessageStartEvent` / `TextMessageChunkEvent`, + `ToolCallStartEvent.parentMessageId`, the optional fields of + `ToolCallChunkEvent` and `ReasoningMessageChunkEvent`, + `RunStartedEvent.parentRunId` / `RunStartedEvent.input`, + `RunAgentInput.parentRunId` / `RunAgentInput.state` / + `RunAgentInput.forwardedProps`, `Run.result`, the message-class nullables + (`name`, `content`, `toolCalls`, `error`, `encryptedValue`), + `ThinkingStartEvent.title`, `ToolCallResultEvent.role`, + `StateSnapshotEvent.snapshot`, and `RunErrorEvent.code`. +- `RunFinishedEvent.result` is dropped from `toJson()` when null: an + inbound explicit-null `'result': null` does not survive a Dart→Dart + re-serialization round-trip. This matches the canonical TS/Python schemas + (`z.any().optional()` / `Optional[Any] = None`), so cross-SDK forwarding + is unaffected. Consumers relying on byte-for-byte round-trip fidelity + should read `rawEvent` instead of re-serializing. + ## [0.1.0] - 2025-01-21 ### Added @@ -35,4 +564,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Advanced retry strategies planned for future release - Event caching and offline support planned for future release -[0.1.0]: https://github.com/ag-ui-protocol/ag-ui/releases/tag/dart-v0.1.0 \ No newline at end of file +[0.2.0]: https://github.com/ag-ui-protocol/ag-ui/releases/tag/dart-v0.2.0 +[0.1.0]: https://github.com/ag-ui-protocol/ag-ui/releases/tag/dart-v0.1.0 diff --git a/sdks/community/dart/README.md b/sdks/community/dart/README.md index 63c0cae482..0be14991a4 100644 --- a/sdks/community/dart/README.md +++ b/sdks/community/dart/README.md @@ -14,14 +14,14 @@ Or add to your `pubspec.yaml`: ```yaml dependencies: - ag_ui: ^0.1.0 + ag_ui: ^0.2.0 ``` ## Features - 🎯 **Dart-native** – Idiomatic Dart APIs with full type safety and null safety - 🔗 **HTTP connectivity** – `AgUiClient` for direct server connections with SSE streaming -- 📡 **Event streaming** – 16 core event types for real-time agent communication +- 📡 **Event streaming** – Event-type parity with the canonical Python and TypeScript SDKs (text messages, tool calls, state, activity, reasoning, lifecycle, and more) for real-time agent communication. - 🔄 **State management** – Automatic message/state tracking with JSON Patch support - 🛠️ **Tool interactions** – Full support for tool calls and generative UI - ⚡ **High performance** – Efficient event decoding with backpressure handling @@ -52,7 +52,7 @@ final input = SimpleRunAgentInput( // Stream response events await for (final event in client.runAgent('agentic_chat', input)) { if (event is TextMessageContentEvent) { - print('Assistant: ${event.text}'); + print('Assistant: ${event.delta}'); } } ``` @@ -99,9 +99,9 @@ final input = SimpleRunAgentInput( ); await for (final event in client.runAgent('agentic_chat', input)) { - switch (event.type) { + switch (event.eventType) { case EventType.textMessageContent: - final text = (event as TextMessageContentEvent).text; + final text = (event as TextMessageContentEvent).delta; print(text); // Stream tokens break; case EventType.runFinished: @@ -111,6 +111,40 @@ await for (final event in client.runAgent('agentic_chat', input)) { } ``` +### Activity & Reasoning Events + +```dart +import 'dart:io'; // for `stderr` in the example below + +await for (final event in client.runAgent('agentic_chat', input)) { + if (event is ActivitySnapshotEvent) { + // `content` is `Object?` — the Python reference server may emit a + // primitive or `null`. Guard before treating it as a structured record. + final content = event.content; + if (content is Map) { + // `event.replace == true` → discard prior content for this messageId. + // `event.replace == false` → merge/extend on top of existing content. + print( + 'Activity (${event.activityType}, replace=${event.replace}): $content', + ); + } else { + // Wire-protocol surprise: log and skip rather than crash. + stderr.writeln( + 'ActivitySnapshotEvent.content is ${content.runtimeType}, ' + 'expected Map', + ); + } + } else if (event is ActivityDeltaEvent) { + print('Activity patch (${event.activityType}): ${event.patch}'); + } else if (event is ReasoningMessageContentEvent) { + print('Reasoning: ${event.delta}'); + } else if (event is ReasoningEncryptedValueEvent) { + // Opaque cipher payload — pass through to the next agent rather than + // attempting to decode locally. + } +} +``` + ### Tool-Based Interactions ```dart @@ -152,7 +186,7 @@ Map state = {}; List messages = []; await for (final event in client.runSharedState(input)) { - switch (event.type) { + switch (event.eventType) { case EventType.stateSnapshot: state = (event as StateSnapshotEvent).snapshot; break; @@ -169,6 +203,8 @@ await for (final event in client.runSharedState(input)) { ### Error Handling +The Dart SDK errors form a single hierarchy under [`AGUIError`](https://pub.dev/documentation/ag_ui/latest/ag_ui/AGUIError-class.html). Catch that base if you want one handler for everything; catch the specific subclasses below for targeted recovery. Through [`EventDecoder`](https://pub.dev/documentation/ag_ui/latest/ag_ui/EventDecoder-class.html) the wire-decode side throws [`DecodingError`]; the client-side request/transport layer throws [`TransportError`] and [`ValidationError`]; cancellation surfaces as [`CancellationError`]. + ```dart final cancelToken = CancelToken(); @@ -180,15 +216,45 @@ try { break; } } -} on ConnectionException catch (e) { +} on TransportError catch (e) { print('Connection error: ${e.message}'); +} on DecodingError catch (e) { + print('Decode error: ${e.message}'); } on ValidationError catch (e) { print('Validation error: ${e.message}'); -} on CancelledException { +} on CancellationError { print('Request cancelled'); +} on AGUIError catch (e) { + // Catch-all for any AG-UI-originated error (covers + // AGUIValidationError thrown directly from a `Type.fromJson` call + // when the event isn't routed through the EventDecoder pipeline). + print('AG-UI error: $e'); } ``` +> **Cancellation note:** `CancelToken.cancel()` stops event delivery to your stream, but does **not** abort the underlying HTTP socket. The connection releases when the server closes it or the OS idle-timeout fires. If you need true connection abort, provide a custom `IOClient` per request. + +### Proxy notes: wire-spelling normalization + +The Dart SDK accepts both **camelCase** (TypeScript-canonical, e.g. `threadId`, +`runId`, `parentRunId`, `encryptedValue`, `rawEvent`) and **snake_case** +(Python-canonical, e.g. `thread_id`, `run_id`, `parent_run_id`, +`encrypted_value`, `raw_event`) on every `fromJson` factory, but always +emits **camelCase** on `toJson` — there is no opt-in to snake_case wire +output. + +If you use the Dart SDK as a proxy between a snake_case-emitting Python +server and a strictly snake_case-only consumer, you must convert keys +back at the boundary. The TypeScript and Python canonical SDKs both +tolerate the camelCase form on input, so this is rarely an issue in +practice — but a strict snake_case consumer is technically protocol-valid +and will see a normalized payload from a Dart middle-tier. + +Within a single `BaseEvent.rawEvent` round-trip the spelling is +preserved by the helper that reads both keys (`rawEvent` / +`raw_event`); the camelCase emit on the Dart side is the only +normalization point. + ## Complete Example ```dart @@ -222,10 +288,10 @@ void main() async { stdout.write('Assistant: '); await for (final event in client.runAgent('agentic_chat', input)) { if (event is TextMessageContentEvent) { - stdout.write(event.text); + stdout.write(event.delta); } else if (event is ToolCallStartEvent) { - print('\nCalling tool: ${event.toolName}'); - } else if (event.type == EventType.runFinished) { + print('\nCalling tool: ${event.toolCallName}'); + } else if (event.eventType == EventType.runFinished) { print('\nDone!'); break; } @@ -235,6 +301,110 @@ void main() async { } ``` +## Migrating from 0.1.0 + +0.2.0 introduces one source-breaking change for callers that construct +events directly: + +- **`ToolCallResultEvent.role` is now `ToolCallResultRole?` instead of + `String?`.** Update direct constructions: + + ```dart + // Before (0.1.0) + ToolCallResultEvent( + messageId: '...', + toolCallId: '...', + content: '...', + role: 'tool', + ); + + // After (0.2.0) + ToolCallResultEvent( + messageId: '...', + toolCallId: '...', + content: '...', + role: ToolCallResultRole.tool, + ); + ``` + + Wire decoding is unaffected: an unknown `role` string on the wire is + absorbed via `ToolCallResultRole.fromString` and falls back to + `ToolCallResultRole.tool` for forward compatibility. See + [`CHANGELOG.md`](CHANGELOG.md) "Breaking Changes" for the full + rationale. + +- **`TimeoutError` was renamed to `AGUITimeoutError`** to avoid + shadowing `dart:async.TimeoutError` (raised by `Future.timeout(...)` / + `Stream.timeout(...)`). The bare name is preserved as a deprecated + typedef alias and will be removed in 1.0.0: + + ```dart + // Before (0.1.0) + } on TimeoutError catch (e) { /* ... */ } + + // After (0.2.0) + } on AGUITimeoutError catch (e) { /* ... */ } + ``` + + If you import both `package:ag_ui/ag_ui.dart` and `dart:async`, prefer + the new name to avoid a symbol collision and to ensure raw + `dart:async.TimeoutError` instances (very common from any + `.timeout(...)` call) are not silently absorbed by an `on TimeoutError` + arm targeting the SDK type. + + Note for the inverse case: if you previously meant + `dart:async.TimeoutError` and were accidentally catching SDK instances + (because `package:ag_ui/ag_ui.dart`'s `TimeoutError` won the unqualified + name resolution), the rename surfaces the prior collision. After you + migrate to `AGUITimeoutError`, the bare `TimeoutError` arm now + unambiguously refers to `dart:async.TimeoutError` — runtime behavior + changes accordingly. + +The `THINKING_TEXT_MESSAGE_*` event types are also deprecated in 0.2.0 +in favor of the canonical `REASONING_*` events; decoding remains +supported until 1.0.0. See `CHANGELOG.md` "Deprecated" for the migration +mapping. + +## Errors + +The SDK exposes a small error hierarchy that is intentionally split by origin: + +- `AGUIError` — the SDK-wide root. Catching `on AGUIError` covers every + error the SDK can raise: runtime, transport, decoding, AND direct-factory + validation. Use this when you want a single catch-all. +- `AgUiError` — extends `AGUIError`. Covers runtime / transport / decoding: + `TransportError`, `AGUITimeoutError`, `CancellationError`, `DecodingError`, + and the client-side `ValidationError`. Catch this when you want to scope + to "the SDK encountered a runtime problem" but explicitly do NOT want to + catch direct-factory validation errors. (`TimeoutError` is preserved as + a deprecated alias for `AGUITimeoutError`; prefer the new name to avoid + shadowing `dart:async.TimeoutError`.) +- `AGUIValidationError` — extends `AGUIError` (NOT `AgUiError`). Thrown by + `*.fromJson` factory constructors at the wire-decoding boundary. When + events flow through `EventDecoder`, this is wrapped as `DecodingError`, + so consumers using the decoder pipeline never see this directly. Direct + factory callers (`TextMessageStartEvent.fromJson(...)`) do. +- `EncoderError` and its subtypes (`DecodeError`, `EncodeError`, + encoder-side `ValidationError`) extend `AGUIError`. The `EventDecoder` + pipeline rethrows these unchanged so callers can pattern-match by type. + +Recommended catch recipe in production code that uses `EventDecoder`: + +```dart +try { + for (final event in stream) { handle(event); } +} on DecodingError catch (e) { + // Wire-format problem — log e.field, e.expectedType, e.actualValue. +} on TransportError catch (e) { + // HTTP / SSE transport failure. +} on AgUiError catch (e) { + // Anything else from the runtime/transport family. +} on AGUIError catch (e) { + // Catch-all (would also catch direct-factory AGUIValidationError if you + // ever bypass the decoder). +} +``` + ## Examples See the [`example/`](example/) directory for: @@ -265,6 +435,35 @@ Contributions are welcome! Please: 4. Ensure all tests pass 5. Submit a pull request +## Cipher-data preservation + +Some AG-UI events (`ReasoningEncryptedValueEvent`, `ReasoningMessage`, `ToolMessage`) carry +opaque cipher payloads that must be forwarded verbatim between agents. This SDK implements +defense-in-depth around those payloads: + +**Success paths** — the `rawEvent` field on every `BaseEvent` is set to the verbatim +wire-format map read from the SSE stream. A proxy that needs to re-emit a +`ReasoningEncryptedValueEvent` should read `rawEvent` (or maintain its own copy of the raw +bytes) and forward it unchanged rather than calling `toJson()`, which emits only the +parsed fields. + +**Error paths** — when a factory (`fromJson`) fails to decode an event, the thrown +`AGUIValidationError` intentionally omits the raw JSON map (`json:` field) for any event +that may carry cipher data. This prevents raw cipher bytes from leaking through +reflection-based log shippers or error serializers that walk the exception cause chain. + +**`ReasoningEncryptedValueEvent` specifically** sets `rawEvent: null` unconditionally — +unlike every other factory, forwarding `_readRawEvent(json)` would store the full cipher +payload in-memory on `BaseEvent.rawEvent`, undoing the per-field cipher scrubbing above. +Proxy operators that need the verbatim wire form must maintain their own copy before +calling `fromJson`. + +**`copyWith` and `rawEvent`** — the `copyWith` methods across all event types treat +`rawEvent` as "sticky": passing `null` keeps the existing value (i.e. `rawEvent ?? this.rawEvent`). +To clear `rawEvent`, construct the event directly with `rawEvent: null`. This prevents an +accidental `copyWith()` call from silently preserving a cipher payload that the caller +intended to drop. + ## License This SDK is part of the AG-UI Protocol project. See the [main repository](https://github.com/ag-ui-protocol/ag-ui) for license information. diff --git a/sdks/community/dart/lib/ag_ui.dart b/sdks/community/dart/lib/ag_ui.dart index 0b868d3c1f..8169d56779 100644 --- a/sdks/community/dart/lib/ag_ui.dart +++ b/sdks/community/dart/lib/ag_ui.dart @@ -62,15 +62,17 @@ export 'src/client/config.dart'; export 'src/client/errors.dart'; export 'src/client/validators.dart'; -// Client codec (hide ToolResult since it's defined in types/tool.dart) -export 'src/encoder/client_codec.dart' hide ToolResult; +// Client codec — ClientToolResult is an outbound-only model used by +// Encoder.encodeToolResult; it must remain visible so callers can construct +// values to pass to that method. +export 'src/encoder/client_codec.dart'; // Core exports will be added in subsequent tasks // export 'src/agent.dart'; // export 'src/transport.dart'; /// SDK version -const String agUiVersion = '0.1.0'; +const String agUiVersion = '0.2.0'; /// Initialize the AG-UI SDK void initAgUI() { diff --git a/sdks/community/dart/lib/src/client/client.dart b/sdks/community/dart/lib/src/client/client.dart index b1d2533087..c2ebc3f6b2 100644 --- a/sdks/community/dart/lib/src/client/client.dart +++ b/sdks/community/dart/lib/src/client/client.dart @@ -1,5 +1,7 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:developer' as developer; +import 'dart:math'; import 'package:http/http.dart' as http; import 'package:meta/meta.dart'; @@ -63,8 +65,15 @@ class AgUiClient { /// Returns a stream of [BaseEvent] objects representing the agent's response. /// /// Throws: - /// - [ValidationError] if the input is invalid - /// - [ConnectionException] if the connection fails + /// - [ValidationError] if the input is invalid (URL, message shape, etc.) + /// - [TransportError] if the HTTP/SSE connection fails or the server + /// returns a non-success status + /// - [DecodingError] if an SSE payload cannot be decoded into a + /// [BaseEvent] + /// - [CancellationError] if the request is cancelled via [cancelToken] + /// + /// All four extend [AGUIError] — catch that base for one-shot + /// handling. Stream runAgent( String endpoint, SimpleRunAgentInput input, { @@ -149,16 +158,33 @@ class AgUiClient { }) async* { final runId = input.runId ?? _generateRunId(); cancelToken ??= CancelToken(); + + // Validate BEFORE registering in _requestTokens so a caller-supplied + // bad runId (empty, over-length, control chars) never enters the map. + _validateRunAgentInput(input); + + // Reject a caller-supplied runId that collides with an in-flight run. + // Without this guard the second call would silently overwrite + // `_requestTokens[runId]` and `_activeStreams[runId]`, making the first + // run's CancelToken unreachable and leaking its SseClient when the first + // run's `finally` block calls `_closeStream(runId)` and closes the second + // run's client instead. + if (_requestTokens.containsKey(runId)) { + throw ValidationError( + 'Duplicate runId "$runId": another run with the same id is in flight', + field: 'runId', + constraint: 'unique-in-flight', + value: runId, + ); + } _requestTokens[runId] = cancelToken; try { - // Validate input - _validateRunAgentInput(input); // Send POST request with RunAgentInput final headers = _buildHeaders(); headers['Content-Type'] = 'application/json'; - headers['Accept'] = 'text/event-stream'; + headers.putIfAbsent('Accept', () => 'text/event-stream'); final uri = Uri.parse(endpoint); final request = http.Request('POST', uri) @@ -200,17 +226,16 @@ class AgUiClient { yield* _transformSseStream(sseStream, runId); } on AgUiError { rethrow; + } on TimeoutException { + throw AGUITimeoutError( + 'Agent request timed out', + timeout: config.requestTimeout, + operation: endpoint, + ); } catch (e) { if (cancelToken.isCancelled) { throw CancellationError('Request was cancelled', operation: endpoint); } - if (e is TimeoutException) { - throw TimeoutError( - 'Agent request timed out', - timeout: config.requestTimeout, - operation: endpoint, - ); - } throw TransportError( 'Failed to run agent', endpoint: endpoint, @@ -222,41 +247,75 @@ class AgUiClient { } } - /// Send request with cancellation support + /// Send request with cancellation support. + /// + /// **Known limitation**: cancellation only drops the response at the + /// Dart completer level — the underlying HTTP connection is NOT aborted. + /// The `http.Client` interface does not expose per-request abort; closing + /// the shared `_httpClient` would affect all concurrent requests. In + /// practice the OS/server timeout eventually cleans up the socket. A + /// future refactor to per-request `IOClient` instances could add true + /// abort support. + /// + /// Late-arriving responses or errors from the HTTP future after + /// cancellation are silently swallowed by the `onError` handler below + /// to prevent unhandled-future-error warnings. Future _sendWithCancellation( http.Request request, CancelToken cancelToken, Duration timeout, ) async { - // Create completer for cancellation final completer = Completer(); - - // Start the request + final future = _httpClient.send(request).timeout(timeout); - - // Listen for cancellation - cancelToken.onCancel.then((_) { + + unawaited(cancelToken.onCancel.then((_) { if (!completer.isCompleted) { completer.completeError( CancellationError('Request cancelled', operation: request.url.toString()), ); } - }); - - // Complete with result or error - future.then( + })); + + unawaited(future.then( (response) { if (!completer.isCompleted) { completer.complete(response); + } else { + // Late response after cancellation — caller already received + // CancellationError. Log so silent swallows are observable in + // dev tools / dart:developer listeners without surfacing to the + // stream consumer. + developer.log( + 'Late HTTP response after cancellation; discarding ' + '(status ${response.statusCode})', + name: 'ag_ui.client', + ); + // Immediately subscribe-and-cancel to signal the underlying platform + // to close the socket. Do NOT await drain() — for SSE responses the + // body stream never ends until the server disconnects, so drain() + // would hold the socket open indefinitely. + unawaited( + response.stream + .listen((_) {}) + .cancel() + .catchError((_) {}), + ); } }, onError: (Object error) { if (!completer.isCompleted) { completer.completeError(error); + } else { + // Late error after cancellation — log for debuggability. + developer.log( + 'Late HTTP error after cancellation; discarded: $error', + name: 'ag_ui.client', + ); } }, - ); - + )); + return completer.future; } @@ -272,44 +331,55 @@ class AgUiClient { await _closeStream(runId); } - /// Transform SSE messages to typed AG-UI events + /// Transform SSE messages to typed AG-UI events. + /// + /// Lifecycle note: `_runAgentInternal` owns the `runId`/`SseClient` pair + /// and calls `_closeStream` in its own `finally` block. This method does + /// NOT clean up — do not add a `finally` here to avoid a redundant second + /// `_closeStream` call. Stream _transformSseStream( Stream sseStream, String runId, ) async* { - try { - await for (final message in sseStream) { - if (message.data == null || message.data!.isEmpty) { - continue; - } + await for (final message in sseStream) { + if (message.data == null || message.data!.isEmpty) { + continue; + } + // Mirror the keep-alive filter in EventStreamAdapter.fromSseStream: + // some servers emit `data: :` as a keep-alive sentinel alongside + // spec-correct comment-only keep-alives. Passing it to json.decode + // raises FormatException and wraps it as a spurious DecodingError. + if (message.data!.trim() == ':') { + continue; + } - try { - // Parse the SSE data as JSON - final jsonData = json.decode(message.data!); - - // Use the stream adapter to convert to typed events - final events = _streamAdapter.adaptJsonToEvents(jsonData); - - for (final event in events) { - yield event; - } - } on AgUiError catch (e) { - // Re-throw AG-UI errors to the stream - yield* Stream.error(e); - } catch (e) { - // Wrap other errors - yield* Stream.error(DecodingError( - 'Failed to decode SSE message', - field: 'message.data', - expectedType: 'BaseEvent', - actualValue: message.data, - cause: e, - )); + try { + // Parse the SSE data as JSON + final jsonData = json.decode(message.data!); + + // Use the stream adapter to convert to typed events + final events = _streamAdapter.adaptJsonToEvents(jsonData); + + for (final event in events) { + yield event; } + } on AGUIError catch (e) { + // Re-throw any AG-UI error (AGUIValidationError, EncoderError, + // AgUiError, …) unchanged so field info is preserved. The former + // `on AgUiError` clause silently wrapped AGUIValidationError (which + // extends AGUIError but not AgUiError) as a generic DecodingError, + // discarding the structured field path. + yield* Stream.error(e); + } catch (e) { + // Wrap other errors + yield* Stream.error(DecodingError( + 'Failed to decode SSE message', + field: 'message.data', + expectedType: 'BaseEvent', + actualValue: message.data, + cause: e, + )); } - } finally { - // Clean up when stream ends - await _closeStream(runId); } } @@ -371,7 +441,7 @@ class AgUiClient { } on TimeoutException { attempts++; if (attempts > config.maxRetries) { - throw TimeoutError( + throw AGUITimeoutError( 'Request timed out after ${config.maxRetries} attempts', timeout: config.requestTimeout, operation: '$method $endpoint', @@ -433,28 +503,86 @@ class AgUiClient { if (input.threadId != null) { Validators.requireNonEmpty(input.threadId!, 'threadId'); } - - // Validate messages if present + + // Validate caller-supplied runId if present — it flows into _activeStreams + // and _requestTokens as a map key, so an empty or oversized value must be + // rejected at the boundary rather than silently stored. + if (input.runId != null) { + Validators.validateRunId(input.runId!); + } + + if (input.parentRunId != null) { + Validators.requireNonEmpty(input.parentRunId!, 'parentRunId'); + } + + // Validate messages using an exhaustive sealed switch so every concrete + // subtype is explicitly covered. A partial `is UserMessage` check implied + // validation coverage that didn't exist — this makes the boundary clear. if (input.messages != null) { + final seenMessageIds = {}; for (final message in input.messages!) { - if (message is UserMessage) { - Validators.validateMessageContent(message.content); + // `Message.id` is declared nullable (to accommodate inbound + // MESSAGES_SNAPSHOT payloads where the server may omit the field), + // but outbound messages MUST carry a non-empty id: the server uses + // it as the stable identity key for conversation history. + // `requireNonEmpty` rejects both null and empty-string. + Validators.requireNonEmpty(message.id, 'message.id'); + if (!seenMessageIds.add(message.id!)) { + throw ValidationError( + 'Duplicate message.id "${message.id}"', + field: 'message.id', + constraint: 'unique-id', + value: message.id, + ); + } + switch (message) { + case UserMessage(:final content): + Validators.validateMessageContent(content); + case AssistantMessage(:final content): + if (content != null) Validators.validateMessageContent(content); + case DeveloperMessage(:final content): + Validators.validateMessageContent(content); + case SystemMessage(:final content): + Validators.validateMessageContent(content); + case ToolMessage(:final content): + Validators.validateMessageContent(content); + case ReasoningMessage(:final content): + Validators.validateMessageContent(content); + case ActivityMessage(): + // ActivityMessage carries structured activityContent (Map), not + // a string content field — nothing to validate here. + break; } } } } - /// Generate a unique run ID + /// Lazily initialized secure RNG, shared across all `_generateRunId` + /// calls on this instance. `Random.secure()` seeds from the OS CSPRNG + /// on first access; creating one per call wastes that OS round-trip. + static final _secureRandom = Random.secure(); + + /// Generate a unique run ID using a timestamp + 8 cryptographically + /// random bytes. The random suffix prevents collisions for concurrent + /// calls within the same millisecond, which is important because run IDs + /// are used as map keys in `_activeStreams` / `_requestTokens` — a + /// collision would silently overwrite an in-flight stream entry. String _generateRunId() { final timestamp = DateTime.now().millisecondsSinceEpoch; - final random = DateTime.now().microsecond; - return 'run_${timestamp}_$random'; + final hex = List.generate( + 8, + (_) => _secureRandom.nextInt(256).toRadixString(16).padLeft(2, '0'), + ).join(); + return 'run_${timestamp}_$hex'; } /// Truncate response body for error messages String _truncateBody(String body, {int maxLength = 500}) { if (body.length <= maxLength) return body; - return '${body.substring(0, maxLength)}...'; + var end = maxLength; + final cu = body.codeUnitAt(end - 1); + if (cu >= 0xD800 && cu <= 0xDBFF) end--; // avoid splitting surrogate pair + return '${body.substring(0, end)}...'; } /// Build headers for requests @@ -489,7 +617,19 @@ class AgUiClient { } } -/// Cancel token for request cancellation +/// Cancel token for request cancellation. +/// +/// **One-shot contract**: a [CancelToken] must be used with exactly ONE +/// request. Once [cancel] is called the token is permanently cancelled — +/// passing the same token to a second [AgUiClient.runAgent] call will +/// cause that call to see [isCancelled] as `true` immediately and +/// complete with a [CancellationError] before the HTTP request is sent. +/// +/// **Listener accumulation**: [_sendWithCancellation] attaches a single +/// `.then` handler to [onCancel] per request via [unawaited]. Because +/// [CancelToken] is one-shot (one request, one cancel), the handler is +/// never re-attached across multiple calls, so no listener accumulation +/// occurs as long as the one-shot contract is honored. class CancelToken { final _completer = Completer(); bool _isCancelled = false; @@ -511,6 +651,7 @@ class CancelToken { class SimpleRunAgentInput { final String? threadId; final String? runId; + final String? parentRunId; final List? messages; final List? tools; final List? context; @@ -522,6 +663,7 @@ class SimpleRunAgentInput { const SimpleRunAgentInput({ this.threadId, this.runId, + this.parentRunId, this.messages, this.tools, this.context, @@ -533,13 +675,14 @@ class SimpleRunAgentInput { Map toJson() { return { - if (threadId != null) 'thread_id': threadId, - if (runId != null) 'run_id': runId, - 'state': state ?? {}, - 'messages': messages?.map((m) => m.toJson()).toList() ?? [], - 'tools': tools?.map((t) => t.toJson()).toList() ?? [], - 'context': context?.map((c) => c.toJson()).toList() ?? [], - 'forwardedProps': forwardedProps ?? {}, + if (threadId != null) 'threadId': threadId, + if (runId != null) 'runId': runId, + if (parentRunId != null) 'parentRunId': parentRunId, + if (state != null) 'state': state, + if (messages != null) 'messages': messages!.map((m) => m.toJson()).toList(), + if (tools != null) 'tools': tools!.map((t) => t.toJson()).toList(), + if (context != null) 'context': context!.map((c) => c.toJson()).toList(), + if (forwardedProps != null) 'forwardedProps': forwardedProps, if (config != null) 'config': config, if (metadata != null) 'metadata': metadata, }; diff --git a/sdks/community/dart/lib/src/client/errors.dart b/sdks/community/dart/lib/src/client/errors.dart index b3dc41d3cb..bf7b75ec32 100644 --- a/sdks/community/dart/lib/src/client/errors.dart +++ b/sdks/community/dart/lib/src/client/errors.dart @@ -1,8 +1,25 @@ -/// Base class for all AG-UI errors -abstract class AgUiError implements Exception { - /// Human-readable error message - final String message; +import '../types/base.dart'; + +// Truncate [s] to at most [maxLen] UTF-16 code units, backing up by 1 if the +// cut falls on the high surrogate of a pair, to avoid emitting lone surrogates. +String _safeTruncate(String s, int maxLen) { + if (s.length <= maxLen) return s; + var end = maxLen; + final cu = s.codeUnitAt(end - 1); + if (cu >= 0xD800 && cu <= 0xDBFF) end--; // high surrogate: back up + return s.substring(0, end); +} +/// Base class for runtime / transport / decoding AG-UI errors. +/// +/// Extends the SDK-wide [AGUIError] root in `lib/src/types/base.dart`, +/// so a consumer that catches `on AGUIError` will also catch every +/// `AgUiError` subtype (transport, timeout, decoding, ...) along with +/// `AGUIValidationError` from the factory boundary. Catching +/// `on AgUiError` continues to scope strictly to runtime / transport / +/// decoding — direct factory-side `AGUIValidationError` is NOT caught +/// by `on AgUiError`. See README → "Errors" for the recipe. +abstract class AgUiError extends AGUIError { /// Optional error details for debugging final Map? details; @@ -10,7 +27,7 @@ abstract class AgUiError implements Exception { final Object? cause; const AgUiError( - this.message, { + super.message, { this.details, this.cause, }); @@ -72,15 +89,23 @@ class TransportError extends AgUiError { } } -/// Error when operation times out -class TimeoutError extends AgUiError { +/// Error when operation times out. +/// +/// Renamed from `TimeoutError` to avoid shadowing the built-in +/// `dart:async.TimeoutError` (raised by `Future.timeout(...)` / +/// `Stream.timeout(...)`). A consumer that imports both +/// `package:ag_ui/ag_ui.dart` and `dart:async` would otherwise hit a +/// symbol collision; the README "Errors" recipe used to inadvertently +/// mask the built-in. The old `TimeoutError` name is preserved as a +/// deprecated typedef bridge below — prefer this class. +class AGUITimeoutError extends AgUiError { /// Duration that was exceeded final Duration? timeout; /// Operation that timed out final String? operation; - const TimeoutError( + const AGUITimeoutError( super.message, { this.timeout, this.operation, @@ -91,7 +116,7 @@ class TimeoutError extends AgUiError { @override String toString() { final buffer = StringBuffer(); - buffer.write('TimeoutError: $message'); + buffer.write('AGUITimeoutError: $message'); if (operation != null) { buffer.write(' (operation: $operation)'); } @@ -102,6 +127,17 @@ class TimeoutError extends AgUiError { } } +/// Deprecated alias for [AGUITimeoutError]. +/// +/// The bare name `TimeoutError` shadows `dart:async.TimeoutError` when +/// callers import both libraries. Migrate to [AGUITimeoutError]; this +/// alias will be removed in 1.0.0. +@Deprecated( + 'Use AGUITimeoutError. The bare TimeoutError name shadows ' + 'dart:async.TimeoutError and will be removed in 1.0.0.', +) +typedef TimeoutError = AGUITimeoutError; + /// Error when operation is cancelled class CancellationError extends AgUiError { /// Operation that was cancelled @@ -165,11 +201,19 @@ class DecodingError extends AgUiError { if (actualValue != null) { buffer.write(' (actual: ${actualValue.runtimeType})'); } + if (cause != null) buffer.write('\nCaused by: $cause'); return buffer.toString(); } } -/// Error validating input or output data +/// Error validating input or output data. +/// +/// Thrown by `Validators` (e.g. `Validators.requireNonEmpty`) — not by +/// `fromJson` factories. The factory-side counterpart is +/// `AGUIValidationError` in `lib/src/types/base.dart`, which has a +/// different parent (does NOT extend `AgUiError`). When events flow +/// through the public [EventDecoder] pipeline, both are caught and +/// re-wrapped as `DecodingError`. class ValidationError extends AgUiError { /// Field that failed validation final String? field; @@ -202,10 +246,11 @@ class ValidationError extends AgUiError { if (value != null) { final valueStr = value.toString(); final excerpt = valueStr.length > 100 - ? '${valueStr.substring(0, 100)}...' + ? '${_safeTruncate(valueStr, 100)}...' : valueStr; buffer.write(' (value: $excerpt)'); } + if (cause != null) buffer.write('\nCaused by: $cause'); return buffer.toString(); } } @@ -291,8 +336,8 @@ typedef AgUiHttpException = TransportError; @Deprecated('Use TransportError instead') typedef AgUiConnectionException = TransportError; -@Deprecated('Use TimeoutError instead') -typedef AgUiTimeoutException = TimeoutError; +@Deprecated('Use AGUITimeoutError instead') +typedef AgUiTimeoutException = AGUITimeoutError; @Deprecated('Use ValidationError instead') typedef AgUiValidationException = ValidationError; diff --git a/sdks/community/dart/lib/src/client/validators.dart b/sdks/community/dart/lib/src/client/validators.dart index cc51ad7115..c0faa6bf42 100644 --- a/sdks/community/dart/lib/src/client/validators.dart +++ b/sdks/community/dart/lib/src/client/validators.dart @@ -2,6 +2,16 @@ import 'errors.dart'; /// Validation utilities for AG-UI SDK class Validators { + // Hoisted to avoid recompiling on every validateUrl call (hot path). + // The explicit \u escapes make the matched code points visible in source: + // \x00\u2013\x1f C0 control codes (including \t, \n, \r) + // \x7f DEL + // \u0085 NEL (U+0085, C1 Next-Line \u2014 accepted verbatim by Uri.parse) + // \u2028 Line Separator (Unicode LS) + // \u2029 Paragraph Separator (Unicode PS) + static final RegExp _kUrlControlChars = + RegExp('[\x00-\x1f\x7f\u0085\u2028\u2029]'); + /// Validates that a string is not empty static void requireNonEmpty(String? value, String fieldName) { if (value == null || value.isEmpty) { @@ -27,15 +37,51 @@ class Validators { return value; } - /// Validates a URL format + /// Validates a URL format. + /// + /// Rejects null/empty URLs, URLs with embedded control characters or DEL + /// (C0 + Unicode line-terminators), non-http/https schemes, and + /// credential-bearing URLs (`http://user:pass@host/`). + /// + /// **Defense-in-depth note.** The credentials block + /// (`uri.userInfo.isNotEmpty`) ALSO defends against percent-encoded + /// control-char injection (e.g. `http://%0a:@host/` → newline in + /// `userInfo` after `Uri.parse` decodes it). If the no-credentials rule + /// is ever relaxed, ALSO run `_kUrlControlChars` against + /// `uri.userInfo`, `uri.path`, `uri.query`, and `uri.fragment` — those + /// fields are percent-decoded at access time, so the top-of-function + /// string check on the raw URL string is not sufficient on its own. static void validateUrl(String? url, String fieldName) { requireNonEmpty(url, fieldName); - + + // Reject embedded control characters and DEL before delegating to + // `Uri.parse`. `Uri.parse('http://example.com/\nfoo')` returns a + // valid Uri with `\n` in the path, which then flows into HTTP + // request lines as a header-injection vector. The check covers: + // • C0 controls (`\x00`–`\x1f`) and DEL (`\x7f`) — including `\t`, + // `\n`, `\r`. + // • U+0085 (NEL), U+2028 (LS), U+2029 (PS) — Unicode logical-line + // terminators that Dart's `Uri.parse` accepts verbatim and a naive + // custom transport re-emitting the URL into an HTTP header line + // would interpret as a line break. + if (_kUrlControlChars.hasMatch(url!)) { + throw ValidationError( + 'URL contains control characters for "$fieldName"', + field: fieldName, + constraint: 'no-control-chars', + value: url, + ); + } + try { - final uri = Uri.parse(url!); - if (!uri.hasScheme || !uri.hasAuthority) { + final uri = Uri.parse(url); + // `uri.hasAuthority` is true for `http://` (authority = empty string, + // host = ""). Add the explicit `uri.host.isEmpty` guard so bare-scheme + // URLs like `http://` are rejected as invalid rather than passing + // through to the scheme / credentials checks. + if (!uri.hasScheme || !uri.hasAuthority || uri.host.isEmpty) { throw ValidationError( - 'Invalid URL format for "$fieldName"', + 'Invalid URL format or empty host for "$fieldName"', field: fieldName, constraint: 'valid-url', value: url, @@ -49,6 +95,36 @@ class Validators { value: url, ); } + // Reject credential-bearing URLs (`http://user:pass@host/`) to + // prevent credentials from leaking into logs, error messages, or + // HTTP Referer headers on redirects. + if (uri.userInfo.isNotEmpty) { + throw ValidationError( + 'URL must not contain user credentials for "$fieldName"', + field: fieldName, + constraint: 'no-user-credentials', + value: url, + ); + } + // Defense-in-depth: also check percent-DECODED host / path / query / + // fragment. `Uri.parse` decodes percent-escapes at access time, so a + // raw URL like `http://host/%0a/foo` passes the top-of-function string + // check but `uri.path` returns a newline — a header-injection vector + // for any consumer that reflects these fields into HTTP request lines. + // `uri.host` is included because Dart allows percent-encoded IDNA host + // labels, and the decoded host can carry control characters that a + // custom transport places into `Host:` headers. + for (final part in [uri.host, uri.path, uri.query, uri.fragment]) { + if (_kUrlControlChars.hasMatch(part)) { + throw ValidationError( + 'URL contains percent-encoded control characters in ' + 'path/query/fragment for "$fieldName"', + field: fieldName, + constraint: 'no-control-chars-decoded', + value: url, + ); + } + } } catch (e) { if (e is ValidationError) rethrow; throw ValidationError( @@ -86,14 +162,20 @@ class Validators { } } - /// Validates a run ID format + /// Validates a run ID format. + /// + /// The 100-unit cap is measured in UTF-16 code units (Dart's [String.length]), + /// not Unicode code points or user-perceived grapheme clusters. Identifiers + /// containing characters outside the Basic Multilingual Plane (e.g. emoji) + /// consume two code units per character and reach the cap sooner than + /// ASCII-only identifiers of the same visible length. static void validateRunId(String? runId) { requireNonEmpty(runId, 'runId'); - + // Run IDs are typically UUIDs or similar identifiers if (runId!.length > 100) { throw ValidationError( - 'Run ID too long (max 100 characters)', + 'Run ID too long (max 100 UTF-16 code units)', field: 'runId', constraint: 'max-length-100', value: runId, @@ -101,13 +183,16 @@ class Validators { } } - /// Validates a thread ID format + /// Validates a thread ID format. + /// + /// The 100-unit cap is measured in UTF-16 code units (Dart's [String.length]). + /// See [validateRunId] for the full rationale. static void validateThreadId(String? threadId) { requireNonEmpty(threadId, 'threadId'); - + if (threadId!.length > 100) { throw ValidationError( - 'Thread ID too long (max 100 characters)', + 'Thread ID too long (max 100 UTF-16 code units)', field: 'threadId', constraint: 'max-length-100', value: threadId, @@ -115,8 +200,24 @@ class Validators { } } - /// Validates message content - static void validateMessageContent(dynamic content) { + /// Validates message content shape. + /// + /// Canonical contract: TS `BaseMessageSchema.content: z.string().optional()` + /// and Python `BaseMessage.content: Optional[str]`. The multimodal + /// `UserMessage.content: Union[str, List[InputContent]]` variant is not + /// yet supported in this Dart SDK (see CHANGELOG → "Known parity + /// gaps"). Until it is, this validator only accepts `String` — the + /// pre-0.2.0 permissive Map/List branches were dead code (no caller in + /// the SDK passes those types) and would have silently accepted a + /// malformed payload if anyone ever adopted them. + /// + /// **Defense-in-depth note.** The null rejection here is a last line of + /// defense for raw-input callers. Every protocol-correct call site in the + /// SDK already guards null before reaching this method (the canonical + /// `content` field is `Optional[str]` and is only forwarded to callers + /// that need a non-null value). If null is somehow passed, this surfaces + /// the bug early rather than producing a silent empty-string or NPE. + static void validateMessageContent(String? content) { if (content == null) { throw ValidationError( 'Message content cannot be null', @@ -125,22 +226,20 @@ class Validators { value: content, ); } - - // Content should be either a string or a structured object - if (content is! String && content is! Map && content is! List) { - throw ValidationError( - 'Message content must be a string, map, or list', - field: 'content', - constraint: 'valid-type', - value: content, - ); - } } + /// Maximum allowed value for any [Duration] passed through + /// [validateTimeout]. Conservative for an agent SDK where long-running + /// tool sequences and human-in-the-loop steps can sometimes legitimately + /// approach this cap; bumping is a behavior change deferred to a future + /// release. Exposed so callers can inspect the limit (e.g. to warn the + /// user before submitting a request that will be rejected). + static const Duration maxTimeout = Duration(minutes: 10); + /// Validates timeout duration static void validateTimeout(Duration? timeout) { if (timeout == null) return; - + if (timeout.isNegative) { throw ValidationError( 'Timeout cannot be negative', @@ -149,14 +248,12 @@ class Validators { value: timeout.toString(), ); } - - // Max timeout of 10 minutes - const maxTimeout = Duration(minutes: 10); + if (timeout > maxTimeout) { throw ValidationError( - 'Timeout exceeds maximum of 10 minutes', + 'Timeout exceeds maximum of ${maxTimeout.inMinutes} minutes', field: 'timeout', - constraint: 'max-10-minutes', + constraint: 'max-${maxTimeout.inMinutes}-minutes', value: timeout.toString(), ); } @@ -199,12 +296,17 @@ class Validators { return json; } - /// Validates event type + /// Validates that an event type string matches UPPER_SNAKE_CASE format. + /// + /// This is a format-only check. Format conformance does not imply that the + /// SDK can dispatch the type — [EventType.fromString] and the exhaustive + /// switch in [BaseEvent.fromJson] / [EventDecoder.validate] are the actual + /// authority for recognized types. Adding a new event type requires a + /// coordinated enum addition regardless of whether this regex accepts it. static void validateEventType(String? eventType) { requireNonEmpty(eventType, 'eventType'); - - // Event types should follow the naming convention - final pattern = RegExp(r'^[A-Z][A-Z_]*$'); + + final pattern = RegExp(r'^[A-Z][A-Z0-9_]*$'); if (!pattern.hasMatch(eventType!)) { throw ValidationError( 'Invalid event type format (should be UPPER_SNAKE_CASE)', @@ -259,7 +361,17 @@ class Validators { } } - /// Validates protocol compliance for event sequences + /// Validates protocol compliance for event sequences. + /// + /// **Note:** This method was never wired up in the SDK client path and is + /// not called from any production code in `lib/`. The SDK does not enforce + /// sequence rules client-side. This method is retained for consumers who + /// want to validate sequences in their own code, but may be removed in + /// a future major version. + @Deprecated( + 'Not enforced by the SDK client-side. ' + 'May be removed in a future major release.', + ) static void validateEventSequence(String currentEvent, String? previousEvent, String? state) { // RUN_STARTED must be first or after RUN_FINISHED if (currentEvent == 'RUN_STARTED') { diff --git a/sdks/community/dart/lib/src/encoder/client_codec.dart b/sdks/community/dart/lib/src/encoder/client_codec.dart index 10f86b88a3..c7164d6e2c 100644 --- a/sdks/community/dart/lib/src/encoder/client_codec.dart +++ b/sdks/community/dart/lib/src/encoder/client_codec.dart @@ -19,8 +19,8 @@ class Encoder { return message.toJson(); } - /// Encode ToolResult to JSON - Map encodeToolResult(ToolResult result) { + /// Encode ClientToolResult to JSON + Map encodeToolResult(ClientToolResult result) { return { 'toolCallId': result.toolCallId, 'result': result.result, @@ -35,14 +35,18 @@ class Decoder { const Decoder(); } -/// ToolResult model for submitting tool execution results -class ToolResult { +/// ToolResult model for submitting tool execution results to the server. +/// +/// Named [ClientToolResult] to distinguish it from [types/tool.dart:ToolResult], +/// which models results received FROM the server (`content: String`). This +/// class is for the outbound direction (`result: dynamic`, `metadata`). +class ClientToolResult { final String toolCallId; final dynamic result; final String? error; final Map? metadata; - const ToolResult({ + const ClientToolResult({ required this.toolCallId, required this.result, this.error, diff --git a/sdks/community/dart/lib/src/encoder/decoder.dart b/sdks/community/dart/lib/src/encoder/decoder.dart index 19b8fd387a..822941394a 100644 --- a/sdks/community/dart/lib/src/encoder/decoder.dart +++ b/sdks/community/dart/lib/src/encoder/decoder.dart @@ -10,6 +10,12 @@ import '../client/errors.dart'; import '../client/validators.dart'; import '../events/events.dart'; import '../types/base.dart'; +// `encoder/errors.dart` defines its own `ValidationError`, distinct from +// the `client/errors.dart` one. Hide it on import so the `on ValidationError` +// clauses below unambiguously resolve to the client-side class that +// `Validators.requireNonEmpty` actually throws — see lib/ag_ui.dart:52 +// for the parallel public-export disambiguation. +import 'errors.dart' hide ValidationError; /// Decoder for AG-UI events. /// @@ -24,8 +30,25 @@ class EventDecoder { /// This method expects a JSON string without the SSE "data: " prefix. BaseEvent decode(String data) { try { - final json = jsonDecode(data) as Map; - return decodeJson(json); + final decoded = jsonDecode(data); + // Validate the top-level shape explicitly so a list/primitive + // payload (`[1,2,3]`, `"hello"`, `42`) produces a structured + // [DecodingError] instead of a `TypeError` swallowed by the + // catch-all below — which was being wrapped as a generic "Failed + // to decode event" with no hint about the actual mismatch. + if (decoded is! Map) { + throw DecodingError( + 'Expected JSON object at top level', + field: 'data', + expectedType: 'Map', + // Surface the runtime type (e.g. `List`, `String`, + // `int`) rather than the raw value so debug logs read + // "actual: List" instead of dumping the whole + // payload — much more useful when the payload is large. + actualValue: decoded.runtimeType.toString(), + ); + } + return decodeJson(decoded); } on FormatException catch (e) { throw DecodingError( 'Invalid JSON format', @@ -34,8 +57,26 @@ class EventDecoder { actualValue: data, cause: e, ); + } on ValidationError catch (e, stack) { + // Mirror `decodeJson`'s clauses so a factory-side validation error + // raised before `decodeJson` ever runs (e.g. via a future inline + // pre-check) still surfaces as a structured `DecodingError` with + // the originating field preserved, instead of falling to the + // catch-all and getting flattened to `field: 'event'`. + // `Error.throwWithStackTrace` preserves the original stack so the + // debug trace points at the failing field, not the wrapper. + return _wrapValidation(e, e.field, {'data': data}, stack); + } on AGUIValidationError catch (e, stack) { + return _wrapValidation(e, e.field, {'data': data}, stack); } on AgUiError { rethrow; + } on EncoderError { + // Encoder-side family (`EncoderError`, `DecodeError`, `EncodeError`, + // and `encoder/errors.dart`'s `ValidationError`) extends `AGUIError` + // but NOT `AgUiError`, so without this clause it would fall through + // to the catch-all and get re-wrapped as a generic decode failure. + // Rethrow so callers can pattern-match on the original encoder type. + rethrow; } catch (e) { throw DecodingError( 'Failed to decode event', @@ -50,17 +91,45 @@ class EventDecoder { /// Decodes an event from a JSON map. BaseEvent decodeJson(Map json) { try { - // Validate required fields - Validators.requireNonEmpty(json['type'] as String?, 'type'); - + // `BaseEvent.fromJson` already enforces presence and string-type + // for the `type` discriminator via `JsonDecoder.requireField`, + // and `validate()` below enforces non-empty on identifier strings. + // No standalone pre-check needed — keeping one collapsed the + // `type: 123` (wrong-typed) path into a single `AGUIValidationError` + // wrapped uniformly into [DecodingError] by the handlers below. final event = BaseEvent.fromJson(json); - + // Validate the created event validate(event); - + return event; + } on ValidationError catch (e, stack) { + // Wire-boundary contract documented on `AGUIValidationError` + // (lib/src/types/base.dart): both `AGUIValidationError` (from + // `fromJson` factories) and `ValidationError` (from `validate()` + // via `Validators.requireNonEmpty`) surface to consumers as + // `DecodingError` so callers only need to catch one error type at + // the decode boundary. This `on` clause covers the + // `AgUiError`-extending sibling so it does not bypass the wrapping + // via the `on AgUiError` rethrow. + // `Error.throwWithStackTrace` preserves the original stack so the + // debug trace points at the failing field, not the wrapper. + return _wrapValidation(e, e.field, json, stack); + } on AGUIValidationError catch (e, stack) { + // Companion clause for the factory-side error. Without this branch, + // `AGUIValidationError` (which only `implements Exception`, not + // `AgUiError`) falls through to the catch-all below and the + // original failing field — `role`, `messageId`, `subtype`, etc. — + // is flattened to `field: 'json'`, breaking the public decoder + // error surface. + return _wrapValidation(e, e.field, json, stack); } on AgUiError { rethrow; + } on EncoderError { + // See the matching clause in `decode()` above — encoder-side + // errors extend `AGUIError` (not `AgUiError`), so we rethrow them + // unchanged rather than re-wrapping as a generic decode failure. + rethrow; } catch (e) { throw DecodingError( 'Failed to create event from JSON', @@ -72,14 +141,43 @@ class EventDecoder { } } - /// Decodes an SSE message. + /// Decodes a complete SSE message string. + /// + /// Expects a complete SSE frame (one logical message, from the first line + /// through the terminating blank line) with a `data:` prefix. Uses + /// [LineSplitter] so `\n`, `\r`, and `\r\n` terminators are all handled per + /// the WHATWG SSE spec — a trailing `\r` from a CRLF-encoded payload no + /// longer leaks into the joined `data` value. /// - /// Expects a complete SSE message with "data: " prefix and double newlines. + /// **Semantic divergence from `EventStreamAdapter.fromRawSseStream`:** + /// - This method receives a COMPLETE frame and throws [DecodingError] for + /// keep-alive frames (comment-only lines or `data: :`) and for frames + /// with no `data:` lines at all (see "No data found"). + /// - `fromRawSseStream` buffers streaming chunks, accumulates `data:` lines + /// across chunk boundaries, and silently discards keep-alives (it never + /// calls `decodeSSE` — it invokes `decode` directly after accumulation). + /// Use this method when you have a pre-assembled SSE frame; use + /// `fromRawSseStream` for raw streaming bytes. BaseEvent decodeSSE(String sseMessage) { - // Extract data from SSE format - final lines = sseMessage.split('\n'); + // Reject keep-alive / comment-only frames before any `data:` collection. + // A frame that is entirely `:`-prefixed comment lines (with optional + // blank lines) carries no payload and must surface as a structured + // keep-alive error rather than the misleading "No data found" path + // that the previous `dataLines.isEmpty`-first ordering produced. + final lines = const LineSplitter().convert(sseMessage); + final hasOnlyComments = lines.every( + (line) => line.isEmpty || line.startsWith(':'), + ); + if (hasOnlyComments && lines.any((line) => line.startsWith(':'))) { + throw DecodingError( + 'SSE keep-alive comment, not an event', + field: 'data', + expectedType: 'JSON event data', + actualValue: sseMessage, + ); + } + final dataLines = []; - for (final line in lines) { if (line.startsWith('data: ')) { dataLines.add(line.substring(6)); // Remove "data: " prefix @@ -87,7 +185,14 @@ class EventDecoder { dataLines.add(line.substring(5)); // Remove "data:" prefix } } - + + // A frame whose lines are ALL empty (no comment, no data prefix) falls + // here. This can happen with a bare double-newline `\n\n` that acts as an + // SSE message boundary with no payload — the WHATWG spec says to dispatch + // the event but if there's nothing to decode, "No data found" is the + // correct outcome. Treat as a non-event rather than a keep-alive because + // there is no `:` comment marker to distinguish it; callers that care + // about empty-frame detection should observe the DecodingError. if (dataLines.isEmpty) { throw DecodingError( 'No data found in SSE message', @@ -96,11 +201,16 @@ class EventDecoder { actualValue: sseMessage, ); } - - // Join all data lines (for multi-line data) + + // Join all data lines (for multi-line data) with `\n`, per spec. final data = dataLines.join('\n'); - - // Handle special SSE comment for keep-alive + + // Legacy compatibility: a single `data: :` line (with the field value + // being the bare colon character) is treated as a keep-alive + // sentinel by some servers. Surface it as a structured keep-alive + // error rather than letting `jsonDecode(':')` raise a generic + // FormatException. Spec-compliant keep-alives are top-level `:`-only + // lines, which are caught earlier in [hasOnlyComments]. if (data.trim() == ':') { throw DecodingError( 'SSE keep-alive comment, not an event', @@ -109,13 +219,19 @@ class EventDecoder { actualValue: data, ); } - + return decode(data); } /// Decodes an event from binary data. /// - /// Currently assumes the binary data is UTF-8 encoded SSE. + /// Currently assumes the binary data is UTF-8 encoded SSE/JSON. + /// Protobuf is NOT yet supported — a server emitting actual protobuf bytes + /// will raise [DecodingError] with message "Invalid UTF-8 data" rather than + /// a descriptive "protobuf not implemented" error. Negotiate + /// `acceptsProtobuf=false` (i.e. use SSE transport) until protobuf support + /// lands end-to-end in both encoder and decoder. + /// /// TODO: Add protobuf support when proto definitions are available. BaseEvent decodeBinary(Uint8List data) { try { @@ -141,31 +257,209 @@ class EventDecoder { /// Validates that an event has all required fields. /// + /// Defensive re-check on top of `fromJson`: catches empty-string values + /// (which `JsonDecoder.requireField` permits), and any event + /// constructed outside `fromJson` (e.g. via a `copyWith` that violates + /// the non-empty contract). The asymmetry is intentional — `fromJson` + /// only enforces presence and type; `validate()` is the single source of + /// truth for non-empty constraints on string identifiers. + /// + /// **Error class note.** `validate()` raises [ValidationError] + /// (`lib/src/client/errors.dart`, extends `AgUiError`). The eager + /// `fromJson`-side rejections (e.g. unknown role, unknown subtype) + /// raise [AGUIValidationError] (`lib/src/types/base.dart`, extends + /// `AGUIError` directly). Through the public [decode] / [decodeJson] + /// boundary both surface uniformly as [DecodingError], so the + /// asymmetry is only visible to direct callers of [validate] vs. + /// direct callers of `fromJson`. A consumer that wants to catch both + /// without distinguishing class can `on AGUIError catch (e)` — + /// `ValidationError` and `AGUIValidationError` both extend it. + /// /// Returns true if valid, throws [ValidationError] if not. bool validate(BaseEvent event) { // Basic validation - ensure type is set Validators.validateEventType(event.type); - // Type-specific validation + // Type-specific validation. Listing every sealed subtype explicitly + // (no `default`) makes the analyzer flag any new event type that is + // added without a corresponding decision here. The `exhaustive_cases` + // lint in `analysis_options.yaml` enforces this at analysis time — + // without it a new sealed subtype would silently pass `validate`. + // When you add a case here, also update `BaseEvent.fromJson` in + // `lib/src/events/events.dart` so the discriminator-dispatch switch + // and this validator remain in sync. switch (event) { case TextMessageStartEvent(): Validators.requireNonEmpty(event.messageId, 'messageId'); case TextMessageContentEvent(): Validators.requireNonEmpty(event.messageId, 'messageId'); - Validators.requireNonEmpty(event.delta, 'delta'); - case ThinkingContentEvent(): - Validators.requireNonEmpty(event.delta, 'delta'); + // `delta` may be empty per canonical TS/Python schemas + // (`TextMessageContentEventSchema.delta: z.string()` / + // pydantic `delta: str`). Do not enforce non-empty here. + case TextMessageEndEvent(): + Validators.requireNonEmpty(event.messageId, 'messageId'); + case TextMessageChunkEvent(): + break; + // TODO(1.0.0): Remove the following deprecated cases + their event classes: + // ThinkingTextMessageStartEvent, ThinkingTextMessageContentEvent, + // ThinkingTextMessageEndEvent, ThinkingContentEvent. + // Also remove EventType.thinkingTextMessage* / thinkingContent enum + // values, the _kThinkingTextMessage*Deprecation / _kThinkingContent* + // Deprecation constants, and the deprecated TimeoutError typedef in + // client/errors.dart. + // ignore: deprecated_member_use_from_same_package + case ThinkingTextMessageStartEvent(): + // Deprecated; no `messageId` on the wire by design — matches the + // canonical TS `THINKING_TEXT_MESSAGE_START` shape this event + // mirrors. The migration target [ReasoningMessageStartEvent] + // adds `messageId` per canonical `REASONING_MESSAGE_START`. Do + // NOT add validation here at 1.0.0 removal — that would tighten + // the deprecated contract retroactively and break consumers + // still on the old wire shape. + break; + // ignore: deprecated_member_use_from_same_package + case ThinkingTextMessageContentEvent(): + // Empty `delta` is accepted — relaxed to match the canonical + // `z.string()` / `delta: str` contract (parity with + // `TextMessageContentEvent`, `ReasoningMessageContentEvent`, etc.). + break; + // ignore: deprecated_member_use_from_same_package + case ThinkingTextMessageEndEvent(): + // Same rationale as `ThinkingTextMessageStartEvent` above: no + // `messageId` on the wire by design; the migration target + // [ReasoningMessageEndEvent] adds it. + break; case ToolCallStartEvent(): Validators.requireNonEmpty(event.toolCallId, 'toolCallId'); Validators.requireNonEmpty(event.toolCallName, 'toolCallName'); + case ToolCallArgsEvent(): + Validators.requireNonEmpty(event.toolCallId, 'toolCallId'); + // `delta` may be empty per canonical TS/Python schemas + // (`ToolCallArgsEventSchema.delta: z.string()` / pydantic + // `delta: str`). Do not enforce non-empty here. + case ToolCallEndEvent(): + Validators.requireNonEmpty(event.toolCallId, 'toolCallId'); + case ToolCallChunkEvent(): + break; + case ToolCallResultEvent(): + Validators.requireNonEmpty(event.messageId, 'messageId'); + Validators.requireNonEmpty(event.toolCallId, 'toolCallId'); + // `content` may be empty per canonical TS/Python schemas + // (`ToolCallResultEventSchema.content: z.string()` / pydantic + // `content: str`). Do not enforce non-empty here. + case ThinkingStartEvent(): + break; + // ignore: deprecated_member_use_from_same_package + case ThinkingContentEvent(): + // Empty `delta` is accepted — relaxed to match canonical contract. + break; + case ThinkingEndEvent(): + break; + case StateSnapshotEvent(): + // `snapshot` is an opaque JSON value — presence is enforced in + // `StateSnapshotEvent.fromJson`; there is no non-empty constraint + // we can express on `dynamic` content here. + break; + case StateDeltaEvent(): + break; + case MessagesSnapshotEvent(): + break; + case ActivitySnapshotEvent(): + Validators.requireNonEmpty(event.messageId, 'messageId'); + Validators.requireNonEmpty(event.activityType, 'activityType'); + case ActivityDeltaEvent(): + // `patch` is allowed to be empty per canonical TS/Python + // (`z.array(JsonPatchOperationSchema).min(0)` / list with no + // length floor). This matches `StateDeltaEvent` which similarly + // does not enforce non-empty on its patch list. Do not add + // `requireNonEmpty(...patch...)` here without a corresponding + // schema change in the canonical SDKs. + Validators.requireNonEmpty(event.messageId, 'messageId'); + Validators.requireNonEmpty(event.activityType, 'activityType'); + case RawEvent(): + // `event` payload presence is enforced in `RawEvent.fromJson`. + break; + case CustomEvent(): + Validators.requireNonEmpty(event.name, 'name'); case RunStartedEvent(): Validators.validateThreadId(event.threadId); Validators.validateRunId(event.runId); - default: - // No specific validation for other event types + case RunFinishedEvent(): + Validators.validateThreadId(event.threadId); + Validators.validateRunId(event.runId); + case RunErrorEvent(): + Validators.requireNonEmpty(event.message, 'message'); + case StepStartedEvent(): + Validators.requireNonEmpty(event.stepName, 'stepName'); + case StepFinishedEvent(): + Validators.requireNonEmpty(event.stepName, 'stepName'); + case ReasoningStartEvent(): + Validators.requireNonEmpty(event.messageId, 'messageId'); + case ReasoningMessageStartEvent(): + Validators.requireNonEmpty(event.messageId, 'messageId'); + case ReasoningMessageContentEvent(): + Validators.requireNonEmpty(event.messageId, 'messageId'); + // `delta` may be empty per canonical TS/Python schemas + // (`ReasoningMessageContentEventSchema.delta: z.string()` / + // pydantic `delta: str`). Do not enforce non-empty here. + case ReasoningMessageEndEvent(): + Validators.requireNonEmpty(event.messageId, 'messageId'); + case ReasoningMessageChunkEvent(): + break; + case ReasoningEndEvent(): + Validators.requireNonEmpty(event.messageId, 'messageId'); + case ReasoningEncryptedValueEvent(): + // `subtype` is enum-typed and constructor-required, so it cannot + // be null/invalid here. If the enum ever gains an `unknown` + // member (currently `fromString` throws — see the dartdoc on + // `ReasoningEncryptedValueSubtype.fromString`), this case is the + // place to reject it. + // TODO: revisit if `ReasoningEncryptedValueSubtype` gains an + // `unknown` member — at that point the comment above goes + // stale and this case must explicitly reject the unknown + // subtype to preserve the "no graceful default for cipher + // payloads" contract. + // + // `entityId` and `encryptedValue` are accepted as plain strings + // (including empty) to match canonical TS `z.string()` and + // Python `str` schemas — neither imposes a minimum length. + // + // **Operational risk of empty `entityId`.** An empty `entityId` + // will pass validation here but the referenced entity cannot be + // located by consumers. This matches the canonical SDK behavior + // (no min-length constraint). If your deployment routes these + // events to a decryption service that fails on empty entityId, + // add a length check at the consumer or via a proxy validator. break; } - + return true; } + + /// Wraps a factory-side or validate-side validation failure into the + /// public [DecodingError] envelope, preserving the original failing + /// field name so consumers can react to specific field violations + /// instead of getting a flattened `field: 'json'` everywhere. + /// + /// Returns [Never] so the analyzer verifies that all call sites are + /// unconditionally throwing — callers pass `stack` instead of wrapping + /// in `Error.throwWithStackTrace(...)` themselves, which keeps the + /// original stack trace intact. + Never _wrapValidation( + Object cause, + String? field, + Map json, + StackTrace stack, + ) { + Error.throwWithStackTrace( + DecodingError( + 'Failed to create event from JSON', + field: field ?? 'json', + expectedType: 'BaseEvent', + actualValue: json, + cause: cause, + ), + stack, + ); + } } \ No newline at end of file diff --git a/sdks/community/dart/lib/src/encoder/encoder.dart b/sdks/community/dart/lib/src/encoder/encoder.dart index cc2b5b054b..206c8c1695 100644 --- a/sdks/community/dart/lib/src/encoder/encoder.dart +++ b/sdks/community/dart/lib/src/encoder/encoder.dart @@ -7,6 +7,7 @@ import 'dart:convert'; import 'dart:typed_data'; import '../events/events.dart'; +import 'errors.dart'; /// The AG-UI protobuf media type constant. const String aguiMediaType = 'application/vnd.ag-ui.event+proto'; @@ -17,6 +18,13 @@ const String aguiMediaType = 'application/vnd.ag-ui.event+proto'; /// and binary format (protobuf or SSE as bytes). class EventEncoder { /// Whether this encoder accepts protobuf format. + /// + /// **Important:** Setting this to `true` (via an `Accept: + /// application/vnd.ag-ui.event+proto` header) makes [encodeBinary] fall + /// back to SSE-as-bytes, not real protobuf. [EventDecoder.decodeBinary] + /// similarly has NO protobuf support — a server emitting real protobuf bytes + /// will fail with a misleading "Invalid UTF-8 data" error. Do not negotiate + /// `acceptsProtobuf=true` until protobuf support is implemented end-to-end. final bool acceptsProtobuf; /// Creates an encoder with optional format preferences. @@ -48,9 +56,27 @@ class EventEncoder { /// ``` String encodeSSE(BaseEvent event) { final json = event.toJson(); - // Remove null values for cleaner output - json.removeWhere((key, value) => value == null); - final jsonString = jsonEncode(json); + // Do NOT strip null values: each `toJson()` already uses + // `if (field != null) 'field': field` for fields that should be omitted + // when null. Stripping here would silently drop fields that intentionally + // serialize as `null` (e.g. `ActivitySnapshotEvent.content`, + // `RawEvent.event`, `CustomEvent.value`, `StateSnapshotEvent.snapshot`) + // — their factories require the key to be present and reject + // missing-key with `AGUIValidationError`, so a null-strip pass would + // break the encode→decode round-trip. See + // `fixtures_integration_test.dart` "round-trip preserves explicit-null + // payload" for the regression guard. + final String jsonString; + try { + jsonString = jsonEncode(json); + } on JsonUnsupportedObjectError catch (e) { + throw EncodeError( + message: 'Event payload is not JSON-encodable: ' + '${event.runtimeType} contains a non-serializable value', + source: event, + cause: e, + ); + } return 'data: $jsonString\n\n'; } diff --git a/sdks/community/dart/lib/src/encoder/stream_adapter.dart b/sdks/community/dart/lib/src/encoder/stream_adapter.dart index f1621cb2cf..ea4abe30cb 100644 --- a/sdks/community/dart/lib/src/encoder/stream_adapter.dart +++ b/sdks/community/dart/lib/src/encoder/stream_adapter.dart @@ -4,9 +4,9 @@ library; import 'dart:async'; import '../client/errors.dart'; -import '../client/validators.dart'; import '../events/events.dart'; import '../sse/sse_message.dart'; +import '../types/base.dart'; import 'decoder.dart'; /// Adapter for converting streams of SSE messages to typed AG-UI events. @@ -18,19 +18,29 @@ import 'decoder.dart'; /// - Handle errors gracefully class EventStreamAdapter { final EventDecoder _decoder; - - /// Buffer for accumulating partial SSE data. - final StringBuffer _buffer = StringBuffer(); - - /// Buffer for accumulating data field values (without "data: " prefix). - final StringBuffer _dataBuffer = StringBuffer(); - - /// Whether we're currently in a multi-line data block. - bool _inDataBlock = false; + + /// Maximum number of UTF-16 code units accepted per SSE data block and + /// per raw-input buffer in [fromRawSseStream]. Matches [SseParser]'s + /// default of 8 MiB (8 × 1 048 576 code units) so both SSE paths enforce + /// the same bound. A misbehaving server that streams `data:` without a + /// blank-line terminator can otherwise grow [fromRawSseStream]'s internal + /// buffers without bound. + final int maxDataCodeUnits; /// Creates a new stream adapter with an optional custom decoder. - EventStreamAdapter({EventDecoder? decoder}) - : _decoder = decoder ?? const EventDecoder(); + /// + /// [maxDataCodeUnits] caps the in-memory SSE data buffer in + /// [fromRawSseStream]. Defaults to 8 MiB, matching [SseParser]. + /// + /// SSE line-buffering state for [fromRawSseStream] lives in locals scoped + /// to each invocation, not on the adapter instance. This means the same + /// adapter can safely process multiple streams sequentially or + /// concurrently — abnormal termination of one stream cannot leak partial + /// `data:` payloads or a stale `inDataBlock` flag into the next. + EventStreamAdapter({ + EventDecoder? decoder, + this.maxDataCodeUnits = 8 * 1024 * 1024, + }) : _decoder = decoder ?? const EventDecoder(); /// Adapts JSON data to AG-UI events. /// @@ -46,18 +56,42 @@ class EventStreamAdapter { // Array of events final events = []; for (var i = 0; i < jsonData.length; i++) { - if (jsonData[i] is Map) { - try { - events.add(_decoder.decodeJson(jsonData[i] as Map)); - } catch (e) { - throw DecodingError( - 'Failed to decode event at index $i', - field: 'jsonData[$i]', - expectedType: 'BaseEvent', - actualValue: jsonData[i], - cause: e, - ); + final element = jsonData[i]; + if (element is! Map) { + // Reject non-object elements explicitly so a list with a + // primitive or non-record entry produces a structured error + // naming the bad index, rather than silently skipping or + // throwing a `TypeError` swallowed by the catch-all below. + throw DecodingError( + 'Expected JSON object at index $i', + field: 'jsonData[$i]', + expectedType: 'Map', + actualValue: element, + ); + } + try { + events.add(_decoder.decodeJson(element)); + } catch (e) { + // Compose the inner field path so consumers driving on `.field` + // see 'jsonData[i].role' instead of the coarser 'jsonData[i]'. + final String? innerField; + if (e is DecodingError) { + innerField = e.field; + } else if (e is AGUIValidationError) { + innerField = e.field; + } else { + innerField = null; } + final composedField = innerField != null + ? 'jsonData[$i].$innerField' + : 'jsonData[$i]'; + throw DecodingError( + 'Failed to decode event at index $i', + field: composedField, + expectedType: 'BaseEvent', + actualValue: element, + cause: e, + ); } } return events; @@ -89,6 +123,16 @@ class EventStreamAdapter { /// - Parsing JSON to typed event objects /// - Filtering out non-data messages (comments, etc.) /// - Error handling with optional recovery + /// + /// When [skipInvalidEvents] is `true`, decode failures (malformed JSON, + /// unknown event types, validation errors) are routed to [onError] and + /// the stream continues. This includes silent loss of any + /// `REASONING_ENCRYPTED_VALUE` event whose `subtype` is unknown to this + /// SDK version: there is no sensible default for an encrypted-payload + /// subtype, so the event becomes a `DecodingError` and is dropped under + /// the flag. Most other enums (`ReasoningMessageRole`, `TextMessageRole`) + /// absorb unknown values at the event-decoding boundary instead. + /// Consumers that need to react to such drops should observe [onError]. Stream fromSseStream( Stream sseStream, { bool skipInvalidEvents = false, @@ -101,28 +145,33 @@ class EventStreamAdapter { // Only process data messages final data = message.data; if (data != null && data.isNotEmpty) { - // Skip keep-alive messages + // Keep-alive sentinels (data field whose trimmed value is `:`). + // Silently discard regardless of `skipInvalidEvents` — a + // keep-alive is not a protocol error; routing it through + // `onError` would cause consumers that log on `onError` to + // receive spurious noise on every server keep-alive ping. if (data.trim() == ':') { return; } - final event = _decoder.decode(data); - - // Validate event before adding to stream - if (_decoder.validate(event)) { - sink.add(event); - } + // `decode` already runs `validate` via `decodeJson`; no + // second pass needed here. + sink.add(_decoder.decode(data)); } // Ignore non-data messages (id, event, retry, comments) } catch (e, stack) { - final error = e is AgUiError ? e : DecodingError( + // Preserve any `AGUIError` subtype (covers `AgUiError`, + // `AGUIValidationError`, and `EncoderError` siblings) so the + // unified error-surface contract documented on `EventDecoder` + // is not undone by re-wrapping at the stream-adapter layer. + final error = e is AGUIError ? e : DecodingError( 'Failed to process SSE message', field: 'message', expectedType: 'BaseEvent', actualValue: message.data, cause: e, ); - + if (skipInvalidEvents) { // Log error but continue processing onError?.call(error, stack); @@ -149,171 +198,389 @@ class EventStreamAdapter { /// /// This handles partial messages that may be split across multiple /// stream events, buffering as needed. + /// + /// Line terminators: per the WHATWG SSE spec, `\r\n`, lone `\n`, and + /// lone `\r` are all valid. This implementation supports all three. + /// A trailing `\r` at the end of a chunk is deferred to the next chunk + /// to disambiguate from a chunk-spanning `\r\n`; on stream close the + /// deferred `\r` is consumed as a complete lone-CR terminator. + /// + /// **Semantic divergence from [EventDecoder.decodeSSE]:** + /// - `decodeSSE` receives a complete SSE message string and throws a + /// structured [DecodingError] for keep-alive frames (comment-only or + /// `data: :` payloads) and for frames with no `data:` lines. + /// - `fromRawSseStream` receives raw streaming chunks; keep-alives + /// (`data.trim() == ':'`) are silently discarded in [flushDataBlock] + /// and partial frames accumulate across chunks. The two methods share + /// the same final `decode` call but differ on keep-alive routing and + /// partial-frame handling. + /// + /// See [fromSseStream] for the [skipInvalidEvents] / [onError] + /// semantics, including the silent-drop note for + /// `REASONING_ENCRYPTED_VALUE` events with unknown subtypes. + /// + /// Edge case on abnormal termination: when the stream ends mid-line + /// (no trailing terminator) AND the partial line in the buffer is NOT + /// `data:`-prefixed (e.g. it is `event:`, `id:`, `retry:`, a `:`-comment, + /// or an in-progress continuation of a multi-line `data:` block), that + /// partial line is silently dropped. Steady-state SSE parsing already + /// ignores those lines per the spec; the drop only affects truly + /// abnormal close-without-newline cases. A trailing `data:`-prefixed + /// partial line, by contrast, is flushed and decoded. Stream fromRawSseStream( Stream rawStream, { bool skipInvalidEvents = false, void Function(Object error, StackTrace stackTrace)? onError, }) { + // `sync: true` means `controller.add(...)` calls downstream listeners + // synchronously on the same call stack. Re-entrancy contract: + // consumers MUST NOT call `subscription.cancel()` synchronously from + // inside a `listen` data handler — doing so cancels the underlying + // subscription while it is still being iterated and can cause a + // `ConcurrentModificationError` or double-close. If you need to + // cancel on a received event, schedule it via `Future.microtask`. + // + // Re-entrancy guard: if synchronous re-entry through controller.add + // is detected (e.g. a downstream data handler cancels the subscription + // during dispatch), flushDataBlock throws StateError before state is + // corrupted. Note this guard only covers the dispatch site inside + // flushDataBlock, not the buffer-mutation path. final controller = StreamController(sync: true); - - rawStream.listen( - (chunk) { + var inDispatch = false; + + // Per-invocation state. Keeping these local (not instance fields) + // ensures abnormal termination of one stream cannot leak partial + // `data:` payloads or a stale `inDataBlock` flag into a subsequent + // invocation on the same adapter. + final buffer = StringBuffer(); + final dataBuffer = StringBuffer(); + var inDataBlock = false; + // Tracks whether the last terminator seen across ALL prior chunks was a + // lone CR. Persisting this across processChunk calls lets _scanLines + // skip the trailing-\r deferral for producers that use lone-CR style + // and deliver each terminator in its own chunk — without persistence the + // flag resets to false on every call, adding a full chunk-RTT of latency + // per event. See Important #II2 (review-fix pass). + var lastWasLoneCr = false; + + // Append the value portion of a `data:` or `data: ` line to the + // active data block. Lines that aren't `data:`-prefixed are silently + // ignored per the WHATWG SSE spec (event:, id:, retry:, comments). + // Closes over `dataBuffer` and `inDataBlock` so the per-line loop + // and the `onDone` final flush share the same logic. + void appendDataLine(String line) { + String value; + if (line.startsWith('data: ')) { + value = line.substring(6); + } else if (line.startsWith('data:')) { + value = line.substring(5); + } else { + return; // Not a data line — ignore per spec. + } + // Size cap: mirrors SseParser._processField. The +1 is for the newline + // separator added between multi-line data blocks. + final addedLen = inDataBlock ? (1 + value.length) : value.length; + if (dataBuffer.length + addedLen > maxDataCodeUnits) { + // Clear state before throwing so partial data doesn't pollute the + // next frame. The thrown DecodingError is caught by processChunk's + // outer try/catch and routed via controller.addError. + dataBuffer.clear(); + inDataBlock = false; + throw DecodingError( + 'SSE data block exceeds $maxDataCodeUnits code units', + field: 'data', + expectedType: 'String', + ); + } + if (inDataBlock) { + // Multi-line data: add newline between lines per spec. + dataBuffer.write('\n'); + dataBuffer.write(value); + } else { + dataBuffer.clear(); + dataBuffer.write(value); + inDataBlock = true; + } + } + + // Flush the accumulated data block as a single decoded event. + // Used by the empty-line dispatch and the `onDone` final flush. + // Returns `true` if an error was routed to the controller so callers + // can suppress a redundant second `addError` from their own catch. + bool flushDataBlock() { + if (!inDataBlock) return false; + final data = dataBuffer.toString(); + dataBuffer.clear(); + inDataBlock = false; + + if (data.isEmpty || data.trim() == ':') return false; + + // Programmer-error guard sits outside the wire-error catch so a + // re-entrancy bug doesn't masquerade as a decoding failure. + if (inDispatch) { + throw StateError( + 'sync re-entrancy: cancel() must not be called synchronously ' + 'from inside a data handler; use Future.microtask. See ' + 'fromRawSseStream dartdoc for details.', + ); + } + + try { + // `decode` already runs `validate` via `decodeJson`; no + // second pass needed here. + inDispatch = true; try { - _processChunk(chunk, controller, skipInvalidEvents, onError); - } catch (e, stack) { - if (!skipInvalidEvents) { - controller.addError(e, stack); - } else { - onError?.call(e, stack); - } + controller.add(_decoder.decode(data)); + } finally { + inDispatch = false; } - }, - onError: (Object error, StackTrace stack) { + return false; + } catch (e, stack) { + // Preserve any `AGUIError` subtype (`AgUiError`, + // `AGUIValidationError`, `EncoderError`) so the unified + // error-surface contract from `EventDecoder` is not undone by + // re-wrapping here. Only foreign exceptions become a generic + // `DecodingError`. + final error = e is AGUIError + ? e + : DecodingError( + 'Failed to decode SSE data', + field: 'data', + expectedType: 'BaseEvent', + actualValue: data, + cause: e, + ); + if (!skipInvalidEvents) { controller.addError(error, stack); } else { onError?.call(error, stack); } - }, - onDone: () { - // Process any remaining incomplete line in buffer - final remaining = _buffer.toString(); - if (remaining.isNotEmpty) { - // Treat remaining content as a complete line - if (remaining.startsWith('data: ')) { - final value = remaining.substring(6); - if (_inDataBlock) { - _dataBuffer.write('\n'); - _dataBuffer.write(value); - } else { - _dataBuffer.clear(); - _dataBuffer.write(value); - _inDataBlock = true; - } - } else if (remaining.startsWith('data:')) { - final value = remaining.substring(5); - if (_inDataBlock) { - _dataBuffer.write('\n'); - _dataBuffer.write(value); - } else { - _dataBuffer.clear(); - _dataBuffer.write(value); - _inDataBlock = true; - } - } + return true; // error was already routed + } + } + + // Whether the current chunk's `flushDataBlock` call already routed an + // error so the outer `onListen` catch can skip a second `addError`. + var errorRoutedInChunk = false; + + void processChunk(String chunk) { + // Size cap on the raw line buffer. A server that sends a line without + // any newline would otherwise grow `buffer` without bound. + if (buffer.length + chunk.length > maxDataCodeUnits) { + buffer.clear(); + throw DecodingError( + 'SSE input line exceeds $maxDataCodeUnits code units', + field: 'chunk', + expectedType: 'String', + ); + } + // Add chunk to buffer to handle partial lines. + buffer.write(chunk); + + // Multi-terminator scan: see [_scanLines] for the spec rationale. + // `endOfStream: false` defers a trailing `\r` so a chunk-spanning + // `\r\n` doesn't double-fire as two empty lines. + // Pass `lastWasLoneCrAtStart` so the flag survives chunk boundaries + // and capture the updated value for the next call. + final scan = _scanLines( + buffer.toString(), + endOfStream: false, + lastWasLoneCrAtStart: lastWasLoneCr, + ); + lastWasLoneCr = scan.lastWasLoneCr; + buffer.clear(); + buffer.write(scan.unconsumed); + + for (final line in scan.lines) { + if (line.isEmpty) { + // Empty line signals end of SSE message — flush the data block. + // Reset per-frame (not per-chunk) so a later frame's flush error + // is not silently swallowed because an earlier frame in the same + // chunk already routed its own error and set this flag true. + errorRoutedInChunk = false; + if (flushDataBlock()) errorRoutedInChunk = true; + } else { + appendDataLine(line); } - - // Process any accumulated data - if (_inDataBlock && _dataBuffer.isNotEmpty) { - final data = _dataBuffer.toString(); + } + } + + // Defer the upstream subscription to `onListen` so a caller that + // obtains the returned stream but never subscribes does not leak the + // upstream connection. Without deferral, `rawStream.listen(...)` fires + // immediately on the `fromRawSseStream` call — a caller that stores the + // stream for later or abandons it would keep the upstream alive until the + // server closes the SSE connection. Mirroring the standard Dart lazy- + // subscription idiom also makes the backpressure propagation below + // consistent: `onCancel` only fires after `onListen`, so `subscription` + // is always initialized by the time any lifecycle callback runs. + StreamSubscription? subscription; + + controller.onListen = () { + subscription = rawStream.listen( + (chunk) { + errorRoutedInChunk = false; try { - final event = _decoder.decode(data); - controller.add(event); + processChunk(chunk); } catch (e, stack) { + // If `flushDataBlock` already routed an error to the controller + // (via `controller.addError`), skip a second `addError` here to + // avoid double-firing the same error at the stream consumer. + if (errorRoutedInChunk) return; + final error = e is AGUIError + ? e + : DecodingError( + 'Internal error processing SSE chunk', + field: 'chunk', + expectedType: 'String', + actualValue: chunk, + cause: e, + ); if (!skipInvalidEvents) { - controller.addError(e, stack); + controller.addError(error, stack); } else { - onError?.call(e, stack); + onError?.call(error, stack); } } - } - // Clear buffers - _buffer.clear(); - _dataBuffer.clear(); - _inDataBlock = false; - controller.close(); - }, - cancelOnError: false, - ); - + }, + onError: (Object error, StackTrace stack) { + if (!skipInvalidEvents) { + controller.addError(error, stack); + } else { + onError?.call(error, stack); + } + }, + onDone: () { + errorRoutedInChunk = false; // defensive reset; flag lifecycle ends at chunk handler + // End-of-stream: any deferred trailing `\r` is now a complete + // terminator. Run the scanner with `endOfStream: true` to + // consume it (and any other complete lines still in the buffer). + final scan = _scanLines(buffer.toString(), endOfStream: true); + buffer.clear(); + + for (final line in scan.lines) { + if (line.isEmpty) { + flushDataBlock(); + } else { + appendDataLine(line); + } + } + + // Any unconsumed suffix is a final partial line with no + // terminator. The pre-CRLF-fix code only handled `data:`-prefixed + // partials here; `appendDataLine` preserves that behavior because + // it ignores non-`data:` lines per spec. + if (scan.unconsumed.isNotEmpty) { + appendDataLine(scan.unconsumed); + } + + // Final flush — emits any leftover data block accumulated from + // either the deferred-line scan or the partial-line append above. + flushDataBlock(); + controller.close(); + }, + cancelOnError: false, + ); + }; + controller.onCancel = () async { + await subscription?.cancel(); + subscription = null; + }; + controller.onPause = () => subscription?.pause(); + controller.onResume = () => subscription?.resume(); + return controller.stream; } - /// Process a chunk of SSE data. - void _processChunk( - String chunk, - StreamController controller, - bool skipInvalidEvents, - void Function(Object error, StackTrace stackTrace)? onError, - ) { - // Add chunk to buffer to handle partial lines - _buffer.write(chunk); - - // Process complete lines only - String bufferStr = _buffer.toString(); + /// Scans [input] for complete lines, returning the complete lines and + /// the unconsumed suffix. Per the WHATWG SSE spec, line terminators + /// can be `\r\n`, lone `\n`, or lone `\r`. + /// + /// When [endOfStream] is `false`, a trailing `\r` at the end of the + /// buffer is left in the unconsumed suffix to disambiguate a + /// chunk-spanning `\r\n` (the next chunk could start with `\n`). + /// EXCEPTION: when the immediately preceding terminator in this scan + /// was also a lone `\r`, the producer is committed to lone-CR style and + /// the trailing `\r` is consumed immediately — without this exception + /// a single-chunk `data: foo\r\r` would defer the event-boundary `\r` + /// and stall steady-state lone-CR streams. CRLF producers cannot + /// trigger this exception because every `\r` is paired with `\n` + /// (so `lastWasLoneCr` never becomes `true` in the same scan). + /// + /// When [endOfStream] is `true`, the deferral is disabled entirely — + /// any trailing `\r` is consumed as a lone-CR terminator since no + /// further chunks are coming. + static ({List lines, String unconsumed, bool lastWasLoneCr}) _scanLines( + String input, { + required bool endOfStream, + bool lastWasLoneCrAtStart = false, + }) { final lines = []; - - // Extract complete lines (those ending with \n) - while (bufferStr.contains('\n')) { - final lineEnd = bufferStr.indexOf('\n'); - final line = bufferStr.substring(0, lineEnd); - lines.add(line); - bufferStr = bufferStr.substring(lineEnd + 1); + + // Edge case: when `lastWasLoneCrAtStart` is true, the previous scan + // consumed a lone-CR at its boundary immediately (because the exception + // that skips deferral for known-lone-CR producers applied). If the new + // chunk starts with `\n`, that `\n` is the second half of a + // chunk-spanning CRLF pair — skip it so the pair does not dispatch an + // extra empty-line boundary. + String s; + bool lastWasLoneCr; + if (lastWasLoneCrAtStart && + input.isNotEmpty && + input.codeUnitAt(0) == 0x0A /* \n */) { + s = input.substring(1); + lastWasLoneCr = false; // was actually CRLF, not lone-CR + } else { + s = input; + lastWasLoneCr = lastWasLoneCrAtStart; } - - // Keep any incomplete line in the buffer - _buffer.clear(); - _buffer.write(bufferStr); - - // Process each complete line - for (final line in lines) { - if (line.isEmpty) { - // Empty line signals end of SSE message - if (_inDataBlock) { - final data = _dataBuffer.toString(); - _dataBuffer.clear(); - _inDataBlock = false; - - if (data.isNotEmpty && data.trim() != ':') { - try { - final event = _decoder.decode(data); - if (_decoder.validate(event)) { - controller.add(event); - } - } catch (e, stack) { - final error = e is AgUiError ? e : DecodingError( - 'Failed to decode SSE data', - field: 'data', - expectedType: 'BaseEvent', - actualValue: data, - cause: e, - ); - - if (!skipInvalidEvents) { - controller.addError(error, stack); - } else { - onError?.call(error, stack); - } - } - } - } - } else if (line.startsWith('data: ')) { - // Extract data value (after "data: ") - final value = line.substring(6); - if (_inDataBlock) { - // Multi-line data: add newline between lines - _dataBuffer.write('\n'); - _dataBuffer.write(value); - } else { - // Start new data block - _dataBuffer.clear(); - _dataBuffer.write(value); - _inDataBlock = true; - } - } else if (line.startsWith('data:')) { - // Handle no space after colon - final value = line.substring(5); - if (_inDataBlock) { - _dataBuffer.write('\n'); - _dataBuffer.write(value); - } else { - _dataBuffer.clear(); - _dataBuffer.write(value); - _inDataBlock = true; + // Single-pass O(n) scan: advance index `i` forward rather than + // repeatedly calling indexOf + substring (which was O(n²) on inputs + // with many lines, since each iteration re-scanned the remaining string). + var i = 0; + while (i < s.length) { + // Scan forward for the next \r or \n terminator. + int brk = -1; + for (var j = i; j < s.length; j++) { + final c = s.codeUnitAt(j); + if (c == 0x0A /* \n */ || c == 0x0D /* \r */) { + brk = j; + break; } } - // Ignore other lines (comments, event:, id:, retry:, etc.) + if (brk == -1) break; // no more terminators in remaining input + + // Defer a trailing `\r` so a chunk-spanning `\r\n` doesn't appear + // as two terminators (lone `\r` then lone `\n`). Skip the deferral + // when the previous terminator was lone-CR — the producer is + // clearly using lone-CR style, so the trailing `\r` IS its own + // terminator. See class-level scan rationale above. + // + // NOTE on the "chunk ends exactly at \r" case (e.g. chunk = "foo\r"): + // This deferral fires and leaves `\r` in the unconsumed suffix. + // `lastWasLoneCrAtStart` is NOT involved here — that flag is only set + // when a PREVIOUS scan already consumed a lone-CR at its boundary + // (the producer was confirmed lone-CR style). In this path the `\r` + // is tentative: the next chunk may start with `\n` (making it CRLF) + // or not (making it lone-CR). The next scan will resolve it via the + // `lastWasLoneCrAtStart` edge-case check at the top of `_scanLines`. + if (!endOfStream && + !lastWasLoneCr && + s.codeUnitAt(brk) == 0x0D /* \r */ && + brk == s.length - 1) { + break; + } + + final isCrLf = s.codeUnitAt(brk) == 0x0D && + brk + 1 < s.length && + s.codeUnitAt(brk + 1) == 0x0A /* \n */; + lastWasLoneCr = + s.codeUnitAt(brk) == 0x0D /* \r */ && !isCrLf; + lines.add(s.substring(i, brk)); + i = brk + (isCrLf ? 2 : 1); } + return (lines: lines, unconsumed: s.substring(i), lastWasLoneCr: lastWasLoneCr); } /// Filters a stream of events to only include specific event types. @@ -327,90 +594,302 @@ class EventStreamAdapter { /// /// For example, groups TEXT_MESSAGE_START, TEXT_MESSAGE_CONTENT, /// and TEXT_MESSAGE_END events for the same messageId. + /// + /// **Unbounded-state warning.** Open groups (where `*Start` was + /// received but `*End` has not yet arrived) are held in memory until + /// the matching `*End` event arrives or the upstream stream + /// completes. A producer that opens IDs without closing them — for + /// instance, an interrupted upstream connection or a buggy server — + /// will grow the internal map indefinitely. Use [maxOpenGroups] to cap + /// the number of concurrently open groups; when the cap is reached the + /// oldest open group is evicted (emitted as-is) before the new one is + /// added. Set to 0 (the default) for no cap. The same caveat and option + /// apply to [accumulateTextMessages]. + /// + /// **Duplicate-start policy.** If a second `*Start` event arrives with + /// the same id while the prior group is still open, the prior group's + /// accumulated events are discarded silently and a new group begins + /// ("last-Start-wins"). This matches the behavior of the TS/Python + /// reference SDKs. Consumers that need strict sequencing should validate + /// the upstream event stream before passing it here. + /// + /// **On stream close:** any open groups (where a `*Start` was received + /// but `*End` has not yet arrived) are emitted in `*Start` arrival order. + /// Consumers should treat such groups as potentially incomplete — they + /// will be missing the terminal `*End` event and any final content that + /// never arrived. + /// + /// **Reasoning event asymmetry.** Only message-level + /// `REASONING_MESSAGE_START` / `REASONING_MESSAGE_CONTENT` / + /// `REASONING_MESSAGE_END` events are grouped (under the key + /// `reasoning:`). The phase-level `REASONING_START` / + /// `REASONING_END` events are emitted as standalone singletons — they + /// fall through to the `default` case. Consumers that need to associate + /// phase-level markers with the messages they wrap should track the phase + /// boundary in their own state, or subscribe to the typed event stream + /// directly. static Stream> groupRelatedEvents( - Stream eventStream, - ) { + Stream eventStream, { + int maxOpenGroups = 0, + }) { + // `sync: true` — see re-entrancy note on [fromRawSseStream]. final controller = StreamController>(sync: true); + // LinkedHashMap insertion order is relied upon by the onDone flush AND by + // the maxOpenGroups eviction (evicts oldest — first insertion-order entry). + // Do NOT replace with HashMap (unordered) or SplayTreeMap (sorted). final Map> activeGroups = {}; - - eventStream.listen( - (event) { - switch (event) { - case TextMessageStartEvent(:final messageId): - activeGroups[messageId] = [event]; - case TextMessageContentEvent(:final messageId): - activeGroups[messageId]?.add(event); - case TextMessageEndEvent(:final messageId): - final group = activeGroups.remove(messageId); - if (group != null) { - group.add(event); - controller.add(group); + StreamSubscription? subscription; + var inDispatch = false; + + // Defer subscription to `onListen` so that: + // • A caller that stores the stream but never subscribes does not + // leak the upstream listener. + // • Backpressure (pause/resume/cancel) propagates correctly to + // the upstream, matching the pattern used by `fromRawSseStream`. + controller.onListen = () { + subscription = eventStream.listen( + (event) { + // Route the re-entrancy StateError through controller.addError so + // the downstream consumer receives a structured error rather than + // an unhandled async exception. Mirrors fromRawSseStream's outer + // try/catch around processChunk. + try { + if (inDispatch) { + throw StateError( + 'sync re-entrancy: cancel() must not be called synchronously ' + 'from inside a groupRelatedEvents data handler; use ' + 'Future.microtask.', + ); + } + inDispatch = true; + try { + // Open a new group, evicting the oldest open group first if the + // maxOpenGroups cap is exceeded. Eviction emits the oldest group + // as-is (without a terminal *End event) — consumers should treat + // evicted groups the same as groups emitted on stream close. + void openGroup(String key, BaseEvent startEvent) { + if (maxOpenGroups > 0 && + activeGroups.length >= maxOpenGroups && + !activeGroups.containsKey(key)) { + final oldestKey = activeGroups.keys.first; + final evicted = activeGroups.remove(oldestKey)!; + if (evicted.isNotEmpty) controller.add(evicted); } - case ToolCallStartEvent(:final toolCallId): - activeGroups[toolCallId] = [event]; - case ToolCallArgsEvent(:final toolCallId): - activeGroups[toolCallId]?.add(event); - case ToolCallEndEvent(:final toolCallId): - final group = activeGroups.remove(toolCallId); - if (group != null) { - group.add(event); + activeGroups[key] = [startEvent]; + } + + switch (event) { + // Keys are namespaced by event family ('text:', 'reasoning:', + // 'tool:') so that a producer reusing the same id across families + // (e.g. a text message and a reasoning step sharing a messageId) + // does not overwrite one group with another. + case TextMessageStartEvent(:final messageId): + openGroup('text:$messageId', event); + case TextMessageContentEvent(:final messageId): + activeGroups['text:$messageId']?.add(event); + case TextMessageEndEvent(:final messageId): + final group = activeGroups.remove('text:$messageId'); + if (group != null) { + group.add(event); + controller.add(group); + } + case ToolCallStartEvent(:final toolCallId): + openGroup('tool:$toolCallId', event); + case ToolCallArgsEvent(:final toolCallId): + activeGroups['tool:$toolCallId']?.add(event); + case ToolCallEndEvent(:final toolCallId): + final group = activeGroups.remove('tool:$toolCallId'); + if (group != null) { + group.add(event); + controller.add(group); + } + case ReasoningMessageStartEvent(:final messageId): + openGroup('reasoning:$messageId', event); + case ReasoningMessageContentEvent(:final messageId): + activeGroups['reasoning:$messageId']?.add(event); + case ReasoningMessageEndEvent(:final messageId): + final group = activeGroups.remove('reasoning:$messageId'); + if (group != null) { + group.add(event); + controller.add(group); + } + case TextMessageChunkEvent(:final messageId): + // Fold into the open text group when one exists; otherwise emit + // standalone — chunks may arrive without a preceding *Start. + if (messageId != null && + activeGroups.containsKey('text:$messageId')) { + activeGroups['text:$messageId']!.add(event); + } else { + controller.add([event]); + } + case ToolCallChunkEvent(:final toolCallId): + // Fold into the open tool group when one exists; otherwise emit + // standalone — chunks may arrive without a preceding *Start. + if (toolCallId != null && + activeGroups.containsKey('tool:$toolCallId')) { + activeGroups['tool:$toolCallId']!.add(event); + } else { + controller.add([event]); + } + case ReasoningMessageChunkEvent(:final messageId): + // Fold into the open reasoning group when one exists; otherwise + // emit standalone — chunks may arrive without a preceding *Start. + if (messageId != null && + activeGroups.containsKey('reasoning:$messageId')) { + activeGroups['reasoning:$messageId']!.add(event); + } else { + controller.add([event]); + } + default: + // Single events not part of a group + controller.add([event]); + } + } finally { + inDispatch = false; + } + } catch (e, stack) { + controller.addError(e, stack); + } + }, + onError: controller.addError, + onDone: () { + // Emit any incomplete groups + for (final group in activeGroups.values) { + if (group.isNotEmpty) { controller.add(group); } - default: - // Single events not part of a group - controller.add([event]); - } - }, - onError: controller.addError, - onDone: () { - // Emit any incomplete groups - for (final group in activeGroups.values) { - if (group.isNotEmpty) { - controller.add(group); } - } - controller.close(); - }, - cancelOnError: false, - ); - + controller.close(); + }, + cancelOnError: false, + ); + }; + controller.onCancel = () async { + await subscription?.cancel(); + subscription = null; + }; + controller.onPause = () => subscription?.pause(); + controller.onResume = () => subscription?.resume(); + return controller.stream; } - /// Accumulates text message content into complete messages. + /// Accumulates user-visible text message content into complete messages. + /// + /// **Scope: user-visible text only.** Only `TEXT_MESSAGE_*` and + /// `TEXT_MESSAGE_CHUNK` events are handled. `REASONING_MESSAGE_*` events + /// (model-internal reasoning chains, not shown to the end user) are + /// intentionally excluded — consumers that need to accumulate reasoning + /// content should use [groupRelatedEvents] and filter by type, or write + /// a dedicated sibling accumulator. + /// + /// Emits one [String] per logical message when its `TextMessageEnd` event + /// arrives. **On stream close:** any accumulated-but-not-ended message + /// buffers are flushed in `*Start` arrival order as a final [String], + /// matching [groupRelatedEvents]' "emit incomplete groups on close" + /// behavior. Empty buffers are not emitted. Consumers cannot distinguish + /// between a normally-completed message and a flushed-on-close partial + /// without observing the absence of `TextMessageEnd` upstream. static Stream accumulateTextMessages( - Stream eventStream, - ) { - final controller = StreamController(); + Stream eventStream, { + int maxOpenGroups = 0, + }) { + // `sync: true` — see re-entrancy note on [fromRawSseStream]. + final controller = StreamController(sync: true); + // LinkedHashMap insertion order is relied upon by the onDone flush AND by + // the maxOpenGroups eviction (evicts oldest open message first). + // Do NOT replace with HashMap (unordered) or SplayTreeMap (sorted). final Map activeMessages = {}; - - eventStream.listen( - (event) { - switch (event) { - case TextMessageStartEvent(:final messageId): - activeMessages[messageId] = StringBuffer(); - case TextMessageContentEvent(:final messageId, :final delta): - activeMessages[messageId]?.write(delta); - case TextMessageEndEvent(:final messageId): - final buffer = activeMessages.remove(messageId); - if (buffer != null) { - controller.add(buffer.toString()); - } - case TextMessageChunkEvent(:final messageId, :final delta): - // Handle chunk events (single event with complete content) - if (messageId != null && delta != null) { - controller.add(delta); - } - default: - // Ignore other event types - break; - } - }, - onError: controller.addError, - onDone: controller.close, - cancelOnError: false, - ); - + StreamSubscription? subscription; + var inDispatch = false; + + // Defer subscription to `onListen` — mirrors `groupRelatedEvents` + // and `fromRawSseStream` so upstream leaks and backpressure issues + // are avoided. Uses `sync: true` to match the synchronous-emit + // contract of the other stream helpers in this class. + controller.onListen = () { + subscription = eventStream.listen( + (event) { + // Route the re-entrancy StateError through controller.addError. + // Mirrors the groupRelatedEvents and fromRawSseStream patterns. + try { + if (inDispatch) { + throw StateError( + 'sync re-entrancy: cancel() must not be called synchronously ' + 'from inside an accumulateTextMessages data handler; use ' + 'Future.microtask.', + ); + } + inDispatch = true; + try { + switch (event) { + case TextMessageStartEvent(:final messageId): + // Evict the oldest open message when the cap is reached. + if (maxOpenGroups > 0 && + activeMessages.length >= maxOpenGroups && + !activeMessages.containsKey(messageId)) { + final oldestKey = activeMessages.keys.first; + final evicted = activeMessages.remove(oldestKey)!; + final content = evicted.toString(); + if (content.isNotEmpty) controller.add(content); + } + activeMessages[messageId] = StringBuffer(); + case TextMessageContentEvent(:final messageId, :final delta): + activeMessages[messageId]?.write(delta); + case TextMessageEndEvent(:final messageId): + final buffer = activeMessages.remove(messageId); + if (buffer != null) { + controller.add(buffer.toString()); + } + case TextMessageChunkEvent(:final messageId, :final delta): + // A chunk is a standalone text fragment. If a Start/End cycle is + // open for the same messageId, route it into the active buffer — + // otherwise a standalone chunk would appear before the eventual + // End-triggered buffer flush (Start/Content events have not been + // emitted yet at that point). When messageId is null or no open + // buffer exists, emit the delta immediately. + if (delta == null) break; // genuinely nothing to emit + if (messageId != null) { + final activeBuffer = activeMessages[messageId]; + if (activeBuffer != null) { + activeBuffer.write(delta); + break; + } + } + controller.add(delta); // standalone fragment — emit even when messageId is null + default: + // Ignore other event types + break; + } + } finally { + inDispatch = false; + } + } catch (e, stack) { + controller.addError(e, stack); + } + }, + onError: controller.addError, + onDone: () { + // Emit accumulated content for messages that never received + // TextMessageEnd (e.g. abnormal stream close). Mirrors + // groupRelatedEvents which emits incomplete groups on close. + for (final entry in activeMessages.entries) { + final content = entry.value.toString(); + if (content.isNotEmpty) controller.add(content); + } + activeMessages.clear(); + controller.close(); + }, + cancelOnError: false, + ); + }; + controller.onCancel = () async { + await subscription?.cancel(); + subscription = null; + }; + controller.onPause = () => subscription?.pause(); + controller.onResume = () => subscription?.resume(); + return controller.stream; } } \ No newline at end of file diff --git a/sdks/community/dart/lib/src/events/event_type.dart b/sdks/community/dart/lib/src/events/event_type.dart index 2edb8e2072..3c5d292863 100644 --- a/sdks/community/dart/lib/src/events/event_type.dart +++ b/sdks/community/dart/lib/src/events/event_type.dart @@ -1,14 +1,42 @@ /// Event type enumeration for AG-UI protocol. library; +// Hoisted `@Deprecated` messages: each is referenced exactly once below, +// but the long form is repeated again in `events.dart` per event class. +// Centralizing lets the planned-removal version (1.0.0) get edited in one +// place per surface (enum value vs. event class) instead of drifting. +const String _kThinkingTextMessageStartEnumDeprecation = + 'Use reasoningMessageStart (ReasoningMessageStartEvent) instead. ' + 'Mirrors the canonical TypeScript SDK deprecation of ' + 'THINKING_TEXT_MESSAGE_* in favor of REASONING_*. ' + 'Scheduled for removal in 1.0.0.'; +const String _kThinkingTextMessageContentEnumDeprecation = + 'Use reasoningMessageContent (ReasoningMessageContentEvent) instead. ' + 'Mirrors the canonical TypeScript SDK deprecation of ' + 'THINKING_TEXT_MESSAGE_* in favor of REASONING_*. ' + 'Scheduled for removal in 1.0.0.'; +const String _kThinkingTextMessageEndEnumDeprecation = + 'Use reasoningMessageEnd (ReasoningMessageEndEvent) instead. ' + 'Mirrors the canonical TypeScript SDK deprecation of ' + 'THINKING_TEXT_MESSAGE_* in favor of REASONING_*. ' + 'Scheduled for removal in 1.0.0.'; +const String _kThinkingContentEnumDeprecation = + 'Dart-only legacy: never part of the canonical AG-UI protocol ' + '(TypeScript/Python). ' + 'Use reasoningMessageContent (ReasoningMessageContentEvent) instead. ' + 'Scheduled for removal in 1.0.0.'; + /// Enumeration of all AG-UI event types enum EventType { textMessageStart('TEXT_MESSAGE_START'), textMessageContent('TEXT_MESSAGE_CONTENT'), textMessageEnd('TEXT_MESSAGE_END'), textMessageChunk('TEXT_MESSAGE_CHUNK'), + @Deprecated(_kThinkingTextMessageStartEnumDeprecation) thinkingTextMessageStart('THINKING_TEXT_MESSAGE_START'), + @Deprecated(_kThinkingTextMessageContentEnumDeprecation) thinkingTextMessageContent('THINKING_TEXT_MESSAGE_CONTENT'), + @Deprecated(_kThinkingTextMessageEndEnumDeprecation) thinkingTextMessageEnd('THINKING_TEXT_MESSAGE_END'), toolCallStart('TOOL_CALL_START'), toolCallArgs('TOOL_CALL_ARGS'), @@ -16,26 +44,49 @@ enum EventType { toolCallChunk('TOOL_CALL_CHUNK'), toolCallResult('TOOL_CALL_RESULT'), thinkingStart('THINKING_START'), + @Deprecated(_kThinkingContentEnumDeprecation) thinkingContent('THINKING_CONTENT'), thinkingEnd('THINKING_END'), 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'); + 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'); final String value; const EventType(this.value); + static final Map _byValue = { + for (final t in EventType.values) t.value: t, + }; + + /// Parses [value] into an [EventType]. + /// + /// **Contract:** throws [ArgumentError] for unknown values. Do NOT change + /// this to throw any other exception type — `BaseEvent.fromJson` uses a + /// narrow `on ArgumentError` catch to distinguish unknown event types + /// (recoverable: wrap as `AGUIValidationError`) from genuine bugs in the + /// factory body (rethrow). Breaking this contract will silently swallow + /// factory errors or surface them as unknown-type errors. Wire decoding via + /// `BaseEvent.fromJson` ultimately surfaces `AGUIValidationError` as + /// `DecodingError`. Direct callers must catch [ArgumentError] if they want + /// to handle unknown event types gracefully — see + /// `dart-enum-parsing-safety.md` for the throw-vs-fallback rationale. static EventType fromString(String value) { - return EventType.values.firstWhere( - (type) => type.value == value, - orElse: () => throw ArgumentError('Invalid event type: $value'), - ); + return _byValue[value] ?? (throw ArgumentError('Invalid event type: $value')); } } \ No newline at end of file diff --git a/sdks/community/dart/lib/src/events/events.dart b/sdks/community/dart/lib/src/events/events.dart index 7562b6c39e..85e680ee31 100644 --- a/sdks/community/dart/lib/src/events/events.dart +++ b/sdks/community/dart/lib/src/events/events.dart @@ -14,6 +14,68 @@ import 'event_type.dart'; export 'event_type.dart'; +// `kUnsetSentinel` (from `base.dart`) is the shared sentinel for all +// `copyWith` methods in this file. With the default `?? this.field` pattern, +// a caller cannot distinguish "argument omitted" from "argument explicitly set +// to `null`". Comparing against `kUnsetSentinel` with `identical(...)` makes +// that distinction explicit. +// +// **`rawEvent` is intentionally sticky** — all `copyWith` methods use +// `rawEvent ?? this.rawEvent` rather than the sentinel pattern. Passing +// `null` for `rawEvent` keeps the existing value; to clear it, construct +// the event directly with `rawEvent: null`. This is a deliberate design: +// `ReasoningEncryptedValueEvent.fromJson` explicitly sets `rawEvent: null` +// to scrub cipher data, and the sentinel approach would inadvertently +// re-expose a prior non-null value when the caller omits the argument. +// See `BaseEvent.rawEvent` dartdoc for the full consumer note. +// +// Applied to every nullable payload field on the events whose `copyWith` +// callers may legitimately want to clear: +// `ActivitySnapshotEvent.content`, `RawEvent.event`, `CustomEvent.value`, +// `RunFinishedEvent.result`, `RunStartedEvent.parentRunId` / +// `RunStartedEvent.input`, the `name` field of `TextMessageStartEvent`, +// the optional fields of `TextMessageChunkEvent`, +// `ToolCallStartEvent.parentMessageId`, the optional fields of +// `ToolCallChunkEvent`, the optional fields of `ReasoningMessageChunkEvent`, +// `ThinkingStartEvent.title`, `ToolCallResultEvent.role`, +// `StateSnapshotEvent.snapshot`, and `RunErrorEvent.code`. + +/// Reads the `rawEvent` field from a wire payload, accepting both +/// `rawEvent` (TypeScript-canonical) and `raw_event` (Python-canonical). +/// `containsKey` precedence — a present `rawEvent` key wins even when its +/// value is explicitly `null`, matching the documented `requireEitherField` +/// rule for camelCase-vs-snake_case dual reads. Used by every event +/// factory in this library so a Python-emitted `raw_event` survives the +/// proxy round-trip. +dynamic _readRawEvent(Map json) => + json.containsKey('rawEvent') ? json['rawEvent'] : json['raw_event']; + +// Hoisted `@Deprecated` messages: each is repeated on the class +// declaration AND the constructor of the corresponding event type, so a +// constant lets the planned-removal version (1.0.0) and migration target +// get edited in one place per event class. Sibling enum-side messages +// live in `event_type.dart`; the surfaces are intentionally different +// (enum names vs. event class names). +// IMPORTANT: Do NOT add `// ignore_for_file: deprecated_member_use_from_same_package` +// to this file. The per-line `// ignore:` comments below are load-bearing: +// they enumerate every deprecated event type use so the 1.0.0 removal sweep +// knows exactly which lines to delete. A file-level suppression would silence +// the deprecation alarm and make the sweep invisible to the analyzer. +const String _kThinkingTextMessageStartEventDeprecation = + 'Use ReasoningMessageStartEvent instead. ' + 'Scheduled for removal in 1.0.0.'; +const String _kThinkingTextMessageContentEventDeprecation = + 'Use ReasoningMessageContentEvent instead. ' + 'Scheduled for removal in 1.0.0.'; +const String _kThinkingTextMessageEndEventDeprecation = + 'Use ReasoningMessageEndEvent instead. ' + 'Scheduled for removal in 1.0.0.'; +const String _kThinkingContentEventDeprecation = + 'Dart-only legacy: never part of the canonical AG-UI protocol ' + '(TypeScript/Python). ' + 'Use ReasoningMessageContentEvent instead. ' + 'Scheduled for removal in 1.0.0.'; + /// Base event for all AG-UI protocol events. /// /// All protocol events extend this class and are identified by their @@ -22,6 +84,23 @@ export 'event_type.dart'; sealed class BaseEvent extends AGUIModel with TypeDiscriminator { final EventType eventType; final int? timestamp; + + /// The original wire-format payload, preserved verbatim for proxy + /// scenarios. Typed `dynamic` because the protocol does not constrain + /// the shape (TS: `z.unknown()`, Python: `Any`). No validation is + /// performed; the raw value flows through unchanged via every factory + /// (which reads both `rawEvent` and `raw_event` via the private + /// `_readRawEvent` helper, with camelCase precedence) and is + /// re-emitted as-is from `toJson` when non-null. + /// + /// **Consumer note: round-trip emission.** Anything assigned to this + /// field WILL be serialized on the next `encode`. If you don't want + /// the upstream payload echoed downstream, set `rawEvent: null` on + /// the in-flight event before re-encoding by constructing a new event + /// directly with `rawEvent: null` — the `copyWith` methods do NOT clear + /// this field (they use `rawEvent ?? this.rawEvent`, so passing `null` + /// keeps the existing value). Wire output uses the camelCase key + /// `rawEvent` regardless of which spelling came in. final dynamic rawEvent; const BaseEvent({ @@ -33,10 +112,34 @@ sealed class BaseEvent extends AGUIModel with TypeDiscriminator { @override String get type => eventType.value; - /// Factory constructor to create specific event types from JSON + /// Factory constructor to create specific event types from JSON. + /// + /// When you add a case here, also update `EventDecoder.validate` in + /// `lib/src/encoder/decoder.dart` so the analyzer-enforced exhaustive + /// switch on the sealed `BaseEvent` hierarchy continues to compile. + /// + /// Throws [AGUIValidationError] for missing/wrong-typed `type` AND for + /// unknown event types — `EventType.fromString` raises a raw + /// `ArgumentError` for unknown values, and we wrap it here so direct + /// callers see the same error surface as every other validation failure. + /// (Through the [EventDecoder] pipeline, both surface as [DecodingError].) + /// + /// Note on equality: event subtypes are `final class` and do NOT + /// override `==`/`hashCode`. Use field-by-field assertions in tests + /// rather than `expect(a, equals(b))` on whole events. factory BaseEvent.fromJson(Map json) { final typeStr = JsonDecoder.requireField(json, 'type'); - final eventType = EventType.fromString(typeStr); + final EventType eventType; + try { + eventType = EventType.fromString(typeStr); + } on ArgumentError { + throw AGUIValidationError( + message: 'Unknown event type: $typeStr', + field: 'type', + value: typeStr, + json: json, + ); + } switch (eventType) { case EventType.textMessageStart: @@ -47,11 +150,24 @@ sealed class BaseEvent extends AGUIModel with TypeDiscriminator { return TextMessageEndEvent.fromJson(json); case EventType.textMessageChunk: return TextMessageChunkEvent.fromJson(json); + // TODO(1.0.0): Remove the following deprecated cases + their event classes: + // ThinkingTextMessageStartEvent, ThinkingTextMessageContentEvent, + // ThinkingTextMessageEndEvent, ThinkingContentEvent. + // Also remove EventType.thinkingTextMessage* / thinkingContent enum + // values, the _kThinkingTextMessage*Deprecation / _kThinkingContent* + // Deprecation constants, and the deprecated TimeoutError typedef in + // client/errors.dart. + // ignore: deprecated_member_use_from_same_package case EventType.thinkingTextMessageStart: + // ignore: deprecated_member_use_from_same_package return ThinkingTextMessageStartEvent.fromJson(json); + // ignore: deprecated_member_use_from_same_package case EventType.thinkingTextMessageContent: + // ignore: deprecated_member_use_from_same_package return ThinkingTextMessageContentEvent.fromJson(json); + // ignore: deprecated_member_use_from_same_package case EventType.thinkingTextMessageEnd: + // ignore: deprecated_member_use_from_same_package return ThinkingTextMessageEndEvent.fromJson(json); case EventType.toolCallStart: return ToolCallStartEvent.fromJson(json); @@ -65,7 +181,9 @@ sealed class BaseEvent extends AGUIModel with TypeDiscriminator { return ToolCallResultEvent.fromJson(json); case EventType.thinkingStart: return ThinkingStartEvent.fromJson(json); + // ignore: deprecated_member_use_from_same_package case EventType.thinkingContent: + // ignore: deprecated_member_use_from_same_package return ThinkingContentEvent.fromJson(json); case EventType.thinkingEnd: return ThinkingEndEvent.fromJson(json); @@ -75,6 +193,10 @@ sealed class BaseEvent extends AGUIModel with TypeDiscriminator { return StateDeltaEvent.fromJson(json); case EventType.messagesSnapshot: return MessagesSnapshotEvent.fromJson(json); + case EventType.activitySnapshot: + return ActivitySnapshotEvent.fromJson(json); + case EventType.activityDelta: + return ActivityDeltaEvent.fromJson(json); case EventType.raw: return RawEvent.fromJson(json); case EventType.custom: @@ -89,6 +211,24 @@ sealed class BaseEvent extends AGUIModel with TypeDiscriminator { return StepStartedEvent.fromJson(json); case EventType.stepFinished: return StepFinishedEvent.fromJson(json); + case EventType.reasoningStart: + return ReasoningStartEvent.fromJson(json); + case EventType.reasoningMessageStart: + return ReasoningMessageStartEvent.fromJson(json); + case EventType.reasoningMessageContent: + return ReasoningMessageContentEvent.fromJson(json); + case EventType.reasoningMessageEnd: + return ReasoningMessageEndEvent.fromJson(json); + case EventType.reasoningMessageChunk: + return ReasoningMessageChunkEvent.fromJson(json); + case EventType.reasoningEnd: + return ReasoningEndEvent.fromJson(json); + case EventType.reasoningEncryptedValue: + return ReasoningEncryptedValueEvent.fromJson(json); + // No `default` clause — exhaustive switch on the [EventType] enum + // (analyzer-enforced). A new EventType value will produce a compile + // error here AND in `EventDecoder.validate`, which is the desired + // outcome rather than a runtime fall-through. } } @@ -112,11 +252,22 @@ enum TextMessageRole { final String value; const TextMessageRole(this.value); + /// Parses [value] into a [TextMessageRole]. + /// + /// Throws [ArgumentError] for unknown values. Callers decoding from the + /// wire should use `TextMessageStartEvent.fromJson`, which absorbs the + /// throw and falls back to [TextMessageRole.assistant] so a future + /// server-side role does not tear down the SSE stream. This is the + /// same "throw at the enum, absorb at the factory" pattern used by + /// [ReasoningMessageRole] — see `dart-enum-parsing-safety.md` for the + /// consistency rationale. + static final Map _byValue = { + for (final r in TextMessageRole.values) r.value: r, + }; + static TextMessageRole fromString(String value) { - return TextMessageRole.values.firstWhere( - (role) => role.value == value, - orElse: () => TextMessageRole.assistant, - ); + return _byValue[value] ?? + (throw ArgumentError('Invalid text message role: $value')); } } @@ -128,22 +279,48 @@ enum TextMessageRole { final class TextMessageStartEvent extends BaseEvent { final String messageId; final TextMessageRole role; + final String? name; const TextMessageStartEvent({ required this.messageId, this.role = TextMessageRole.assistant, + this.name, super.timestamp, super.rawEvent, }) : super(eventType: EventType.textMessageStart); factory TextMessageStartEvent.fromJson(Map json) { + final messageId = JsonDecoder.requireEitherField( + json, + 'messageId', + 'message_id', + ); + final roleStr = JsonDecoder.optionalField(json, 'role'); + var role = TextMessageRole.assistant; + if (roleStr != null) { + try { + role = TextMessageRole.fromString(roleStr); + } on ArgumentError { + // Forward-compat: an unknown wire role falls back to + // `assistant` to keep the stream alive. + // + // We intentionally do NOT broaden to `catch (e)` or + // `on Exception`: a wrong-typed `role` raises + // `AGUIValidationError` from `optionalField` above, and + // a missing `messageId` raises `AGUIValidationError` from + // `requireEitherField` — those MUST propagate to the decoder + // boundary as protocol violations. Widening the catch would + // silently absorb them. Mirrors + // `ReasoningMessageStartEvent.fromJson`. + role = TextMessageRole.assistant; + } + } return TextMessageStartEvent( - messageId: JsonDecoder.requireField(json, 'messageId'), - role: TextMessageRole.fromString( - JsonDecoder.optionalField(json, 'role') ?? 'assistant', - ), - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + messageId: messageId, + role: role, + name: JsonDecoder.optionalField(json, 'name'), + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @@ -152,18 +329,22 @@ final class TextMessageStartEvent extends BaseEvent { ...super.toJson(), 'messageId': messageId, 'role': role.value, + if (name != null) 'name': name, }; + // See `_Unset` (top of file) for the sentinel rationale. @override TextMessageStartEvent copyWith({ String? messageId, TextMessageRole? role, + Object? name = kUnsetSentinel, int? timestamp, dynamic rawEvent, }) { return TextMessageStartEvent( messageId: messageId ?? this.messageId, role: role ?? this.role, + name: identical(name, kUnsetSentinel) ? this.name : name as String?, timestamp: timestamp ?? this.timestamp, rawEvent: rawEvent ?? this.rawEvent, ); @@ -183,21 +364,25 @@ final class TextMessageContentEvent extends BaseEvent { }) : super(eventType: EventType.textMessageContent); factory TextMessageContentEvent.fromJson(Map json) { + // Validate the cheap required identifier FIRST so a missing-id error + // surfaces before any payload-validation work — same convention as + // `ReasoningMessageStartEvent.fromJson`. + final messageId = JsonDecoder.requireEitherField( + json, + 'messageId', + 'message_id', + ); + // Empty `delta` is accepted to match canonical TS/Python schemas + // (`TextMessageContentEventSchema.delta: z.string()` / + // pydantic `delta: str`). Servers may legitimately emit empty + // chunks (e.g. a noop content refresh). final delta = JsonDecoder.requireField(json, 'delta'); - if (delta.isEmpty) { - throw AGUIValidationError( - message: 'Delta must not be an empty string', - field: 'delta', - value: delta, - json: json, - ); - } - + return TextMessageContentEvent( - messageId: JsonDecoder.requireField(json, 'messageId'), + messageId: messageId, delta: delta, - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @@ -236,9 +421,13 @@ final class TextMessageEndEvent extends BaseEvent { factory TextMessageEndEvent.fromJson(Map json) { return TextMessageEndEvent( - messageId: JsonDecoder.requireField(json, 'messageId'), - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + messageId: JsonDecoder.requireEitherField( + json, + 'messageId', + 'message_id', + ), + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @@ -267,23 +456,42 @@ final class TextMessageChunkEvent extends BaseEvent { final String? messageId; final TextMessageRole? role; final String? delta; + final String? name; const TextMessageChunkEvent({ this.messageId, this.role, this.delta, + this.name, super.timestamp, super.rawEvent, }) : super(eventType: EventType.textMessageChunk); factory TextMessageChunkEvent.fromJson(Map json) { final roleStr = JsonDecoder.optionalField(json, 'role'); + TextMessageRole? role; + if (roleStr != null) { + try { + role = TextMessageRole.fromString(roleStr); + } on ArgumentError { + // Forward-compat: unknown wire role falls back to null. + // Unlike TextMessageStartEvent (required role → assistant default), + // role here is nullable/optional — null is the correct sentinel for + // "value was present on the wire but unrecognized." + role = null; + } + } return TextMessageChunkEvent( - messageId: JsonDecoder.optionalField(json, 'messageId'), - role: roleStr != null ? TextMessageRole.fromString(roleStr) : null, + messageId: JsonDecoder.optionalEitherField( + json, + 'messageId', + 'message_id', + ), + role: role, delta: JsonDecoder.optionalField(json, 'delta'), - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + name: JsonDecoder.optionalField(json, 'name'), + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @@ -293,20 +501,29 @@ final class TextMessageChunkEvent extends BaseEvent { if (messageId != null) 'messageId': messageId, if (role != null) 'role': role!.value, if (delta != null) 'delta': delta, + if (name != null) 'name': name, }; + // See `_Unset` (top of file) for the sentinel rationale. @override TextMessageChunkEvent copyWith({ - String? messageId, - TextMessageRole? role, - String? delta, + Object? messageId = kUnsetSentinel, + Object? role = kUnsetSentinel, + Object? delta = kUnsetSentinel, + Object? name = kUnsetSentinel, int? timestamp, dynamic rawEvent, }) { return TextMessageChunkEvent( - messageId: messageId ?? this.messageId, - role: role ?? this.role, - delta: delta ?? this.delta, + messageId: identical(messageId, kUnsetSentinel) + ? this.messageId + : messageId as String?, + role: identical(role, kUnsetSentinel) + ? this.role + : role as TextMessageRole?, + delta: + identical(delta, kUnsetSentinel) ? this.delta : delta as String?, + name: identical(name, kUnsetSentinel) ? this.name : name as String?, timestamp: timestamp ?? this.timestamp, rawEvent: rawEvent ?? this.rawEvent, ); @@ -330,8 +547,8 @@ final class ThinkingStartEvent extends BaseEvent { factory ThinkingStartEvent.fromJson(Map json) { return ThinkingStartEvent( title: JsonDecoder.optionalField(json, 'title'), - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @@ -343,22 +560,28 @@ final class ThinkingStartEvent extends BaseEvent { @override ThinkingStartEvent copyWith({ - String? title, + Object? title = kUnsetSentinel, int? timestamp, dynamic rawEvent, }) { return ThinkingStartEvent( - title: title ?? this.title, + title: identical(title, kUnsetSentinel) ? this.title : title as String?, timestamp: timestamp ?? this.timestamp, rawEvent: rawEvent ?? this.rawEvent, ); } } -/// Event containing thinking content +/// Event containing thinking content. +/// +/// Dart-only legacy: never part of the canonical AG-UI protocol +/// (TypeScript/Python). Included only for backward compatibility with +/// pre-0.2.0 Dart consumers. Use [ThinkingTextMessageContentEvent] instead. +@Deprecated(_kThinkingContentEventDeprecation) final class ThinkingContentEvent extends BaseEvent { final String delta; + @Deprecated(_kThinkingContentEventDeprecation) const ThinkingContentEvent({ required this.delta, super.timestamp, @@ -366,20 +589,13 @@ final class ThinkingContentEvent extends BaseEvent { }) : super(eventType: EventType.thinkingContent); factory ThinkingContentEvent.fromJson(Map json) { + // Empty `delta` is accepted to match the relaxed canonical contract + // (`z.string()` / `delta: str`). Migrate to [ReasoningMessageContentEvent]. final delta = JsonDecoder.requireField(json, 'delta'); - if (delta.isEmpty) { - throw AGUIValidationError( - message: 'Delta must not be an empty string', - field: 'delta', - value: delta, - json: json, - ); - } - return ThinkingContentEvent( delta: delta, - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @@ -412,8 +628,8 @@ final class ThinkingEndEvent extends BaseEvent { factory ThinkingEndEvent.fromJson(Map json) { return ThinkingEndEvent( - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @@ -429,17 +645,25 @@ final class ThinkingEndEvent extends BaseEvent { } } -/// Event indicating the start of a thinking text message +/// Event indicating the start of a thinking text message. +/// +/// Deprecated in favor of [ReasoningMessageStartEvent], mirroring the +/// canonical TypeScript SDK deprecation of `THINKING_TEXT_MESSAGE_*` in +/// favor of `REASONING_*`. Decoding remains supported for backward +/// compatibility; scheduled for removal in 1.0.0. +@Deprecated(_kThinkingTextMessageStartEventDeprecation) final class ThinkingTextMessageStartEvent extends BaseEvent { + @Deprecated(_kThinkingTextMessageStartEventDeprecation) const ThinkingTextMessageStartEvent({ super.timestamp, super.rawEvent, + // ignore: deprecated_member_use_from_same_package }) : super(eventType: EventType.thinkingTextMessageStart); factory ThinkingTextMessageStartEvent.fromJson(Map json) { return ThinkingTextMessageStartEvent( - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @@ -455,31 +679,32 @@ final class ThinkingTextMessageStartEvent extends BaseEvent { } } -/// Event containing thinking text message content +/// Event containing thinking text message content. +/// +/// Deprecated in favor of [ReasoningMessageContentEvent], mirroring the +/// canonical TypeScript SDK deprecation of `THINKING_TEXT_MESSAGE_*` in +/// favor of `REASONING_*`. Decoding remains supported for backward +/// compatibility; scheduled for removal in 1.0.0. +@Deprecated(_kThinkingTextMessageContentEventDeprecation) final class ThinkingTextMessageContentEvent extends BaseEvent { final String delta; + @Deprecated(_kThinkingTextMessageContentEventDeprecation) const ThinkingTextMessageContentEvent({ required this.delta, super.timestamp, super.rawEvent, + // ignore: deprecated_member_use_from_same_package }) : super(eventType: EventType.thinkingTextMessageContent); factory ThinkingTextMessageContentEvent.fromJson(Map json) { + // No identifier on this event. Empty `delta` is accepted to match the + // relaxed canonical contract (`z.string()` / `delta: str`). final delta = JsonDecoder.requireField(json, 'delta'); - if (delta.isEmpty) { - throw AGUIValidationError( - message: 'Delta must not be an empty string', - field: 'delta', - value: delta, - json: json, - ); - } - return ThinkingTextMessageContentEvent( delta: delta, - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @@ -503,17 +728,25 @@ final class ThinkingTextMessageContentEvent extends BaseEvent { } } -/// Event indicating the end of a thinking text message +/// Event indicating the end of a thinking text message. +/// +/// Deprecated in favor of [ReasoningMessageEndEvent], mirroring the +/// canonical TypeScript SDK deprecation of `THINKING_TEXT_MESSAGE_*` in +/// favor of `REASONING_*`. Decoding remains supported for backward +/// compatibility; scheduled for removal in 1.0.0. +@Deprecated(_kThinkingTextMessageEndEventDeprecation) final class ThinkingTextMessageEndEvent extends BaseEvent { + @Deprecated(_kThinkingTextMessageEndEventDeprecation) const ThinkingTextMessageEndEvent({ super.timestamp, super.rawEvent, + // ignore: deprecated_member_use_from_same_package }) : super(eventType: EventType.thinkingTextMessageEnd); factory ThinkingTextMessageEndEvent.fromJson(Map json) { return ThinkingTextMessageEndEvent( - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @@ -549,11 +782,23 @@ final class ToolCallStartEvent extends BaseEvent { factory ToolCallStartEvent.fromJson(Map json) { return ToolCallStartEvent( - toolCallId: JsonDecoder.requireField(json, 'toolCallId'), - toolCallName: JsonDecoder.requireField(json, 'toolCallName'), - parentMessageId: JsonDecoder.optionalField(json, 'parentMessageId'), - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + toolCallId: JsonDecoder.requireEitherField( + json, + 'toolCallId', + 'tool_call_id', + ), + toolCallName: JsonDecoder.requireEitherField( + json, + 'toolCallName', + 'tool_call_name', + ), + parentMessageId: JsonDecoder.optionalEitherField( + json, + 'parentMessageId', + 'parent_message_id', + ), + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @@ -565,18 +810,21 @@ final class ToolCallStartEvent extends BaseEvent { if (parentMessageId != null) 'parentMessageId': parentMessageId, }; + // See `_Unset` (top of file) for the sentinel rationale. @override ToolCallStartEvent copyWith({ String? toolCallId, String? toolCallName, - String? parentMessageId, + Object? parentMessageId = kUnsetSentinel, int? timestamp, dynamic rawEvent, }) { return ToolCallStartEvent( toolCallId: toolCallId ?? this.toolCallId, toolCallName: toolCallName ?? this.toolCallName, - parentMessageId: parentMessageId ?? this.parentMessageId, + parentMessageId: identical(parentMessageId, kUnsetSentinel) + ? this.parentMessageId + : parentMessageId as String?, timestamp: timestamp ?? this.timestamp, rawEvent: rawEvent ?? this.rawEvent, ); @@ -596,11 +844,19 @@ final class ToolCallArgsEvent extends BaseEvent { }) : super(eventType: EventType.toolCallArgs); factory ToolCallArgsEvent.fromJson(Map json) { + final toolCallId = JsonDecoder.requireEitherField( + json, + 'toolCallId', + 'tool_call_id', + ); + // Empty `delta` is accepted to match canonical TS/Python schemas + // (`ToolCallArgsEventSchema.delta: z.string()` / pydantic `delta: str`). + final delta = JsonDecoder.requireField(json, 'delta'); return ToolCallArgsEvent( - toolCallId: JsonDecoder.requireField(json, 'toolCallId'), - delta: JsonDecoder.requireField(json, 'delta'), - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + toolCallId: toolCallId, + delta: delta, + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @@ -639,9 +895,13 @@ final class ToolCallEndEvent extends BaseEvent { factory ToolCallEndEvent.fromJson(Map json) { return ToolCallEndEvent( - toolCallId: JsonDecoder.requireField(json, 'toolCallId'), - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + toolCallId: JsonDecoder.requireEitherField( + json, + 'toolCallId', + 'tool_call_id', + ), + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @@ -683,12 +943,24 @@ final class ToolCallChunkEvent extends BaseEvent { factory ToolCallChunkEvent.fromJson(Map json) { return ToolCallChunkEvent( - toolCallId: JsonDecoder.optionalField(json, 'toolCallId'), - toolCallName: JsonDecoder.optionalField(json, 'toolCallName'), - parentMessageId: JsonDecoder.optionalField(json, 'parentMessageId'), + toolCallId: JsonDecoder.optionalEitherField( + json, + 'toolCallId', + 'tool_call_id', + ), + toolCallName: JsonDecoder.optionalEitherField( + json, + 'toolCallName', + 'tool_call_name', + ), + parentMessageId: JsonDecoder.optionalEitherField( + json, + 'parentMessageId', + 'parent_message_id', + ), delta: JsonDecoder.optionalField(json, 'delta'), - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @@ -701,32 +973,75 @@ final class ToolCallChunkEvent extends BaseEvent { if (delta != null) 'delta': delta, }; + // See `_Unset` (top of file) for the sentinel rationale. @override ToolCallChunkEvent copyWith({ - String? toolCallId, - String? toolCallName, - String? parentMessageId, - String? delta, + Object? toolCallId = kUnsetSentinel, + Object? toolCallName = kUnsetSentinel, + Object? parentMessageId = kUnsetSentinel, + Object? delta = kUnsetSentinel, int? timestamp, dynamic rawEvent, }) { return ToolCallChunkEvent( - toolCallId: toolCallId ?? this.toolCallId, - toolCallName: toolCallName ?? this.toolCallName, - parentMessageId: parentMessageId ?? this.parentMessageId, - delta: delta ?? this.delta, + toolCallId: identical(toolCallId, kUnsetSentinel) + ? this.toolCallId + : toolCallId as String?, + toolCallName: identical(toolCallName, kUnsetSentinel) + ? this.toolCallName + : toolCallName as String?, + parentMessageId: identical(parentMessageId, kUnsetSentinel) + ? this.parentMessageId + : parentMessageId as String?, + delta: + identical(delta, kUnsetSentinel) ? this.delta : delta as String?, timestamp: timestamp ?? this.timestamp, rawEvent: rawEvent ?? this.rawEvent, ); } } +/// Role for tool-call result messages (aligned with the AG-UI protocol). +/// +/// Currently a single-variant enum mirroring the canonical +/// `Literal["tool"]` (Python) / `z.literal("tool")` (TypeScript). Modeled +/// as an enum so a future role addition can land without churning every +/// call site, and so producers cannot accidentally emit a free-form +/// string like `'developer'` on a `TOOL_CALL_RESULT` event. +enum ToolCallResultRole { + tool('tool'); + + final String value; + const ToolCallResultRole(this.value); + + /// Parses [value] into a [ToolCallResultRole]. + /// + /// Throws [ArgumentError] for unknown values. Callers decoding from the + /// wire should use `ToolCallResultEvent.fromJson`, which absorbs the + /// throw and falls back to [ToolCallResultRole.tool] so a future + /// server-side role does not tear down the SSE stream. Mirrors + /// `ReasoningMessageRole.fromString` and `TextMessageRole.fromString`. + static final Map _byValue = { + for (final r in ToolCallResultRole.values) r.value: r, + }; + + static ToolCallResultRole fromString(String value) { + return _byValue[value] ?? + (throw ArgumentError('Invalid tool call result role: $value')); + } +} + /// Event containing the result of a tool call final class ToolCallResultEvent extends BaseEvent { final String messageId; final String toolCallId; final String content; - final String? role; + + /// Optional role discriminator for the tool-call result. + /// + /// `copyWith(role: null)` clears this field via the [kUnsetSentinel] + /// pattern — same as every other nullable field on this event. + final ToolCallResultRole? role; const ToolCallResultEvent({ required this.messageId, @@ -738,13 +1053,36 @@ final class ToolCallResultEvent extends BaseEvent { }) : super(eventType: EventType.toolCallResult); factory ToolCallResultEvent.fromJson(Map json) { + final roleStr = JsonDecoder.optionalField(json, 'role'); + ToolCallResultRole? role; + if (roleStr != null) { + try { + role = ToolCallResultRole.fromString(roleStr); + } on ArgumentError { + // Forward-compat: an unknown wire role falls back to `tool` so a + // future server-side role does not tear down the SSE stream. + // Mirrors `TextMessageStartEvent.fromJson` / + // `ReasoningMessageStartEvent.fromJson`. Narrow `on ArgumentError` + // (not `catch (e)`) preserves propagation of `AGUIValidationError` + // raised by `optionalField` for a wrong-typed `role`. + role = ToolCallResultRole.tool; + } + } return ToolCallResultEvent( - messageId: JsonDecoder.requireField(json, 'messageId'), - toolCallId: JsonDecoder.requireField(json, 'toolCallId'), + messageId: JsonDecoder.requireEitherField( + json, + 'messageId', + 'message_id', + ), + toolCallId: JsonDecoder.requireEitherField( + json, + 'toolCallId', + 'tool_call_id', + ), content: JsonDecoder.requireField(json, 'content'), - role: JsonDecoder.optionalField(json, 'role'), - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + role: role, + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @@ -754,7 +1092,7 @@ final class ToolCallResultEvent extends BaseEvent { 'messageId': messageId, 'toolCallId': toolCallId, 'content': content, - if (role != null) 'role': role, + if (role != null) 'role': role!.value, }; @override @@ -762,7 +1100,7 @@ final class ToolCallResultEvent extends BaseEvent { String? messageId, String? toolCallId, String? content, - String? role, + Object? role = kUnsetSentinel, int? timestamp, dynamic rawEvent, }) { @@ -770,7 +1108,7 @@ final class ToolCallResultEvent extends BaseEvent { messageId: messageId ?? this.messageId, toolCallId: toolCallId ?? this.toolCallId, content: content ?? this.content, - role: role ?? this.role, + role: identical(role, kUnsetSentinel) ? this.role : role as ToolCallResultRole?, timestamp: timestamp ?? this.timestamp, rawEvent: rawEvent ?? this.rawEvent, ); @@ -783,6 +1121,9 @@ final class ToolCallResultEvent extends BaseEvent { /// Event containing a snapshot of the state final class StateSnapshotEvent extends BaseEvent { + /// The state snapshot. Type [State] permits any JSON shape including + /// `null` (an empty / cleared state is a valid wire payload — see the + /// matching note on [StateSnapshotEvent.fromJson]). final State snapshot; const StateSnapshotEvent({ @@ -792,10 +1133,22 @@ final class StateSnapshotEvent extends BaseEvent { }) : super(eventType: EventType.stateSnapshot); factory StateSnapshotEvent.fromJson(Map json) { + // `snapshot` may be any JSON shape (including `null` for an empty + // state), so we cannot use `requireField` (which rejects null + // values). The field MUST be present though — its absence is a + // protocol violation, not "the snapshot is empty". Distinguishing + // missing-key from explicit-null is the whole point of this check. + if (!json.containsKey('snapshot')) { + throw AGUIValidationError( + message: 'Missing required field', + field: 'snapshot', + json: json, + ); + } return StateSnapshotEvent( snapshot: json['snapshot'], - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @@ -807,12 +1160,12 @@ final class StateSnapshotEvent extends BaseEvent { @override StateSnapshotEvent copyWith({ - State? snapshot, + Object? snapshot = kUnsetSentinel, int? timestamp, dynamic rawEvent, }) { return StateSnapshotEvent( - snapshot: snapshot ?? this.snapshot, + snapshot: identical(snapshot, kUnsetSentinel) ? this.snapshot : snapshot, timestamp: timestamp ?? this.timestamp, rawEvent: rawEvent ?? this.rawEvent, ); @@ -821,7 +1174,11 @@ final class StateSnapshotEvent extends BaseEvent { /// Event containing a delta of the state (JSON Patch RFC 6902) final class StateDeltaEvent extends BaseEvent { - final List delta; + // RFC 6902 patch operations are always JSON objects ({op, path, …}). + // Using List> (via requireListField) surfaces + // non-object elements as AGUIValidationError at the decoder boundary + // instead of leaking a downstream TypeError at the first op['op'] access. + final List> delta; const StateDeltaEvent({ required this.delta, @@ -831,9 +1188,9 @@ final class StateDeltaEvent extends BaseEvent { factory StateDeltaEvent.fromJson(Map json) { return StateDeltaEvent( - delta: JsonDecoder.requireField>(json, 'delta'), - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + delta: JsonDecoder.requireListField>(json, 'delta'), + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @@ -845,7 +1202,7 @@ final class StateDeltaEvent extends BaseEvent { @override StateDeltaEvent copyWith({ - List? delta, + List>? delta, int? timestamp, dynamic rawEvent, }) { @@ -868,13 +1225,44 @@ final class MessagesSnapshotEvent extends BaseEvent { }) : super(eventType: EventType.messagesSnapshot); factory MessagesSnapshotEvent.fromJson(Map json) { + final rawMessages = JsonDecoder.requireListField>( + json, + 'messages', + ); + final messages = []; + for (var i = 0; i < rawMessages.length; i++) { + try { + messages.add(Message.fromJson(rawMessages[i])); + } catch (e) { + if (e is AGUIValidationError) { + // Always drop json: — the inner Message map can carry encryptedValue + // for Tool/Reasoning subtypes. Preserve cause: only when the inner + // error already cleared its own json: field (e.json == null), which + // indicates the inner factory was cipher-aware and the cause chain + // does not expose raw wire data. Non-cipher messages (Developer, + // System, User) typically produce errors with e.json == null, so + // their cause is preserved for ergonomic debugging. + throw AGUIValidationError( + message: e.message, + field: 'messages[$i].${e.field ?? 'unknown'}', + value: e.value, + cause: e.json == null ? e : null, + ); + } + throw AGUIValidationError( + message: 'Failed to decode message at index $i: $e', + field: 'messages[$i]', + cause: e, + ); + } + } return MessagesSnapshotEvent( - messages: JsonDecoder.requireListField>( - json, - 'messages', - ).map((item) => Message.fromJson(item)).toList(), - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + messages: messages, + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + // rawEvent is preserved verbatim and may duplicate cipher data + // already present in inner ReasoningMessages. Proxy operators should + // drop rawEvent before forwarding to log sinks. + rawEvent: _readRawEvent(json), ); } @@ -898,7 +1286,183 @@ final class MessagesSnapshotEvent extends BaseEvent { } } -/// Event containing a raw event +// ============================================================================ +// Activity Events +// ============================================================================ + +/// Event containing a snapshot of an activity message. +/// +/// Note: [content] is typed `Object?` rather than `Map`. +/// The canonical TypeScript schema requires a non-null record +/// (`z.record(z.any())`); the Dart SDK is intentionally more permissive on +/// the *value* (allows primitives and `null`) to stay forward-compatible +/// with the Python reference server's `content: Any`. The *key itself* +/// is still required — see the matching note on `StateSnapshotEvent.fromJson` +/// for why we check key-presence rather than `requireField`. Treat any +/// non-record value you encounter as a wire-protocol surprise rather than +/// a contract. +final class ActivitySnapshotEvent extends BaseEvent { + final String messageId; + final String activityType; + final Object? content; + + /// `true` (the default) means this snapshot replaces any prior content + /// for the same [messageId]; `false` means it merges/extends. + /// + /// Optional on the wire (`replace: z.boolean().optional().default(true)` + /// in TS, `replace: bool = True` in Python). [toJson] emits the field + /// unconditionally — slightly heavier than the protocol minimum, but + /// makes the round-trip contract explicit and matches what + /// `event_test.dart` locks in. + /// + /// **Known parity gap.** Canonical TypeScript and Python SDKs omit + /// `replace` from the wire output when it equals the default (`true`). + /// This Dart SDK always emits it for round-trip explicitness. See + /// CHANGELOG → "Known parity gaps" for the full list. + final bool replace; + + const ActivitySnapshotEvent({ + required this.messageId, + required this.activityType, + required this.content, + this.replace = true, + super.timestamp, + super.rawEvent, + }) : super(eventType: EventType.activitySnapshot); + + factory ActivitySnapshotEvent.fromJson(Map json) { + // `content` may be any JSON shape (including `null`) but MUST be + // present — see the matching note on `StateSnapshotEvent.fromJson` + // for why we check key-presence rather than `requireField`. + if (!json.containsKey('content')) { + throw AGUIValidationError( + message: 'Missing required field', + field: 'content', + json: json, + ); + } + return ActivitySnapshotEvent( + messageId: JsonDecoder.requireEitherField( + json, + 'messageId', + 'message_id', + ), + activityType: JsonDecoder.requireEitherField( + json, + 'activityType', + 'activity_type', + ), + content: json['content'], + replace: JsonDecoder.optionalField(json, 'replace') ?? true, + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), + ); + } + + @override + Map toJson() => { + ...super.toJson(), + 'messageId': messageId, + 'activityType': activityType, + 'content': content, + // Always emitted, even when default `true`; see class dartdoc for the + // round-trip rationale and the `event_test.dart` assertion that pins it. + 'replace': replace, + }; + + // See `_Unset` (top of file) for the sentinel rationale. + @override + ActivitySnapshotEvent copyWith({ + String? messageId, + String? activityType, + Object? content = kUnsetSentinel, + bool? replace, + int? timestamp, + dynamic rawEvent, + }) { + return ActivitySnapshotEvent( + messageId: messageId ?? this.messageId, + activityType: activityType ?? this.activityType, + content: identical(content, kUnsetSentinel) ? this.content : content, + replace: replace ?? this.replace, + timestamp: timestamp ?? this.timestamp, + rawEvent: rawEvent ?? this.rawEvent, + ); + } +} + +/// Event containing a JSON Patch (RFC 6902) delta for an activity message +final class ActivityDeltaEvent extends BaseEvent { + final String messageId; + final String activityType; + // RFC 6902 patch operations are always JSON objects ({op, path, …}). + // Using List> (via requireListField) surfaces + // non-object elements as AGUIValidationError at the decoder boundary. + final List> patch; + + const ActivityDeltaEvent({ + required this.messageId, + required this.activityType, + required this.patch, + super.timestamp, + super.rawEvent, + }) : super(eventType: EventType.activityDelta); + + factory ActivityDeltaEvent.fromJson(Map json) { + return ActivityDeltaEvent( + messageId: JsonDecoder.requireEitherField( + json, + 'messageId', + 'message_id', + ), + activityType: JsonDecoder.requireEitherField( + json, + 'activityType', + 'activity_type', + ), + patch: JsonDecoder.requireListField>(json, 'patch'), + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), + ); + } + + @override + Map toJson() => { + ...super.toJson(), + 'messageId': messageId, + 'activityType': activityType, + 'patch': patch, + }; + + @override + ActivityDeltaEvent copyWith({ + String? messageId, + String? activityType, + List>? patch, + int? timestamp, + dynamic rawEvent, + }) { + return ActivityDeltaEvent( + messageId: messageId ?? this.messageId, + activityType: activityType ?? this.activityType, + patch: patch ?? this.patch, + timestamp: timestamp ?? this.timestamp, + rawEvent: rawEvent ?? this.rawEvent, + ); + } +} + +/// Event wrapping a raw, uninterpreted upstream event payload. +/// +/// Three related but distinct concepts coexist on this class: +/// - [eventType]: always `EventType.raw` — the discriminator that routes wire +/// payloads here via `BaseEvent.fromJson`. +/// - [event]: the raw upstream event payload as decoded from the wire JSON +/// `event` field. May be any JSON shape, including `null`. +/// - [rawEvent]: inherited from [BaseEvent] — the verbatim wire JSON of the +/// *enclosing* SSE message (the whole `{type, event, ...}` map). Populated +/// by `_readRawEvent` when the producer includes a `rawEvent` / +/// `raw_event` key. Unrelated to the [event] field above. final class RawEvent extends BaseEvent { final dynamic event; final String? source; @@ -911,11 +1475,21 @@ final class RawEvent extends BaseEvent { }) : super(eventType: EventType.raw); factory RawEvent.fromJson(Map json) { + // `event` may be any JSON shape but MUST be present — see the + // matching note on `StateSnapshotEvent.fromJson` for why we check + // key-presence rather than `requireField`. + if (!json.containsKey('event')) { + throw AGUIValidationError( + message: 'Missing required field', + field: 'event', + json: json, + ); + } return RawEvent( event: json['event'], source: JsonDecoder.optionalField(json, 'source'), - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @@ -926,16 +1500,21 @@ final class RawEvent extends BaseEvent { if (source != null) 'source': source, }; + // See `_Unset` (top of file) for the sentinel rationale. Both `event` + // and `source` are nullable on the wire, so callers need explicit-clear + // semantics to drop a stale upstream payload. @override RawEvent copyWith({ - dynamic event, - String? source, + Object? event = kUnsetSentinel, + Object? source = kUnsetSentinel, int? timestamp, dynamic rawEvent, }) { return RawEvent( - event: event ?? this.event, - source: source ?? this.source, + event: identical(event, kUnsetSentinel) ? this.event : event, + source: identical(source, kUnsetSentinel) + ? this.source + : source as String?, timestamp: timestamp ?? this.timestamp, rawEvent: rawEvent ?? this.rawEvent, ); @@ -955,11 +1534,21 @@ final class CustomEvent extends BaseEvent { }) : super(eventType: EventType.custom); factory CustomEvent.fromJson(Map json) { + // `value` may be any JSON shape but MUST be present — see the + // matching note on `StateSnapshotEvent.fromJson` for why we check + // key-presence rather than `requireField`. + if (!json.containsKey('value')) { + throw AGUIValidationError( + message: 'Missing required field', + field: 'value', + json: json, + ); + } return CustomEvent( name: JsonDecoder.requireField(json, 'name'), value: json['value'], - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @@ -970,16 +1559,17 @@ final class CustomEvent extends BaseEvent { 'value': value, }; + // See `_Unset` (top of file) for the sentinel rationale. @override CustomEvent copyWith({ String? name, - dynamic value, + Object? value = kUnsetSentinel, int? timestamp, dynamic rawEvent, }) { return CustomEvent( name: name ?? this.name, - value: value ?? this.value, + value: identical(value, kUnsetSentinel) ? this.value : value, timestamp: timestamp ?? this.timestamp, rawEvent: rawEvent ?? this.rawEvent, ); @@ -994,26 +1584,64 @@ final class CustomEvent extends BaseEvent { final class RunStartedEvent extends BaseEvent { final String threadId; final String runId; + final String? parentRunId; + + /// Optional `RUN_STARTED` input snapshot. On the wire the `input` key + /// must hold a JSON object — `optionalField>` in + /// [RunStartedEvent.fromJson] rejects a wrong-typed value (string, list, + /// number, etc.) with `AGUIValidationError(field: 'input')`. An absent + /// or explicit-null `input` decodes as `null`. + final RunAgentInput? input; const RunStartedEvent({ required this.threadId, required this.runId, + this.parentRunId, + this.input, super.timestamp, super.rawEvent, }) : super(eventType: EventType.runStarted); factory RunStartedEvent.fromJson(Map json) { - // Handle both camelCase and snake_case field names - final threadId = JsonDecoder.optionalField(json, 'threadId') ?? - JsonDecoder.requireField(json, 'thread_id'); - final runId = JsonDecoder.optionalField(json, 'runId') ?? - JsonDecoder.requireField(json, 'run_id'); - + final inputJson = JsonDecoder.optionalField>( + json, + 'input', + ); + RunAgentInput? input; + if (inputJson != null) { + try { + input = RunAgentInput.fromJson(inputJson); + } on AGUIValidationError catch (e) { + // Omit json: — e.json (the inner RunAgentInput payload) can carry + // encryptedValue via input.messages[*]. Omit cause: for the same + // reason: the cause chain exposes e.json to reflection-based log + // shippers. Surface only the field path and the non-cipher value. + throw AGUIValidationError( + message: e.message, + field: 'input.${e.field ?? 'unknown'}', + value: e.value, + ); + } + } return RunStartedEvent( - threadId: threadId, - runId: runId, - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + threadId: JsonDecoder.requireEitherField( + json, + 'threadId', + 'thread_id', + ), + runId: JsonDecoder.requireEitherField( + json, + 'runId', + 'run_id', + ), + parentRunId: JsonDecoder.optionalEitherField( + json, + 'parentRunId', + 'parent_run_id', + ), + input: input, + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @@ -1022,18 +1650,29 @@ final class RunStartedEvent extends BaseEvent { ...super.toJson(), 'threadId': threadId, 'runId': runId, + if (parentRunId != null) 'parentRunId': parentRunId, + if (input != null) 'input': input!.toJson(), }; + // See `_Unset` (top of file) for the sentinel rationale. @override RunStartedEvent copyWith({ String? threadId, String? runId, + Object? parentRunId = kUnsetSentinel, + Object? input = kUnsetSentinel, int? timestamp, dynamic rawEvent, }) { return RunStartedEvent( threadId: threadId ?? this.threadId, runId: runId ?? this.runId, + parentRunId: identical(parentRunId, kUnsetSentinel) + ? this.parentRunId + : parentRunId as String?, + input: identical(input, kUnsetSentinel) + ? this.input + : input as RunAgentInput?, timestamp: timestamp ?? this.timestamp, rawEvent: rawEvent ?? this.rawEvent, ); @@ -1044,6 +1683,22 @@ final class RunStartedEvent extends BaseEvent { final class RunFinishedEvent extends BaseEvent { final String threadId; final String runId; + + /// Optional run-completion payload (`z.any().optional()` / + /// `Optional[Any] = None` in TS/Python). On the wire, an explicit + /// `'result': null` and an absent `result` key are equivalent — both + /// produce a [RunFinishedEvent] with `result == null`, and [toJson] + /// drops the key when `result` is null. + /// + /// The [kUnsetSentinel] on [copyWith] (`Object? result = kUnsetSentinel`) + /// is for in-memory disambiguation only — it lets callers explicitly clear + /// a previously-set result without constructing a new event. It is NOT a + /// wire-protocol distinction: both `null` and absent produce identical + /// `toJson` output (key omitted). Do not mirror the + /// `ActivitySnapshotEvent.content` always-emit pattern here; the protocol + /// does not require [RunFinishedEvent.result] on the wire. If you need the + /// distinction visible in the wire output, construct a new [RunFinishedEvent] + /// directly with the field always emitted. final dynamic result; const RunFinishedEvent({ @@ -1055,18 +1710,24 @@ final class RunFinishedEvent extends BaseEvent { }) : super(eventType: EventType.runFinished); factory RunFinishedEvent.fromJson(Map json) { - // Handle both camelCase and snake_case field names - final threadId = JsonDecoder.optionalField(json, 'threadId') ?? - JsonDecoder.requireField(json, 'thread_id'); - final runId = JsonDecoder.optionalField(json, 'runId') ?? - JsonDecoder.requireField(json, 'run_id'); - + // Unlike StateSnapshotEvent / RawEvent / CustomEvent / ActivitySnapshotEvent + // which use containsKey to enforce key presence, `result` is truly optional + // (canonical `z.any().optional()` / `Optional[Any] = None`). An absent key + // and an explicit `'result': null` are equivalent — both produce `result == null`. return RunFinishedEvent( - threadId: threadId, - runId: runId, + threadId: JsonDecoder.requireEitherField( + json, + 'threadId', + 'thread_id', + ), + runId: JsonDecoder.requireEitherField( + json, + 'runId', + 'run_id', + ), result: json['result'], - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @@ -1078,18 +1739,19 @@ final class RunFinishedEvent extends BaseEvent { if (result != null) 'result': result, }; + // See `_Unset` (top of file) for the sentinel rationale. @override RunFinishedEvent copyWith({ String? threadId, String? runId, - dynamic result, + Object? result = kUnsetSentinel, int? timestamp, dynamic rawEvent, }) { return RunFinishedEvent( threadId: threadId ?? this.threadId, runId: runId ?? this.runId, - result: result ?? this.result, + result: identical(result, kUnsetSentinel) ? this.result : result, timestamp: timestamp ?? this.timestamp, rawEvent: rawEvent ?? this.rawEvent, ); @@ -1099,6 +1761,8 @@ final class RunFinishedEvent extends BaseEvent { /// Event indicating that a run has encountered an error final class RunErrorEvent extends BaseEvent { final String message; + + /// Optional machine-readable error code. final String? code; const RunErrorEvent({ @@ -1112,8 +1776,8 @@ final class RunErrorEvent extends BaseEvent { return RunErrorEvent( message: JsonDecoder.requireField(json, 'message'), code: JsonDecoder.optionalField(json, 'code'), - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @@ -1127,13 +1791,13 @@ final class RunErrorEvent extends BaseEvent { @override RunErrorEvent copyWith({ String? message, - String? code, + Object? code = kUnsetSentinel, int? timestamp, dynamic rawEvent, }) { return RunErrorEvent( message: message ?? this.message, - code: code ?? this.code, + code: identical(code, kUnsetSentinel) ? this.code : code as String?, timestamp: timestamp ?? this.timestamp, rawEvent: rawEvent ?? this.rawEvent, ); @@ -1151,14 +1815,14 @@ final class StepStartedEvent extends BaseEvent { }) : super(eventType: EventType.stepStarted); factory StepStartedEvent.fromJson(Map json) { - // Handle both camelCase and snake_case field names - final stepName = JsonDecoder.optionalField(json, 'stepName') ?? - JsonDecoder.requireField(json, 'step_name'); - return StepStartedEvent( - stepName: stepName, - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + stepName: JsonDecoder.requireEitherField( + json, + 'stepName', + 'step_name', + ), + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @@ -1193,14 +1857,14 @@ final class StepFinishedEvent extends BaseEvent { }) : super(eventType: EventType.stepFinished); factory StepFinishedEvent.fromJson(Map json) { - // Handle both camelCase and snake_case field names - final stepName = JsonDecoder.optionalField(json, 'stepName') ?? - JsonDecoder.requireField(json, 'step_name'); - return StepFinishedEvent( - stepName: stepName, - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + stepName: JsonDecoder.requireEitherField( + json, + 'stepName', + 'step_name', + ), + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @@ -1222,4 +1886,555 @@ final class StepFinishedEvent extends BaseEvent { rawEvent: rawEvent ?? this.rawEvent, ); } +} + +// ============================================================================ +// Reasoning Events +// ============================================================================ + +/// Role for reasoning messages (aligned with the AG-UI protocol). +/// +/// Currently a single-variant enum mirroring the canonical +/// `Literal["reasoning"]` (Python) / `z.literal("reasoning")` (TypeScript). +/// Modeled as an enum so a future role addition can land without churning +/// every call site. +enum ReasoningMessageRole { + reasoning('reasoning'); + + final String value; + const ReasoningMessageRole(this.value); + + /// Parses [value] into a [ReasoningMessageRole]. + /// + /// Throws [ArgumentError] for unknown values. Callers decoding from the + /// wire should use `ReasoningMessageStartEvent.fromJson`, which absorbs + /// the throw and falls back to [ReasoningMessageRole.reasoning] so a + /// future server-side role does not tear down the SSE stream. + static final Map _byValue = { + for (final r in ReasoningMessageRole.values) r.value: r, + }; + + static ReasoningMessageRole fromString(String value) { + return _byValue[value] ?? + (throw ArgumentError('Invalid reasoning message role: $value')); + } +} + +/// Subtype for [ReasoningEncryptedValueEvent]. +enum ReasoningEncryptedValueSubtype { + /// Wire spelling is `'tool-call'` with a hyphen — canonical across the + /// AG-UI protocol (Python `Literal["tool-call"]`, TypeScript + /// `z.literal("tool-call")`). The Dart symbol is `toolCall`; the dash is + /// intentional, not a typo. + toolCall('tool-call'), + message('message'); + + final String value; + const ReasoningEncryptedValueSubtype(this.value); + + /// Parses [value] into a [ReasoningEncryptedValueSubtype]. + /// + /// Throws [ArgumentError] for unknown values. The subtype is part of the + /// protocol contract — there is no graceful fallback at the event level + /// because choosing a default would silently mis-tag encrypted payloads. + /// Wire failures bubble up as [DecodingError] under the standard decoder + /// pipeline; consumers that want per-event recovery should set + /// `skipInvalidEvents: true` on `EventStreamAdapter`. + static final Map _byValue = { + for (final s in ReasoningEncryptedValueSubtype.values) s.value: s, + }; + + static ReasoningEncryptedValueSubtype fromString(String value) { + return _byValue[value] ?? + (throw ArgumentError( + 'Invalid reasoning encrypted value subtype: $value', + )); + } +} + +/// Event indicating the start of a reasoning phase. +final class ReasoningStartEvent extends BaseEvent { + final String messageId; + + const ReasoningStartEvent({ + required this.messageId, + super.timestamp, + super.rawEvent, + }) : super(eventType: EventType.reasoningStart); + + factory ReasoningStartEvent.fromJson(Map json) { + return ReasoningStartEvent( + messageId: JsonDecoder.requireEitherField( + json, + 'messageId', + 'message_id', + ), + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), + ); + } + + @override + Map toJson() => { + ...super.toJson(), + 'messageId': messageId, + }; + + @override + ReasoningStartEvent copyWith({ + String? messageId, + int? timestamp, + dynamic rawEvent, + }) { + return ReasoningStartEvent( + messageId: messageId ?? this.messageId, + timestamp: timestamp ?? this.timestamp, + rawEvent: rawEvent ?? this.rawEvent, + ); + } +} + +/// Event indicating the start of a reasoning message. +final class ReasoningMessageStartEvent extends BaseEvent { + final String messageId; + final ReasoningMessageRole role; + + const ReasoningMessageStartEvent({ + required this.messageId, + this.role = ReasoningMessageRole.reasoning, + super.timestamp, + super.rawEvent, + }) : super(eventType: EventType.reasoningMessageStart); + + factory ReasoningMessageStartEvent.fromJson(Map json) { + // Validate the cheap required field FIRST so a missing-id error + // surfaces before any role-parsing work. + final messageId = JsonDecoder.requireEitherField( + json, + 'messageId', + 'message_id', + ); + // `role` is required by the canonical TypeScript and Python schemas + // (see sdks/typescript/packages/core/src/events.ts and + // sdks/python/ag_ui/core/events.py). A missing `role` is a protocol + // violation and must fail decoding so it surfaces at the boundary + // instead of silently coercing downstream. + final roleStr = JsonDecoder.requireField(json, 'role'); + ReasoningMessageRole role; + try { + role = ReasoningMessageRole.fromString(roleStr); + } on ArgumentError { + // Forward-compat: a future server may introduce a new role *value* + // (e.g. an as-yet-unspecified reasoning sub-role). The field is + // present and string-typed, so this is a recoverable enum-mapping + // failure — keep the stream alive by defaulting to `reasoning`. + // + // We intentionally do NOT broaden to `catch (e)` or `on Exception`: + // a missing-key or wrong-typed `role` raises `AGUIValidationError` + // from `requireField` above, which MUST propagate to the + // decoder boundary as a protocol violation. Widening the catch + // would silently absorb those — the test at + // `event_test.dart` ("rejects missing role (parity with TS/Python)") + // is the regression guard for that contract. + role = ReasoningMessageRole.reasoning; + } + return ReasoningMessageStartEvent( + messageId: messageId, + role: role, + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), + ); + } + + @override + Map toJson() => { + ...super.toJson(), + 'messageId': messageId, + 'role': role.value, + }; + + @override + ReasoningMessageStartEvent copyWith({ + String? messageId, + ReasoningMessageRole? role, + int? timestamp, + dynamic rawEvent, + }) { + return ReasoningMessageStartEvent( + messageId: messageId ?? this.messageId, + role: role ?? this.role, + timestamp: timestamp ?? this.timestamp, + rawEvent: rawEvent ?? this.rawEvent, + ); + } +} + +/// Event containing a piece of reasoning message content. +final class ReasoningMessageContentEvent extends BaseEvent { + final String messageId; + final String delta; + + const ReasoningMessageContentEvent({ + required this.messageId, + required this.delta, + super.timestamp, + super.rawEvent, + }) : super(eventType: EventType.reasoningMessageContent); + + factory ReasoningMessageContentEvent.fromJson(Map json) { + // Validate the cheap required identifier FIRST so a missing-id error + // surfaces before any payload-validation work — same convention as + // `ReasoningMessageStartEvent.fromJson`. + final messageId = JsonDecoder.requireEitherField( + json, + 'messageId', + 'message_id', + ); + // Empty `delta` is accepted to match canonical TS/Python schemas + // (`ReasoningMessageContentEventSchema.delta: z.string()` / + // pydantic `delta: str`). + final delta = JsonDecoder.requireField(json, 'delta'); + + return ReasoningMessageContentEvent( + messageId: messageId, + delta: delta, + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), + ); + } + + @override + Map toJson() => { + ...super.toJson(), + 'messageId': messageId, + 'delta': delta, + }; + + @override + ReasoningMessageContentEvent copyWith({ + String? messageId, + String? delta, + int? timestamp, + dynamic rawEvent, + }) { + return ReasoningMessageContentEvent( + messageId: messageId ?? this.messageId, + delta: delta ?? this.delta, + timestamp: timestamp ?? this.timestamp, + rawEvent: rawEvent ?? this.rawEvent, + ); + } +} + +/// Event indicating the end of a reasoning message. +final class ReasoningMessageEndEvent extends BaseEvent { + final String messageId; + + const ReasoningMessageEndEvent({ + required this.messageId, + super.timestamp, + super.rawEvent, + }) : super(eventType: EventType.reasoningMessageEnd); + + factory ReasoningMessageEndEvent.fromJson(Map json) { + return ReasoningMessageEndEvent( + messageId: JsonDecoder.requireEitherField( + json, + 'messageId', + 'message_id', + ), + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), + ); + } + + @override + Map toJson() => { + ...super.toJson(), + 'messageId': messageId, + }; + + @override + ReasoningMessageEndEvent copyWith({ + String? messageId, + int? timestamp, + dynamic rawEvent, + }) { + return ReasoningMessageEndEvent( + messageId: messageId ?? this.messageId, + timestamp: timestamp ?? this.timestamp, + rawEvent: rawEvent ?? this.rawEvent, + ); + } +} + +/// Event containing a chunk of reasoning message content. +final class ReasoningMessageChunkEvent extends BaseEvent { + final String? messageId; + final String? delta; + + const ReasoningMessageChunkEvent({ + this.messageId, + this.delta, + super.timestamp, + super.rawEvent, + }) : super(eventType: EventType.reasoningMessageChunk); + + factory ReasoningMessageChunkEvent.fromJson(Map json) { + return ReasoningMessageChunkEvent( + messageId: JsonDecoder.optionalEitherField( + json, + 'messageId', + 'message_id', + ), + // `delta` has no snake_case spelling in any AG-UI SDK — read it + // canonically and skip the dual-key lookup. + delta: JsonDecoder.optionalField(json, 'delta'), + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), + ); + } + + @override + Map toJson() => { + ...super.toJson(), + if (messageId != null) 'messageId': messageId, + if (delta != null) 'delta': delta, + }; + + // See `_Unset` (top of file) for the sentinel rationale. + @override + ReasoningMessageChunkEvent copyWith({ + Object? messageId = kUnsetSentinel, + Object? delta = kUnsetSentinel, + int? timestamp, + dynamic rawEvent, + }) { + return ReasoningMessageChunkEvent( + messageId: identical(messageId, kUnsetSentinel) + ? this.messageId + : messageId as String?, + delta: + identical(delta, kUnsetSentinel) ? this.delta : delta as String?, + timestamp: timestamp ?? this.timestamp, + rawEvent: rawEvent ?? this.rawEvent, + ); + } +} + +/// Event indicating the end of a reasoning phase. +final class ReasoningEndEvent extends BaseEvent { + final String messageId; + + const ReasoningEndEvent({ + required this.messageId, + super.timestamp, + super.rawEvent, + }) : super(eventType: EventType.reasoningEnd); + + factory ReasoningEndEvent.fromJson(Map json) { + return ReasoningEndEvent( + messageId: JsonDecoder.requireEitherField( + json, + 'messageId', + 'message_id', + ), + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), + ); + } + + @override + Map toJson() => { + ...super.toJson(), + 'messageId': messageId, + }; + + @override + ReasoningEndEvent copyWith({ + String? messageId, + int? timestamp, + dynamic rawEvent, + }) { + return ReasoningEndEvent( + messageId: messageId ?? this.messageId, + timestamp: timestamp ?? this.timestamp, + rawEvent: rawEvent ?? this.rawEvent, + ); + } +} + +/// Event containing an encrypted value for a message or tool call. +/// +/// Forward-compat note: a future server-side [subtype] value will cause +/// [ReasoningEncryptedValueSubtype.fromString] to throw, which propagates +/// out of `fromJson` as an [AGUIValidationError] (wrapped in a +/// [DecodingError] when reached through [EventDecoder]). To keep streams +/// alive across an unknown subtype, opt in to per-event recovery via +/// `EventStreamAdapter(skipInvalidEvents: true)` — the rest of the SDK's +/// enums absorb unknown values at the event-decoding boundary, but the +/// encrypted-payload subtype has no sensible default to fall back to. +final class ReasoningEncryptedValueEvent extends BaseEvent { + final ReasoningEncryptedValueSubtype subtype; + final String entityId; + final String encryptedValue; + + const ReasoningEncryptedValueEvent({ + required this.subtype, + required this.entityId, + required this.encryptedValue, + super.timestamp, + super.rawEvent, + }) : super(eventType: EventType.reasoningEncryptedValue); + + factory ReasoningEncryptedValueEvent.fromJson(Map json) { + // All three required fields on this event use manual presence/type checks + // rather than `requireField`/`requireEitherField` so that every error path + // can intentionally omit `json:` — the payload contains cipher data and + // forwarding the full wire map to `AGUIValidationError.json` would leak it + // through reflection-based error serializers and log shippers. + if (!json.containsKey('subtype')) { + throw AGUIValidationError( + message: 'Missing required field "subtype"', + field: 'subtype', + // Intentionally omit json: — payload contains cipher data. + ); + } + if (json['subtype'] == null) { + throw AGUIValidationError( + message: 'Field "subtype" must not be null', + field: 'subtype', + // Intentionally omit json: — payload contains cipher data. + ); + } + final subtypeRaw = json['subtype']; + if (subtypeRaw is! String) { + throw AGUIValidationError( + message: + 'Field "subtype" has incorrect type. Expected String, got ${subtypeRaw.runtimeType}', + field: 'subtype', + value: subtypeRaw, + // Intentionally omit json: — payload contains cipher data. + ); + } + final ReasoningEncryptedValueSubtype subtype; + try { + subtype = ReasoningEncryptedValueSubtype.fromString(subtypeRaw); + } on ArgumentError { + // Honor the class-level dartdoc contract: an unknown subtype + // surfaces as `AGUIValidationError` (and as `DecodingError` through + // `EventDecoder`), not as the raw `ArgumentError` the enum throws. + // Narrow `on ArgumentError` (not `catch (e)`) preserves the discipline + // that other errors from checked paths MUST propagate unchanged. + throw AGUIValidationError( + message: 'Invalid reasoning encrypted value subtype: $subtypeRaw', + field: 'subtype', + value: subtypeRaw, + // Intentionally omit json: — payload contains cipher data. + ); + } + + // `entityId` — prefer camelCase per requireEitherField contract. + final bool entityIdPresent = + json.containsKey('entityId') || json.containsKey('entity_id'); + final entityIdRaw = + json.containsKey('entityId') ? json['entityId'] : json['entity_id']; + if (!entityIdPresent) { + throw AGUIValidationError( + message: 'Missing required field "entityId" (or "entity_id")', + field: 'entityId', + // Intentionally omit json: — payload contains cipher data. + ); + } + if (entityIdRaw == null) { + throw AGUIValidationError( + message: 'Field "entityId" must not be null', + field: 'entityId', + // Intentionally omit json: — payload contains cipher data. + ); + } + if (entityIdRaw is! String) { + throw AGUIValidationError( + message: + 'Field "entityId" has incorrect type. Expected String, got ${entityIdRaw.runtimeType}', + field: 'entityId', + value: entityIdRaw, + // Intentionally omit json: — payload contains cipher data. + ); + } + + // `encryptedValue` — prefer camelCase per requireEitherField contract. + final bool encryptedValuePresent = json.containsKey('encryptedValue') || + json.containsKey('encrypted_value'); + final encryptedValueRaw = json.containsKey('encryptedValue') + ? json['encryptedValue'] + : json['encrypted_value']; + if (!encryptedValuePresent) { + throw AGUIValidationError( + message: + 'Missing required field "encryptedValue" (or "encrypted_value")', + field: 'encryptedValue', + // Intentionally omit json: — payload contains cipher data. + ); + } + if (encryptedValueRaw == null) { + throw AGUIValidationError( + message: 'Field "encryptedValue" must not be null', + field: 'encryptedValue', + // Intentionally omit json: — payload contains cipher data. + ); + } + if (encryptedValueRaw is! String) { + throw AGUIValidationError( + message: + 'Field "encryptedValue" has incorrect type. Expected String, got ${encryptedValueRaw.runtimeType}', + field: 'encryptedValue', + value: encryptedValueRaw, + // Intentionally omit json: — payload contains cipher data. + ); + } + + // entityId and encryptedValue are accepted as plain strings (including + // empty) to match canonical schemas: TS `z.string()` and Python `str` + // (no `min_length`). The strict subtype discriminator above stays — + // unknown subtypes still throw. + // + // rawEvent is explicitly set to null here — unlike every other factory + // in this file, forwarding _readRawEvent(json) would store the full + // cipher payload in BaseEvent.rawEvent, undoing the cipher-data scrubbing + // in every error path above. Proxies that need the raw wire form should + // maintain their own copy before calling fromJson. + return ReasoningEncryptedValueEvent( + subtype: subtype, + entityId: entityIdRaw, + encryptedValue: encryptedValueRaw, + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: null, + ); + } + + @override + Map toJson() => { + ...super.toJson(), + 'subtype': subtype.value, + 'entityId': entityId, + 'encryptedValue': encryptedValue, + }; + + @override + ReasoningEncryptedValueEvent copyWith({ + ReasoningEncryptedValueSubtype? subtype, + String? entityId, + String? encryptedValue, + int? timestamp, + dynamic rawEvent, + }) { + return ReasoningEncryptedValueEvent( + subtype: subtype ?? this.subtype, + entityId: entityId ?? this.entityId, + encryptedValue: encryptedValue ?? this.encryptedValue, + timestamp: timestamp ?? this.timestamp, + rawEvent: rawEvent ?? this.rawEvent, + ); + } } \ No newline at end of file diff --git a/sdks/community/dart/lib/src/sse/sse_client.dart b/sdks/community/dart/lib/src/sse/sse_client.dart index 64707dbd62..fc52979726 100644 --- a/sdks/community/dart/lib/src/sse/sse_client.dart +++ b/sdks/community/dart/lib/src/sse/sse_client.dart @@ -16,10 +16,12 @@ class SseClient { StreamSubscription? _subscription; http.StreamedResponse? _currentResponse; Timer? _idleTimer; + Timer? _reconnectTimer; String? _lastEventId; Duration? _serverRetryDuration; bool _isClosed = false; bool _isConnecting = false; + bool _hasEverConnected = false; int _reconnectAttempt = 0; /// Creates a new SSE client. @@ -61,7 +63,12 @@ class SseClient { } /// Parse an existing byte stream as SSE messages. - /// + /// + /// **Stateless.** Creates a fresh [SseParser] per call; does not touch the + /// client's reconnection state (`_lastEventId`, `_reconnectAttempt`, + /// `_subscription`). Independent of [connect]; safe to call without a prior + /// [connect] call or concurrently with an active [connect] session. + /// /// [stream] - The byte stream to parse. /// [headers] - Optional response headers for context. Stream parseStream( @@ -115,6 +122,7 @@ class SseClient { // Reset backoff on successful connection _backoffStrategy.reset(); _reconnectAttempt = 0; + _hasEverConnected = true; // Create parser for this connection final parser = SseParser(); @@ -180,7 +188,17 @@ class SseClient { Duration? requestTimeout, ) { if (_isClosed) return; - + + // Surface the first connection failure directly to the consumer rather than + // entering the reconnect loop — a server that never accepted the initial + // request is unlikely to accept a retry, and silently looping would mask + // the root cause from the caller. + if (!_hasEverConnected) { + _controller?.addError(error); + _controller?.close(); + return; + } + // Schedule reconnection if we have connection info if (url != null) { _scheduleReconnection(url, headers, requestTimeout); @@ -219,8 +237,11 @@ class SseClient { _reconnectAttempt++; final delay = _serverRetryDuration ?? _backoffStrategy.nextDelay(_reconnectAttempt); - // Schedule reconnection - Timer(delay, () { + // Schedule reconnection. Store the timer so close() can cancel it and + // avoid a connect() call racing against a concurrent close(). + _reconnectTimer?.cancel(); + _reconnectTimer = Timer(delay, () { + _reconnectTimer = null; if (!_isClosed) { _connect(url, headers, requestTimeout); } @@ -230,9 +251,11 @@ class SseClient { /// Close the connection and clean up resources. Future close() async { if (_isClosed) return; - + _isClosed = true; _idleTimer?.cancel(); + _reconnectTimer?.cancel(); + _reconnectTimer = null; await _subscription?.cancel(); _currentResponse = null; await _controller?.close(); diff --git a/sdks/community/dart/lib/src/sse/sse_parser.dart b/sdks/community/dart/lib/src/sse/sse_parser.dart index ae3f43afbf..8be15507b7 100644 --- a/sdks/community/dart/lib/src/sse/sse_parser.dart +++ b/sdks/community/dart/lib/src/sse/sse_parser.dart @@ -4,13 +4,61 @@ import 'dart:convert'; import 'sse_message.dart'; /// Parses Server-Sent Events according to the WHATWG specification. +/// +/// `SseParser` instances are intended to be **per-connection**. The +/// `_eventBuffer`, `_dataBuffer`, `_retry`, and `_hasDataField` fields +/// are reset between events via [_resetBuffers], but `_lastEventId` is +/// intentionally sticky across messages on the same connection (per the +/// SSE spec: the last `id:` field is preserved so a reconnecting client +/// can supply it via the `Last-Event-ID` request header). +/// +/// If you reuse a single `SseParser` instance across multiple +/// independent streams (e.g. in tests), `_lastEventId` carries across — +/// which is consistent with the spec's reconnection semantics but can +/// be surprising in test harnesses. Construct a fresh parser per stream +/// when you want clean isolation, or call [reset] to clear all parser +/// state including `_lastEventId`. The streaming-side counterpart in +/// `EventStreamAdapter.fromRawSseStream` keeps its parsing state in +/// per-invocation locals and does not have this concern. class SseParser { + /// Maximum number of UTF-16 code units the `_dataBuffer` may accumulate + /// before a message is dispatched. Prevents a malicious or misbehaving SSE + /// producer from growing the buffer without bound across `data:` lines, + /// causing an OOM before the terminating blank line arrives. + /// + /// **Note:** The cap is measured in UTF-16 code units (Dart's internal string + /// unit), not UTF-8 bytes. ASCII content has a 1:1 ratio; BMP characters + /// outside ASCII still count as one code unit; supplementary characters + /// (emoji, etc.) count as two. For most JSON payloads the difference is + /// negligible, but the name reflects what is actually measured. + /// + /// Default: 8 MiB (8 × 1024 × 1024 code units). Adjust via the + /// [SseParser.new] constructor when your use-case legitimately requires + /// larger payloads. + final int maxDataCodeUnits; + + // `_eventBuffer` stores the SSE `event:` field for the current message. + // Unlike `_dataBuffer`, it is REPLACED (not appended) on each `event:` line + // per the WHATWG SSE spec, so its maximum size is bounded by the line + // splitter upstream rather than accumulating across lines. Only `_dataBuffer` + // needs an explicit `maxDataCodeUnits` cap because it accumulates across multiple + // `data:` lines within a single message. final _eventBuffer = StringBuffer(); final _dataBuffer = StringBuffer(); String? _lastEventId; Duration? _retry; bool _hasDataField = false; + SseParser({this.maxDataCodeUnits = 8 * 1024 * 1024}); + + /// Clears all parser state, including the otherwise-sticky + /// `_lastEventId`. Use when reusing a parser instance across + /// independent streams that should not share reconnection state. + void reset() { + _resetBuffers(); + _lastEventId = null; + } + /// Parses SSE data and yields messages. /// /// The input should be a stream of text lines from an SSE endpoint. @@ -31,8 +79,12 @@ class SseParser { } /// Parses raw bytes from an SSE stream. + /// + /// Routes through [parseLines] so the end-of-stream flush in + /// [parseLines] also fires here — a byte source that closes without + /// a trailing blank line still emits its final buffered event. Stream parseBytes(Stream> bytes) { - return utf8.decoder + final lines = utf8.decoder .bind(bytes) .transform(const LineSplitter()) .transform(StreamTransformer.fromHandlers( @@ -43,11 +95,8 @@ class SseParser { } sink.add(line); }, - )) - .asyncExpand((String line) { - final message = _processLine(line); - return message != null ? Stream.value(message) : Stream.empty(); - }); + )); + return parseLines(lines); } /// Process a single line according to SSE spec. @@ -85,18 +134,58 @@ class SseParser { void _processField(String field, String value) { switch (field) { case 'event': - _eventBuffer.write(value); + // Per WHATWG: "If the field name is 'event', set the event type + // buffer to field value." The buffer is REPLACED on each `event:` + // line, not appended to. The previous `_eventBuffer.write(value)` + // concatenated repeated `event:` lines within a single dispatch + // block — spec-non-compliant and divergent from the canonical + // SDKs. + _eventBuffer + ..clear() + ..write(value); break; case 'data': - _hasDataField = true; - if (_dataBuffer.isNotEmpty) { - _dataBuffer.writeln(); // Add newline between data fields + // Per WHATWG: every `data:` field appends `\n` BEFORE its value + // (the trailing `\n` is then stripped at dispatch). The previous + // `_dataBuffer.isNotEmpty` heuristic skipped the leading `\n` + // when the first `data:` line was empty, collapsing + // `data:\ndata: x` to `"x"` instead of the spec-correct `"\nx"`. + // Use `_hasDataField` to track "have we already received a + // `data:` field in this block?" — which is the actual + // spec-mandated condition. Mirrors the `inDataBlock` flag pattern + // in `EventStreamAdapter.appendDataLine`. + // Guard against unbounded growth from a malicious/misbehaving + // producer. Reject the entire message if the accumulated data + // would exceed [maxDataCodeUnits], reset buffers, and throw so the + // caller's stream adapter can surface a structured error instead + // of quietly OOM-ing. + final newlineBytes = _hasDataField ? 1 : 0; // \n separator between lines + if (_dataBuffer.length + newlineBytes + value.length > maxDataCodeUnits) { + _resetBuffers(); + throw FormatException( + 'SSE data field exceeds $maxDataCodeUnits-code-unit limit ' + '(current ${_dataBuffer.length} + incoming ' + '${newlineBytes + value.length} code units)', + ); } + if (_hasDataField) { + _dataBuffer.write('\n'); // explicit \n, not platform line terminator + } + _hasDataField = true; _dataBuffer.write(value); break; case 'id': - // id field doesn't contain newlines - if (!value.contains('\n') && !value.contains('\r')) { + // Per WHATWG SSE spec: id values must not contain \n, \r, or \x00 + // (NUL). NUL-bearing ids are silently ignored and the prior + // `_lastEventId` survives unchanged. Cap at ≤1024 UTF-16 code units + // (~1–4 KB on the wire depending on encoding) to prevent a malicious + // server from growing the stored value across reconnects via an + // oversized `id:` line (the value persists for the lifetime of the + // connection and propagates via `Last-Event-ID` headers). + if (!value.contains('\n') && + !value.contains('\r') && + !value.contains('\x00') && + value.length <= 1024) { _lastEventId = value; } break; diff --git a/sdks/community/dart/lib/src/types/base.dart b/sdks/community/dart/lib/src/types/base.dart index 4a44a96030..893b30d7ba 100644 --- a/sdks/community/dart/lib/src/types/base.dart +++ b/sdks/community/dart/lib/src/types/base.dart @@ -6,6 +6,16 @@ library; import 'dart:convert'; +// Truncate [s] to at most [maxLen] UTF-16 code units, backing up by 1 if the +// cut falls on the high surrogate of a pair, to avoid emitting lone surrogates. +String _safeTruncate(String s, int maxLen) { + if (s.length <= maxLen) return s; + var end = maxLen; + final cu = s.codeUnitAt(end - 1); + if (cu >= 0xD800 && cu <= 0xDBFF) end--; // high surrogate: back up + return s.substring(0, end); +} + /// Base class for all AG-UI models with JSON serialization support. /// /// All protocol models extend this class to provide consistent JSON @@ -33,48 +43,98 @@ mixin TypeDiscriminator { String get type; } -/// Represents a validation error during JSON decoding. +/// Base exception for AG-UI protocol errors. /// -/// Thrown when JSON data does not match the expected schema for -/// AG-UI protocol models. -class AGUIValidationError implements Exception { +/// The root exception class for all AG-UI protocol-related errors. +/// `AgUiError` (lib/src/client/errors.dart) and [AGUIValidationError] +/// both extend this class — so callers can catch the entire SDK error +/// surface with `on AGUIError`. Catching `on AgUiError` covers +/// transport / decoder / runtime errors but NOT direct-factory +/// `AGUIValidationError`. See README → "Errors" for the catch-recipe. +class AGUIError implements Exception { + /// Human-readable error message. final String message; + + const AGUIError(this.message); + + @override + String toString() => 'AGUIError: $message'; +} + +/// Represents a validation error during JSON decoding. +/// +/// Thrown by `fromJson` factories at the wire-decoding boundary. Extends +/// [AGUIError] so `on AGUIError` catches both factory-side and +/// runtime-side failures uniformly. The separate `ValidationError` in +/// `lib/src/client/errors.dart` is thrown by `Validators.requireNonEmpty` +/// inside `EventDecoder.validate`. When events are decoded through the +/// public [EventDecoder] pipeline, both classes are caught and re-thrown +/// as `DecodingError` — see `decoder.dart` for the wrapping logic. Direct +/// callers of `Event.fromJson` see this `AGUIValidationError` directly. +class AGUIValidationError extends AGUIError { final String? field; final dynamic value; + + /// The originating JSON payload that failed validation. + /// + /// **Sensitive-data warning.** This carries the entire wire payload + /// the factory was given, including cipher fields like + /// `encryptedValue` / `encrypted_value` on the + /// `REASONING_ENCRYPTED_VALUE` / `ToolMessage` / `ReasoningMessage` / + /// `BaseMessage` decode paths. The default `toString()` does NOT emit + /// this field, so error printing is safe by default — but consumers + /// that reflect-serialize errors (e.g. + /// `log.error('decode failed', extra: {'error': error})` with a + /// reflection-based serializer) will leak the cipher payload. For + /// log lines shipped to external sinks, prefer [field] and [value] + /// over [json]. final Map? json; + /// Originating exception, if this validation error was raised in + /// response to another error (e.g. a wrong-typed field caught inside a + /// `transform` callback). Preserves structured info that would + /// otherwise be flattened by `'$e'` interpolation. + final Object? cause; + const AGUIValidationError({ - required this.message, + required String message, this.field, this.value, this.json, - }); + this.cause, + }) : super(message); @override String toString() { final buffer = StringBuffer('AGUIValidationError: $message'); if (field != null) buffer.write(' (field: $field)'); - if (value != null) buffer.write(' (value: $value)'); + if (value != null) { + final valueStr = value.toString(); + final excerpt = valueStr.length > 100 + ? '${_safeTruncate(valueStr, 100)}...' + : valueStr; + buffer.write(' (value: $excerpt)'); + } + if (cause != null) buffer.write('\nCaused by: $cause'); return buffer.toString(); } } -/// Base exception for AG-UI protocol errors. -/// -/// The root exception class for all AG-UI protocol-related errors. -class AGUIError implements Exception { - final String message; - - const AGUIError(this.message); - - @override - String toString() => 'AGUIError: $message'; -} - /// Utility for tolerant JSON decoding that ignores unknown fields. /// /// Provides helper methods for safely extracting and validating fields /// from JSON maps, with proper error handling. +/// +/// camelCase/snake_case parity is handled by [requireEitherField] and +/// [optionalEitherField] for keys whose two spellings differ — +/// e.g. `messageId` / `message_id`, `toolCallId` / `tool_call_id`, +/// `parentRunId` / `parent_run_id`. Single-word keys whose camelCase and +/// snake_case spellings are identical (`delta`, `name`, `title`, +/// `replace`, `content`, `value`, `event`, `source`, `code`, `subtype`, +/// `messages`, `patch`, `snapshot`, `role`, `result`, `input`, +/// `timestamp`, `details`, `error`, `state`) are read with the bare +/// [requireField] / [optionalField] helpers — they don't need +/// `*EitherField` because there's no second spelling to fall back to. class JsonDecoder { /// Safely extracts a required field from JSON. static T requireField( @@ -103,12 +163,15 @@ class JsonDecoder { if (transform != null) { try { return transform(value); + } on AGUIError { + rethrow; } catch (e) { throw AGUIValidationError( message: 'Failed to transform field: $e', field: field, value: value, json: json, + cause: e, ); } } @@ -136,16 +199,19 @@ class JsonDecoder { } final value = json[field]; - + if (transform != null) { try { return transform(value); + } on AGUIError { + rethrow; } catch (e) { throw AGUIValidationError( message: 'Failed to transform field: $e', field: field, value: value, json: json, + cause: e, ); } } @@ -162,33 +228,186 @@ class JsonDecoder { return value; } + /// Reads a required field that may arrive under either of two keys. + /// + /// Servers in this protocol use camelCase (TypeScript) or snake_case + /// (Python) field names interchangeably. Resolution is by KEY PRESENCE + /// via `containsKey` — matching the rule documented on + /// [optionalEitherField]: + /// • If [camelKey] is present (even when its value is explicitly + /// `null`), [camelKey] wins and [snakeKey] is NOT consulted. + /// • [snakeKey] is consulted ONLY when [camelKey] is entirely absent. + /// + /// If neither key resolves to a non-null value, throws an + /// [AGUIValidationError] naming BOTH keys — avoiding the misleading + /// "missing message_id" error when the caller actually sent `messageId`. + /// + /// Note on short-circuit behavior: if [camelKey] is present but holds + /// a wrong-typed value, [optionalField] throws and the [snakeKey] + /// fallback is NOT attempted — a payload that carries both keys with + /// conflicting types is a protocol violation, and surfacing the type + /// error at [camelKey] is more useful than silently rescuing via the + /// snake_case alias. The same rule applies to [optionalEitherField]. + static T requireEitherField( + Map json, + String camelKey, + String snakeKey, + ) { + if (json.containsKey(camelKey)) { + final v = optionalField(json, camelKey); + if (v == null) { + throw AGUIValidationError( + message: 'Required field "$camelKey" is present but null', + field: camelKey, + json: json, + ); + } + return v; + } + if (json.containsKey(snakeKey)) { + final v = optionalField(json, snakeKey); + if (v == null) { + throw AGUIValidationError( + message: 'Required field "$snakeKey" is present but null', + field: snakeKey, + json: json, + ); + } + return v; + } + throw AGUIValidationError( + message: 'Missing required field "$camelKey" (or "$snakeKey")', + field: camelKey, + json: json, + ); + } + + /// Reads an optional field that may arrive under either of two keys. + /// + /// Resolution is by KEY presence, matching the contract documented on + /// [requireEitherField]: if `camelKey` is present in `json` (even when + /// its value is explicitly `null`), the camelCase value wins. + /// `snakeKey` is consulted only when `camelKey` is entirely absent. + /// + /// This `containsKey` rule replaced the prior `??`-chain implementation, + /// which fell through to `snakeKey` whenever the camelCase value was + /// `null`-or-absent — silently overriding an explicit-null camelCase + /// payload with a populated snake_case one. + /// + /// **Error field name note.** When the snake_case path is taken (camelKey + /// absent) and a type mismatch occurs, [optionalField] reports the error + /// using [snakeKey] as the field name — the wire spelling, not the + /// canonical camelCase name. Callers that need to report the canonical + /// name in error messages should catch [AGUIValidationError] and remap + /// `field` to [camelKey] themselves. + static T? optionalEitherField( + Map json, + String camelKey, + String snakeKey, + ) { + if (json.containsKey(camelKey)) { + return optionalField(json, camelKey); + } + return optionalField(json, snakeKey); + } + + /// Reads an optional integer field, accepting either `int` or `num` + /// on the wire. + /// + /// JS/TS producers serialize all numbers through a single Number type, + /// so a server emitting `Date.now() / 1000` (or any fractional value) + /// arrives in Dart as `double`. `optionalField` rejects that with + /// `AGUIValidationError` even when the value is integer-shaped. This + /// helper accepts any `num` and coerces via `.floor()`, matching + /// TS `Math.floor` rounding semantics (rounds toward −∞ for negative + /// values, identical to `.toInt()` for non-negative). + /// + /// Non-finite `num` values (`NaN`, `±Infinity`) are rejected with an + /// `AGUIValidationError` rather than letting `.floor()` throw a raw + /// `UnsupportedError` — keeping all decode failures in the AG-UI error + /// hierarchy. + static int? optionalIntField( + Map json, + String field, + ) { + if (!json.containsKey(field) || json[field] == null) return null; + final value = json[field]; + if (value is int) return value; + if (value is num) { + if (value.isNaN || value.isInfinite) { + throw AGUIValidationError( + message: 'Field is non-finite (NaN or Infinity)', + field: field, + value: value, + json: json, + ); + } + // Guard against silent precision loss on Dart-on-JS where `int` is + // double-backed and integers above 2^53 lose precision silently. + // 2^53 is the largest integer exactly representable as a 64-bit double. + const maxSafeInt = 9007199254740992; // 2^53 + if (value > maxSafeInt || value < -maxSafeInt) { + throw AGUIValidationError( + message: 'Field value out of safe int range (±2^53)', + field: field, + value: value, + json: json, + ); + } + return value.floor(); + } + throw AGUIValidationError( + message: + 'Field has incorrect type. Expected int or num, got ${value.runtimeType}', + field: field, + value: value, + json: json, + ); + } + /// Safely extracts a list field from JSON. + /// + /// Use this when the elements have a concrete element type that the SDK + /// strongly types (`requireListField>` for nested + /// records, etc.) — the inner per-element type check provides the type + /// safety. Wrong-typed elements raise [AGUIValidationError] eagerly with + /// `field: '$field[$i]'` so the decoder pipeline can preserve the + /// originating index instead of flattening to a generic `field: 'json'`. + /// For loosely-typed payloads where the elements are intentionally + /// `dynamic`, prefer `requireField>` to avoid an + /// unnecessary check. static List requireListField( Map json, String field, { T Function(dynamic)? itemTransform, }) { final list = requireField>(json, field); - + if (itemTransform != null) { return list.map((item) { try { return itemTransform(item); } catch (e) { throw AGUIValidationError( - message: 'Failed to transform list item: $e', + message: 'Failed to transform list item', field: field, value: item, json: json, + cause: e, ); } }).toList(); } - return list.cast(); + return _eagerCast(list, field, json); } /// Safely extracts an optional list field from JSON. + /// + /// Mirrors [requireListField]'s eager element-type validation when no + /// transform is supplied, so a malformed list element raises + /// [AGUIValidationError] with the originating index instead of leaking + /// a `TypeError` to the decoder catch-all. static List? optionalListField( Map json, String field, { @@ -196,26 +415,123 @@ class JsonDecoder { }) { final list = optionalField>(json, field); if (list == null) return null; - + if (itemTransform != null) { return list.map((item) { try { return itemTransform(item); } catch (e) { throw AGUIValidationError( - message: 'Failed to transform list item: $e', + message: 'Failed to transform list item', field: field, value: item, json: json, + cause: e, + ); + } + }).toList(); + } + + return _eagerCast(list, field, json); + } + + /// Reads an optional list field that may arrive under either of two + /// keys, with the same eager element-type validation as + /// [optionalListField] / [requireListField]. + /// + /// Composes the dual-key resolution rule from [optionalEitherField] + /// (camelCase wins when present, even when the list is empty; snake_case + /// is consulted ONLY when camelCase is absent) with the index-aware + /// element-type errors from [_eagerCast]. Use this when a list-shaped + /// field has both camelCase and snake_case wire spellings AND the + /// elements have a concrete type the SDK strongly types. + /// + /// The behavior matches [optionalListField] when [itemTransform] is + /// supplied: the transform is wrapped in a per-element try/catch + /// producing an [AGUIValidationError] (without index info, for + /// transform-side failures). Without [itemTransform], element type + /// mismatches are reported with `field: '$camelKey[$i]'`. + static List? optionalEitherListField( + Map json, + String camelKey, + String snakeKey, { + T Function(dynamic)? itemTransform, + }) { + // Resolve the wire spelling BEFORE calling optionalEitherField so that + // error messages produced by _eagerCast (and itemTransform errors) use + // the key that was actually present on the wire — matching the contract + // documented on optionalEitherField (snakeKey wins when camelKey absent). + final resolvedKey = json.containsKey(camelKey) ? camelKey : snakeKey; + final list = optionalEitherField>(json, camelKey, snakeKey); + if (list == null) return null; + + if (itemTransform != null) { + return list.map((item) { + try { + return itemTransform(item); + } catch (e) { + throw AGUIValidationError( + message: 'Failed to transform list item', + field: resolvedKey, + value: item, + json: json, + cause: e, ); } }).toList(); } - return list.cast(); + return _eagerCast(list, resolvedKey, json); } + + /// Eagerly validates element types in a list and returns a typed copy. + /// + /// Replaces `list.cast()`'s lazy view (which raises a raw `TypeError` + /// at access time, swallowed by the decoder catch-all and flattened to + /// `field: 'json'`) with a fail-fast loop that names the bad index. + /// + /// **Field-naming convention**: errors report `'$field[$i]'` (e.g. + /// `"messages[2]"`). Per-factory list decoders that re-wrap validation + /// errors from nested factories use a more precise `'$field[$i].$nestedField'` + /// form (e.g. `"messages[2].role"`) — `_eagerCast` cannot do this + /// because it only checks the element's Dart type, not its internal shape. + static List _eagerCast( + List list, + String field, + Map json, + ) { + final out = []; + for (var i = 0; i < list.length; i++) { + final item = list[i]; + if (item is! T) { + throw AGUIValidationError( + message: + 'List item has incorrect type. Expected $T, got ${item.runtimeType}', + field: '$field[$i]', + value: item, + json: json, + ); + } + out.add(item); + } + return out; + } +} + +/// Shared sentinel for `copyWith` methods across all AG-UI type families. +/// +/// Each copyWith that guards a nullable field uses `Object? field = kUnsetSentinel` +/// and checks `identical(field, kUnsetSentinel)` to distinguish "argument +/// omitted" (preserve current value) from "argument explicitly null" (clear +/// the field). The class is private to prevent re-construction — the only valid +/// sentinel is this canonical constant. +class _CopyWithSentinel { + const _CopyWithSentinel(); } +/// Single shared sentinel instance used across all AG-UI `copyWith` methods. +const _CopyWithSentinel kUnsetSentinel = _CopyWithSentinel(); + /// Converts snake_case to camelCase String snakeToCamel(String snake) { final parts = snake.split('_'); diff --git a/sdks/community/dart/lib/src/types/context.dart b/sdks/community/dart/lib/src/types/context.dart index 849045ebb5..ce4f2938a0 100644 --- a/sdks/community/dart/lib/src/types/context.dart +++ b/sdks/community/dart/lib/src/types/context.dart @@ -5,6 +5,9 @@ import 'base.dart'; import 'message.dart'; import 'tool.dart'; +// `kUnsetSentinel` (from `base.dart`) is the shared sentinel for all +// `copyWith` methods in this file. + /// Additional context for the agent class Context extends AGUIModel { final String description; @@ -40,10 +43,15 @@ class Context extends AGUIModel { } } -/// Input for running an agent +/// Input for running an agent. +/// +/// The optional [parentRunId] mirrors the canonical TS/Python +/// `RunAgentInput.parentRunId` / `parent_run_id` field; it links the +/// run to a parent run in nested-run scenarios. class RunAgentInput extends AGUIModel { final String threadId; final String runId; + final String? parentRunId; final dynamic state; final List messages; final List tools; @@ -53,6 +61,7 @@ class RunAgentInput extends AGUIModel { const RunAgentInput({ required this.threadId, required this.runId, + this.parentRunId, this.state, required this.messages, required this.tools, @@ -61,44 +70,118 @@ class RunAgentInput extends AGUIModel { }); factory RunAgentInput.fromJson(Map json) { - // Handle both camelCase and snake_case field names - final threadId = JsonDecoder.optionalField(json, 'threadId') ?? - JsonDecoder.optionalField(json, 'thread_id'); - final runId = JsonDecoder.optionalField(json, 'runId') ?? - JsonDecoder.optionalField(json, 'run_id'); - - if (threadId == null) { - throw AGUIValidationError( - message: 'Missing required field: threadId or thread_id', - field: 'threadId', - json: json, - ); - } - if (runId == null) { - throw AGUIValidationError( - message: 'Missing required field: runId or run_id', - field: 'runId', - json: json, - ); - } - return RunAgentInput( - threadId: threadId, - runId: runId, - state: json['state'], - messages: JsonDecoder.requireListField>( + threadId: JsonDecoder.requireEitherField( json, - 'messages', - ).map((item) => Message.fromJson(item)).toList(), - tools: JsonDecoder.requireListField>( + 'threadId', + 'thread_id', + ), + runId: JsonDecoder.requireEitherField( json, - 'tools', - ).map((item) => Tool.fromJson(item)).toList(), - context: JsonDecoder.requireListField>( + 'runId', + 'run_id', + ), + parentRunId: JsonDecoder.optionalEitherField( json, - 'context', - ).map((item) => Context.fromJson(item)).toList(), - forwardedProps: json['forwardedProps'] ?? json['forwarded_props'], + 'parentRunId', + 'parent_run_id', + ), + state: json['state'], + messages: () { + final raw = JsonDecoder.requireListField>( + json, + 'messages', + ); + final out = []; + for (var i = 0; i < raw.length; i++) { + try { + out.add(Message.fromJson(raw[i])); + } on AGUIValidationError catch (e) { + // Drop json: — the inner payload may carry encryptedValue or tool + // arguments. Preserve cause: when the inner error already cleared + // its own json: (e.json == null), meaning the inner factory was + // cipher-aware and the cause chain is safe to forward. + throw AGUIValidationError( + message: e.message, + field: 'messages[$i].${e.field ?? 'unknown'}', + value: e.value, + cause: e.json == null ? e : null, + ); + } catch (e) { + throw AGUIValidationError( + message: 'Failed to decode message at index $i: $e', + field: 'messages[$i]', + cause: e, + ); + } + } + return out; + }(), + tools: () { + final raw = JsonDecoder.requireListField>( + json, + 'tools', + ); + final out = []; + for (var i = 0; i < raw.length; i++) { + try { + out.add(Tool.fromJson(raw[i])); + } on AGUIValidationError catch (e) { + // Drop json: — tool arguments may be sensitive. Preserve cause: + // when e.json == null (inner factory already scrubbed it). + throw AGUIValidationError( + message: e.message, + field: 'tools[$i].${e.field ?? 'unknown'}', + value: e.value, + cause: e.json == null ? e : null, + ); + } catch (e) { + throw AGUIValidationError( + message: 'Failed to decode tool at index $i: $e', + field: 'tools[$i]', + cause: e, + ); + } + } + return out; + }(), + context: () { + final raw = JsonDecoder.requireListField>( + json, + 'context', + ); + final out = []; + for (var i = 0; i < raw.length; i++) { + try { + out.add(Context.fromJson(raw[i])); + } on AGUIValidationError catch (e) { + // Drop json: — context values may carry sensitive data. Preserve + // cause: when e.json == null (inner factory already scrubbed it). + throw AGUIValidationError( + message: e.message, + field: 'context[$i].${e.field ?? 'unknown'}', + value: e.value, + cause: e.json == null ? e : null, + ); + } catch (e) { + throw AGUIValidationError( + message: 'Failed to decode context at index $i: $e', + field: 'context[$i]', + cause: e, + ); + } + } + return out; + }(), + // `forwardedProps` is intentionally `dynamic` (any JSON shape), + // so the inline KEY-presence chain is preferred over + // `optionalEitherField` (which requires a concrete `T`). Behavior + // matches the helper: `camelKey` wins when the key is present (even + // when its value is explicitly `null`); `snake_case` is consulted + // ONLY when camelCase is entirely absent. + forwardedProps: json.containsKey('forwardedProps') + ? json['forwardedProps'] + : json['forwarded_props'], ); } @@ -106,6 +189,7 @@ class RunAgentInput extends AGUIModel { Map toJson() => { 'threadId': threadId, 'runId': runId, + if (parentRunId != null) 'parentRunId': parentRunId, if (state != null) 'state': state, 'messages': messages.map((m) => m.toJson()).toList(), 'tools': tools.map((t) => t.toJson()).toList(), @@ -113,24 +197,33 @@ class RunAgentInput extends AGUIModel { if (forwardedProps != null) 'forwardedProps': forwardedProps, }; + // `parentRunId`, `state`, and `forwardedProps` are nullable — + // sentinel lets callers clear them explicitly via `copyWith(field: null)`. + // Mirrors the message-class sentinel in lib/src/types/message.dart. @override RunAgentInput copyWith({ String? threadId, String? runId, - dynamic state, + Object? parentRunId = kUnsetSentinel, + Object? state = kUnsetSentinel, List? messages, List? tools, List? context, - dynamic forwardedProps, + Object? forwardedProps = kUnsetSentinel, }) { return RunAgentInput( threadId: threadId ?? this.threadId, runId: runId ?? this.runId, - state: state ?? this.state, + parentRunId: identical(parentRunId, kUnsetSentinel) + ? this.parentRunId + : parentRunId as String?, + state: identical(state, kUnsetSentinel) ? this.state : state, messages: messages ?? this.messages, tools: tools ?? this.tools, context: context ?? this.context, - forwardedProps: forwardedProps ?? this.forwardedProps, + forwardedProps: identical(forwardedProps, kUnsetSentinel) + ? this.forwardedProps + : forwardedProps, ); } } @@ -148,30 +241,17 @@ class Run extends AGUIModel { }); factory Run.fromJson(Map json) { - // Handle both camelCase and snake_case field names - final threadId = JsonDecoder.optionalField(json, 'threadId') ?? - JsonDecoder.optionalField(json, 'thread_id'); - final runId = JsonDecoder.optionalField(json, 'runId') ?? - JsonDecoder.optionalField(json, 'run_id'); - - if (threadId == null) { - throw AGUIValidationError( - message: 'Missing required field: threadId or thread_id', - field: 'threadId', - json: json, - ); - } - if (runId == null) { - throw AGUIValidationError( - message: 'Missing required field: runId or run_id', - field: 'runId', - json: json, - ); - } - return Run( - threadId: threadId, - runId: runId, + threadId: JsonDecoder.requireEitherField( + json, + 'threadId', + 'thread_id', + ), + runId: JsonDecoder.requireEitherField( + json, + 'runId', + 'run_id', + ), result: json['result'], ); } @@ -183,16 +263,17 @@ class Run extends AGUIModel { if (result != null) 'result': result, }; + // `result` is nullable — sentinel for explicit-clear semantics. @override Run copyWith({ String? threadId, String? runId, - dynamic result, + Object? result = kUnsetSentinel, }) { return Run( threadId: threadId ?? this.threadId, runId: runId ?? this.runId, - result: result ?? this.result, + result: identical(result, kUnsetSentinel) ? this.result : result, ); } } diff --git a/sdks/community/dart/lib/src/types/message.dart b/sdks/community/dart/lib/src/types/message.dart index 945b917182..dc315ebe56 100644 --- a/sdks/community/dart/lib/src/types/message.dart +++ b/sdks/community/dart/lib/src/types/message.dart @@ -1,34 +1,75 @@ /// Message types for AG-UI protocol. /// /// This library defines the message types used in agent-user conversations, -/// including user, assistant, system, tool, and developer messages. +/// including user, assistant, system, tool, developer, activity, and +/// reasoning messages. library; import 'base.dart'; import 'tool.dart'; +// `kUnsetSentinel` (from `base.dart`) is the shared sentinel for all +// `copyWith` methods in this file. The pattern lets callers distinguish +// "argument omitted" (preserve current value via `?? this.field`) from +// "argument explicitly null" (clear the field). Compared with `identical(...)`. + /// Role types for messages in the AG-UI protocol. /// -/// Defines the possible roles a message can have in a conversation. +/// Mirrors the canonical TypeScript and Python `Message` discriminated +/// unions (see `sdks/typescript/packages/core/src/types.ts` and +/// `sdks/python/ag_ui/core/types.py`). The `activity` and `reasoning` +/// values exist so `MESSAGES_SNAPSHOT` payloads carrying those message +/// shapes decode in Dart with the same schema as the other SDKs. enum MessageRole { developer('developer'), system('system'), assistant('assistant'), user('user'), - tool('tool'); + tool('tool'), + + /// Wire spelling is `'activity'` (lowercase, single word) — canonical + /// across the AG-UI protocol (TS `Literal["activity"]`, Python + /// `Literal["activity"]`). The Dart symbol matches; this enum value + /// pins the wire constant for [MessageRole.fromString] dispatch into + /// [ActivityMessage]. Mirrors the wire-spelling-pinning style used by + /// [ReasoningEncryptedValueSubtype.toolCall] (where the spelling + /// difference is more consequential). + activity('activity'), + + /// Wire spelling is `'reasoning'` (lowercase, single word) — canonical + /// across the AG-UI protocol. The Dart symbol matches; this enum value + /// pins the wire constant for [MessageRole.fromString] dispatch into + /// [ReasoningMessage]. + reasoning('reasoning'); final String value; const MessageRole(this.value); + /// Parses [value] into a [MessageRole]. + /// + /// Unlike `TextMessageRole.fromString` / `ReasoningMessageRole.fromString` + /// (which throw `ArgumentError` and are absorbed at the event-factory + /// level for forward-compat), this enum throws [AGUIValidationError] + /// directly — the value is the discriminator that selects which + /// [Message] subtype's `fromJson` to dispatch to, so an unknown role + /// has no safe default. Mis-tagging a `MESSAGES_SNAPSHOT` payload + /// would corrupt the snapshot rather than just lose one field. + /// + /// Through the public [EventDecoder] pipeline, this surfaces as + /// `DecodingError(field: 'role')`. Direct callers of `Message.fromJson` + /// see `AGUIValidationError` directly. See `dart-enum-parsing-safety.md` + /// for the closed-vs-open enum rationale. + static final Map _byValue = { + for (final r in MessageRole.values) r.value: r, + }; + static MessageRole fromString(String value) { - return MessageRole.values.firstWhere( - (role) => role.value == value, - orElse: () => throw AGUIValidationError( - message: 'Invalid message role: $value', - field: 'role', - value: value, - ), - ); + return _byValue[value] ?? + (throw AGUIValidationError( + message: 'Invalid message role: $value', + field: 'role', + value: value, + )); } } @@ -38,17 +79,44 @@ enum MessageRole { /// Each message has a role, optional content, and may include additional metadata. /// /// Use the [Message.fromJson] factory to deserialize messages from JSON. +/// +/// Known parity gap with the canonical TS/Python SDKs: the canonical +/// `BaseMessageSchema.id` is `z.string()` (non-nullable). Dart keeps +/// `id` typed `String?` for legacy reasons but every concrete subtype +/// constructor declares it `required`, so a constructed in-memory +/// instance is null-safe by convention. A future major version may +/// tighten the type. See CHANGELOG → "Known parity gaps". sealed class Message extends AGUIModel with TypeDiscriminator { final String? id; final MessageRole role; final String? content; final String? name; + /// Opaque cipher payload preserved verbatim across proxy hops. + /// + /// Mirrors the canonical TS `BaseMessageSchema.encryptedValue: + /// z.string().optional()` and Python `BaseMessage.encrypted_value: + /// Optional[str]` — every concrete subtype that extends `BaseMessage` + /// (Developer/System/Assistant/User/Tool) inherits this field. The + /// canonical `ActivityMessage` and `ReasoningMessage` are NOT + /// `BaseMessage` extensions; in this Dart sealed-class hierarchy they + /// inherit the field too but their `fromJson` / `toJson` ignore it + /// (`ActivityMessage`) or inherit it through the sealed parent without + /// re-declaring locally (`ReasoningMessage` passes it via + /// `super.encryptedValue` — there is no shadowing field on that subtype). + /// + /// Wire dual-key: factories read both `encryptedValue` (TS-canonical) + /// and `encrypted_value` (Python-canonical) via + /// [JsonDecoder.optionalEitherField]. `toJson` emits the camelCase + /// spelling. + final String? encryptedValue; + const Message({ this.id, required this.role, this.content, this.name, + this.encryptedValue, }); @override @@ -57,8 +125,28 @@ sealed class Message extends AGUIModel with TypeDiscriminator { /// Factory constructor to create specific message types from JSON factory Message.fromJson(Map json) { final roleStr = JsonDecoder.requireField(json, 'role'); - final role = MessageRole.fromString(roleStr); + final MessageRole role; + try { + role = MessageRole.fromString(roleStr); + } on AGUIValidationError catch (e) { + // Drop json: — the message map may carry encryptedValue. Preserve + // cause: because MessageRole.fromString errors do not embed raw JSON + // (e.json == null), so the cause chain is safe to forward. + throw AGUIValidationError( + message: e.message, + field: e.field, + value: e.value, + cause: e, + ); + } + // `MessageRole.fromString` deliberately throws on unknown values rather + // than falling back to a default — unlike `TextMessageRole.fromString` + // and `ReasoningMessageRole.fromString`, which absorb `ArgumentError` for + // forward-compat. The role is the *dispatch discriminator*: an unknown role + // has no safe default subtype. Changing this to a fallback would silently + // mis-tag a MESSAGES_SNAPSHOT message, corrupting the list instead of + // surfacing the wire violation at the decoder boundary. switch (role) { case MessageRole.developer: return DeveloperMessage.fromJson(json); @@ -70,6 +158,14 @@ sealed class Message extends AGUIModel with TypeDiscriminator { return UserMessage.fromJson(json); case MessageRole.tool: return ToolMessage.fromJson(json); + case MessageRole.activity: + return ActivityMessage.fromJson(json); + case MessageRole.reasoning: + return ReasoningMessage.fromJson(json); + // No `default` clause — exhaustive switch on the [MessageRole] enum + // (analyzer-enforced). A new MessageRole value will produce a compile + // error here, which is the desired outcome rather than a runtime + // fall-through. } } @@ -79,13 +175,14 @@ sealed class Message extends AGUIModel with TypeDiscriminator { 'role': role.value, if (content != null) 'content': content, if (name != null) 'name': name, + if (encryptedValue != null) 'encryptedValue': encryptedValue, }; } /// Developer message with required content. /// /// Used for system-level or developer-facing messages in the conversation. -class DeveloperMessage extends Message { +final class DeveloperMessage extends Message { @override final String content; @@ -93,6 +190,7 @@ class DeveloperMessage extends Message { required super.id, required this.content, super.name, + super.encryptedValue, }) : super(role: MessageRole.developer); factory DeveloperMessage.fromJson(Map json) { @@ -100,19 +198,43 @@ class DeveloperMessage extends Message { id: JsonDecoder.requireField(json, 'id'), content: JsonDecoder.requireField(json, 'content'), name: JsonDecoder.optionalField(json, 'name'), + encryptedValue: JsonDecoder.optionalEitherField( + json, + 'encryptedValue', + 'encrypted_value', + ), ); } + // Emit `content` unconditionally — it is constructor-required and non-null + // on this subtype. The parent's conditional `if (content != null) 'content'` + // would also work by construction, but emitting it here makes the contract + // explicit and independent of the parent implementation. + @override + Map toJson() => { + if (id != null) 'id': id, + 'role': role.value, + 'content': content, + if (name != null) 'name': name, + if (encryptedValue != null) 'encryptedValue': encryptedValue, + }; + + // `name` and `encryptedValue` are nullable on the parent — use the + // sentinel so callers can clear either explicitly. See [kUnsetSentinel]. @override DeveloperMessage copyWith({ String? id, String? content, - String? name, + Object? name = kUnsetSentinel, + Object? encryptedValue = kUnsetSentinel, }) { return DeveloperMessage( id: id ?? this.id, content: content ?? this.content, - name: name ?? this.name, + name: identical(name, kUnsetSentinel) ? this.name : name as String?, + encryptedValue: identical(encryptedValue, kUnsetSentinel) + ? this.encryptedValue + : encryptedValue as String?, ); } } @@ -120,7 +242,7 @@ class DeveloperMessage extends Message { /// System message with required content. /// /// Represents system-level instructions or context provided to the agent. -class SystemMessage extends Message { +final class SystemMessage extends Message { @override final String content; @@ -128,6 +250,7 @@ class SystemMessage extends Message { required super.id, required this.content, super.name, + super.encryptedValue, }) : super(role: MessageRole.system); factory SystemMessage.fromJson(Map json) { @@ -135,19 +258,39 @@ class SystemMessage extends Message { id: JsonDecoder.requireField(json, 'id'), content: JsonDecoder.requireField(json, 'content'), name: JsonDecoder.optionalField(json, 'name'), + encryptedValue: JsonDecoder.optionalEitherField( + json, + 'encryptedValue', + 'encrypted_value', + ), ); } + @override + Map toJson() => { + if (id != null) 'id': id, + 'role': role.value, + 'content': content, + if (name != null) 'name': name, + if (encryptedValue != null) 'encryptedValue': encryptedValue, + }; + + // `name` and `encryptedValue` are nullable on the parent — sentinel + // for explicit-clear semantics. @override SystemMessage copyWith({ String? id, String? content, - String? name, + Object? name = kUnsetSentinel, + Object? encryptedValue = kUnsetSentinel, }) { return SystemMessage( id: id ?? this.id, content: content ?? this.content, - name: name ?? this.name, + name: identical(name, kUnsetSentinel) ? this.name : name as String?, + encryptedValue: identical(encryptedValue, kUnsetSentinel) + ? this.encryptedValue + : encryptedValue as String?, ); } } @@ -156,7 +299,7 @@ class SystemMessage extends Message { /// /// Represents responses from the AI assistant, which may include /// text content and/or tool call requests. -class AssistantMessage extends Message { +final class AssistantMessage extends Message { final List? toolCalls; const AssistantMessage({ @@ -164,43 +307,105 @@ class AssistantMessage extends Message { super.content, super.name, this.toolCalls, + super.encryptedValue, }) : super(role: MessageRole.assistant); factory AssistantMessage.fromJson(Map json) { + // KEY-level dual-key resolution with eager element-type validation. + // Documented precedence rule (see [JsonDecoder.requireEitherField] + // dartdoc): if camelCase `toolCalls` is present, it wins even when the + // list is empty; snake_case `tool_calls` is consulted ONLY when + // camelCase is absent. The pre-fix `??`-on-value chain incorrectly + // surfaced `tool_calls` whenever camelCase resolved to null OR an + // empty list — silently dropping snake_case data on payloads that + // (incorrectly) carry both keys. The regression test + // `message_test.dart:401-446` ("AssistantMessage.fromJson dual-key + // precedence") pins this contract. + // + // Element-type validation: `optionalEitherListField` reports + // `field: 'toolCalls[$i]'` on a malformed nested element rather than + // letting a raw `TypeError` leak from the `as Map` + // cast — same convention as `MessagesSnapshotEvent.fromJson`. + final rawToolCalls = + JsonDecoder.optionalEitherListField>( + json, + 'toolCalls', + 'tool_calls', + ); return AssistantMessage( id: JsonDecoder.requireField(json, 'id'), content: JsonDecoder.optionalField(json, 'content'), name: JsonDecoder.optionalField(json, 'name'), - toolCalls: JsonDecoder.optionalListField>( + toolCalls: rawToolCalls == null ? null : () { + final result = []; + for (var i = 0; i < rawToolCalls.length; i++) { + try { + result.add(ToolCall.fromJson(rawToolCalls[i])); + } catch (e) { + if (e is AGUIValidationError) { + // Omit `json:` and `cause:` — ToolCall.fromJson can set e.json + // to a payload with sensitive `arguments`; the cause chain + // exposes it to reflection-based log shippers. + throw AGUIValidationError( + message: e.message, + field: 'toolCalls[$i].${e.field ?? 'unknown'}', + value: e.value, + ); + } + throw AGUIValidationError( + message: 'Failed to decode tool call at index $i: $e', + field: 'toolCalls[$i]', + cause: e, + ); + } + } + return result; + }(), + encryptedValue: JsonDecoder.optionalEitherField( json, - 'toolCalls', - )?.map((item) => ToolCall.fromJson(item)).toList() ?? - JsonDecoder.optionalListField>( - json, - 'tool_calls', - )?.map((item) => ToolCall.fromJson(item)).toList(), + 'encryptedValue', + 'encrypted_value', + ), ); } @override Map toJson() => { ...super.toJson(), - if (toolCalls != null && toolCalls!.isNotEmpty) + // Emit `toolCalls` whenever the in-memory field is non-null, even + // when empty, so the round-trip `fromJson(m.toJson()) == m` is + // symmetric. The previous `&& toolCalls!.isNotEmpty` guard dropped + // the key on empty lists, which decoded back to `null` instead of + // `[]` and made tests that depend on field-by-field equality + // surprising. + if (toolCalls != null) 'toolCalls': toolCalls!.map((tc) => tc.toJson()).toList(), }; + // See [kUnsetSentinel] for the sentinel rationale. `content`, + // `name`, `toolCalls`, and `encryptedValue` are all nullable on + // `AssistantMessage`, so callers may legitimately want to clear any + // of them via `copyWith`. @override AssistantMessage copyWith({ String? id, - String? content, - String? name, - List? toolCalls, + Object? content = kUnsetSentinel, + Object? name = kUnsetSentinel, + Object? toolCalls = kUnsetSentinel, + Object? encryptedValue = kUnsetSentinel, }) { return AssistantMessage( id: id ?? this.id, - content: content ?? this.content, - name: name ?? this.name, - toolCalls: toolCalls ?? this.toolCalls, + content: identical(content, kUnsetSentinel) + ? this.content + : content as String?, + name: identical(name, kUnsetSentinel) ? this.name : name as String?, + toolCalls: identical(toolCalls, kUnsetSentinel) + ? this.toolCalls + : toolCalls as List?, + encryptedValue: identical(encryptedValue, kUnsetSentinel) + ? this.encryptedValue + : encryptedValue as String?, ); } } @@ -208,7 +413,16 @@ class AssistantMessage extends Message { /// User message with required content. /// /// Represents input from the user in the conversation. -class UserMessage extends Message { +/// +/// Known parity gap with the canonical TS/Python schemas: TS uses +/// `content: z.union([z.string(), z.array(InputContentSchema)])` and +/// Python uses `content: Union[str, List[InputContent]]` for full +/// multimodal support. This Dart SDK currently only supports the string +/// variant — a multimodal payload from a TS or Python server raises +/// `AGUIValidationError(field: 'content')` because the factory's +/// `requireField` rejects the list type. Tracked for a future +/// release; see CHANGELOG → "Known parity gaps". +final class UserMessage extends Message { @override final String content; @@ -216,6 +430,7 @@ class UserMessage extends Message { required super.id, required this.content, super.name, + super.encryptedValue, }) : super(role: MessageRole.user); factory UserMessage.fromJson(Map json) { @@ -223,19 +438,39 @@ class UserMessage extends Message { id: JsonDecoder.requireField(json, 'id'), content: JsonDecoder.requireField(json, 'content'), name: JsonDecoder.optionalField(json, 'name'), + encryptedValue: JsonDecoder.optionalEitherField( + json, + 'encryptedValue', + 'encrypted_value', + ), ); } + @override + Map toJson() => { + if (id != null) 'id': id, + 'role': role.value, + 'content': content, + if (name != null) 'name': name, + if (encryptedValue != null) 'encryptedValue': encryptedValue, + }; + + // `name` and `encryptedValue` are nullable on the parent — sentinel + // for explicit-clear semantics. @override UserMessage copyWith({ String? id, String? content, - String? name, + Object? name = kUnsetSentinel, + Object? encryptedValue = kUnsetSentinel, }) { return UserMessage( id: id ?? this.id, content: content ?? this.content, - name: name ?? this.name, + name: identical(name, kUnsetSentinel) ? this.name : name as String?, + encryptedValue: identical(encryptedValue, kUnsetSentinel) + ? this.encryptedValue + : encryptedValue as String?, ); } } @@ -243,59 +478,203 @@ class UserMessage extends Message { /// Tool message with tool call result. /// /// Contains the result of a tool execution, linked to a specific tool call -/// via the [toolCallId] field. -class ToolMessage extends Message { +/// via the [toolCallId] field. The optional [encryptedValue] mirrors the +/// canonical TypeScript `ToolMessageSchema` and Python `ToolMessage` and +/// carries an opaque cipher payload that a Dart proxy must forward +/// verbatim to a downstream agent. +final class ToolMessage extends Message { @override final String content; final String toolCallId; final String? error; const ToolMessage({ - super.id, + required super.id, required this.content, required this.toolCallId, this.error, + super.encryptedValue, }) : super(role: MessageRole.tool); factory ToolMessage.fromJson(Map json) { - final toolCallId = JsonDecoder.optionalField(json, 'toolCallId') ?? - JsonDecoder.optionalField(json, 'tool_call_id'); - - if (toolCallId == null) { - throw AGUIValidationError( - message: 'Missing required field: toolCallId or tool_call_id', - field: 'toolCallId', - json: json, - ); - } - return ToolMessage( - id: JsonDecoder.optionalField(json, 'id'), + id: JsonDecoder.requireField(json, 'id'), content: JsonDecoder.requireField(json, 'content'), - toolCallId: toolCallId, + toolCallId: JsonDecoder.requireEitherField( + json, + 'toolCallId', + 'tool_call_id', + ), error: JsonDecoder.optionalField(json, 'error'), + encryptedValue: JsonDecoder.optionalEitherField( + json, + 'encryptedValue', + 'encrypted_value', + ), ); } @override Map toJson() => { - ...super.toJson(), - 'toolCallId': toolCallId, - if (error != null) 'error': error, - }; - + if (id != null) 'id': id, + 'role': role.value, + 'content': content, + if (name != null) 'name': name, + if (encryptedValue != null) 'encryptedValue': encryptedValue, + 'toolCallId': toolCallId, + if (error != null) 'error': error, + }; + + // `error` and `encryptedValue` are nullable — use the sentinel so a + // caller can explicitly clear either via `copyWith(error: null)` / + // `copyWith(encryptedValue: null)`. Mirrors the event-class sentinel + // discipline. @override ToolMessage copyWith({ String? id, String? content, String? toolCallId, - String? error, + Object? error = kUnsetSentinel, + Object? encryptedValue = kUnsetSentinel, }) { return ToolMessage( id: id ?? this.id, content: content ?? this.content, toolCallId: toolCallId ?? this.toolCallId, - error: error ?? this.error, + error: identical(error, kUnsetSentinel) ? this.error : error as String?, + encryptedValue: identical(encryptedValue, kUnsetSentinel) + ? this.encryptedValue + : encryptedValue as String?, + ); + } +} + +/// Activity message embedded in a `MESSAGES_SNAPSHOT` payload. +/// +/// Mirrors the canonical TypeScript `ActivityMessageSchema` +/// (`sdks/typescript/packages/core/src/types.ts`) and the Python +/// `ActivityMessage` model (`sdks/python/ag_ui/core/types.py`). The wire +/// shape is `{id, role: 'activity', activityType, content}` where +/// `content` is a JSON object (`z.record(z.any())` / `Dict[str, Any]`). +/// +/// The Dart in-memory accessor for the wire `content` field is named +/// [activityContent] to avoid shadowing the parent [Message.content] +/// (which is `String?`). The wire key remains `content` in [toJson] / +/// [fromJson] for protocol parity. +/// +/// **`encryptedValue` note.** `ActivityMessage` inherits [encryptedValue] +/// from [Message] but intentionally does not expose it in the constructor, +/// [fromJson], or [toJson]. In the canonical protocol `ActivityMessage` is +/// NOT a `BaseMessage` extension (unlike Developer/System/Assistant/User/Tool +/// messages), so cipher-payload forwarding does not apply here. If the wire +/// payload contains `encryptedValue` / `encrypted_value`, [fromJson] strips +/// it silently (matching TS zod-default strip behavior). In-memory instances +/// constructed via [copyWith] on a parent [Message] may inherit the field, +/// but [toJson] never emits it. +final class ActivityMessage extends Message { + final String activityType; + final Map activityContent; + + const ActivityMessage({ + required super.id, + required this.activityType, + required this.activityContent, + }) : super(role: MessageRole.activity); + + factory ActivityMessage.fromJson(Map json) { + // `ActivityMessage` is NOT a `BaseMessage` extension in the canonical + // protocol — cipher-payload forwarding does not apply. Strip any inbound + // `encryptedValue` / `encrypted_value` silently, matching TS zod-default + // strip behavior. A hard-fail here would make Dart the only SDK that tears + // down the stream when a proxy emits the field (TS strips, Python preserves). + return ActivityMessage( + id: JsonDecoder.requireField(json, 'id'), + activityType: JsonDecoder.requireEitherField( + json, + 'activityType', + 'activity_type', + ), + activityContent: + JsonDecoder.requireField>(json, 'content'), + ); + } + + @override + Map toJson() => { + // Explicitly skip super.toJson() — the inherited Message.content field + // must not appear in the wire output (activityContent is the `content` + // key here). Using ...super.toJson() would rely on map-spread + // overwrite order to mask any future super.content emission. + if (id != null) 'id': id, + 'role': role.value, + 'activityType': activityType, + 'content': activityContent, + }; + + @override + ActivityMessage copyWith({ + String? id, + String? activityType, + Map? activityContent, + }) { + return ActivityMessage( + id: id ?? this.id, + activityType: activityType ?? this.activityType, + activityContent: activityContent ?? this.activityContent, + ); + } +} + +/// Reasoning message embedded in a `MESSAGES_SNAPSHOT` payload. +/// +/// Mirrors the canonical TypeScript `ReasoningMessageSchema` and the +/// Python `ReasoningMessage` model. The wire shape is +/// `{id, role: 'reasoning', content, encryptedValue?}` with `content` as +/// a string and `encryptedValue` as an optional opaque cipher payload. +final class ReasoningMessage extends Message { + @override + final String content; + + const ReasoningMessage({ + required super.id, + required this.content, + super.encryptedValue, + }) : super(role: MessageRole.reasoning); + + factory ReasoningMessage.fromJson(Map json) { + return ReasoningMessage( + id: JsonDecoder.requireField(json, 'id'), + content: JsonDecoder.requireField(json, 'content'), + encryptedValue: JsonDecoder.optionalEitherField( + json, + 'encryptedValue', + 'encrypted_value', + ), + ); + } + + @override + Map toJson() => { + if (id != null) 'id': id, + 'role': role.value, + 'content': content, + if (encryptedValue != null) 'encryptedValue': encryptedValue, + }; + + // `encryptedValue` is nullable on the parent — sentinel lets callers + // clear it. + @override + ReasoningMessage copyWith({ + String? id, + String? content, + Object? encryptedValue = kUnsetSentinel, + }) { + return ReasoningMessage( + id: id ?? this.id, + content: content ?? this.content, + encryptedValue: identical(encryptedValue, kUnsetSentinel) + ? this.encryptedValue + : encryptedValue as String?, ); } } \ No newline at end of file diff --git a/sdks/community/dart/lib/src/types/tool.dart b/sdks/community/dart/lib/src/types/tool.dart index c0283f4cdc..70364d3f21 100644 --- a/sdks/community/dart/lib/src/types/tool.dart +++ b/sdks/community/dart/lib/src/types/tool.dart @@ -6,6 +6,9 @@ library; import 'base.dart'; +// `kUnsetSentinel` (from `base.dart`) is the shared sentinel for all +// `copyWith` methods in this file. + /// Represents a function call within a tool call. /// /// Contains the function name and serialized arguments for execution. @@ -47,15 +50,21 @@ class FunctionCall extends AGUIModel { /// /// Tool calls allow the assistant to request execution of external functions /// or tools to gather information or perform actions. +/// +/// The optional [encryptedValue] is an opaque cipher payload that a Dart +/// proxy must forward verbatim. It mirrors the canonical TS/Python +/// `ToolCall.encryptedValue` / `ToolCall.encrypted_value` field. class ToolCall extends AGUIModel { final String id; final String type; final FunctionCall function; + final String? encryptedValue; const ToolCall({ required this.id, this.type = 'function', required this.function, + this.encryptedValue, }); factory ToolCall.fromJson(Map json) { @@ -65,6 +74,11 @@ class ToolCall extends AGUIModel { function: FunctionCall.fromJson( JsonDecoder.requireField>(json, 'function'), ), + encryptedValue: JsonDecoder.optionalEitherField( + json, + 'encryptedValue', + 'encrypted_value', + ), ); } @@ -73,18 +87,26 @@ class ToolCall extends AGUIModel { 'id': id, 'type': type, 'function': function.toJson(), + if (encryptedValue != null) 'encryptedValue': encryptedValue, }; + // `encryptedValue` is nullable — sentinel lets callers clear it + // explicitly. Mirrors the message-class sentinel in + // lib/src/types/message.dart. @override ToolCall copyWith({ String? id, String? type, FunctionCall? function, + Object? encryptedValue = kUnsetSentinel, }) { return ToolCall( id: id ?? this.id, type: type ?? this.type, function: function ?? this.function, + encryptedValue: identical(encryptedValue, kUnsetSentinel) + ? this.encryptedValue + : encryptedValue as String?, ); } } @@ -93,15 +115,22 @@ class ToolCall extends AGUIModel { /// /// Defines a tool that can be called by the assistant, including its /// name, description, and parameter schema. +/// +/// [metadata] mirrors the canonical TS `ToolSchema.metadata: +/// z.record(z.any()).optional()` and Python's `extra='allow'` config. +/// A Dart proxy that decodes a tool list from a TS server and re-emits +/// it will round-trip arbitrary tool metadata without dropping it. class Tool extends AGUIModel { final String name; final String description; final dynamic parameters; // JSON Schema for the tool parameters + final Map? metadata; const Tool({ required this.name, required this.description, this.parameters, + this.metadata, }); factory Tool.fromJson(Map json) { @@ -109,6 +138,10 @@ class Tool extends AGUIModel { name: JsonDecoder.requireField(json, 'name'), description: JsonDecoder.requireField(json, 'description'), parameters: json['parameters'], // Allow any JSON Schema + metadata: JsonDecoder.optionalField>( + json, + 'metadata', + ), ); } @@ -117,18 +150,28 @@ class Tool extends AGUIModel { 'name': name, 'description': description, if (parameters != null) 'parameters': parameters, + if (metadata != null) 'metadata': metadata, }; + // Both `parameters` and `metadata` are nullable — sentinels let callers + // clear either field explicitly via `copyWith(field: null)`. Without the + // sentinel, `copyWith(metadata: null)` would silently retain the existing + // value because the `?? this.field` fallback treats explicit-null and + // "omitted" identically. @override Tool copyWith({ String? name, String? description, - dynamic parameters, + Object? parameters = kUnsetSentinel, + Object? metadata = kUnsetSentinel, }) { return Tool( name: name ?? this.name, description: description ?? this.description, - parameters: parameters ?? this.parameters, + parameters: identical(parameters, kUnsetSentinel) ? this.parameters : parameters, + metadata: identical(metadata, kUnsetSentinel) + ? this.metadata + : metadata as Map?, ); } } @@ -146,19 +189,12 @@ class ToolResult extends AGUIModel { }); factory ToolResult.fromJson(Map json) { - final toolCallId = JsonDecoder.optionalField(json, 'toolCallId') ?? - JsonDecoder.optionalField(json, 'tool_call_id'); - - if (toolCallId == null) { - throw AGUIValidationError( - message: 'Missing required field: toolCallId or tool_call_id', - field: 'toolCallId', - json: json, - ); - } - return ToolResult( - toolCallId: toolCallId, + toolCallId: JsonDecoder.requireEitherField( + json, + 'toolCallId', + 'tool_call_id', + ), content: JsonDecoder.requireField(json, 'content'), error: JsonDecoder.optionalField(json, 'error'), ); @@ -171,16 +207,18 @@ class ToolResult extends AGUIModel { if (error != null) 'error': error, }; + // `error` is nullable — sentinel lets callers clear it explicitly via + // `copyWith(error: null)`. Mirrors `ToolCall.encryptedValue` above. @override ToolResult copyWith({ String? toolCallId, String? content, - String? error, + Object? error = kUnsetSentinel, }) { return ToolResult( toolCallId: toolCallId ?? this.toolCallId, content: content ?? this.content, - error: error ?? this.error, + error: identical(error, kUnsetSentinel) ? this.error : error as String?, ); } } \ No newline at end of file diff --git a/sdks/community/dart/pubspec.yaml b/sdks/community/dart/pubspec.yaml index 43b14854ec..9e2c0a4227 100644 --- a/sdks/community/dart/pubspec.yaml +++ b/sdks/community/dart/pubspec.yaml @@ -1,6 +1,6 @@ name: ag_ui description: Dart SDK for AG-UI protocol - standardizing agent-user interactions through event-based communication -version: 0.1.0 +version: 0.2.0 homepage: https://github.com/ag-ui-protocol/ag-ui repository: https://github.com/ag-ui-protocol/ag-ui/tree/main/sdks/community/dart issue_tracker: https://github.com/ag-ui-protocol/ag-ui/issues diff --git a/sdks/community/dart/test/ag_ui_test.dart b/sdks/community/dart/test/ag_ui_test.dart index 10c2dcd08b..d6ef81e35c 100644 --- a/sdks/community/dart/test/ag_ui_test.dart +++ b/sdks/community/dart/test/ag_ui_test.dart @@ -4,7 +4,7 @@ import 'package:test/test.dart'; void main() { group('AG-UI SDK', () { test('has correct version', () { - expect(agUiVersion, '0.1.0'); + expect(agUiVersion, '0.2.0'); }); test('can initialize', () { diff --git a/sdks/community/dart/test/client/client_test.dart b/sdks/community/dart/test/client/client_test.dart index 0efc34b0f8..77399ba1d4 100644 --- a/sdks/community/dart/test/client/client_test.dart +++ b/sdks/community/dart/test/client/client_test.dart @@ -147,7 +147,7 @@ void main() { expect( () => client.runAgent('test_endpoint', SimpleRunAgentInput()).toList(), - throwsA(isA()), + throwsA(isA()), ); }); }); diff --git a/sdks/community/dart/test/client/errors_test.dart b/sdks/community/dart/test/client/errors_test.dart index 8e52bf0d83..260ddf44c2 100644 --- a/sdks/community/dart/test/client/errors_test.dart +++ b/sdks/community/dart/test/client/errors_test.dart @@ -58,9 +58,9 @@ void main() { }); }); - group('TimeoutError', () { + group('AGUITimeoutError', () { test('includes timeout duration', () { - final error = TimeoutError( + final error = AGUITimeoutError( 'Operation timed out', timeout: Duration(seconds: 30), operation: 'POST /runs', @@ -68,6 +68,16 @@ void main() { expect(error.toString(), contains('timeout: 30s')); expect(error.toString(), contains('operation: POST /runs')); }); + + test('deprecated TimeoutError typedef resolves to AGUITimeoutError', () { + // Backward-compat: pre-rename callers using the bare name still work. + // ignore: deprecated_member_use_from_same_package + final TimeoutError error = AGUITimeoutError( + 'Legacy alias', + timeout: Duration(seconds: 5), + ); + expect(error, isA()); + }); }); group('CancellationError', () { diff --git a/sdks/community/dart/test/client/http_endpoints_test.dart b/sdks/community/dart/test/client/http_endpoints_test.dart index b2c9044bcd..2f8186d49e 100644 --- a/sdks/community/dart/test/client/http_endpoints_test.dart +++ b/sdks/community/dart/test/client/http_endpoints_test.dart @@ -107,8 +107,8 @@ void main() { expect(capturedHeaders?['Accept'], contains('text/event-stream')); final bodyJson = json.decode(capturedBody!); - expect(bodyJson['thread_id'], 'thread_123'); - expect(bodyJson['run_id'], 'run_456'); + expect(bodyJson['threadId'], 'thread_123'); + expect(bodyJson['runId'], 'run_456'); expect(bodyJson['messages'], hasLength(1)); expect(bodyJson['config']['temperature'], 0.7); expect(bodyJson['metadata']['source'], 'test'); @@ -198,7 +198,7 @@ void main() { // Act & Assert expect( () => client.runAgent('test_endpoint', input).toList(), - throwsA(isA()), + throwsA(isA()), ); }); diff --git a/sdks/community/dart/test/client/validators_test.dart b/sdks/community/dart/test/client/validators_test.dart index 418b3f5867..b8abc8907b 100644 --- a/sdks/community/dart/test/client/validators_test.dart +++ b/sdks/community/dart/test/client/validators_test.dart @@ -74,6 +74,19 @@ void main() { .having((e) => e.constraint, 'constraint', 'non-empty')), ); }); + + test('rejects credential-bearing URLs (userInfo component)', () { + expect( + () => Validators.validateUrl('http://user:pass@example.com', 'url'), + throwsA(isA() + .having((e) => e.constraint, 'constraint', 'no-user-credentials')), + ); + expect( + () => Validators.validateUrl('https://token@api.example.com', 'url'), + throwsA(isA() + .having((e) => e.constraint, 'constraint', 'no-user-credentials')), + ); + }); }); group('Validators.validateAgentId', () { @@ -161,10 +174,15 @@ void main() { }); group('Validators.validateMessageContent', () { - test('accepts valid content types', () { - expect(() => Validators.validateMessageContent('Hello world'), returnsNormally); - expect(() => Validators.validateMessageContent({'text': 'Hello'}), returnsNormally); - expect(() => Validators.validateMessageContent(['item1', 'item2']), returnsNormally); + test('accepts string content (canonical schema)', () { + // Tightened in 0.2.0 to match canonical + // `BaseMessage.content: Optional[str]`. The pre-0.2.0 permissive + // Map/List branches were dead code — no caller in the SDK passed + // those types — and disagreed with the protocol. Multimodal + // `UserMessage.content` (string-or-list-of-InputContent) is + // tracked as a "Known parity gap" in the CHANGELOG. + expect(() => Validators.validateMessageContent('Hello world'), + returnsNormally); }); test('rejects null content', () { @@ -176,13 +194,8 @@ void main() { ); }); - test('rejects invalid types', () { - expect( - () => Validators.validateMessageContent(123), - throwsA(isA() - .having((e) => e.constraint, 'constraint', 'valid-type')), - ); - }); + // Non-String values are rejected at compile time by the `String?` parameter + // type — no runtime `is! String` check is needed or present. }); group('Validators.validateTimeout', () { diff --git a/sdks/community/dart/test/encoder/client_codec_test.dart b/sdks/community/dart/test/encoder/client_codec_test.dart index 2ab873bcf5..d6605ff20d 100644 --- a/sdks/community/dart/test/encoder/client_codec_test.dart +++ b/sdks/community/dart/test/encoder/client_codec_test.dart @@ -53,6 +53,8 @@ void main() { }); test('encodeRunAgentInput handles empty input', () { + // Only non-null fields are emitted — null fields are omitted to avoid + // sending spurious empty defaults that the server may not expect. final input = SimpleRunAgentInput( messages: [], ); @@ -60,12 +62,11 @@ void main() { final encoded = encoder.encodeRunAgentInput(input); expect(encoded, isA>()); - expect(encoded['messages'], isEmpty); - // These fields are always included with defaults for API consistency - expect(encoded['state'], equals({})); - expect(encoded['tools'], isEmpty); - expect(encoded['context'], isEmpty); - expect(encoded['forwardedProps'], equals({})); + expect(encoded['messages'], isEmpty); // non-null empty list → emitted + expect(encoded.containsKey('state'), isFalse); // null → omitted + expect(encoded.containsKey('tools'), isFalse); // null → omitted + expect(encoded.containsKey('context'), isFalse); // null → omitted + expect(encoded.containsKey('forwardedProps'), isFalse); // null → omitted }); test('encodeUserMessage encodes UserMessage correctly', () { @@ -95,8 +96,8 @@ void main() { expect(encoded['id'], equals('msg-simple')); }); - test('encodeToolResult encodes ToolResult with all fields', () { - final result = codec.ToolResult( + test('encodeToolResult encodes ClientToolResult with all fields', () { + final result = codec.ClientToolResult( toolCallId: 'call_123', result: {'data': 'test result'}, error: 'Some error occurred', @@ -113,7 +114,7 @@ void main() { }); test('encodeToolResult handles result without optional fields', () { - final result = codec.ToolResult( + final result = codec.ClientToolResult( toolCallId: 'call_456', result: 'Simple result', ); @@ -136,7 +137,7 @@ void main() { 'number': 42.5, }; - final result = codec.ToolResult( + final result = codec.ClientToolResult( toolCallId: 'call_789', result: complexResult, ); @@ -147,7 +148,7 @@ void main() { }); test('encodeToolResult handles null result', () { - final result = codec.ToolResult( + final result = codec.ClientToolResult( toolCallId: 'call_null', result: null, ); @@ -172,9 +173,9 @@ void main() { }); }); - group('ToolResult', () { + group('ClientToolResult', () { test('creates with required fields only', () { - final result = codec.ToolResult( + final result = codec.ClientToolResult( toolCallId: 'id_123', result: 'test', ); @@ -186,7 +187,7 @@ void main() { }); test('creates with all fields', () { - final result = codec.ToolResult( + final result = codec.ClientToolResult( toolCallId: 'id_456', result: {'key': 'value'}, error: 'Error message', @@ -200,7 +201,7 @@ void main() { }); test('const constructor works', () { - const result = codec.ToolResult( + const result = codec.ClientToolResult( toolCallId: 'const_id', result: 'const_result', ); @@ -211,23 +212,23 @@ void main() { test('handles different result types', () { // String result - var result = codec.ToolResult(toolCallId: '1', result: 'string'); + var result = codec.ClientToolResult(toolCallId: '1', result: 'string'); expect(result.result, isA()); // Number result - result = codec.ToolResult(toolCallId: '2', result: 42); + result = codec.ClientToolResult(toolCallId: '2', result: 42); expect(result.result, isA()); // Boolean result - result = codec.ToolResult(toolCallId: '3', result: true); + result = codec.ClientToolResult(toolCallId: '3', result: true); expect(result.result, isA()); // List result - result = codec.ToolResult(toolCallId: '4', result: [1, 2, 3]); + result = codec.ClientToolResult(toolCallId: '4', result: [1, 2, 3]); expect(result.result, isA()); // Map result - result = codec.ToolResult(toolCallId: '5', result: {'nested': 'object'}); + result = codec.ClientToolResult(toolCallId: '5', result: {'nested': 'object'}); expect(result.result, isA()); }); }); diff --git a/sdks/community/dart/test/encoder/decoder_test.dart b/sdks/community/dart/test/encoder/decoder_test.dart index 3af8496b6c..f64c53467a 100644 --- a/sdks/community/dart/test/encoder/decoder_test.dart +++ b/sdks/community/dart/test/encoder/decoder_test.dart @@ -67,13 +67,16 @@ void main() { ); }); - test('throws DecodingError for empty delta in content event', () { - final json = '{"type":"TEXT_MESSAGE_CONTENT","messageId":"msg123","delta":""}'; - - expect( - () => decoder.decode(json), - throwsA(isA()), // Event creation fails - ); + test('accepts empty delta in TEXT_MESSAGE_CONTENT (canonical parity)', + () { + // Canonical TS/Python schemas allow empty `delta`. Decoder + // pipeline must mirror the relaxed contract end-to-end. + final json = + '{"type":"TEXT_MESSAGE_CONTENT","messageId":"msg123","delta":""}'; + + final event = decoder.decode(json); + expect(event, isA()); + expect((event as TextMessageContentEvent).delta, isEmpty); }); }); @@ -318,20 +321,33 @@ data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1"} ); }); - test('throws ValidationError for empty delta in content event', () { + test('validate() accepts empty delta in content event (canonical parity)', + () { + // Canonical TS/Python schemas allow empty `delta` + // (`TextMessageContentEventSchema.delta: z.string()`). The + // decoder pipeline must NOT reject it. The deprecated + // `Thinking*Content` events now also accept empty `delta`. final event = TextMessageContentEvent( messageId: 'msg123', delta: '', ); - - expect( - () => decoder.validate(event), - throwsA(isA() - .having((e) => e.field, 'field', equals('delta')) - .having((e) => e.message, 'message', contains('cannot be empty'))), - ); + + expect(decoder.validate(event), isTrue); }); + test( + 'accepts empty delta in deprecated thinking-text content event', + () { + // Relaxed to match the canonical `z.string()` contract — empty + // `delta` is now accepted. Consumers should migrate to + // [ReasoningMessageContentEvent] which uses the relaxed contract. + // ignore: deprecated_member_use_from_same_package + final event = ThinkingTextMessageContentEvent(delta: ''); + + expect(decoder.validate(event), isTrue); + }, + ); + test('throws ValidationError for empty tool call fields', () { final event = ToolCallStartEvent( toolCallId: '', diff --git a/sdks/community/dart/test/encoder/encoder_test.dart b/sdks/community/dart/test/encoder/encoder_test.dart index ad66dd6cbc..b856970e19 100644 --- a/sdks/community/dart/test/encoder/encoder_test.dart +++ b/sdks/community/dart/test/encoder/encoder_test.dart @@ -223,7 +223,7 @@ void main() { messageId: 'msg123', toolCallId: 'tool456', content: 'Search results: ...', - role: 'tool', + role: ToolCallResultRole.tool, ); final encoded = encoder.encodeSSE(originalEvent); diff --git a/sdks/community/dart/test/encoder/stream_adapter_test.dart b/sdks/community/dart/test/encoder/stream_adapter_test.dart index 394ee3d5eb..05ba252d1a 100644 --- a/sdks/community/dart/test/encoder/stream_adapter_test.dart +++ b/sdks/community/dart/test/encoder/stream_adapter_test.dart @@ -215,21 +215,184 @@ void main() { test('processes remaining buffered data on close', () async { final rawController = StreamController(); final eventStream = adapter.fromRawSseStream(rawController.stream); - + final events = []; final subscription = eventStream.listen(events.add); - + // Add data without final newlines rawController.add('data: {"type":"STATE_SNAPSHOT","snapshot":{"count":42}}'); - + await rawController.close(); await subscription.cancel(); - + expect(events.length, equals(1)); expect(events[0], isA()); final event = events[0] as StateSnapshotEvent; expect(event.snapshot['count'], equals(42)); }); + + test('handles CRLF split across chunks without double-dispatch', () async { + // Regression for Opus2 I3: when lastWasLoneCrAtStart=true and the new + // chunk starts with '\n', that '\n' is the second half of a chunk-spanning + // CRLF pair and must NOT produce an extra empty line (which would cause a + // spurious flush of an in-progress data block). + // + // Chunk 1: "data: foo\r\r" + // - First \r terminates "data: foo" (lone-CR, sets lastWasLoneCr=true) + // - Second \r terminates "" (empty line, dispatches "foo", keeps lastWasLoneCr=true) + // Chunk 2: "\ndata: bar\n\n" + // - Leading \n is the CRLF complement of a PRIOR chunk boundary + // (skipped by the edge-case fix so it doesn't dispatch an extra event) + // - "data: bar" + "\n\n" dispatches "bar" + final rawController = StreamController(); + final eventStream = + adapter.fromRawSseStream(rawController.stream); + + final events = []; + final subscription = eventStream.listen(events.add); + + rawController.add( + 'data: {"type":"RUN_STARTED","threadId":"t1","runId":"r1"}\r\r', + ); + rawController.add( + '\ndata: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1"}\n\n', + ); + + await rawController.close(); + await subscription.cancel(); + + // Must produce exactly 2 events, not 3 (the spurious empty-flush + // from the lone \n would have caused a double-dispatch before the fix). + expect(events.length, equals(2), + reason: 'leading \\n in chunk 2 must not produce an extra dispatch'); + expect(events[0], isA()); + expect(events[1], isA()); + }); + + test('lone-CR: lastWasLoneCr persists through zero-length intermediate chunk', + () async { + // Regression for II5: when a lone-CR terminator is delivered in one + // chunk and the next chunk is empty (zero-length), lastWasLoneCr must + // survive across the empty chunk so the subsequent real chunk does not + // stall waiting for a deferred \r resolution. + final rawController = StreamController(); + final eventStream = adapter.fromRawSseStream(rawController.stream); + + final events = []; + final subscription = eventStream.listen(events.add); + + // Chunk 1: event + lone-CR terminator pair (CR = end of data line, CR = empty line → flush) + rawController.add( + 'data: {"type":"RUN_STARTED","threadId":"t1","runId":"r1"}\r\r', + ); + // Chunk 2: zero-length — must not reset lastWasLoneCr state + rawController.add(''); + // Chunk 3: second event using lone-CR style + rawController.add( + 'data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1"}\r\r', + ); + + await rawController.close(); + await subscription.cancel(); + + expect(events.length, equals(2)); + expect(events[0], isA()); + expect(events[1], isA()); + }); + + test('lone-CR: three back-to-back events each delivered in their own chunk', + () async { + // Regression for I4/II5: three consecutive lone-CR-terminated events + // delivered one per chunk. Each chunk ends with \r\r (data line CR + + // empty-line CR). The lastWasLoneCr flag must persist correctly so + // each event is dispatched exactly once. + final rawController = StreamController(); + final eventStream = adapter.fromRawSseStream(rawController.stream); + + final events = []; + final subscription = eventStream.listen(events.add); + + for (final runId in ['r1', 'r2', 'r3']) { + rawController.add( + 'data: {"type":"RUN_STARTED","threadId":"t1","runId":"$runId"}\r\r', + ); + } + + await rawController.close(); + await subscription.cancel(); + + expect(events.length, equals(3)); + expect((events[0] as RunStartedEvent).runId, equals('r1')); + expect((events[1] as RunStartedEvent).runId, equals('r2')); + expect((events[2] as RunStartedEvent).runId, equals('r3')); + }); + + test('mixed lone-CR + CRLF terminators in adjacent events', () async { + // Regression for I4: chunk1 uses lone-CR style, chunk2 uses CRLF. + // The transition must not double-dispatch or lose an event. + // chunk1: "data: foo\r" — lone-CR terminates the line; trailing \r + // is deferred (not yet a lone-CR producer confirmation) + // chunk2: "\r\ndata: bar\n\n" — the leading \r is interpreted as the + // continuation of the prior deferred \r, making it a lone-CR + // (empty line → flush foo), then \n is handled as a new + // terminator for the CRLF-style event. + // Actually the simpler test: lone-CR event in chunk1, CRLF event in chunk2. + final rawController = StreamController(); + final eventStream = adapter.fromRawSseStream(rawController.stream); + + final events = []; + final subscription = eventStream.listen(events.add); + + // Chunk 1: lone-CR event (data line + empty line via lone-CR) + rawController.add( + 'data: {"type":"RUN_STARTED","threadId":"t1","runId":"r1"}\r\r', + ); + // Chunk 2: CRLF-terminated event + rawController.add( + 'data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1"}\r\n\r\n', + ); + + await rawController.close(); + await subscription.cancel(); + + expect(events.length, equals(2)); + expect(events[0], isA()); + expect(events[1], isA()); + }); + + test('downstream cancellation propagates to upstream subscription', + () async { + // Regression for the leaked-subscription bug noted in the #1018 + // review: pre-fix, `rawStream.listen(...)` was fire-and-forget — + // the returned stream's `controller.onCancel` did not cancel the + // upstream subscription. A consumer that stops listening early + // left the upstream draining indefinitely. + final rawController = StreamController(); + final eventStream = adapter.fromRawSseStream(rawController.stream); + + final events = []; + final subscription = eventStream.listen(events.add); + + // Push one complete event, then assert the upstream is alive. + rawController.add( + 'data: {"type":"RUN_STARTED","threadId":"t1","runId":"r1"}\n\n', + ); + await Future.delayed(Duration.zero); + expect(events.length, equals(1)); + expect(rawController.hasListener, isTrue); + + // Cancel the downstream subscription; upstream listener should + // be released. + await subscription.cancel(); + // A microtask hop lets the cancel propagate through the + // controller before we sample `hasListener`. + await Future.delayed(Duration.zero); + expect(rawController.hasListener, isFalse, + reason: 'fromRawSseStream must cancel its upstream subscription ' + 'when the downstream stream is cancelled'); + + await rawController.close(); + }); }); group('filterByType', () { @@ -371,27 +534,174 @@ void main() { test('emits incomplete groups on stream close', () async { final controller = StreamController(); final grouped = EventStreamAdapter.groupRelatedEvents(controller.stream); - + final groups = >[]; final completer = Completer(); final subscription = grouped.listen( groups.add, onDone: completer.complete, ); - + // Incomplete message (no END event) controller.add(TextMessageStartEvent(messageId: 'msg1')); controller.add(TextMessageContentEvent(messageId: 'msg1', delta: 'Hello')); - + await controller.close(); await completer.future; // Wait for stream to complete await subscription.cancel(); - + expect(groups.length, equals(1)); expect(groups[0].length, equals(2)); expect(groups[0][0], isA()); expect(groups[0][1], isA()); }); + + test('groups ReasoningMessage* events by messageId', () async { + // Regression for Opus1 I1: ReasoningMessage* events must be grouped + // like TextMessage* events, not fall to the default single-event branch. + final controller = StreamController(); + final grouped = EventStreamAdapter.groupRelatedEvents(controller.stream); + + final groups = >[]; + final subscription = grouped.listen(groups.add); + + controller.add(ReasoningMessageStartEvent(messageId: 'rsn1')); + controller.add(ReasoningMessageContentEvent( + messageId: 'rsn1', + delta: 'Thinking...', + )); + controller.add(ReasoningMessageEndEvent(messageId: 'rsn1')); + + await controller.close(); + await subscription.cancel(); + + expect(groups.length, equals(1)); + expect(groups[0].length, equals(3)); + expect(groups[0][0], isA()); + expect(groups[0][1], isA()); + expect(groups[0][2], isA()); + }); + + test('routes chunk into open group when Start/End cycle is active', () async { + // Regression: *Chunk events must be routed into an active group rather + // than emitted as standalone single-element groups via the default branch. + final controller = StreamController(); + final grouped = EventStreamAdapter.groupRelatedEvents(controller.stream); + + final groups = >[]; + final subscription = grouped.listen(groups.add); + + // TextMessageChunkEvent arriving while a Start/End cycle is open + controller.add(TextMessageStartEvent(messageId: 'msg1')); + controller.add(TextMessageChunkEvent(messageId: 'msg1', delta: 'chunk')); + controller.add(TextMessageEndEvent(messageId: 'msg1')); + + await controller.close(); + await subscription.cancel(); + + // All three events must land in a single group, not 2 groups + expect(groups.length, equals(1)); + expect(groups[0].length, equals(3)); + expect(groups[0][1], isA()); + }); + + test('emits standalone chunk when no matching open group exists', () async { + // A *Chunk with no active group (e.g. server sends only chunks, no + // Start/End) must still be emitted, just as a single-element group. + final controller = StreamController(); + final grouped = EventStreamAdapter.groupRelatedEvents(controller.stream); + + final groups = >[]; + final subscription = grouped.listen(groups.add); + + controller.add(TextMessageChunkEvent(messageId: 'msg1', delta: 'standalone')); + + await controller.close(); + await subscription.cancel(); + + expect(groups.length, equals(1)); + expect(groups[0].length, equals(1)); + expect(groups[0][0], isA()); + }); + + // Regression for I-J: Tool and Reasoning chunk families were not covered. + test('routes ToolCallChunkEvent into open tool group', () async { + final controller = StreamController(); + final grouped = EventStreamAdapter.groupRelatedEvents(controller.stream); + + final groups = >[]; + final subscription = grouped.listen(groups.add); + + controller.add(ToolCallStartEvent( + toolCallId: 'tc1', + toolCallName: 'search', + parentMessageId: 'msg1', + )); + controller.add(ToolCallChunkEvent(toolCallId: 'tc1', delta: '{"q"')); + controller.add(ToolCallEndEvent(toolCallId: 'tc1')); + + await controller.close(); + await subscription.cancel(); + + // All three must land in a single group, not 2 groups + expect(groups.length, equals(1)); + expect(groups[0].length, equals(3)); + expect(groups[0][1], isA()); + }); + + test('emits standalone ToolCallChunkEvent when no open group exists', () async { + final controller = StreamController(); + final grouped = EventStreamAdapter.groupRelatedEvents(controller.stream); + + final groups = >[]; + final subscription = grouped.listen(groups.add); + + controller.add(ToolCallChunkEvent(toolCallId: 'tc1', delta: '{}')); + + await controller.close(); + await subscription.cancel(); + + expect(groups.length, equals(1)); + expect(groups[0].length, equals(1)); + expect(groups[0][0], isA()); + }); + + test('routes ReasoningMessageChunkEvent into open reasoning group', () async { + final controller = StreamController(); + final grouped = EventStreamAdapter.groupRelatedEvents(controller.stream); + + final groups = >[]; + final subscription = grouped.listen(groups.add); + + controller.add(ReasoningMessageStartEvent(messageId: 'rm1')); + controller.add(ReasoningMessageChunkEvent(messageId: 'rm1', delta: 'thinking')); + controller.add(ReasoningMessageEndEvent(messageId: 'rm1')); + + await controller.close(); + await subscription.cancel(); + + // All three must land in a single group, not 2 groups + expect(groups.length, equals(1)); + expect(groups[0].length, equals(3)); + expect(groups[0][1], isA()); + }); + + test('emits standalone ReasoningMessageChunkEvent when no open group exists', () async { + final controller = StreamController(); + final grouped = EventStreamAdapter.groupRelatedEvents(controller.stream); + + final groups = >[]; + final subscription = grouped.listen(groups.add); + + controller.add(ReasoningMessageChunkEvent(messageId: 'rm1', delta: 'standalone')); + + await controller.close(); + await subscription.cancel(); + + expect(groups.length, equals(1)); + expect(groups[0].length, equals(1)); + expect(groups[0][0], isA()); + }); }); group('accumulateTextMessages', () { @@ -502,20 +812,47 @@ void main() { final accumulated = EventStreamAdapter.accumulateTextMessages( controller.stream, ); - + final messages = []; final subscription = accumulated.listen(messages.add); - + // Message with no content events controller.add(TextMessageStartEvent(messageId: 'msg1')); controller.add(TextMessageEndEvent(messageId: 'msg1')); - + await controller.close(); await subscription.cancel(); - + expect(messages.length, equals(1)); expect(messages[0], equals('')); }); + + test('flushes partial content on stream close without TextMessageEnd', () async { + // Regression: When the upstream closes abnormally (no TextMessageEnd), + // accumulated content must be flushed rather than silently discarded. + // Mirrors groupRelatedEvents which emits incomplete groups on close. + final controller = StreamController(); + final accumulated = EventStreamAdapter.accumulateTextMessages( + controller.stream, + ); + + final messages = []; + final completer = Completer(); + final subscription = accumulated.listen( + messages.add, + onDone: completer.complete, + ); + + controller.add(TextMessageStartEvent(messageId: 'msg1')); + controller.add(TextMessageContentEvent(messageId: 'msg1', delta: 'partial')); + // No TextMessageEndEvent — simulates abnormal stream close + await controller.close(); + await completer.future; + await subscription.cancel(); + + expect(messages.length, equals(1)); + expect(messages[0], equals('partial')); + }); }); }); } \ No newline at end of file diff --git a/sdks/community/dart/test/events/event_test.dart b/sdks/community/dart/test/events/event_test.dart index c1246cc467..9598c4902c 100644 --- a/sdks/community/dart/test/events/event_test.dart +++ b/sdks/community/dart/test/events/event_test.dart @@ -23,25 +23,55 @@ void main() { expect(decoded.timestamp, event.timestamp); }); - test('TextMessageContentEvent validation', () { - // Valid event with non-empty delta + test('TextMessageContentEvent accepts empty delta (canonical parity)', + () { + // Canonical TS/Python schemas allow empty `delta` + // (`TextMessageContentEventSchema.delta: z.string()` / + // pydantic `delta: str` with no `min_length`). Servers may + // legitimately emit a deliberate empty content chunk. final validEvent = TextMessageContentEvent( messageId: 'msg_001', delta: 'Hello world', ); expect(validEvent.delta, 'Hello world'); - // Invalid event with empty delta should throw - final invalidJson = { + final empty = TextMessageContentEvent.fromJson({ 'type': 'TEXT_MESSAGE_CONTENT', 'messageId': 'msg_001', 'delta': '', - }; + }); + expect(empty.delta, isEmpty); + }); - expect( - () => TextMessageContentEvent.fromJson(invalidJson), - throwsA(isA()), - ); + test('TextMessage* events accept snake_case (Python server)', () { + final start = TextMessageStartEvent.fromJson({ + 'type': 'TEXT_MESSAGE_START', + 'message_id': 'msg_001', + 'role': 'assistant', + }); + expect(start.messageId, 'msg_001'); + + final content = TextMessageContentEvent.fromJson({ + 'type': 'TEXT_MESSAGE_CONTENT', + 'message_id': 'msg_001', + 'delta': 'hello', + }); + expect(content.messageId, 'msg_001'); + expect(content.delta, 'hello'); + + final end = TextMessageEndEvent.fromJson({ + 'type': 'TEXT_MESSAGE_END', + 'message_id': 'msg_001', + }); + expect(end.messageId, 'msg_001'); + + final chunk = TextMessageChunkEvent.fromJson({ + 'type': 'TEXT_MESSAGE_CHUNK', + 'message_id': 'msg_001', + 'delta': 'partial', + }); + expect(chunk.messageId, 'msg_001'); + expect(chunk.delta, 'partial'); }); test('TextMessageChunkEvent optional fields', () { @@ -63,6 +93,100 @@ void main() { expect(minimalJson.containsKey('role'), false); expect(minimalJson.containsKey('delta'), false); }); + + test('TextMessageRole.fromString throws on unknown values', () { + // Aligned with `ReasoningMessageRole.fromString` — unknown wire + // values throw at the enum so direct callers see a visible + // failure mode. Wire decoding still succeeds via the factory's + // absorb (see the `falls back to assistant` test below). + expect( + () => TextMessageRole.fromString('bogus'), + throwsA(isA()), + ); + }); + + test( + 'TextMessageStartEvent falls back to assistant for an unknown ' + 'role (forward-compat, no stream tear-down)', () { + final decoded = TextMessageStartEvent.fromJson({ + 'type': 'TEXT_MESSAGE_START', + 'messageId': 'msg_001', + 'role': 'bogus', + }); + expect(decoded.role, TextMessageRole.assistant); + expect(decoded.messageId, 'msg_001'); + }); + + test( + 'TextMessageChunkEvent falls back to null for an unknown role ' + '(forward-compat: nullable field, not required like TextMessageStartEvent)', () { + final decoded = TextMessageChunkEvent.fromJson({ + 'type': 'TEXT_MESSAGE_CHUNK', + 'messageId': 'msg_001', + 'role': 'bogus', + 'delta': 'partial', + }); + // role is nullable/optional on TextMessageChunkEvent — an unknown wire + // value should produce null so callers can distinguish "absent" from + // "unrecognized." Contrast: TextMessageStartEvent has a required role, + // so the assistant fallback is appropriate there. + expect(decoded.role, isNull); + expect(decoded.messageId, 'msg_001'); + expect(decoded.delta, 'partial'); + }); + + test('TextMessageStartEvent preserves name across round-trip', () { + // Regression guard for #1018: pre-PR `name` was silently dropped + // on decode. Now decode/re-encode preserves the field, and + // omitting it round-trips as absent (no `'name': null`). + final withName = TextMessageStartEvent.fromJson({ + 'type': 'TEXT_MESSAGE_START', + 'messageId': 'msg_001', + 'role': 'assistant', + 'name': 'tool_response', + }); + expect(withName.name, 'tool_response'); + expect(withName.toJson()['name'], 'tool_response'); + + final withoutName = TextMessageStartEvent.fromJson({ + 'type': 'TEXT_MESSAGE_START', + 'messageId': 'msg_002', + 'role': 'assistant', + }); + expect(withoutName.name, isNull); + expect(withoutName.toJson().containsKey('name'), false); + }); + + test('TextMessageChunkEvent preserves name across round-trip', () { + // Same parity fix as TextMessageStartEvent. `name` on chunk is + // optional; presence/absence must round-trip cleanly. + final withName = TextMessageChunkEvent.fromJson({ + 'type': 'TEXT_MESSAGE_CHUNK', + 'messageId': 'msg_001', + 'name': 'tool_response', + 'delta': 'hello', + }); + expect(withName.name, 'tool_response'); + expect(withName.toJson()['name'], 'tool_response'); + + final withoutName = TextMessageChunkEvent.fromJson({ + 'type': 'TEXT_MESSAGE_CHUNK', + 'messageId': 'msg_002', + 'delta': 'hello', + }); + expect(withoutName.name, isNull); + expect(withoutName.toJson().containsKey('name'), false); + }); + + test('TextMessageStartEvent.copyWith(name: null) clears name', () { + // Sentinel-pattern verification — `name` uses `_unsetCopyWith`. + final event = TextMessageStartEvent( + messageId: 'msg_001', + name: 'foo', + ); + expect(event.copyWith(name: null).name, isNull); + expect(event.copyWith().name, 'foo'); + }); }); group('ToolCallEvents', () { @@ -85,19 +209,140 @@ void main() { expect(decoded.parentMessageId, event.parentMessageId); }); + test('ToolCall* events accept snake_case (Python server)', () { + final start = ToolCallStartEvent.fromJson({ + 'type': 'TOOL_CALL_START', + 'tool_call_id': 'call_001', + 'tool_call_name': 'get_weather', + 'parent_message_id': 'msg_001', + }); + expect(start.toolCallId, 'call_001'); + expect(start.toolCallName, 'get_weather'); + expect(start.parentMessageId, 'msg_001'); + + final args = ToolCallArgsEvent.fromJson({ + 'type': 'TOOL_CALL_ARGS', + 'tool_call_id': 'call_001', + 'delta': '{"q":"x"}', + }); + expect(args.toolCallId, 'call_001'); + + final end = ToolCallEndEvent.fromJson({ + 'type': 'TOOL_CALL_END', + 'tool_call_id': 'call_001', + }); + expect(end.toolCallId, 'call_001'); + + final chunk = ToolCallChunkEvent.fromJson({ + 'type': 'TOOL_CALL_CHUNK', + 'tool_call_id': 'call_001', + 'tool_call_name': 'get_weather', + 'parent_message_id': 'msg_001', + 'delta': '{', + }); + expect(chunk.toolCallId, 'call_001'); + expect(chunk.toolCallName, 'get_weather'); + expect(chunk.parentMessageId, 'msg_001'); + + final result = ToolCallResultEvent.fromJson({ + 'type': 'TOOL_CALL_RESULT', + 'message_id': 'msg_001', + 'tool_call_id': 'call_001', + 'content': '72F sunny', + 'role': 'tool', + }); + expect(result.messageId, 'msg_001'); + expect(result.toolCallId, 'call_001'); + }); + test('ToolCallResultEvent role field', () { final event = ToolCallResultEvent( messageId: 'msg_001', toolCallId: 'call_001', content: 'Weather: Sunny, 72°F', - role: 'tool', + role: ToolCallResultRole.tool, ); final json = event.toJson(); expect(json['role'], 'tool'); final decoded = ToolCallResultEvent.fromJson(json); - expect(decoded.role, 'tool'); + expect(decoded.role, ToolCallResultRole.tool); + }); + + test('ToolCallResultEvent absorbs unknown wire role', () { + // Forward-compat: an unknown role on the wire falls back to + // `tool` so the stream stays alive. Mirrors `TextMessageRole` / + // `ReasoningMessageRole` semantics — see + // `dart-enum-parsing-safety.md`. + final decoded = ToolCallResultEvent.fromJson({ + 'type': 'TOOL_CALL_RESULT', + 'messageId': 'msg_001', + 'toolCallId': 'call_001', + 'content': 'ok', + 'role': 'developer', + }); + expect(decoded.role, ToolCallResultRole.tool); + }); + + test('ToolCallResultEvent.copyWith(role: null) clears the role', () { + final event = ToolCallResultEvent( + messageId: 'msg_001', + toolCallId: 'call_001', + content: 'ok', + role: ToolCallResultRole.tool, + ); + expect(event.copyWith(role: null).role, isNull); + expect(event.copyWith().role, ToolCallResultRole.tool); + }); + + test('ToolCallStartEvent.copyWith(parentMessageId: null) clears it', () { + // Sentinel-pattern verification for `parentMessageId`. + final event = ToolCallStartEvent( + toolCallId: 'call_001', + toolCallName: 'get_weather', + parentMessageId: 'msg_001', + ); + expect(event.copyWith(parentMessageId: null).parentMessageId, isNull); + expect(event.copyWith().parentMessageId, 'msg_001'); + }); + + test('ToolCallArgsEvent accepts empty delta (canonical parity)', () { + // Canonical TS/Python schemas allow empty `delta` + // (`ToolCallArgsEventSchema.delta: z.string()` / pydantic + // `delta: str`). Direct factory and decoder pipeline both + // accept it. + final ev = ToolCallArgsEvent.fromJson({ + 'type': 'TOOL_CALL_ARGS', + 'toolCallId': 'call_001', + 'delta': '', + }); + expect(ev.delta, isEmpty); + }); + + test('ToolCallChunkEvent allows all-optional payload', () { + // Pins the deliberate `case ToolCallChunkEvent(): break;` in + // `EventDecoder.validate` (decoder.dart). An entirely empty chunk + // is a valid wire shape; it round-trips and survives the decoder + // boundary. Mirrors the equivalent assertion for + // `ReasoningMessageChunkEvent`. + final empty = ToolCallChunkEvent(); + final emptyJson = empty.toJson(); + expect(emptyJson['type'], 'TOOL_CALL_CHUNK'); + expect(emptyJson.containsKey('toolCallId'), false); + expect(emptyJson.containsKey('toolCallName'), false); + expect(emptyJson.containsKey('parentMessageId'), false); + expect(emptyJson.containsKey('delta'), false); + + final decoded = ToolCallChunkEvent.fromJson(emptyJson); + expect(decoded.toolCallId, isNull); + expect(decoded.toolCallName, isNull); + expect(decoded.parentMessageId, isNull); + expect(decoded.delta, isNull); + + const decoder = EventDecoder(); + final viaDecoder = decoder.decodeJson({'type': 'TOOL_CALL_CHUNK'}); + expect(viaDecoder, isA()); }); }); @@ -162,6 +407,39 @@ void main() { expect(decoded.messages[1], isA()); expect(decoded.messages[2], isA()); }); + + test('MessagesSnapshotEvent round-trips activity and reasoning messages', + () { + final messages = [ + UserMessage(id: 'u1', content: 'Index this directory.'), + ActivityMessage( + id: 'act1', + activityType: 'task.run', + activityContent: const {'progress': 0.0, 'items': []}, + ), + ReasoningMessage( + id: 'rsn1', + content: 'Considering file types', + encryptedValue: 'cGF5bG9hZA==', + ), + ]; + + final event = MessagesSnapshotEvent(messages: messages); + final json = event.toJson(); + + final decoded = MessagesSnapshotEvent.fromJson(json); + expect(decoded.messages.length, 3); + expect(decoded.messages[1], isA()); + expect(decoded.messages[2], isA()); + + final activity = decoded.messages[1] as ActivityMessage; + expect(activity.activityType, 'task.run'); + expect(activity.activityContent['progress'], 0.0); + + final reasoning = decoded.messages[2] as ReasoningMessage; + expect(reasoning.content, 'Considering file types'); + expect(reasoning.encryptedValue, 'cGF5bG9hZA=='); + }); }); group('LifecycleEvents', () { @@ -204,6 +482,33 @@ void main() { expect(decoded.result, result); }); + test('RunFinishedEvent.copyWith(result: null) clears the result', () { + // The sentinel pattern lets a caller intentionally clear `result`, + // matching the factory contract (which already accepts an absent + // / null `result`). + final original = RunFinishedEvent( + threadId: 't', + runId: 'r', + result: {'status': 'success'}, + ); + final keep = original.copyWith(); + expect(keep.result, equals({'status': 'success'})); + + final cleared = original.copyWith(result: null); + expect(cleared.result, isNull); + expect(cleared.threadId, equals('t')); + expect(cleared.runId, equals('r')); + }); + + test('RunFinishedEvent absent result key decodes identically to explicit null', () { + final absentJson = {'type': 'RUN_FINISHED', 'threadId': 't', 'runId': 'r'}; + final nullJson = {'type': 'RUN_FINISHED', 'threadId': 't', 'runId': 'r', 'result': null}; + expect(RunFinishedEvent.fromJson(absentJson).result, isNull); + expect(RunFinishedEvent.fromJson(nullJson).result, isNull); + expect(RunFinishedEvent.fromJson(absentJson).toJson().containsKey('result'), isFalse); + expect(RunFinishedEvent.fromJson(nullJson).toJson().containsKey('result'), isFalse); + }); + test('RunErrorEvent with error code', () { final event = RunErrorEvent( message: 'Something went wrong', @@ -238,6 +543,180 @@ void main() { final stepEnd = StepFinishedEvent.fromJson(stepEndCamel); expect(stepEnd.stepName, 'processing'); }); + + test('RunStartedEvent preserves parentRunId and input across round-trip', + () { + // Regression guard for #1018: pre-PR `parentRunId` and `input` + // were silently dropped on decode. Both fields now round-trip, + // including via the camelCase and snake_case wire spellings for + // `parentRunId`. `input` itself has no snake_case variant for the + // event-level key (single-word). + final inputJson = { + 'threadId': 'tid', + 'runId': 'rid', + 'messages': >[], + 'tools': >[], + 'context': >[], + }; + final camelJson = { + 'type': 'RUN_STARTED', + 'threadId': 'tid', + 'runId': 'rid', + 'parentRunId': 'parent_rid', + 'input': inputJson, + }; + final fromCamel = RunStartedEvent.fromJson(camelJson); + expect(fromCamel.parentRunId, 'parent_rid'); + expect(fromCamel.input, isNotNull); + expect(fromCamel.input!.threadId, 'tid'); + expect(fromCamel.input!.runId, 'rid'); + + final reEmitted = fromCamel.toJson(); + expect(reEmitted['parentRunId'], 'parent_rid'); + expect(reEmitted['input'], isA>()); + expect(reEmitted['input']['threadId'], 'tid'); + + // snake_case parity for parentRunId + final snakeJson = { + 'type': 'RUN_STARTED', + 'thread_id': 'tid2', + 'run_id': 'rid2', + 'parent_run_id': 'parent_rid2', + }; + final fromSnake = RunStartedEvent.fromJson(snakeJson); + expect(fromSnake.parentRunId, 'parent_rid2'); + expect(fromSnake.input, isNull); + + // omitted parent / input → both null and omitted from toJson + final minimal = RunStartedEvent.fromJson({ + 'type': 'RUN_STARTED', + 'threadId': 'tid3', + 'runId': 'rid3', + }); + expect(minimal.parentRunId, isNull); + expect(minimal.input, isNull); + expect(minimal.toJson().containsKey('parentRunId'), false); + expect(minimal.toJson().containsKey('input'), false); + }); + + test( + 'RunStartedEvent.input.parentRunId round-trips ' + '(camelCase and snake_case)', () { + // Parity follow-up: `RunStartedEvent.parentRunId` already + // round-trips at the event level; this pins the embedded + // `RunAgentInput.parentRunId` field, which canonical TS/Python + // schemas also expose (`RunAgentInputSchema.parentRunId` / + // `RunAgentInput.parent_run_id`). Pre-fix, the embedded field + // was silently dropped at decode even when the event-level one + // survived. + final camelInputJson = { + 'threadId': 'tid', + 'runId': 'rid', + 'parentRunId': 'input-parent-rid', + 'messages': >[], + 'tools': >[], + 'context': >[], + }; + final camelEvent = RunStartedEvent.fromJson({ + 'type': 'RUN_STARTED', + 'threadId': 'tid', + 'runId': 'rid', + 'input': camelInputJson, + }); + expect(camelEvent.input!.parentRunId, 'input-parent-rid'); + final reEmitted = camelEvent.toJson(); + expect( + (reEmitted['input'] as Map)['parentRunId'], + 'input-parent-rid', + ); + + // snake_case alias on the embedded input also decodes. + final snakeInputJson = { + 'thread_id': 'tid', + 'run_id': 'rid', + 'parent_run_id': 'input-parent-snake', + 'messages': >[], + 'tools': >[], + 'context': >[], + }; + final snakeEvent = RunStartedEvent.fromJson({ + 'type': 'RUN_STARTED', + 'threadId': 'tid', + 'runId': 'rid', + 'input': snakeInputJson, + }); + expect(snakeEvent.input!.parentRunId, 'input-parent-snake'); + }); + + test( + 'optionalIntField accepts JS/TS-shaped float timestamps ' + '(regression: cross-runtime decode)', () { + // JS/TS producers serialize all numbers through a single Number + // type, so a server emitting `Date.now() / 1000` arrives as + // `double`. The previous `optionalField` rejected `double` + // even when integer-valued. `optionalIntField` accepts any + // `num` and coerces via `.toInt()`. See + // `dart-enum-parsing-safety.md` (cross-runtime decode notes). + final fromDouble = TextMessageStartEvent.fromJson({ + 'type': 'TEXT_MESSAGE_START', + 'messageId': 'msg_001', + 'role': 'assistant', + 'timestamp': 1.7e9, // a float — used to fail decode + }); + expect(fromDouble.timestamp, equals(1700000000)); + + final fromInt = TextMessageStartEvent.fromJson({ + 'type': 'TEXT_MESSAGE_START', + 'messageId': 'msg_002', + 'role': 'assistant', + 'timestamp': 1234567890, + }); + expect(fromInt.timestamp, equals(1234567890)); + + // Wrong type still rejects (string is not a num). + expect( + () => TextMessageStartEvent.fromJson({ + 'type': 'TEXT_MESSAGE_START', + 'messageId': 'msg_003', + 'role': 'assistant', + 'timestamp': 'not-a-number', + }), + throwsA(isA()), + ); + }); + + test('RunStartedEvent.copyWith(parentRunId: null) clears parentRunId', + () { + // Sentinel-pattern verification: per `_Unset` dartdoc, passing + // `null` to a sentinel-using `copyWith` parameter MUST clear the + // field, distinct from "argument omitted" which keeps it. + final event = RunStartedEvent( + threadId: 'tid', + runId: 'rid', + parentRunId: 'pid', + ); + expect(event.copyWith(parentRunId: null).parentRunId, isNull); + // Argument omitted → parentRunId preserved + expect(event.copyWith().parentRunId, 'pid'); + }); + + test('RunStartedEvent.copyWith(input: null) clears input', () { + final input = RunAgentInput( + threadId: 'tid', + runId: 'rid', + messages: const [], + tools: const [], + context: const [], + ); + final event = RunStartedEvent( + threadId: 'tid', + runId: 'rid', + input: input, + ); + expect(event.copyWith(input: null).input, isNull); + // Argument omitted → input preserved + expect(event.copyWith().input, isNotNull); + }); }); group('Event Factory', () { @@ -261,7 +740,13 @@ void main() { expect(events[5], isA()); }); - test('should throw on invalid event type', () { + test('should throw AGUIValidationError on invalid event type', () { + // The factory wraps `EventType.fromString`'s raw `ArgumentError` + // as `AGUIValidationError` so direct callers see the same error + // surface as every other validation failure. Through the public + // `EventDecoder` pipeline this surfaces as `DecodingError` — + // see `event_decoding_integration_test.dart` ("validates + // required fields strictly", invalid event type case). final json = { 'type': 'INVALID_EVENT_TYPE', 'data': 'some data', @@ -269,9 +754,93 @@ void main() { expect( () => BaseEvent.fromJson(json), - throwsArgumentError, + throwsA(isA()), ); }); + + test('every event toJson preserves the type discriminator after spread', + () { + // Pins the invariant that `BaseEvent.toJson` emits `'type': + // eventType.value` AND that no subclass `toJson` ever shadows it + // via `...super.toJson()` spread. A future subclass that + // accidentally adds a `'type'` key would silently overwrite the + // discriminator and the analyzer wouldn't catch it — this test + // would fail concretely. See `dart-sealed-classes-json-serialization.md` + // ("`toJson()` that uses spread `...super.toJson()` will overwrite + // the base's discriminator key"). + final samples = [ + TextMessageStartEvent(messageId: 'm'), + TextMessageContentEvent(messageId: 'm', delta: 'd'), + TextMessageEndEvent(messageId: 'm'), + TextMessageChunkEvent(), + // ignore: deprecated_member_use_from_same_package + ThinkingTextMessageStartEvent(), + // ignore: deprecated_member_use_from_same_package + ThinkingTextMessageContentEvent(delta: 'd'), + // ignore: deprecated_member_use_from_same_package + ThinkingTextMessageEndEvent(), + ToolCallStartEvent(toolCallId: 'c', toolCallName: 'n'), + ToolCallArgsEvent(toolCallId: 'c', delta: 'd'), + ToolCallEndEvent(toolCallId: 'c'), + ToolCallChunkEvent(), + ToolCallResultEvent( + messageId: 'm', + toolCallId: 'c', + content: 'ok', + ), + ThinkingStartEvent(), + ThinkingEndEvent(), + StateSnapshotEvent(snapshot: {}), + StateDeltaEvent(delta: const []), + MessagesSnapshotEvent(messages: const []), + ActivitySnapshotEvent( + messageId: 'm', + activityType: 't', + content: null, + ), + ActivityDeltaEvent( + messageId: 'm', + activityType: 't', + patch: const [], + ), + RawEvent(event: const {'k': 'v'}), + CustomEvent(name: 'n', value: 'v'), + RunStartedEvent(threadId: 'tid', runId: 'rid'), + RunFinishedEvent(threadId: 'tid', runId: 'rid'), + RunErrorEvent(message: 'oops'), + StepStartedEvent(stepName: 's'), + StepFinishedEvent(stepName: 's'), + ReasoningStartEvent(messageId: 'm'), + ReasoningMessageStartEvent(messageId: 'm'), + ReasoningMessageContentEvent(messageId: 'm', delta: 'd'), + ReasoningMessageEndEvent(messageId: 'm'), + ReasoningMessageChunkEvent(), + ReasoningEndEvent(messageId: 'm'), + ReasoningEncryptedValueEvent( + subtype: ReasoningEncryptedValueSubtype.message, + entityId: 'e', + encryptedValue: 'v', + ), + ]; + + for (final e in samples) { + expect( + e.toJson()['type'], + equals(e.eventType.value), + reason: + 'discriminator must survive ...super.toJson() spread for ${e.runtimeType}', + ); + } + + // Sanity: the sample list covers every non-deprecated EventType. + // (`thinkingContent` is intentionally omitted — it is deprecated and + // already covered by the `'deprecated ThinkingContentEvent still + // round-trips'` test in this file.) + final coveredTypes = samples.map((e) => e.eventType).toSet(); + // ignore: deprecated_member_use_from_same_package + final expectedTypes = EventType.values.toSet()..remove(EventType.thinkingContent); + expect(coveredTypes, equals(expectedTypes)); + }); }); group('ThinkingEvents', () { @@ -286,16 +855,47 @@ void main() { expect(decoded.title, 'Processing request'); }); - test('ThinkingTextMessageContentEvent delta validation', () { - final invalidJson = { + test('ThinkingTextMessageContentEvent accepts empty delta', () { + // Relaxed to match canonical `z.string()` contract — empty `delta` + // is now accepted. Migrate to [ReasoningMessageContentEvent]. + final json = { 'type': 'THINKING_TEXT_MESSAGE_CONTENT', 'delta': '', }; - expect( - () => ThinkingTextMessageContentEvent.fromJson(invalidJson), - throwsA(isA()), - ); + // ignore: deprecated_member_use_from_same_package + final event = ThinkingTextMessageContentEvent.fromJson(json); + expect(event.delta, isEmpty); + }); + + test('deprecated ThinkingContentEvent still round-trips', () { + // Locks in the backward-compat contract on the deprecation: + // decoding/encoding must keep working until the planned removal. + // ignore: deprecated_member_use_from_same_package + final original = ThinkingContentEvent(delta: 'still works'); + final json = original.toJson(); + expect(json['type'], 'THINKING_CONTENT'); + expect(json['delta'], 'still works'); + + // ignore: deprecated_member_use_from_same_package + final decoded = ThinkingContentEvent.fromJson(json); + expect(decoded.delta, 'still works'); + }); + + test('EventDecoder still decodes deprecated THINKING_CONTENT', () { + // Backs the CHANGELOG promise that the deprecated path remains + // decodable end-to-end through the public decoder boundary. + const decoder = EventDecoder(); + + final event = decoder.decodeJson({ + 'type': 'THINKING_CONTENT', + 'delta': 'legacy payload', + }); + + // ignore: deprecated_member_use_from_same_package + expect(event, isA()); + // ignore: deprecated_member_use_from_same_package + expect((event as ThinkingContentEvent).delta, 'legacy payload'); }); }); @@ -320,6 +920,49 @@ void main() { expect(decoded.source, 'external_api'); }); + test('rawEvent / raw_event dual-key — Python snake_case is preserved', + () { + // Python emits `raw_event`; TS emits `rawEvent`. Both must decode + // into `BaseEvent.rawEvent` so a Dart proxy can re-emit it + // (camelCase) on the next hop. Regression for the silent-drop bug + // that pre-existed across every event factory. + final upstreamPayload = {'origin': 'python-server', 'seq': 7}; + + // 1. Python-style snake_case input on RunStartedEvent. + final pythonJson = { + 'type': 'RUN_STARTED', + 'thread_id': 'thread_001', + 'run_id': 'run_001', + 'raw_event': upstreamPayload, + }; + final fromSnake = RunStartedEvent.fromJson(pythonJson); + expect(fromSnake.rawEvent, upstreamPayload); + // Output is canonical camelCase. + expect(fromSnake.toJson()['rawEvent'], upstreamPayload); + + // 2. camelCase wins when both keys are present. + final bothKeys = { + 'type': 'RUN_STARTED', + 'thread_id': 'thread_001', + 'run_id': 'run_001', + 'rawEvent': {'winner': 'camel'}, + 'raw_event': {'winner': 'snake'}, + }; + final fromBoth = RunStartedEvent.fromJson(bothKeys); + expect(fromBoth.rawEvent, {'winner': 'camel'}); + + // 3. camelCase explicit-null wins (containsKey precedence). + final nullCamel = { + 'type': 'RUN_STARTED', + 'thread_id': 'thread_001', + 'run_id': 'run_001', + 'rawEvent': null, + 'raw_event': {'winner': 'snake'}, + }; + final fromNullCamel = RunStartedEvent.fromJson(nullCamel); + expect(fromNullCamel.rawEvent, isNull); + }); + test('CustomEvent with complex value', () { final customValue = { 'action': 'update_ui', @@ -339,6 +982,679 @@ void main() { expect(decoded.name, 'ui_config_change'); expect(decoded.value, customValue); }); + + test('RawEvent.copyWith(event: null) clears the payload', () { + // The sentinel pattern (mirroring `ActivitySnapshotEvent.content`) + // distinguishes "argument omitted" from "argument explicitly + // null", so an explicit null actually clears the field. + final original = RawEvent( + event: {'foo': 'bar'}, + source: 'agent', + ); + final keep = original.copyWith(); + expect(keep.event, equals({'foo': 'bar'})); + + final cleared = original.copyWith(event: null); + expect(cleared.event, isNull); + expect(cleared.source, equals('agent')); + }); + + test('RawEvent.copyWith(source: null) clears source', () { + // Sentinel parity for the second nullable field (was `?? this.source` + // before the sentinel sweep). Without the sentinel, an explicit + // `null` was indistinguishable from "argument omitted". + final original = RawEvent( + event: const {'foo': 'bar'}, + source: 'agent', + ); + final keep = original.copyWith(); + expect(keep.source, equals('agent')); + + final cleared = original.copyWith(source: null); + expect(cleared.source, isNull); + // Other fields preserved. + expect(cleared.event, equals(const {'foo': 'bar'})); + }); + + test('CustomEvent.copyWith(value: null) clears the payload', () { + final original = CustomEvent(name: 'evt', value: 42); + final keep = original.copyWith(); + expect(keep.value, equals(42)); + + final cleared = original.copyWith(value: null); + expect(cleared.value, isNull); + expect(cleared.name, equals('evt')); + }); + }); + + group('ActivityEvents', () { + test('ActivitySnapshotEvent serialization round-trip', () { + final content = { + 'title': 'Processing', + 'progress': 0.5, + 'steps': ['fetch', 'parse'], + }; + + final event = ActivitySnapshotEvent( + messageId: 'msg_001', + activityType: 'task.run', + content: content, + replace: false, + ); + + final json = event.toJson(); + expect(json['type'], 'ACTIVITY_SNAPSHOT'); + expect(json['messageId'], 'msg_001'); + expect(json['activityType'], 'task.run'); + expect(json['content'], content); + expect(json['replace'], false); + + final decoded = ActivitySnapshotEvent.fromJson(json); + expect(decoded.messageId, 'msg_001'); + expect(decoded.activityType, 'task.run'); + expect(decoded.content, content); + expect(decoded.replace, false); + }); + + test('ActivitySnapshotEvent defaults replace to true', () { + final json = { + 'type': 'ACTIVITY_SNAPSHOT', + 'messageId': 'msg_001', + 'activityType': 'task.run', + 'content': {'foo': 'bar'}, + }; + + final decoded = ActivitySnapshotEvent.fromJson(json); + expect(decoded.replace, true); + }); + + test('ActivitySnapshotEvent.toJson always emits replace, even when default', + () { + // Locks the always-emit contract documented at the + // `ActivitySnapshotEvent.replace` field — `replace` is optional on + // the wire (`z.boolean().optional().default(true)` in TS), but the + // Dart toJson emits it unconditionally so encoder→decoder symmetry + // doesn't depend on the producer's default. A future refactor that + // switches to `if (!replace) ... ` would break this test. + final event = ActivitySnapshotEvent( + messageId: 'm', + activityType: 't', + content: null, + ); + expect(event.replace, isTrue); + expect(event.toJson().containsKey('replace'), isTrue); + expect(event.toJson()['replace'], isTrue); + }); + + test('ActivitySnapshotEvent treats explicit-null replace as default-true', + () { + // `optionalField` returns null for both an absent key and + // an explicit-null value; the `?? true` coercion at the factory + // pins the documented behavior. This test locks the contract so + // a future change to `optionalField` semantics doesn't + // silently drift. + final decoded = ActivitySnapshotEvent.fromJson({ + 'type': 'ACTIVITY_SNAPSHOT', + 'messageId': 'msg_001', + 'activityType': 'task.run', + 'content': null, + 'replace': null, + }); + expect(decoded.replace, isTrue); + }); + + test('ActivitySnapshotEvent accepts snake_case (Python server)', () { + final pythonJson = { + 'type': 'ACTIVITY_SNAPSHOT', + 'message_id': 'msg_002', + 'activity_type': 'task.run', + 'content': 'hello', + 'replace': true, + }; + + final decoded = ActivitySnapshotEvent.fromJson(pythonJson); + expect(decoded.messageId, 'msg_002'); + expect(decoded.activityType, 'task.run'); + expect(decoded.content, 'hello'); + expect(decoded.replace, true); + }); + + test('ActivityDeltaEvent serialization round-trip', () { + final patch = [ + {'op': 'replace', 'path': '/progress', 'value': 0.75}, + {'op': 'add', 'path': '/steps/-', 'value': 'finalize'}, + ]; + + final event = ActivityDeltaEvent( + messageId: 'msg_001', + activityType: 'task.run', + patch: patch, + ); + + final json = event.toJson(); + expect(json['type'], 'ACTIVITY_DELTA'); + expect(json['messageId'], 'msg_001'); + expect(json['activityType'], 'task.run'); + expect(json['patch'], patch); + + final decoded = ActivityDeltaEvent.fromJson(json); + expect(decoded.messageId, 'msg_001'); + expect(decoded.activityType, 'task.run'); + expect(decoded.patch, patch); + }); + + test('ActivityDeltaEvent accepts snake_case (Python server)', () { + final pythonJson = { + 'type': 'ACTIVITY_DELTA', + 'message_id': 'msg_003', + 'activity_type': 'task.run', + 'patch': [ + {'op': 'replace', 'path': '/x', 'value': 1}, + ], + }; + + final decoded = ActivityDeltaEvent.fromJson(pythonJson); + expect(decoded.messageId, 'msg_003'); + expect(decoded.activityType, 'task.run'); + expect(decoded.patch.length, 1); + }); + + test('Activity events dispatch via BaseEvent.fromJson', () { + final snapshot = BaseEvent.fromJson({ + 'type': 'ACTIVITY_SNAPSHOT', + 'messageId': 'm', + 'activityType': 't', + 'content': null, + }); + expect(snapshot, isA()); + expect((snapshot as ActivitySnapshotEvent).content, isNull); + + final delta = BaseEvent.fromJson({ + 'type': 'ACTIVITY_DELTA', + 'messageId': 'm', + 'activityType': 't', + 'patch': [], + }); + expect(delta, isA()); + }); + + test('ActivitySnapshotEvent rejects missing content key', () { + // Mirrors the `StateSnapshotEvent` / `RawEvent` contract: the + // payload field may be any JSON shape (including `null`) but the + // KEY must be present. Distinguishing missing-key from + // explicit-null is the whole point of this check. + expect( + () => ActivitySnapshotEvent.fromJson({ + 'type': 'ACTIVITY_SNAPSHOT', + 'messageId': 'msg_001', + 'activityType': 'task.run', + }), + throwsA(isA()), + ); + }); + + test('ActivitySnapshotEvent accepts explicit-null content', () { + // The companion to "rejects missing content key": an explicit + // `null` is a valid wire payload (Python's `content: Any` + // permits None) and must round-trip without error. + final decoded = ActivitySnapshotEvent.fromJson({ + 'type': 'ACTIVITY_SNAPSHOT', + 'messageId': 'msg_001', + 'activityType': 'task.run', + 'content': null, + }); + expect(decoded.content, isNull); + }); + + test('ActivitySnapshotEvent.copyWith(content: null) clears content', () { + // The factory contract permits explicit-null `content`, and so + // must `copyWith` — distinguishing "argument omitted" from + // "argument explicitly set to null" via the + // `_unsetCopyWith` sentinel. + final original = ActivitySnapshotEvent( + messageId: 'msg_001', + activityType: 'task.run', + content: {'progress': 0.25}, + ); + // Omitted content keeps the existing value. + final keep = original.copyWith(); + expect(keep.content, equals({'progress': 0.25})); + + // Explicit-null clears the content. + final cleared = original.copyWith(content: null); + expect(cleared.content, isNull); + }); + + test('ActivitySnapshotEvent rejects missing messageId', () { + expect( + () => ActivitySnapshotEvent.fromJson({ + 'type': 'ACTIVITY_SNAPSHOT', + 'activityType': 'task.run', + 'content': null, + }), + throwsA(isA()), + ); + }); + + test('ActivityDeltaEvent rejects missing messageId', () { + expect( + () => ActivityDeltaEvent.fromJson({ + 'type': 'ACTIVITY_DELTA', + 'activityType': 'task.run', + 'patch': [], + }), + throwsA(isA()), + ); + }); + + test('ActivityDeltaEvent rejects missing activityType', () { + expect( + () => ActivityDeltaEvent.fromJson({ + 'type': 'ACTIVITY_DELTA', + 'messageId': 'msg_001', + 'patch': [], + }), + throwsA(isA()), + ); + }); + + test('ActivityDeltaEvent rejects missing patch', () { + expect( + () => ActivityDeltaEvent.fromJson({ + 'type': 'ACTIVITY_DELTA', + 'messageId': 'msg_001', + 'activityType': 'task.run', + }), + throwsA(isA()), + ); + }); + + test('ActivitySnapshotEvent copyWith preserves untouched fields', () { + final original = ActivitySnapshotEvent( + messageId: 'msg_001', + activityType: 'task.run', + content: 'original', + ); + + final updated = original.copyWith(content: 'new'); + expect(updated.messageId, original.messageId); + expect(updated.activityType, original.activityType); + expect(updated.content, 'new'); + expect(updated.replace, original.replace); + }); + }); + + group('ReasoningEvents', () { + test('ReasoningStartEvent serialization round-trip', () { + final event = ReasoningStartEvent(messageId: 'msg_r1'); + + final json = event.toJson(); + expect(json['type'], 'REASONING_START'); + expect(json['messageId'], 'msg_r1'); + + final decoded = ReasoningStartEvent.fromJson(json); + expect(decoded.messageId, 'msg_r1'); + }); + + test('ReasoningStartEvent accepts snake_case', () { + final decoded = ReasoningStartEvent.fromJson({ + 'type': 'REASONING_START', + 'message_id': 'msg_r1', + }); + expect(decoded.messageId, 'msg_r1'); + }); + + test('ReasoningMessageStartEvent accepts snake_case', () { + final decoded = ReasoningMessageStartEvent.fromJson({ + 'type': 'REASONING_MESSAGE_START', + 'message_id': 'msg_r2', + 'role': 'reasoning', + }); + expect(decoded.messageId, 'msg_r2'); + expect(decoded.role, ReasoningMessageRole.reasoning); + }); + + test('ReasoningMessageContentEvent accepts snake_case', () { + final decoded = ReasoningMessageContentEvent.fromJson({ + 'type': 'REASONING_MESSAGE_CONTENT', + 'message_id': 'msg_r3', + 'delta': 'thinking step', + }); + expect(decoded.messageId, 'msg_r3'); + expect(decoded.delta, 'thinking step'); + }); + + test('ReasoningMessageEndEvent accepts snake_case', () { + final decoded = ReasoningMessageEndEvent.fromJson({ + 'type': 'REASONING_MESSAGE_END', + 'message_id': 'msg_r4', + }); + expect(decoded.messageId, 'msg_r4'); + }); + + test('ReasoningEndEvent accepts snake_case', () { + final decoded = ReasoningEndEvent.fromJson({ + 'type': 'REASONING_END', + 'message_id': 'msg_r6', + }); + expect(decoded.messageId, 'msg_r6'); + }); + + test('ReasoningMessageStartEvent default role is reasoning', () { + final event = ReasoningMessageStartEvent(messageId: 'msg_r2'); + expect(event.role, ReasoningMessageRole.reasoning); + + final json = event.toJson(); + expect(json['type'], 'REASONING_MESSAGE_START'); + expect(json['role'], 'reasoning'); + + final decoded = ReasoningMessageStartEvent.fromJson(json); + expect(decoded.role, ReasoningMessageRole.reasoning); + expect(decoded.messageId, 'msg_r2'); + }); + + test('ReasoningMessageContentEvent serialization round-trip', () { + final event = ReasoningMessageContentEvent( + messageId: 'msg_r3', + delta: 'thinking step', + ); + + final json = event.toJson(); + expect(json['type'], 'REASONING_MESSAGE_CONTENT'); + expect(json['delta'], 'thinking step'); + + final decoded = ReasoningMessageContentEvent.fromJson(json); + expect(decoded.messageId, 'msg_r3'); + expect(decoded.delta, 'thinking step'); + }); + + test('ReasoningMessageEndEvent serialization round-trip', () { + final event = ReasoningMessageEndEvent(messageId: 'msg_r4'); + + final json = event.toJson(); + expect(json['type'], 'REASONING_MESSAGE_END'); + + final decoded = ReasoningMessageEndEvent.fromJson(json); + expect(decoded.messageId, 'msg_r4'); + }); + + test('ReasoningMessageChunkEvent allows all-optional payload', () { + final empty = ReasoningMessageChunkEvent(); + final emptyJson = empty.toJson(); + expect(emptyJson['type'], 'REASONING_MESSAGE_CHUNK'); + expect(emptyJson.containsKey('messageId'), false); + expect(emptyJson.containsKey('delta'), false); + + final decoded = ReasoningMessageChunkEvent.fromJson(emptyJson); + expect(decoded.messageId, isNull); + expect(decoded.delta, isNull); + + final populated = ReasoningMessageChunkEvent( + messageId: 'msg_r5', + delta: 'partial', + ); + final pjson = populated.toJson(); + expect(pjson['messageId'], 'msg_r5'); + expect(pjson['delta'], 'partial'); + }); + + test('ReasoningMessageChunkEvent.copyWith(delta: null) clears delta', + () { + // Sentinel-pattern verification for both `messageId` and `delta`. + final event = ReasoningMessageChunkEvent( + messageId: 'msg_r5', + delta: 'partial', + ); + expect(event.copyWith(delta: null).delta, isNull); + expect(event.copyWith(messageId: null).messageId, isNull); + // Argument omitted preserves both + final cloned = event.copyWith(); + expect(cloned.messageId, 'msg_r5'); + expect(cloned.delta, 'partial'); + }); + + test('ReasoningEndEvent serialization round-trip', () { + final event = ReasoningEndEvent(messageId: 'msg_r6'); + + final json = event.toJson(); + expect(json['type'], 'REASONING_END'); + + final decoded = ReasoningEndEvent.fromJson(json); + expect(decoded.messageId, 'msg_r6'); + }); + + test('ReasoningEncryptedValueEvent supports both subtypes', () { + final tool = ReasoningEncryptedValueEvent( + subtype: ReasoningEncryptedValueSubtype.toolCall, + entityId: 'tc_1', + encryptedValue: 'cipher-1', + ); + final toolJson = tool.toJson(); + expect(toolJson['type'], 'REASONING_ENCRYPTED_VALUE'); + expect(toolJson['subtype'], 'tool-call'); + expect(toolJson['entityId'], 'tc_1'); + expect(toolJson['encryptedValue'], 'cipher-1'); + + final decodedTool = ReasoningEncryptedValueEvent.fromJson(toolJson); + expect(decodedTool.subtype, ReasoningEncryptedValueSubtype.toolCall); + expect(decodedTool.entityId, 'tc_1'); + expect(decodedTool.encryptedValue, 'cipher-1'); + + final msg = ReasoningEncryptedValueEvent( + subtype: ReasoningEncryptedValueSubtype.message, + entityId: 'm_1', + encryptedValue: 'cipher-2', + ); + expect(msg.toJson()['subtype'], 'message'); + }); + + test('ReasoningEncryptedValueEvent accepts snake_case', () { + final decoded = ReasoningEncryptedValueEvent.fromJson({ + 'type': 'REASONING_ENCRYPTED_VALUE', + 'subtype': 'tool-call', + 'entity_id': 'tc_2', + 'encrypted_value': 'cipher-3', + }); + expect(decoded.subtype, ReasoningEncryptedValueSubtype.toolCall); + expect(decoded.entityId, 'tc_2'); + expect(decoded.encryptedValue, 'cipher-3'); + }); + + test( + 'ReasoningEncryptedValueSubtype.fromString throws on unknown values', + () { + // Aligned with `TextMessageRole.fromString throws on unknown + // values` and the rest of the `*Role.fromString` family — single + // verb ("throws") across enum-rejection tests in this file. + expect( + () => ReasoningEncryptedValueSubtype.fromString('bogus'), + throwsA(isA()), + ); + }); + + test('ReasoningMessageRole.fromString throws on unknown values', () { + expect( + () => ReasoningMessageRole.fromString('bogus'), + throwsA(isA()), + ); + }); + + test( + 'ReasoningMessageStartEvent falls back to `reasoning` for an ' + 'unknown role (forward-compat, no stream tear-down)', () { + // `ReasoningMessageRole` is currently a single-variant enum + // mirroring the canonical `Literal["reasoning"]` in the Python + // and TypeScript SDKs (see the dartdoc on `ReasoningMessageRole` + // in `lib/src/events/events.dart`). The forward-compat machinery + // — `fromString` throw + factory absorb + fallback — therefore + // exercises a path that cannot legitimately fire today, but + // pins the contract for the day a future spec adds a second + // role value. Do not delete this as tautological. + final decoded = ReasoningMessageStartEvent.fromJson({ + 'type': 'REASONING_MESSAGE_START', + 'messageId': 'msg_r2', + 'role': 'bogus', + }); + expect(decoded.role, ReasoningMessageRole.reasoning); + expect(decoded.messageId, 'msg_r2'); + }); + + test('ReasoningMessageStartEvent rejects missing role (parity with TS/Python)', + () { + // The canonical TypeScript and Python schemas both mark `role` as + // required on REASONING_MESSAGE_START. A producer bug that drops + // the field must surface as a protocol violation here, not be + // silently coerced to `reasoning` (which would let malformed + // payloads pass undetected and diverge from the reference SDKs). + expect( + () => ReasoningMessageStartEvent.fromJson({ + 'type': 'REASONING_MESSAGE_START', + 'messageId': 'msg_r2', + }), + throwsA(isA()), + ); + }); + + test('ReasoningMessageChunkEvent accepts snake_case', () { + final decoded = ReasoningMessageChunkEvent.fromJson({ + 'type': 'REASONING_MESSAGE_CHUNK', + 'message_id': 'msg_r5', + 'delta': 'partial', + }); + + expect(decoded.messageId, 'msg_r5'); + expect(decoded.delta, 'partial'); + }); + + test('ReasoningMessageContentEvent rejects missing delta', () { + expect( + () => ReasoningMessageContentEvent.fromJson({ + 'type': 'REASONING_MESSAGE_CONTENT', + 'messageId': 'msg_r3', + }), + throwsA(isA()), + ); + }); + + test('ReasoningMessageContentEvent accepts empty delta (canonical parity)', + () { + // Canonical TS/Python schemas allow empty `delta` + // (`ReasoningMessageContentEventSchema.delta: z.string()` / + // pydantic `delta: str`). The Dart SDK matches. + final ev = ReasoningMessageContentEvent.fromJson({ + 'type': 'REASONING_MESSAGE_CONTENT', + 'messageId': 'msg_r3', + 'delta': '', + }); + expect(ev.delta, isEmpty); + }); + + test('ReasoningEncryptedValueEvent rejects missing subtype', () { + expect( + () => ReasoningEncryptedValueEvent.fromJson({ + 'type': 'REASONING_ENCRYPTED_VALUE', + 'entityId': 'tc_1', + 'encryptedValue': 'cipher-1', + }), + throwsA(isA()), + ); + }); + + test('ReasoningEncryptedValueEvent rejects missing entityId', () { + expect( + () => ReasoningEncryptedValueEvent.fromJson({ + 'type': 'REASONING_ENCRYPTED_VALUE', + 'subtype': 'message', + 'encryptedValue': 'cipher', + }), + throwsA(isA()), + ); + }); + + test('ReasoningEncryptedValueEvent rejects missing encryptedValue', () { + expect( + () => ReasoningEncryptedValueEvent.fromJson({ + 'type': 'REASONING_ENCRYPTED_VALUE', + 'subtype': 'message', + 'entityId': 'msg_1', + }), + throwsA(isA()), + ); + }); + + test( + 'ReasoningEncryptedValueEvent accepts empty entityId / ' + 'encryptedValue (canonical-schema parity)', () { + // Canonical schemas: TS `events.ts` declares `entityId: z.string()` + // and `encryptedValue: z.string()`; Python `events.py` declares + // `entity_id: str` and `encrypted_value: str`. Neither imposes a + // minimum length. Dart must not be stricter than the protocol — + // a payload accepted by TS/Python must decode in Dart. + final emptyEntity = ReasoningEncryptedValueEvent.fromJson({ + 'type': 'REASONING_ENCRYPTED_VALUE', + 'subtype': 'message', + 'entityId': '', + 'encryptedValue': 'cipher', + }); + expect(emptyEntity.entityId, ''); + expect(emptyEntity.encryptedValue, 'cipher'); + + final emptyCipher = ReasoningEncryptedValueEvent.fromJson({ + 'type': 'REASONING_ENCRYPTED_VALUE', + 'subtype': 'message', + 'entityId': 'rsn_01', + 'encryptedValue': '', + }); + expect(emptyCipher.entityId, 'rsn_01'); + expect(emptyCipher.encryptedValue, ''); + }); + + test('ReasoningEncryptedValueEvent rejects unknown subtype', () { + // Pins the dartdoc contract: an unknown `subtype` must surface + // to direct factory callers as `AGUIValidationError` (not as + // the raw `ArgumentError` that the enum itself throws). The + // matching wire→DecodingError contract is locked in by the + // integration test in + // event_decoding_integration_test.dart. + expect( + () => ReasoningEncryptedValueEvent.fromJson({ + 'type': 'REASONING_ENCRYPTED_VALUE', + 'subtype': 'bogus', + 'entityId': 'rsn_01', + 'encryptedValue': 'cipher', + }), + throwsA(isA()), + ); + }); + + test('Reasoning events dispatch via BaseEvent.fromJson', () { + final cases = , Type>{ + {'type': 'REASONING_START', 'messageId': 'm'}: + ReasoningStartEvent, + { + 'type': 'REASONING_MESSAGE_START', + 'messageId': 'm', + 'role': 'reasoning', + }: ReasoningMessageStartEvent, + {'type': 'REASONING_MESSAGE_CONTENT', 'messageId': 'm', 'delta': 'd'}: + ReasoningMessageContentEvent, + {'type': 'REASONING_MESSAGE_END', 'messageId': 'm'}: + ReasoningMessageEndEvent, + {'type': 'REASONING_MESSAGE_CHUNK'}: ReasoningMessageChunkEvent, + {'type': 'REASONING_END', 'messageId': 'm'}: ReasoningEndEvent, + { + 'type': 'REASONING_ENCRYPTED_VALUE', + 'subtype': 'message', + 'entityId': 'e', + 'encryptedValue': 'v', + }: ReasoningEncryptedValueEvent, + }; + + cases.forEach((json, type) { + final event = BaseEvent.fromJson(json); + expect(event.runtimeType, type, reason: 'for $json'); + }); + }); }); }); } \ No newline at end of file diff --git a/sdks/community/dart/test/events/event_type_test.dart b/sdks/community/dart/test/events/event_type_test.dart index b12feaf47b..6735748ab8 100644 --- a/sdks/community/dart/test/events/event_type_test.dart +++ b/sdks/community/dart/test/events/event_type_test.dart @@ -8,8 +8,11 @@ void main() { expect(EventType.textMessageContent.value, equals('TEXT_MESSAGE_CONTENT')); expect(EventType.textMessageEnd.value, equals('TEXT_MESSAGE_END')); expect(EventType.textMessageChunk.value, equals('TEXT_MESSAGE_CHUNK')); + // ignore: deprecated_member_use_from_same_package expect(EventType.thinkingTextMessageStart.value, equals('THINKING_TEXT_MESSAGE_START')); + // ignore: deprecated_member_use_from_same_package expect(EventType.thinkingTextMessageContent.value, equals('THINKING_TEXT_MESSAGE_CONTENT')); + // ignore: deprecated_member_use_from_same_package expect(EventType.thinkingTextMessageEnd.value, equals('THINKING_TEXT_MESSAGE_END')); expect(EventType.toolCallStart.value, equals('TOOL_CALL_START')); expect(EventType.toolCallArgs.value, equals('TOOL_CALL_ARGS')); @@ -17,11 +20,14 @@ void main() { expect(EventType.toolCallChunk.value, equals('TOOL_CALL_CHUNK')); expect(EventType.toolCallResult.value, equals('TOOL_CALL_RESULT')); expect(EventType.thinkingStart.value, equals('THINKING_START')); + // ignore: deprecated_member_use_from_same_package expect(EventType.thinkingContent.value, equals('THINKING_CONTENT')); expect(EventType.thinkingEnd.value, equals('THINKING_END')); expect(EventType.stateSnapshot.value, equals('STATE_SNAPSHOT')); expect(EventType.stateDelta.value, equals('STATE_DELTA')); expect(EventType.messagesSnapshot.value, equals('MESSAGES_SNAPSHOT')); + expect(EventType.activitySnapshot.value, equals('ACTIVITY_SNAPSHOT')); + expect(EventType.activityDelta.value, equals('ACTIVITY_DELTA')); expect(EventType.raw.value, equals('RAW')); expect(EventType.custom.value, equals('CUSTOM')); expect(EventType.runStarted.value, equals('RUN_STARTED')); @@ -29,6 +35,28 @@ void main() { expect(EventType.runError.value, equals('RUN_ERROR')); expect(EventType.stepStarted.value, equals('STEP_STARTED')); expect(EventType.stepFinished.value, equals('STEP_FINISHED')); + expect(EventType.reasoningStart.value, equals('REASONING_START')); + expect( + EventType.reasoningMessageStart.value, + equals('REASONING_MESSAGE_START'), + ); + expect( + EventType.reasoningMessageContent.value, + equals('REASONING_MESSAGE_CONTENT'), + ); + expect( + EventType.reasoningMessageEnd.value, + equals('REASONING_MESSAGE_END'), + ); + expect( + EventType.reasoningMessageChunk.value, + equals('REASONING_MESSAGE_CHUNK'), + ); + expect(EventType.reasoningEnd.value, equals('REASONING_END')); + expect( + EventType.reasoningEncryptedValue.value, + equals('REASONING_ENCRYPTED_VALUE'), + ); }); test('fromString converts string to correct enum', () { @@ -36,8 +64,11 @@ void main() { expect(EventType.fromString('TEXT_MESSAGE_CONTENT'), equals(EventType.textMessageContent)); expect(EventType.fromString('TEXT_MESSAGE_END'), equals(EventType.textMessageEnd)); expect(EventType.fromString('TEXT_MESSAGE_CHUNK'), equals(EventType.textMessageChunk)); + // ignore: deprecated_member_use_from_same_package expect(EventType.fromString('THINKING_TEXT_MESSAGE_START'), equals(EventType.thinkingTextMessageStart)); + // ignore: deprecated_member_use_from_same_package expect(EventType.fromString('THINKING_TEXT_MESSAGE_CONTENT'), equals(EventType.thinkingTextMessageContent)); + // ignore: deprecated_member_use_from_same_package expect(EventType.fromString('THINKING_TEXT_MESSAGE_END'), equals(EventType.thinkingTextMessageEnd)); expect(EventType.fromString('TOOL_CALL_START'), equals(EventType.toolCallStart)); expect(EventType.fromString('TOOL_CALL_ARGS'), equals(EventType.toolCallArgs)); @@ -45,11 +76,14 @@ void main() { expect(EventType.fromString('TOOL_CALL_CHUNK'), equals(EventType.toolCallChunk)); expect(EventType.fromString('TOOL_CALL_RESULT'), equals(EventType.toolCallResult)); expect(EventType.fromString('THINKING_START'), equals(EventType.thinkingStart)); + // ignore: deprecated_member_use_from_same_package expect(EventType.fromString('THINKING_CONTENT'), equals(EventType.thinkingContent)); expect(EventType.fromString('THINKING_END'), equals(EventType.thinkingEnd)); expect(EventType.fromString('STATE_SNAPSHOT'), equals(EventType.stateSnapshot)); expect(EventType.fromString('STATE_DELTA'), equals(EventType.stateDelta)); expect(EventType.fromString('MESSAGES_SNAPSHOT'), equals(EventType.messagesSnapshot)); + expect(EventType.fromString('ACTIVITY_SNAPSHOT'), equals(EventType.activitySnapshot)); + expect(EventType.fromString('ACTIVITY_DELTA'), equals(EventType.activityDelta)); expect(EventType.fromString('RAW'), equals(EventType.raw)); expect(EventType.fromString('CUSTOM'), equals(EventType.custom)); expect(EventType.fromString('RUN_STARTED'), equals(EventType.runStarted)); @@ -57,6 +91,18 @@ void main() { expect(EventType.fromString('RUN_ERROR'), equals(EventType.runError)); expect(EventType.fromString('STEP_STARTED'), equals(EventType.stepStarted)); expect(EventType.fromString('STEP_FINISHED'), equals(EventType.stepFinished)); + expect(EventType.fromString('REASONING_START'), equals(EventType.reasoningStart)); + expect(EventType.fromString('REASONING_MESSAGE_START'), + equals(EventType.reasoningMessageStart)); + expect(EventType.fromString('REASONING_MESSAGE_CONTENT'), + equals(EventType.reasoningMessageContent)); + expect(EventType.fromString('REASONING_MESSAGE_END'), + equals(EventType.reasoningMessageEnd)); + expect(EventType.fromString('REASONING_MESSAGE_CHUNK'), + equals(EventType.reasoningMessageChunk)); + expect(EventType.fromString('REASONING_END'), equals(EventType.reasoningEnd)); + expect(EventType.fromString('REASONING_ENCRYPTED_VALUE'), + equals(EventType.reasoningEncryptedValue)); }); test('fromString throws ArgumentError for invalid value', () { @@ -91,7 +137,7 @@ void main() { }); test('values list contains all event types', () { - expect(EventType.values.length, equals(25)); + expect(EventType.values.length, equals(34)); // Verify specific important event types are included expect(EventType.values, contains(EventType.textMessageStart)); @@ -99,6 +145,10 @@ void main() { expect(EventType.values, contains(EventType.runStarted)); expect(EventType.values, contains(EventType.runFinished)); expect(EventType.values, contains(EventType.stateSnapshot)); + expect(EventType.values, contains(EventType.activitySnapshot)); + expect(EventType.values, contains(EventType.activityDelta)); + expect(EventType.values, contains(EventType.reasoningStart)); + expect(EventType.values, contains(EventType.reasoningEncryptedValue)); }); test('enum values are unique', () { @@ -146,7 +196,10 @@ void main() { test('enum supports index property', () { expect(EventType.textMessageStart.index, equals(0)); - expect(EventType.stepFinished.index, equals(EventType.values.length - 1)); + expect( + EventType.reasoningEncryptedValue.index, + equals(EventType.values.length - 1), + ); }); test('enum name property returns correct name', () { @@ -190,10 +243,14 @@ void main() { test('thinking events are grouped correctly', () { final thinkingEvents = [ EventType.thinkingStart, + // ignore: deprecated_member_use_from_same_package EventType.thinkingContent, EventType.thinkingEnd, + // ignore: deprecated_member_use_from_same_package EventType.thinkingTextMessageStart, + // ignore: deprecated_member_use_from_same_package EventType.thinkingTextMessageContent, + // ignore: deprecated_member_use_from_same_package EventType.thinkingTextMessageEnd, ]; @@ -202,6 +259,33 @@ void main() { } }); + test('activity events are grouped correctly', () { + final activityEvents = [ + EventType.activitySnapshot, + EventType.activityDelta, + ]; + + for (final event in activityEvents) { + expect(event.value, contains('ACTIVITY')); + } + }); + + test('reasoning events are grouped correctly', () { + final reasoningEvents = [ + EventType.reasoningStart, + EventType.reasoningMessageStart, + EventType.reasoningMessageContent, + EventType.reasoningMessageEnd, + EventType.reasoningMessageChunk, + EventType.reasoningEnd, + EventType.reasoningEncryptedValue, + ]; + + for (final event in reasoningEvents) { + expect(event.value, contains('REASONING')); + } + }); + test('tool call events are grouped correctly', () { final toolEvents = [ EventType.toolCallStart, diff --git a/sdks/community/dart/test/fixtures/events.json b/sdks/community/dart/test/fixtures/events.json index 700c30d0b2..335724f09d 100644 --- a/sdks/community/dart/test/fixtures/events.json +++ b/sdks/community/dart/test/fixtures/events.json @@ -195,6 +195,49 @@ "runId": "run_04" } ], + "messages_snapshot_activity_reasoning": [ + { + "type": "RUN_STARTED", + "threadId": "thread_04b", + "runId": "run_04b" + }, + { + "type": "MESSAGES_SNAPSHOT", + "messages": [ + { + "id": "msg_a1", + "role": "user", + "content": "Help me index this directory." + }, + { + "id": "act_a1", + "role": "activity", + "activityType": "task.run", + "content": { + "title": "Indexing files", + "progress": 0.5 + } + }, + { + "id": "rsn_a1", + "role": "reasoning", + "content": "Considering the file types to skip.", + "encryptedValue": "ZW5jcnlwdGVkLXJlYXNvbmluZw==" + }, + { + "id": "msg_a2", + "role": "assistant", + "content": "Indexing started.", + "encryptedValue": "ZW5jcnlwdGVkLWFzc2lzdGFudA==" + } + ] + }, + { + "type": "RUN_FINISHED", + "threadId": "thread_04b", + "runId": "run_04b" + } + ], "multiple_runs": [ { "type": "RUN_STARTED", @@ -437,5 +480,90 @@ "threadId": "thread_11", "runId": "run_12" } + ], + "activity_events": [ + { + "type": "RUN_STARTED", + "threadId": "thread_12", + "runId": "run_13" + }, + { + "type": "ACTIVITY_SNAPSHOT", + "messageId": "act_01", + "activityType": "task.run", + "content": { + "title": "Indexing files", + "progress": 0.0, + "items": [] + }, + "replace": true + }, + { + "type": "ACTIVITY_DELTA", + "messageId": "act_01", + "activityType": "task.run", + "patch": [ + {"op": "replace", "path": "/progress", "value": 0.5}, + {"op": "add", "path": "/items/-", "value": "/foo.dart"} + ] + }, + { + "type": "ACTIVITY_DELTA", + "messageId": "act_01", + "activityType": "task.run", + "patch": [ + {"op": "replace", "path": "/progress", "value": 1.0} + ] + }, + { + "type": "RUN_FINISHED", + "threadId": "thread_12", + "runId": "run_13" + } + ], + "reasoning_events": [ + { + "type": "RUN_STARTED", + "threadId": "thread_13", + "runId": "run_14" + }, + { + "type": "REASONING_START", + "messageId": "rsn_01" + }, + { + "type": "REASONING_MESSAGE_START", + "messageId": "rsn_01", + "role": "reasoning" + }, + { + "type": "REASONING_MESSAGE_CONTENT", + "messageId": "rsn_01", + "delta": "Analyzing the request..." + }, + { + "type": "REASONING_MESSAGE_CHUNK", + "messageId": "rsn_01", + "delta": " considering options." + }, + { + "type": "REASONING_MESSAGE_END", + "messageId": "rsn_01" + }, + { + "type": "REASONING_ENCRYPTED_VALUE", + "subtype": "message", + "entityId": "rsn_01", + "encryptedValue": "ZW5jcnlwdGVkLXBheWxvYWQ=" + }, + { + "type": "REASONING_END", + "messageId": "rsn_01" + }, + { + "type": "RUN_FINISHED", + "threadId": "thread_13", + "runId": "run_14" + } ] } \ No newline at end of file diff --git a/sdks/community/dart/test/integration/event_decoding_integration_test.dart b/sdks/community/dart/test/integration/event_decoding_integration_test.dart index 4ca2158059..cabd76666c 100644 --- a/sdks/community/dart/test/integration/event_decoding_integration_test.dart +++ b/sdks/community/dart/test/integration/event_decoding_integration_test.dart @@ -111,11 +111,149 @@ void main() { final event = decoder.decodeJson(pythonJson); expect(event, isA()); - + final runEvent = event as RunFinishedEvent; expect(runEvent.threadId, equals('thread-123')); expect(runEvent.runId, equals('run-456')); }); + + test('decodes ACTIVITY_SNAPSHOT event from Python server format', () { + final pythonJson = { + 'type': 'ACTIVITY_SNAPSHOT', + 'message_id': 'act_001', + 'activity_type': 'task.run', + 'content': {'title': 'Hello', 'progress': 0.25}, + 'replace': false, + }; + + final event = decoder.decodeJson(pythonJson); + expect(event, isA()); + + final activity = event as ActivitySnapshotEvent; + expect(activity.messageId, equals('act_001')); + expect(activity.activityType, equals('task.run')); + expect((activity.content as Map)['title'], equals('Hello')); + expect(activity.replace, isFalse); + }); + + test('decodes ACTIVITY_DELTA event from Python server format', () { + final pythonJson = { + 'type': 'ACTIVITY_DELTA', + 'message_id': 'act_001', + 'activity_type': 'task.run', + 'patch': [ + {'op': 'replace', 'path': '/progress', 'value': 0.5}, + ], + }; + + final event = decoder.decodeJson(pythonJson); + expect(event, isA()); + + final delta = event as ActivityDeltaEvent; + expect(delta.messageId, equals('act_001')); + expect(delta.activityType, equals('task.run')); + expect(delta.patch.length, equals(1)); + }); + + test('decodes TEXT_MESSAGE_* events from Python server format', () { + final start = decoder.decodeJson({ + 'type': 'TEXT_MESSAGE_START', + 'message_id': 'm1', + 'role': 'assistant', + }); + expect(start, isA()); + expect((start as TextMessageStartEvent).messageId, 'm1'); + + final content = decoder.decodeJson({ + 'type': 'TEXT_MESSAGE_CONTENT', + 'message_id': 'm1', + 'delta': 'hello', + }); + expect(content, isA()); + + final end = decoder.decodeJson({ + 'type': 'TEXT_MESSAGE_END', + 'message_id': 'm1', + }); + expect(end, isA()); + }); + + test('decodes TOOL_CALL_* events from Python server format', () { + final start = decoder.decodeJson({ + 'type': 'TOOL_CALL_START', + 'tool_call_id': 'c1', + 'tool_call_name': 'search', + 'parent_message_id': 'm1', + }); + expect(start, isA()); + expect((start as ToolCallStartEvent).toolCallId, 'c1'); + expect(start.toolCallName, 'search'); + expect(start.parentMessageId, 'm1'); + + final args = decoder.decodeJson({ + 'type': 'TOOL_CALL_ARGS', + 'tool_call_id': 'c1', + 'delta': '{"q":"x"}', + }); + expect(args, isA()); + + final end = decoder.decodeJson({ + 'type': 'TOOL_CALL_END', + 'tool_call_id': 'c1', + }); + expect(end, isA()); + + final result = decoder.decodeJson({ + 'type': 'TOOL_CALL_RESULT', + 'message_id': 'm2', + 'tool_call_id': 'c1', + 'content': 'ok', + 'role': 'tool', + }); + expect(result, isA()); + final r = result as ToolCallResultEvent; + expect(r.messageId, 'm2'); + expect(r.toolCallId, 'c1'); + }); + + test('decodes REASONING_* events from Python server format', () { + final start = decoder.decodeJson({ + 'type': 'REASONING_START', + 'message_id': 'rsn_001', + }); + expect(start, isA()); + expect((start as ReasoningStartEvent).messageId, equals('rsn_001')); + + final messageStart = decoder.decodeJson({ + 'type': 'REASONING_MESSAGE_START', + 'message_id': 'rsn_001', + 'role': 'reasoning', + }); + expect(messageStart, isA()); + + final content = decoder.decodeJson({ + 'type': 'REASONING_MESSAGE_CONTENT', + 'message_id': 'rsn_001', + 'delta': 'thinking...', + }); + expect(content, isA()); + expect( + (content as ReasoningMessageContentEvent).delta, + equals('thinking...'), + ); + + final encrypted = decoder.decodeJson({ + 'type': 'REASONING_ENCRYPTED_VALUE', + 'subtype': 'tool-call', + 'entity_id': 'tc_001', + 'encrypted_value': 'cipher', + }); + expect(encrypted, isA()); + final encEvent = encrypted as ReasoningEncryptedValueEvent; + expect(encEvent.subtype, ReasoningEncryptedValueSubtype.toolCall); + expect(encEvent.entityId, equals('tc_001')); + expect(encEvent.encryptedValue, equals('cipher')); + }); }); group('TypeScript Dojo Events', () { @@ -180,7 +318,7 @@ void main() { final resultEvent = decodedEvents[3] as ToolCallResultEvent; expect(resultEvent.content, equals('Found 5 results')); - expect(resultEvent.role, equals('tool')); + expect(resultEvent.role, equals(ToolCallResultRole.tool)); }); test('decodes thinking events', () { @@ -196,8 +334,11 @@ void main() { expect(decodedEvents[0], isA()); expect((decodedEvents[0] as ThinkingStartEvent).title, equals('Planning approach')); + // ignore: deprecated_member_use_from_same_package expect(decodedEvents[1], isA()); + // ignore: deprecated_member_use_from_same_package expect(decodedEvents[2], isA()); + // ignore: deprecated_member_use_from_same_package expect(decodedEvents[3], isA()); expect(decodedEvents[4], isA()); }); @@ -303,7 +444,10 @@ void main() { data: jsonEncode({'type': 'INVALID_TYPE'}), // Unknown type )); sseController.add(SseMessage( - data: jsonEncode({'type': 'TEXT_MESSAGE_CONTENT', 'messageId': 'm1', 'delta': ''}), // Invalid: empty delta + // Invalid: missing required `messageId`. (Empty `delta` is now + // accepted per canonical TS/Python parity, so it can no longer + // serve as the invalid-event trigger here.) + data: jsonEncode({'type': 'TEXT_MESSAGE_CONTENT', 'delta': 'x'}), )); sseController.add(SseMessage( data: jsonEncode({'type': 'RUN_FINISHED', 'thread_id': 't1', 'run_id': 'r1'}), @@ -340,7 +484,11 @@ void main() { final textEvent = event as TextMessageStartEvent; expect(textEvent.messageId, equals('msg-1')); expect(textEvent.role, equals(TextMessageRole.assistant)); - // Unknown fields are preserved in rawEvent if needed + // Unknown top-level fields are tolerated and ignored — the SDK + // does NOT preserve them on `rawEvent` (only `json['rawEvent']` + // populates that field). Re-encoding via `toJson` will drop + // `futureField` / `metadata`. If forward-preserve becomes a + // requirement, see the `BaseEvent.fromJson` factory. }); test('validates required fields strictly', () { @@ -350,21 +498,245 @@ void main() { throwsA(isA()), ); - // Empty required field - validation error is wrapped in DecodingError + // Empty `messageId` (still a contract violation post-0.2.0 + // parity work — empty `delta` is now accepted to match + // canonical TS/Python schemas, but identifiers must be + // non-empty). Validation error is wrapped in DecodingError. expect( () => decoder.decodeJson({ 'type': 'TEXT_MESSAGE_CONTENT', - 'messageId': 'msg-1', - 'delta': '', // Empty delta not allowed + 'messageId': '', + 'delta': 'x', }), throwsA(isA()), ); - // Invalid event type + // Invalid event type — surfaces as DecodingError through the + // decoder boundary. The direct factory path (no decoder) sees + // an `AGUIValidationError` instead; see the companion test in + // `test/events/event_test.dart` ("should throw AGUIValidationError + // on invalid event type"). The two together pin down both seams. expect( () => decoder.decodeJson({'type': 'NOT_A_REAL_EVENT'}), throwsA(isA()), ); + + // The wrapped `DecodingError.field` must preserve the original + // failing field name from `AGUIValidationError`, not collapse to + // `'json'`. Pin the contract on at least one factory-side + // failure so a future refactor can't silently regress. + expect( + () => decoder.decodeJson({ + 'type': 'REASONING_MESSAGE_START', + 'messageId': 'msg-1', + // role intentionally omitted — required since 0.2.0 + }), + throwsA( + isA().having((e) => e.field, 'field', 'role'), + ), + ); + + // TEXT_MESSAGE_END with empty messageId must fail at the + // decoder boundary, matching TEXT_MESSAGE_START / _CONTENT. + expect( + () => decoder.decodeJson({ + 'type': 'TEXT_MESSAGE_END', + 'messageId': '', + }), + throwsA(isA()), + ); + }); + + test( + 'EventDecoder.validate rejects empty required identifiers across ' + 'tool, run, step, activity, and reasoning events', () { + // These cases lock in the boundary contract documented on + // `EventDecoder.validate`: identifiers that pass the + // presence/type check in `fromJson` must still be rejected here + // when they arrive empty from the wire. Adding a new empty-id + // event class without a `validate` case will fail this test. + final emptyIdPayloads = >[ + {'type': 'TOOL_CALL_ARGS', 'toolCallId': '', 'delta': 'x'}, + // NOTE: empty `delta` on TOOL_CALL_ARGS is now accepted per + // canonical TS/Python parity; only empty `toolCallId` is + // still a contract violation. + {'type': 'TOOL_CALL_END', 'toolCallId': ''}, + { + 'type': 'TOOL_CALL_RESULT', + 'messageId': '', + 'toolCallId': 'c', + 'content': 'x', + }, + { + 'type': 'TOOL_CALL_RESULT', + 'messageId': 'm', + 'toolCallId': '', + 'content': 'x', + }, + // NOTE: empty `content` on TOOL_CALL_RESULT is now accepted + // per canonical TS/Python parity. + {'type': 'RUN_FINISHED', 'threadId': '', 'runId': 'r'}, + {'type': 'RUN_FINISHED', 'threadId': 't', 'runId': ''}, + {'type': 'RUN_ERROR', 'message': ''}, + {'type': 'STEP_STARTED', 'stepName': ''}, + {'type': 'STEP_FINISHED', 'stepName': ''}, + {'type': 'CUSTOM', 'name': '', 'value': 1}, + // Activity events — empty messageId or activityType. + { + 'type': 'ACTIVITY_SNAPSHOT', + 'messageId': '', + 'activityType': 't', + 'content': null, + }, + { + 'type': 'ACTIVITY_SNAPSHOT', + 'messageId': 'm', + 'activityType': '', + 'content': null, + }, + { + 'type': 'ACTIVITY_DELTA', + 'messageId': '', + 'activityType': 't', + 'patch': [], + }, + { + 'type': 'ACTIVITY_DELTA', + 'messageId': 'm', + 'activityType': '', + 'patch': [], + }, + // Reasoning events — empty messageId is still a contract + // violation. Empty `delta` on REASONING_MESSAGE_CONTENT is now + // accepted per canonical parity. Empty `entityId` / + // `encryptedValue` on REASONING_ENCRYPTED_VALUE are also + // accepted (canonical TS `z.string()` / Python `str` impose + // no minimum length); only the strict subtype discriminator + // remains. + {'type': 'REASONING_START', 'messageId': ''}, + { + 'type': 'REASONING_MESSAGE_START', + 'messageId': '', + 'role': 'reasoning', + }, + { + 'type': 'REASONING_MESSAGE_CONTENT', + 'messageId': '', + 'delta': 'd', + }, + {'type': 'REASONING_MESSAGE_END', 'messageId': ''}, + {'type': 'REASONING_END', 'messageId': ''}, + ]; + + for (final payload in emptyIdPayloads) { + expect( + () => decoder.decodeJson(payload), + throwsA(isA()), + reason: 'expected DecodingError for $payload', + ); + } + }); + + test( + 'REASONING_ENCRYPTED_VALUE with unknown subtype surfaces as ' + 'DecodingError', () { + // The dartdoc on `ReasoningEncryptedValueEvent` and on + // `ReasoningEncryptedValueSubtype.fromString` documents that + // an unknown subtype value MUST fail decoding (mis-tagging an + // encrypted payload is worse than dropping it). This locks in + // the wire→DecodingError contract end-to-end. + expect( + () => decoder.decodeJson({ + 'type': 'REASONING_ENCRYPTED_VALUE', + 'subtype': 'future-mode', + 'entityId': 'e', + 'encryptedValue': 'v', + }), + throwsA(isA()), + ); + }); + + test( + 'REASONING_ENCRYPTED_VALUE unknown subtype is skipped under ' + 'skipInvalidEvents (forward-compat opt-in)', () async { + // Companion to the test above: with per-event recovery enabled + // on the stream adapter, the malformed event is skipped and + // surrounding events still flow. The dartdoc on + // `ReasoningEncryptedValueEvent` promises this opt-in. + final controller = StreamController(); + final stream = adapter.fromSseStream( + controller.stream, + skipInvalidEvents: true, + ); + final events = []; + final sub = stream.listen(events.add); + + controller.add(SseMessage( + data: jsonEncode({ + 'type': 'REASONING_START', + 'messageId': 'rsn', + }), + )); + controller.add(SseMessage( + data: jsonEncode({ + 'type': 'REASONING_ENCRYPTED_VALUE', + 'subtype': 'future-mode', + 'entityId': 'e', + 'encryptedValue': 'v', + }), + )); + controller.add(SseMessage( + data: jsonEncode({ + 'type': 'REASONING_END', + 'messageId': 'rsn', + }), + )); + + await controller.close(); + await sub.cancel(); + + expect(events.length, 2); + expect(events[0], isA()); + expect(events[1], isA()); + }); + + test( + 'EventDecoder.decodeJson rejects state/raw/custom events missing ' + 'their required value field', () { + // `StateSnapshotEvent.snapshot`, `RawEvent.event`, and + // `CustomEvent.value` accept any JSON shape (including null) but + // the field MUST be present. Distinguishing missing-key from + // explicit-null is the whole point of these checks. + expect( + () => decoder.decodeJson({'type': 'STATE_SNAPSHOT'}), + throwsA(isA()), + ); + expect( + () => decoder.decodeJson({'type': 'RAW'}), + throwsA(isA()), + ); + expect( + () => decoder.decodeJson({'type': 'CUSTOM', 'name': 'n'}), + throwsA(isA()), + ); + + // Explicit-null should be accepted (round-trips a present-but-null + // payload — see the matching note in the fromJson factories). + expect( + () => decoder.decodeJson({ + 'type': 'STATE_SNAPSHOT', + 'snapshot': null, + }), + returnsNormally, + ); + expect( + () => decoder.decodeJson({ + 'type': 'CUSTOM', + 'name': 'n', + 'value': null, + }), + returnsNormally, + ); }); }); @@ -435,6 +807,218 @@ void main() { 'RUN_FINISHED', ])); }); + + test( + 'fromRawSseStream emits events from a CRLF-encoded stream before ' + 'close (regression: line-splitter CRLF handling)', () async { + // The WHATWG SSE spec permits CRLF, lone LF, and lone CR line + // terminators. Before the CRLF fix, `fromRawSseStream` split + // only on `\n`, leaving each line ending in `\r` — the + // `line.isEmpty` event-boundary check never fired and events + // buffered until stream close. This test asserts the steady- + // state path: events MUST be emitted before + // `rawController.close()` even on CRLF input. See + // `sse-protocol-parsing-edge-cases.md`. + final rawController = StreamController(); + final eventStream = adapter.fromRawSseStream(rawController.stream); + + final events = []; + final subscription = eventStream.listen(events.add); + + rawController.add( + 'data: {"type":"RUN_STARTED","thread_id":"t1","run_id":"r1"}\r\n\r\n', + ); + rawController.add( + 'data: {"type":"TEXT_MESSAGE_START","messageId":"m1","role":"assistant"}\r\n\r\n', + ); + rawController.add( + 'data: {"type":"TEXT_MESSAGE_END","messageId":"m1"}\r\n\r\n', + ); + + // Allow the microtask queue to drain so the line buffer + // processes everything BEFORE we close the stream. + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + // Pre-close assertion: events must already be flowing. + expect( + events.length, + equals(3), + reason: + 'CRLF input must be parsed in steady state, not buffered ' + 'until stream close', + ); + + await rawController.close(); + await subscription.cancel(); + + expect(events[0], isA()); + expect(events[1], isA()); + expect(events[2], isA()); + }); + + test( + 'fromRawSseStream handles mixed LF and CRLF in the same stream', + () async { + final rawController = StreamController(); + final eventStream = adapter.fromRawSseStream(rawController.stream); + + final events = []; + final subscription = eventStream.listen(events.add); + + // Mix of pure-LF and CRLF event terminators. + rawController.add( + 'data: {"type":"RUN_STARTED","thread_id":"t1","run_id":"r1"}\n\n', + ); + rawController.add( + 'data: {"type":"TEXT_MESSAGE_END","messageId":"m1"}\r\n\r\n', + ); + + await rawController.close(); + await subscription.cancel(); + + expect(events.length, equals(2)); + expect(events[0], isA()); + expect(events[1], isA()); + }); + + test( + 'fromRawSseStream emits events from a lone-CR-encoded stream ' + '(WHATWG spec: \\r is a valid line terminator)', () async { + // Companion to the CRLF regression at lines 822-868. The WHATWG SSE + // spec permits CRLF, lone LF, and lone CR terminators. Pre-fix, + // `fromRawSseStream` only split on `\n`, so a producer using bare + // `\r` (rare in practice but spec-valid) buffered indefinitely. + // The post-fix multi-terminator scanner consumes lone `\r` in + // steady state, with the trailing-`\r` deferral preserving correct + // chunk-spanning `\r\n` handling. + final rawController = StreamController(); + final eventStream = adapter.fromRawSseStream(rawController.stream); + + final events = []; + final subscription = eventStream.listen(events.add); + + rawController.add( + 'data: {"type":"RUN_STARTED","thread_id":"t1","run_id":"r1"}\r\r', + ); + rawController.add( + 'data: {"type":"TEXT_MESSAGE_START","messageId":"m1","role":"assistant"}\r\r', + ); + rawController.add( + 'data: {"type":"TEXT_MESSAGE_END","messageId":"m1"}\r\r', + ); + + // Drain microtasks before close to verify steady-state, not + // flush-on-close. Same pattern as the CRLF test above. + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + expect( + events.length, + equals(3), + reason: + 'Lone-CR input must be parsed in steady state, not buffered ' + 'until stream close', + ); + + await rawController.close(); + await subscription.cancel(); + + expect(events[0], isA()); + expect(events[1], isA()); + expect(events[2], isA()); + }); + + test( + 'fromRawSseStream correctly disambiguates chunk-spanning \\r\\n ' + 'from lone \\r + lone \\n', () async { + // The trailing-`\r` deferral guarantees that a CRLF split across + // two chunks (chunk1 ends with `\r`, chunk2 starts with `\n`) is + // treated as a single CRLF terminator, not two separate lone + // terminators. Without the deferral, the empty-line dispatch would + // double-fire and the SSE event boundary would be mis-detected. + final rawController = StreamController(); + final eventStream = adapter.fromRawSseStream(rawController.stream); + + final events = []; + final subscription = eventStream.listen(events.add); + + // Split the CRLF terminators so each spans two chunks. + rawController.add( + 'data: {"type":"RUN_STARTED","thread_id":"t1","run_id":"r1"}\r', + ); + rawController.add('\n\r'); + rawController.add( + '\ndata: {"type":"RUN_FINISHED","thread_id":"t1","run_id":"r1"}\r\n\r\n', + ); + + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + await rawController.close(); + await subscription.cancel(); + + expect(events.length, equals(2)); + expect(events[0], isA()); + expect(events[1], isA()); + }); + + test( + 'fromRawSseStream handles per-line-chunked lone-CR producer without ' + 'extra RTT (lastWasLoneCr persists across chunks)', () async { + // Regression for Important #II2: when a producer uses lone-CR + // terminators and delivers each `\r` in its own chunk, the + // `lastWasLoneCr` flag must survive across processChunk calls. + // Without persistence the trailing-`\r` deferral misfired on every + // event, delaying dispatch by one chunk-RTT each time. + // + // Stream shape: each data line ends with `\r`, each event boundary + // is a lone `\r`, and each `\r` arrives in a separate chunk. + final rawController = StreamController(); + final eventStream = adapter.fromRawSseStream(rawController.stream); + + final events = []; + final subscription = eventStream.listen(events.add); + + // Event 1: RUN_STARTED — data line `\r` then boundary `\r`, each + // in its own chunk. + rawController.add( + 'data: {"type":"RUN_STARTED","thread_id":"t1","run_id":"r1"}', + ); + rawController.add('\r'); // data-line terminator + rawController.add('\r'); // event-boundary terminator + + // Event 2: RUN_FINISHED + rawController.add( + 'data: {"type":"RUN_FINISHED","thread_id":"t1","run_id":"r1"}', + ); + rawController.add('\r'); + rawController.add('\r'); + + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + await rawController.close(); + await subscription.cancel(); + + expect(events.length, equals(2), + reason: 'Both events must be emitted without stalling'); + expect(events[0], isA()); + expect(events[1], isA()); + }); + + test('decodeSSE handles CRLF terminators (LineSplitter-based)', () { + // The single-message `decodeSSE` API mirrors the streaming + // parser: a `data: ...\r\n\r\n` payload must decode the same as + // a `data: ...\n\n` payload, with no stray `\r` corrupting the + // joined value. + final crlfMessage = + 'data: {"type":"TEXT_MESSAGE_END","messageId":"m1"}\r\n\r\n'; + final event = decoder.decodeSSE(crlfMessage); + expect(event, isA()); + expect((event as TextMessageEndEvent).messageId, equals('m1')); + }); }); }); } \ No newline at end of file diff --git a/sdks/community/dart/test/integration/fixtures_integration_test.dart b/sdks/community/dart/test/integration/fixtures_integration_test.dart index 881ee3ea03..7a33923a1e 100644 --- a/sdks/community/dart/test/integration/fixtures_integration_test.dart +++ b/sdks/community/dart/test/integration/fixtures_integration_test.dart @@ -106,23 +106,77 @@ void main() { final decodedEvents = events .map((e) => decoder.decodeJson(e as Map)) .toList(); - + final snapshot = decodedEvents .whereType() .first; expect(snapshot.messages.length, equals(3)); - + // Check message types expect(snapshot.messages[0], isA()); expect(snapshot.messages[1], isA()); expect(snapshot.messages[2], isA()); - + // Check assistant message has tool calls final assistantMsg = snapshot.messages[1] as AssistantMessage; expect(assistantMsg.toolCalls, isNotNull); expect(assistantMsg.toolCalls!.length, equals(1)); expect(assistantMsg.toolCalls![0].function.name, equals('get_weather')); }); + + test('processes messages snapshot with activity and reasoning roles', + () { + final events = + fixtures['messages_snapshot_activity_reasoning'] as List; + final decodedEvents = events + .map((e) => decoder.decodeJson(e as Map)) + .toList(); + + final snapshot = + decodedEvents.whereType().first; + expect(snapshot.messages.length, equals(4)); + + expect(snapshot.messages[0], isA()); + expect(snapshot.messages[1], isA()); + expect(snapshot.messages[2], isA()); + expect(snapshot.messages[3], isA()); + + final activity = snapshot.messages[1] as ActivityMessage; + expect(activity.activityType, equals('task.run')); + expect(activity.activityContent['title'], equals('Indexing files')); + expect(activity.activityContent['progress'], equals(0.5)); + + final reasoning = snapshot.messages[2] as ReasoningMessage; + expect(reasoning.content, contains('Considering')); + expect(reasoning.encryptedValue, equals('ZW5jcnlwdGVkLXJlYXNvbmluZw==')); + + // Cross-SDK parity: AssistantMessage carries encryptedValue from + // the canonical BaseMessageSchema. Closes the silent-drop bug + // documented in the #1018 review. + final assistant = snapshot.messages[3] as AssistantMessage; + expect(assistant.encryptedValue, + equals('ZW5jcnlwdGVkLWFzc2lzdGFudA==')); + + // Round-trip the snapshot through the encoder boundary so + // toJson()/fromJson() symmetry is exercised end-to-end for the + // new Message subtypes, not just at the factory level. + final reEncoded = MessagesSnapshotEvent.fromJson(snapshot.toJson()); + expect(reEncoded.messages.length, equals(4)); + expect(reEncoded.messages[1], isA()); + expect(reEncoded.messages[2], isA()); + expect( + (reEncoded.messages[1] as ActivityMessage).activityContent['title'], + equals('Indexing files'), + ); + expect( + (reEncoded.messages[2] as ReasoningMessage).encryptedValue, + equals('ZW5jcnlwdGVkLXJlYXNvbmluZw=='), + ); + expect( + (reEncoded.messages[3] as AssistantMessage).encryptedValue, + equals('ZW5jcnlwdGVkLWFzc2lzdGFudA=='), + ); + }); test('processes multiple sequential runs', () { final events = fixtures['multiple_runs'] as List; @@ -156,8 +210,11 @@ void main() { .first; expect(thinkingStart.title, equals('Analyzing request')); - // Use the new ThinkingContentEvent class + // Decoding still emits the (deprecated) ThinkingContentEvent for + // backward compatibility until removal. See [ThinkingContentEvent]. + // ignore: deprecated_member_use_from_same_package final thinkingEvents = decodedEvents + // ignore: deprecated_member_use_from_same_package .whereType() .toList(); expect(thinkingEvents.length, equals(2)); @@ -250,7 +307,7 @@ void main() { final decodedEvents = events .map((e) => decoder.decodeJson(e as Map)) .toList(); - + final chunkEvent = decodedEvents .whereType() .first; @@ -258,6 +315,64 @@ void main() { expect(chunkEvent.role, equals(TextMessageRole.assistant)); expect(chunkEvent.delta, equals('Complete message in a single chunk')); }); + + test('processes activity events', () { + final events = fixtures['activity_events'] as List; + final decodedEvents = events + .map((e) => decoder.decodeJson(e as Map)) + .toList(); + + final snapshot = + decodedEvents.whereType().first; + expect(snapshot.messageId, equals('act_01')); + expect(snapshot.activityType, equals('task.run')); + expect(snapshot.replace, isTrue); + expect((snapshot.content as Map)['title'], equals('Indexing files')); + + final deltas = decodedEvents.whereType().toList(); + expect(deltas.length, equals(2)); + expect(deltas[0].patch.length, equals(2)); + expect(deltas[0].patch[0]['op'], equals('replace')); + expect(deltas[1].patch[0]['value'], equals(1.0)); + }); + + test('processes reasoning events', () { + final events = fixtures['reasoning_events'] as List; + final decodedEvents = events + .map((e) => decoder.decodeJson(e as Map)) + .toList(); + + expect( + decodedEvents.whereType().length, + equals(1), + ); + expect( + decodedEvents.whereType().single.role, + equals(ReasoningMessageRole.reasoning), + ); + + final content = + decodedEvents.whereType().single; + expect(content.delta, contains('Analyzing')); + + final chunk = + decodedEvents.whereType().single; + expect(chunk.delta, contains('considering options')); + + final encrypted = + decodedEvents.whereType().single; + expect( + encrypted.subtype, + equals(ReasoningEncryptedValueSubtype.message), + ); + expect(encrypted.entityId, equals('rsn_01')); + expect(encrypted.encryptedValue, isNotEmpty); + + expect( + decodedEvents.whereType().length, + equals(1), + ); + }); }); group('SSE Stream Fixtures', () { @@ -410,6 +525,32 @@ void main() { StateDeltaEvent(delta: [ {'op': 'replace', 'path': '/count', 'value': 43}, ]), + ActivitySnapshotEvent( + messageId: 'act_01', + activityType: 'task.run', + content: {'progress': 0.25}, + ), + ActivityDeltaEvent( + messageId: 'act_01', + activityType: 'task.run', + patch: [ + {'op': 'replace', 'path': '/progress', 'value': 0.5}, + ], + ), + ReasoningStartEvent(messageId: 'rsn_01'), + ReasoningMessageStartEvent(messageId: 'rsn_01'), + ReasoningMessageContentEvent( + messageId: 'rsn_01', + delta: 'thinking', + ), + ReasoningMessageEndEvent(messageId: 'rsn_01'), + ReasoningMessageChunkEvent(messageId: 'rsn_01', delta: 'chunk'), + ReasoningEncryptedValueEvent( + subtype: ReasoningEncryptedValueSubtype.message, + entityId: 'rsn_01', + encryptedValue: 'cipher', + ), + ReasoningEndEvent(messageId: 'rsn_01'), RunFinishedEvent(threadId: 'thread_01', runId: 'run_01'), ]; @@ -432,15 +573,83 @@ void main() { final decodedRun = decodedEvents[0] as RunStartedEvent; expect(decodedRun.threadId, equals('thread_01')); expect(decodedRun.runId, equals('run_01')); - + final decodedContent = decodedEvents[2] as TextMessageContentEvent; expect(decodedContent.delta, equals('Hello, world!')); - + final decodedSnapshot = decodedEvents[7] as StateSnapshotEvent; expect(decodedSnapshot.snapshot['count'], equals(42)); expect(decodedSnapshot.snapshot['items'], equals(['a', 'b', 'c'])); + + // Field-value parity for the new activity / reasoning events. + // `whereType().first` is used instead of positional indices + // so the assertions stay stable if the fixture order shifts. + final activitySnapshot = + decodedEvents.whereType().first; + expect(activitySnapshot.replace, isTrue); + expect(activitySnapshot.activityType, equals('task.run')); + expect( + activitySnapshot.content, + equals({'progress': 0.25}), + ); + + final reasoningStart = + decodedEvents.whereType().first; + expect(reasoningStart.role, equals(ReasoningMessageRole.reasoning)); + expect(reasoningStart.messageId, equals('rsn_01')); + + final encrypted = + decodedEvents.whereType().first; + expect( + encrypted.subtype, + equals(ReasoningEncryptedValueSubtype.message), + ); + expect(encrypted.entityId, equals('rsn_01')); + expect(encrypted.encryptedValue, equals('cipher')); }); + test('round-trip preserves explicit-null payload', () { + // Regression guard for the encoder null-strip bug: previously + // `encodeSSE` ran `json.removeWhere((k, v) => v == null)` which + // silently dropped fields that intentionally serialize as `null`. + // The factories below all REQUIRE the key to be present (an absent + // key raises `AGUIValidationError`), so the round-trip would fail + // with `DecodingError(field: 'content' | 'event' | 'value')`. The + // post-fix encoder leaves the toJson output untouched. + final originals = [ + ActivitySnapshotEvent( + messageId: 'm', + activityType: 't', + content: null, + ), + RawEvent(event: null), + CustomEvent(name: 'evt', value: null), + StateSnapshotEvent(snapshot: null), + ]; + + for (final original in originals) { + final sse = encoder.encodeSSE(original); + final decoded = decoder.decodeSSE(sse); + expect( + decoded.runtimeType, + equals(original.runtimeType), + reason: 'round-trip type mismatch for ${original.runtimeType}', + ); + } + + final activity = decoder.decodeSSE(encoder.encodeSSE(originals[0])) + as ActivitySnapshotEvent; + expect(activity.content, isNull); + final raw = decoder.decodeSSE(encoder.encodeSSE(originals[1])) as RawEvent; + expect(raw.event, isNull); + final custom = + decoder.decodeSSE(encoder.encodeSSE(originals[2])) as CustomEvent; + expect(custom.value, isNull); + final snapshot = decoder + .decodeSSE(encoder.encodeSSE(originals[3])) as StateSnapshotEvent; + expect(snapshot.snapshot, isNull); + }); + test('handles protobuf content type negotiation', () { // Test with protobuf accept header final protoEncoder = EventEncoder( diff --git a/sdks/community/dart/test/integration/helpers/test_helpers.dart b/sdks/community/dart/test/integration/helpers/test_helpers.dart index 42bd9b2026..68c82bc409 100644 --- a/sdks/community/dart/test/integration/helpers/test_helpers.dart +++ b/sdks/community/dart/test/integration/helpers/test_helpers.dart @@ -65,7 +65,7 @@ class TestHelpers { completer.complete(); } }, - onError: (error) { + onError: (Object error) { completer.completeError(error); }, onDone: () { @@ -136,21 +136,21 @@ class TestHelpers { return messages; } - /// Find tool calls in messages + /// Find tool calls in messages. + /// + /// Uses the typed accessor on `AssistantMessage` rather than round-tripping + /// through `toJson` — the previous implementation read `json['tool_calls']` + /// (snake_case) but `AssistantMessage.toJson` emits the camelCase key + /// `'toolCalls'`, so the helper silently always returned an empty list. static List findToolCalls(List messages) { final toolCalls = []; - + for (final message in messages) { - // Tool calls are stored in the message's toJson representation - final json = message.toJson(); - if (json['tool_calls'] != null) { - final calls = json['tool_calls'] as List; - for (final call in calls) { - toolCalls.add(ToolCall.fromJson(call as Map)); - } + if (message is AssistantMessage && message.toolCalls != null) { + toolCalls.addAll(message.toolCalls!); } } - + return toolCalls; } @@ -198,8 +198,9 @@ class TestHelpers { json['messages'] = event.messages.map(_messageToJson).toList(); } else if (event is TextMessageChunkEvent) { json['messageId'] = event.messageId; - // TextMessageChunkEvent stores content differently - // Will need to check the actual implementation + // Other chunk fields (`role`, `delta`, `name`) are intentionally + // omitted from this minimal helper; tests that need them build the + // JSON map directly rather than going through this round-tripper. } else if (event is ToolCallStartEvent) { json['toolCallId'] = event.toolCallId; } diff --git a/sdks/community/dart/test/sse/sse_parser_test.dart b/sdks/community/dart/test/sse/sse_parser_test.dart index e1d4062b48..02a2611a08 100644 --- a/sdks/community/dart/test/sse/sse_parser_test.dart +++ b/sdks/community/dart/test/sse/sse_parser_test.dart @@ -78,6 +78,43 @@ void main() { expect(messages[0].data, 'line 1\nline 2\nline 3'); }); + test('preserves leading newline when first data field is empty', + () async { + // Per WHATWG, every `data:` field appends `\n` before its value + // (with the trailing `\n` stripped at dispatch). An empty first + // `data:` followed by `data: x` MUST yield `"\nx"`, not `"x"`. + // Regression for the `_dataBuffer.isNotEmpty` heuristic that + // collapsed the empty-then-non-empty sequence pre-fix. + final lines = Stream.fromIterable([ + 'data:', + 'data: x', + '', + ]); + + final messages = await parser.parseLines(lines).toList(); + expect(messages.length, 1); + expect(messages[0].data, '\nx'); + }); + + test('event field replaces (not appends) on repeated event: lines', + () async { + // Per WHATWG, "If the field name is 'event', set the event type + // buffer to field value." Repeated `event:` lines within one + // dispatch block must REPLACE, not concatenate. Pre-fix, this + // produced `"firstsecond"`. + final lines = Stream.fromIterable([ + 'event: first', + 'event: second', + 'data: payload', + '', + ]); + + final messages = await parser.parseLines(lines).toList(); + expect(messages.length, 1); + expect(messages[0].event, 'second'); + expect(messages[0].data, 'payload'); + }); + test('ignores comments', () async { final lines = Stream.fromIterable([ ': this is a comment', @@ -179,6 +216,28 @@ void main() { expect(messages[0].id, isNull); }); + test('ignores id containing NUL byte per WHATWG SSE spec', () async { + // WHATWG SSE spec: id values must not contain U+0000 (NUL). + // A NUL-bearing id is silently ignored; _lastEventId is not updated. + // Per spec, each dispatched message carries the current _lastEventId + // value, so the second message still inherits 'good-id'. + final lines = Stream.fromIterable([ + 'id: good-id', + 'data: first', + '', + 'id: bad\x00id', + 'data: second', + '', + ]); + + final messages = await parser.parseLines(lines).toList(); + expect(messages.length, 2); + expect(messages[0].id, equals('good-id')); + // NUL id ignored — _lastEventId unchanged, second message inherits it + expect(messages[1].id, equals('good-id')); + expect(parser.lastEventId, equals('good-id')); + }); + test('ignores invalid retry values', () async { final lines = Stream.fromIterable([ 'retry: not-a-number', diff --git a/sdks/community/dart/test/types/message_test.dart b/sdks/community/dart/test/types/message_test.dart index 3d360130e1..4a6454f821 100644 --- a/sdks/community/dart/test/types/message_test.dart +++ b/sdks/community/dart/test/types/message_test.dart @@ -132,6 +132,197 @@ void main() { }); }); + group('ActivityMessage', () { + test('round-trips canonical wire shape', () { + final message = ActivityMessage( + id: 'act_001', + activityType: 'task.run', + activityContent: const {'progress': 0.5, 'items': []}, + ); + + final json = message.toJson(); + expect(json['id'], 'act_001'); + expect(json['role'], 'activity'); + expect(json['activityType'], 'task.run'); + expect(json['content'], const {'progress': 0.5, 'items': []}); + + final decoded = ActivityMessage.fromJson(json); + expect(decoded.id, 'act_001'); + expect(decoded.activityType, 'task.run'); + expect(decoded.activityContent, equals(message.activityContent)); + expect(decoded.role, MessageRole.activity); + }); + + test('accepts snake_case activity_type (Python server)', () { + final message = ActivityMessage.fromJson({ + 'id': 'act_002', + 'role': 'activity', + 'activity_type': 'task.run', + 'content': {'progress': 0.0}, + }); + + expect(message.activityType, 'task.run'); + expect(message.activityContent['progress'], 0.0); + }); + + test('rejects missing required content', () { + expect( + () => ActivityMessage.fromJson({ + 'id': 'act_003', + 'role': 'activity', + 'activityType': 'task.run', + }), + throwsA(isA()), + ); + }); + + test('copyWith preserves subtype', () { + final original = ActivityMessage( + id: 'act_004', + activityType: 'task.run', + activityContent: const {'progress': 0.0}, + ); + + final updated = original.copyWith( + activityContent: const {'progress': 1.0}, + ); + + expect(updated, isA()); + expect(updated.id, original.id); + expect(updated.activityType, original.activityType); + expect(updated.activityContent['progress'], 1.0); + }); + + test('strips camelCase encryptedValue silently (not a BaseMessage extension)', () { + final msg = ActivityMessage.fromJson({ + 'id': 'act_005', + 'role': 'activity', + 'activityType': 'task.run', + 'content': {'progress': 0.5}, + 'encryptedValue': 'ZW5jcnlwdGVkLXBheWxvYWQ=', + }); + expect(msg.id, 'act_005'); + expect(msg.activityType, 'task.run'); + // encryptedValue is not exposed on ActivityMessage — stripping is silent. + expect(msg.toJson().containsKey('encryptedValue'), isFalse); + }); + + test('strips snake_case encrypted_value silently (not a BaseMessage extension)', () { + final msg = ActivityMessage.fromJson({ + 'id': 'act_006', + 'role': 'activity', + 'activityType': 'task.run', + 'content': {'progress': 0.5}, + 'encrypted_value': 'ZW5jcnlwdGVkLXBheWxvYWQ=', + }); + expect(msg.id, 'act_006'); + expect(msg.activityType, 'task.run'); + expect(msg.toJson().containsKey('encryptedValue'), isFalse); + }); + + test('II3 regression: name and encryptedValue are always null on ActivityMessage', () { + // ActivityMessage is NOT a BaseMessage extension — cipher-payload + // forwarding does not apply. The parent Message fields `name` and + // `encryptedValue` are always null on instances constructed via the + // public constructor or fromJson, and toJson never emits them. + final direct = ActivityMessage( + id: 'act_007', + activityType: 'task.run', + activityContent: const {'x': 1}, + ); + expect(direct.name, isNull, + reason: 'name must be null on ActivityMessage'); + expect(direct.encryptedValue, isNull, + reason: 'encryptedValue must be null on ActivityMessage'); + expect(direct.toJson().containsKey('name'), isFalse); + expect(direct.toJson().containsKey('encryptedValue'), isFalse); + + // Also via fromJson — even if a proxy emits name/encryptedValue. + final fromJson = ActivityMessage.fromJson({ + 'id': 'act_008', + 'role': 'activity', + 'activityType': 'task.run', + 'content': {'x': 1}, + 'name': 'should_be_stripped', + 'encryptedValue': 'should_be_stripped', + }); + expect(fromJson.name, isNull); + expect(fromJson.encryptedValue, isNull); + expect(fromJson.toJson().containsKey('name'), isFalse); + expect(fromJson.toJson().containsKey('encryptedValue'), isFalse); + }); + }); + + group('ReasoningMessage', () { + test('round-trips canonical wire shape with encryptedValue', () { + final message = ReasoningMessage( + id: 'rsn_001', + content: 'Analyzing the request...', + encryptedValue: 'ZW5jcnlwdGVkLXBheWxvYWQ=', + ); + + final json = message.toJson(); + expect(json['id'], 'rsn_001'); + expect(json['role'], 'reasoning'); + expect(json['content'], 'Analyzing the request...'); + expect(json['encryptedValue'], 'ZW5jcnlwdGVkLXBheWxvYWQ='); + + final decoded = ReasoningMessage.fromJson(json); + expect(decoded.id, 'rsn_001'); + expect(decoded.content, message.content); + expect(decoded.encryptedValue, message.encryptedValue); + expect(decoded.role, MessageRole.reasoning); + }); + + test('omits encryptedValue when null', () { + final message = ReasoningMessage( + id: 'rsn_002', + content: 'Plain reasoning text', + ); + + final json = message.toJson(); + expect(json.containsKey('encryptedValue'), isFalse); + + final decoded = ReasoningMessage.fromJson(json); + expect(decoded.encryptedValue, isNull); + }); + + test('accepts snake_case encrypted_value (Python server)', () { + final message = ReasoningMessage.fromJson({ + 'id': 'rsn_003', + 'role': 'reasoning', + 'content': 'Thinking', + 'encrypted_value': 'cGF5bG9hZA==', + }); + + expect(message.encryptedValue, 'cGF5bG9hZA=='); + }); + + test('rejects missing required content', () { + expect( + () => ReasoningMessage.fromJson({ + 'id': 'rsn_004', + 'role': 'reasoning', + }), + throwsA(isA()), + ); + }); + + test('copyWith preserves subtype', () { + final original = ReasoningMessage( + id: 'rsn_005', + content: 'first', + ); + + final updated = original.copyWith(content: 'second'); + + expect(updated, isA()); + expect(updated.id, original.id); + expect(updated.content, 'second'); + expect(updated.encryptedValue, isNull); + }); + }); + group('Message Factory', () { test('should create correct message type based on role', () { final messages = [ @@ -145,6 +336,17 @@ void main() { 'content': 'Tool result', 'toolCallId': 'call_001' }, + { + 'id': '6', + 'role': 'activity', + 'activityType': 'task.run', + 'content': {'progress': 0.0}, + }, + { + 'id': '7', + 'role': 'reasoning', + 'content': 'Thinking out loud', + }, ]; final decoded = messages.map((json) => Message.fromJson(json)).toList(); @@ -154,6 +356,8 @@ void main() { expect(decoded[2], isA()); expect(decoded[3], isA()); expect(decoded[4], isA()); + expect(decoded[5], isA()); + expect(decoded[6], isA()); }); test('should throw on invalid role', () { @@ -170,6 +374,203 @@ void main() { }); }); + group('copyWith null-clearing parity (sentinel pattern)', () { + test('DeveloperMessage.copyWith(name: null) clears name', () { + // Sentinel pattern parity with the event layer: a nullable field + // must be clearable via `copyWith(field: null)`. The default + // `?? this.field` pattern (events.dart calls this out via + // `_unsetCopyWith`) cannot distinguish "omitted" from + // "explicitly null" — sentinel resolves it. + final msg = DeveloperMessage( + id: 'd1', + content: 'x', + name: 'devbot', + ); + expect(msg.copyWith(name: null).name, isNull); + expect(msg.copyWith().name, equals('devbot')); + }); + + test('SystemMessage.copyWith(name: null) clears name', () { + final msg = SystemMessage(id: 's1', content: 'x', name: 'sys'); + expect(msg.copyWith(name: null).name, isNull); + expect(msg.copyWith().name, equals('sys')); + }); + + test('UserMessage.copyWith(name: null) clears name', () { + final msg = UserMessage(id: 'u1', content: 'x', name: 'alice'); + expect(msg.copyWith(name: null).name, isNull); + expect(msg.copyWith().name, equals('alice')); + }); + + test( + 'AssistantMessage.copyWith with explicit null clears ' + 'content/name/toolCalls', () { + // All three nullable fields use the sentinel — verify each one + // independently. + final msg = AssistantMessage( + id: 'a1', + content: 'hi', + name: 'asst', + toolCalls: [ + ToolCall( + id: 'c1', + function: FunctionCall(name: 'fn', arguments: '{}'), + ), + ], + ); + expect(msg.copyWith(content: null).content, isNull); + expect(msg.copyWith(name: null).name, isNull); + expect(msg.copyWith(toolCalls: null).toolCalls, isNull); + + // Argument omitted preserves all three fields. + final cloned = msg.copyWith(); + expect(cloned.content, equals('hi')); + expect(cloned.name, equals('asst')); + expect(cloned.toolCalls, isNotNull); + }); + + test('ToolMessage.copyWith with explicit null clears error and ' + 'encryptedValue', () { + final msg = ToolMessage( + id: 't1', + content: 'result', + toolCallId: 'c1', + error: 'oops', + encryptedValue: 'cipher', + ); + expect(msg.copyWith(error: null).error, isNull); + expect(msg.copyWith(encryptedValue: null).encryptedValue, isNull); + + final cloned = msg.copyWith(); + expect(cloned.error, equals('oops')); + expect(cloned.encryptedValue, equals('cipher')); + }); + + test('ReasoningMessage.copyWith(encryptedValue: null) clears it', () { + final msg = ReasoningMessage( + id: 'r1', + content: 'thinking', + encryptedValue: 'cipher', + ); + expect(msg.copyWith(encryptedValue: null).encryptedValue, isNull); + expect(msg.copyWith().encryptedValue, equals('cipher')); + }); + }); + + group('AssistantMessage.fromJson dual-key precedence', () { + test( + 'empty toolCalls does not silently win over snake_case ' + 'tool_calls (regression for #1018 review)', () { + // Before the fix, the `??`-on-value chain only fired on null; + // an empty `toolCalls: []` short-circuited and silently + // dropped the populated `tool_calls` snake_case alias. + // `optionalEitherField` resolves on the KEY itself: camelCase + // wins when present (matching the documented falsy-non-null + // contract in `requireEitherField`), and falls back to + // snake_case ONLY when camelCase is entirely absent. + final emptyCamel = AssistantMessage.fromJson({ + 'id': 'a1', + 'role': 'assistant', + 'toolCalls': [], + 'tool_calls': [ + { + 'id': 'call_1', + 'type': 'function', + 'function': {'name': 'fn', 'arguments': '{}'}, + }, + ], + }); + // Documented behavior: camelCase wins when the key is present, + // even when the list is empty. The snake_case payload is + // silently ignored — surprising if you read the code as a + // "fallback", correct if you read it as + // "camelCase-key-present always wins". + expect(emptyCamel.toolCalls, isEmpty); + + // When camelCase is absent, snake_case is consulted. + final onlySnake = AssistantMessage.fromJson({ + 'id': 'a2', + 'role': 'assistant', + 'tool_calls': [ + { + 'id': 'call_2', + 'type': 'function', + 'function': {'name': 'fn', 'arguments': '{}'}, + }, + ], + }); + expect(onlySnake.toolCalls, isNotNull); + expect(onlySnake.toolCalls!.length, 1); + expect(onlySnake.toolCalls![0].id, equals('call_2')); + }); + }); + + group('ToolCall.encryptedValue parity', () { + test( + 'round-trips encryptedValue (camelCase) on AssistantMessage.toolCalls', + () { + final msg = AssistantMessage.fromJson({ + 'id': 'a1', + 'role': 'assistant', + 'content': null, + 'toolCalls': [ + { + 'id': 'call_1', + 'type': 'function', + 'function': {'name': 'fn', 'arguments': '{"a":1}'}, + 'encryptedValue': 'cipher-camel', + }, + ], + }); + expect(msg.toolCalls!.single.encryptedValue, equals('cipher-camel')); + + final round = AssistantMessage.fromJson(msg.toJson()); + expect(round.toolCalls!.single.encryptedValue, equals('cipher-camel')); + }); + + test( + 'accepts snake_case encrypted_value alias and emits camelCase ' + 'on toJson', () { + final tc = ToolCall.fromJson({ + 'id': 'call_1', + 'type': 'function', + 'function': {'name': 'fn', 'arguments': '{}'}, + 'encrypted_value': 'cipher-snake', + }); + expect(tc.encryptedValue, equals('cipher-snake')); + expect(tc.toJson()['encryptedValue'], equals('cipher-snake')); + expect(tc.toJson().containsKey('encrypted_value'), isFalse); + }); + + test('omits encryptedValue from toJson when null', () { + final tc = ToolCall( + id: 'call_1', + function: const FunctionCall(name: 'fn', arguments: '{}'), + ); + expect(tc.encryptedValue, isNull); + expect(tc.toJson().containsKey('encryptedValue'), isFalse); + }); + + test('copyWith preserves encryptedValue when omitted', () { + final tc = ToolCall( + id: 'call_1', + function: const FunctionCall(name: 'fn', arguments: '{}'), + encryptedValue: 'cipher', + ); + expect(tc.copyWith(id: 'call_2').encryptedValue, equals('cipher')); + }); + + test('copyWith(encryptedValue: null) clears the field', () { + final tc = ToolCall( + id: 'call_1', + function: const FunctionCall(name: 'fn', arguments: '{}'), + encryptedValue: 'cipher', + ); + expect(tc.copyWith(encryptedValue: null).encryptedValue, isNull); + expect(tc.copyWith().encryptedValue, equals('cipher')); + }); + }); + group('Unknown field tolerance', () { test('should ignore unknown fields in JSON', () { final json = { @@ -183,12 +584,152 @@ void main() { final message = UserMessage.fromJson(json); expect(message.id, 'msg_unknown'); expect(message.content, 'User message'); - + // Verify unknown fields are not included in serialized output final serialized = message.toJson(); expect(serialized.containsKey('unknown_field'), false); expect(serialized.containsKey('another_unknown'), false); }); }); + + group('BaseMessage.encryptedValue parity', () { + // Closes the cross-SDK parity gap noted in the #1018 review: + // canonical TS `BaseMessageSchema.encryptedValue: z.string().optional()` + // and Python `BaseMessage.encrypted_value: Optional[str]` mean every + // BaseMessage extension (Developer/System/Assistant/User/Tool) must + // round-trip the field. Before this fix, only `ToolMessage` and + // `ReasoningMessage` (the latter not strictly a BaseMessage) carried + // it; a Dart proxy decoding an `assistant.encryptedValue` from a + // TS or Python server silently dropped the value on every hop. + + test('AssistantMessage round-trips encryptedValue (camelCase)', () { + final original = AssistantMessage( + id: 'asst_001', + content: 'Routed via cipher.', + encryptedValue: 'YXNzaXN0YW50LWNpcGhlcg==', + ); + + final json = original.toJson(); + expect(json['encryptedValue'], 'YXNzaXN0YW50LWNpcGhlcg=='); + expect(json.containsKey('encrypted_value'), isFalse, + reason: 'wire output is camelCase regardless of input spelling'); + + final decoded = AssistantMessage.fromJson(json); + expect(decoded.encryptedValue, original.encryptedValue); + expect(decoded.role, MessageRole.assistant); + }); + + test('AssistantMessage accepts snake_case encrypted_value', () { + final decoded = AssistantMessage.fromJson({ + 'id': 'asst_002', + 'role': 'assistant', + 'content': 'From a Python server', + 'encrypted_value': 'cHl0aG9uLWNpcGhlcg==', + }); + expect(decoded.encryptedValue, 'cHl0aG9uLWNpcGhlcg=='); + // Re-emit on the next hop in canonical camelCase. + expect(decoded.toJson()['encryptedValue'], 'cHl0aG9uLWNpcGhlcg=='); + }); + + test('UserMessage round-trips encryptedValue (camelCase)', () { + final original = UserMessage( + id: 'user_001', + content: 'hi', + encryptedValue: 'dXNlci1jaXBoZXI=', + ); + + final json = original.toJson(); + expect(json['encryptedValue'], 'dXNlci1jaXBoZXI='); + + final decoded = UserMessage.fromJson(json); + expect(decoded.encryptedValue, original.encryptedValue); + expect(decoded.role, MessageRole.user); + }); + + test('UserMessage accepts snake_case encrypted_value', () { + final decoded = UserMessage.fromJson({ + 'id': 'user_002', + 'role': 'user', + 'content': 'hi', + 'encrypted_value': 'cHk=', + }); + expect(decoded.encryptedValue, 'cHk='); + }); + + test( + 'DeveloperMessage and SystemMessage round-trip encryptedValue ' + '(camelCase + snake_case)', () { + final dev = DeveloperMessage( + id: 'd1', + content: 'dev', + encryptedValue: 'ZGV2LWNpcGhlcg==', + ); + expect(dev.toJson()['encryptedValue'], 'ZGV2LWNpcGhlcg=='); + expect( + DeveloperMessage.fromJson(dev.toJson()).encryptedValue, + 'ZGV2LWNpcGhlcg==', + ); + expect( + DeveloperMessage.fromJson({ + 'id': 'd2', + 'role': 'developer', + 'content': 'dev', + 'encrypted_value': 'ZGV2LXNuYWtl', + }).encryptedValue, + 'ZGV2LXNuYWtl', + ); + + final sys = SystemMessage( + id: 's1', + content: 'sys', + encryptedValue: 'c3lzLWNpcGhlcg==', + ); + expect(sys.toJson()['encryptedValue'], 'c3lzLWNpcGhlcg=='); + expect( + SystemMessage.fromJson(sys.toJson()).encryptedValue, + 'c3lzLWNpcGhlcg==', + ); + expect( + SystemMessage.fromJson({ + 'id': 's2', + 'role': 'system', + 'content': 'sys', + 'encrypted_value': 'c3lzLXNuYWtl', + }).encryptedValue, + 'c3lzLXNuYWtl', + ); + }); + + test( + 'AssistantMessage.copyWith(encryptedValue: null) clears the ' + 'field; omitted argument preserves it', () { + final msg = AssistantMessage( + id: 'asst_003', + content: 'hi', + encryptedValue: 'cipher', + ); + expect( + msg.copyWith(encryptedValue: null).encryptedValue, isNull); + expect(msg.copyWith().encryptedValue, equals('cipher')); + }); + + test( + 'UserMessage.copyWith(encryptedValue: null) clears the field; ' + 'omitted argument preserves it', () { + final msg = UserMessage( + id: 'user_003', + content: 'hi', + encryptedValue: 'cipher', + ); + expect( + msg.copyWith(encryptedValue: null).encryptedValue, isNull); + expect(msg.copyWith().encryptedValue, equals('cipher')); + }); + + test('omits encryptedValue from toJson when null', () { + final msg = AssistantMessage(id: 'asst_004', content: 'hi'); + expect(msg.toJson().containsKey('encryptedValue'), isFalse); + }); + }); }); } \ No newline at end of file diff --git a/sdks/community/dart/test/types/tool_context_test.dart b/sdks/community/dart/test/types/tool_context_test.dart index 55da7f3e79..cb617f39e6 100644 --- a/sdks/community/dart/test/types/tool_context_test.dart +++ b/sdks/community/dart/test/types/tool_context_test.dart @@ -282,5 +282,52 @@ void main() { expect(modified.description, 'original'); expect(modified.value, 'value2'); }); + + test( + 'RunAgentInput.copyWith — sentinel-clear semantics for state and ' + 'forwardedProps (regression for #1018 review)', () { + // Before the sentinel sweep these fields used `?? this.field`, so a + // caller could not clear them explicitly via `copyWith(state: null)`. + // Now the sentinel allows omitted-vs-explicit-null to be distinguished. + final original = RunAgentInput( + threadId: 'thread_001', + runId: 'run_001', + state: const {'k': 'v'}, + messages: const [], + tools: const [], + context: const [], + forwardedProps: const {'fp': 1}, + ); + + // Omitted argument preserves the existing value. + final keep = original.copyWith(); + expect(keep.state, equals(const {'k': 'v'})); + expect(keep.forwardedProps, equals(const {'fp': 1})); + + // Explicit null clears each field independently. + final clearedState = original.copyWith(state: null); + expect(clearedState.state, isNull); + expect(clearedState.forwardedProps, equals(const {'fp': 1})); + + final clearedFP = original.copyWith(forwardedProps: null); + expect(clearedFP.forwardedProps, isNull); + expect(clearedFP.state, equals(const {'k': 'v'})); + }); + + test( + 'Run.copyWith(result: null) clears result; omitted preserves it ' + '(regression for #1018 review)', () { + final original = Run( + threadId: 't', + runId: 'r', + result: const {'ok': true}, + ); + + final keep = original.copyWith(); + expect(keep.result, equals(const {'ok': true})); + + final cleared = original.copyWith(result: null); + expect(cleared.result, isNull); + }); }); } \ No newline at end of file