diff --git a/.github/workflows/unit-swift-sdk.yml b/.github/workflows/unit-swift-sdk.yml
new file mode 100644
index 0000000000..9007d9bead
--- /dev/null
+++ b/.github/workflows/unit-swift-sdk.yml
@@ -0,0 +1,33 @@
+name: unit-swift-sdk
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - "sdks/community/swift/**"
+ - ".github/workflows/unit-swift-sdk.yml"
+ pull_request:
+ branches: [main]
+ paths:
+ - "sdks/community/swift/**"
+ - ".github/workflows/unit-swift-sdk.yml"
+
+jobs:
+ swift:
+ runs-on: macos-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+
+ - name: Resolve dependencies
+ working-directory: sdks/community/swift
+ run: swift package resolve
+
+ - name: Build
+ working-directory: sdks/community/swift
+ run: swift build
+
+ - name: Run tests
+ working-directory: sdks/community/swift
+ run: swift test
diff --git a/sdks/community/swift/.gitignore b/sdks/community/swift/.gitignore
new file mode 100644
index 0000000000..8e94bf194f
--- /dev/null
+++ b/sdks/community/swift/.gitignore
@@ -0,0 +1,57 @@
+# Xcode
+
+## User settings
+xcuserdata/
+
+## Compatibility files no longer needed since Xcode 9
+*.xcscmblueprint
+*.xccheckout
+
+## Compatibility files no longer needed since Xcode 4
+build/
+DerivedData/
+*.moved-aside
+*.pbxuser
+!default.pbxuser
+*.mode1v3
+!default.mode1v3
+*.mode2v3
+!default.mode2v3
+*.perspectivev3
+!default.perspectivev3
+
+## Obj-C/Swift specific
+*.hmap
+
+## App packaging
+*.ipa
+*.dSYM.zip
+*.dSYM
+
+## Playgrounds
+timeline.xctimeline
+playground.xcworkspace
+
+# Swift Package Manager
+.build/
+# Package.resolved is intentionally checked in — this is a library package and
+# pinning transitive dependencies makes CI reproducible without a separate lock file.
+
+# macOS
+.DS_Store
+
+# Documentation (generated files, keep README.md and .nojekyll)
+docs/*
+!docs/README.md
+!docs/.nojekyll
+!docs/WORKFLOW.md
+
+# SwiftLint
+.swiftlintcache
+
+# Claude Code worktrees
+.claude/worktrees/
+
+# Local documentation (not for public repository)
+MEDIUM_ARTICLE.md
+docs-test/
diff --git a/sdks/community/swift/.swiftlint.yml b/sdks/community/swift/.swiftlint.yml
new file mode 100644
index 0000000000..31f5ab9a4f
--- /dev/null
+++ b/sdks/community/swift/.swiftlint.yml
@@ -0,0 +1,26 @@
+included:
+ - Sources
+ - Package.swift
+
+excluded:
+ - .build
+ - Tests
+
+only_rules:
+ - force_cast
+ - force_try
+ - force_unwrapping
+ - custom_rules
+ - large_tuple
+
+custom_rules:
+ no_assume_isolated:
+ name: "No assumeIsolated"
+ regex: "\\b(?:MainActor|[A-Za-z_][A-Za-z0-9_]*)\\.assumeIsolated\\s*(?:\\(|\\{)"
+ match_kinds:
+ - identifier
+ message: "Do not use assumeIsolated. Use explicit actor isolation or restructure the code to stay within structured concurrency."
+ severity: error
+
+large_tuple:
+ warning_length: 3
diff --git a/sdks/community/swift/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/sdks/community/swift/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000000..919434a625
--- /dev/null
+++ b/sdks/community/swift/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/sdks/community/swift/ARCHITECTURE.md b/sdks/community/swift/ARCHITECTURE.md
new file mode 100644
index 0000000000..201b0762e2
--- /dev/null
+++ b/sdks/community/swift/ARCHITECTURE.md
@@ -0,0 +1,141 @@
+# AGUISwift Architecture
+
+AGUISwift follows a clean, modular architecture with strict Domain-Driven Design (DDD) layering principles:
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ Your Application │
+│ (iOS, macOS, tvOS, watchOS) │
+├─────────────────────────────────────────────────────────────┤
+│ AGUIAgentSDK │
+│ ┌─────────────┐ ┌───────────────────┐ ┌──────────────┐ │
+│ │ AgUiAgent │ │StatefulAgUiAgent │ │ Builders │ │
+│ │ │ │ │ │ │ │
+│ └─────────────┘ └───────────────────┘ └──────────────┘ │
+├─────────────────────────────────────────────────────────────┤
+│ AGUIClient │ AGUITools │
+│ ┌────────────┐ ┌─────────────┐ │ ┌──────────────────────┐ │
+│ │ HttpAgent │ │AbstractAgent│ │ │ ToolRegistry │ │
+│ ├────────────┤ ├─────────────┤ │ ├──────────────────────┤ │
+│ │EventStream │ │EventDecoder │ │ │ ToolExecutor │ │
+│ │ SseParser │ │ Manager │ │ │ToolExecutionManager │ │
+│ └────────────┘ └─────────────┘ │ └──────────────────────┘ │
+├─────────────────────────────────────────────────────────────┤
+│ AGUICore │
+│ ┌──────────────────────────────────────────────────────┐ │
+│ │ Infrastructure Layer │ │
+│ │ ┌──────────────────┐ ┌─────────────────────────┐ │ │
+│ │ │ Serialization │ │ Adapters │ │ │
+│ │ │ ┌──────────────┐ │ │ ┌─────────────────────┐ │ │ │
+│ │ │ │Serializable │ │ │ │ValueObjectAdapter │ │ │ │
+│ │ │ │ThreadId │ │ │ │ │ │ │ │
+│ │ │ │Serializable │ │ │ │ │ │ │ │
+│ │ │ │RunId │ │ │ │ │ │ │ │
+│ │ │ │Serializable │ │ │ │ │ │ │ │
+│ │ │ │EventTimestamp│ │ │ │ │ │ │ │
+│ │ │ └──────────────┘ │ │ └─────────────────────┘ │ │ │
+│ │ └──────────────────┘ └─────────────────────────┘ │ │
+│ ├──────────────────────────────────────────────────────┤ │
+│ │ Protocol Layer │ │
+│ │ ┌──────────────┐ ┌──────────────┐ ┌───────────┐ │ │
+│ │ │ AGUIEvent │ │ EventType │ │EventDecoder│ │ │
+│ │ │ │ │ │ │ │ │ │
+│ │ │RunStarted │ │ │ │ │ │ │
+│ │ │RunFinished │ │ │ │ │ │ │
+│ │ └──────────────┘ └──────────────┘ └───────────┘ │ │
+│ ├──────────────────────────────────────────────────────┤ │
+│ │ Domain Layer │ │
+│ │ ┌──────────────┐ ┌──────────────┐ ┌───────────┐ │ │
+│ │ │ ValueObjects │ │ Events │ │ Errors │ │ │
+│ │ │ │ │ │ │ │ │ │
+│ │ │ ThreadId │ │ RunStarted │ │DomainError │ │ │
+│ │ │ RunId │ │ RunFinished │ │ │ │ │
+│ │ │EventTimestamp│ │ │ │ │ │ │
+│ │ └──────────────┘ └──────────────┘ └───────────┘ │ │
+│ └──────────────────────────────────────────────────────┘ │
+└─────────────────────────────────────────────────────────────┘
+```
+
+## Module Overview
+
+### AGUICore
+Protocol types, events, and message definitions with strict DDD layering.
+
+**Domain Layer** (Pure domain logic, no infrastructure dependencies):
+- **ValueObjects**: `ThreadId`, `RunId`, `EventTimestamp` - Type-safe domain value objects
+- **Events**: Domain event definitions (to be implemented)
+- **Errors**: `DomainError` - Domain validation errors
+
+**Protocol Layer** (Protocol-specific types):
+- **AGUIEvent**: Base protocol for all AG-UI protocol events
+- **EventType**: Enumeration of all AG-UI protocol event types
+- **EventDecoder**: Polymorphic event decoder helper
+- **Concrete Events**: `RunStartedEvent`, `RunFinishedEvent` (and more to come)
+
+**Infrastructure Layer** (Serialization and adapters):
+- **Serialization**: `SerializableThreadId`, `SerializableRunId`, `SerializableEventTimestamp` - Infrastructure wrappers for JSON serialization
+- **Adapters**: `ValueObjectAdapter` - Converts between domain and infrastructure layers
+
+### AGUIAgentSDK
+High-level APIs for common agent interaction patterns.
+- **AgUiAgent**: Stateless client for cases where no ongoing context is needed
+- **StatefulAgUiAgent**: Stateful client that maintains conversation history
+- **Builders**: Convenient builder patterns for agent configuration
+
+### AGUIClient
+Low-level client infrastructure and transport implementations.
+- **HttpAgent**: Low-level HTTP transport implementation
+- **AbstractAgent**: Base class for custom agent implementations
+- **SseParser**: Server-Sent Events parser for streaming responses
+- **EventStreamManager**: Event stream management and processing
+
+### AGUITools
+Tool execution framework for extending agent capabilities.
+- **ToolExecutor**: Protocol for implementing custom tools
+- **ToolRegistry**: Tool registration and management
+- **ToolExecutionManager**: Tool execution with circuit breaker patterns
+
+## Design Principles
+
+### Domain-Driven Design (DDD)
+- **Domain Layer**: Pure business logic with no infrastructure dependencies
+- **Infrastructure Layer**: Handles serialization, networking, and external concerns
+- **Clear Boundaries**: Strict separation between domain and infrastructure
+- **Value Objects**: Type-safe domain concepts (ThreadId, RunId, EventTimestamp)
+
+### Layered Architecture
+- **Application Layer**: High-level APIs (AGUIAgentSDK)
+- **Client Layer**: Low-level transport and infrastructure (AGUIClient)
+- **Domain Layer**: Core business logic and domain models (AGUICore)
+- **Infrastructure Layer**: Technical implementation details (AGUICore)
+
+### Modularity
+- **AGUICore**: Foundation layer with protocol definitions
+- **AGUIClient**: Low-level client infrastructure and transport
+- **AGUIAgentSDK**: High-level agent APIs and builders
+- **AGUITools**: Tool execution framework
+- Each module can be used independently or together
+
+## Current Implementation Status
+
+### ✅ Completed
+- Domain value objects (ThreadId, RunId, EventTimestamp)
+- Infrastructure serialization wrappers
+- Domain/Infrastructure adapters
+- AGUIEvent protocol and EventType enum
+- RunStartedEvent and RunFinishedEvent implementations
+- Comprehensive unit tests for events
+- DDD layering structure
+
+### 🚧 In Progress
+- Additional event types (TextMessage, ToolCall, StateManagement events)
+- Client agent implementations
+- Tool execution framework
+
+### 📋 Planned
+- HTTP transport layer
+- SSE (Server-Sent Events) parser
+- State management
+- Authentication support
+- Error handling and recovery
+
diff --git a/sdks/community/swift/CLAUDE.md b/sdks/community/swift/CLAUDE.md
new file mode 100644
index 0000000000..a147921c29
--- /dev/null
+++ b/sdks/community/swift/CLAUDE.md
@@ -0,0 +1,197 @@
+# AGUISwift
+
+## Project Context
+
+The AG-UI Swift SDK is an open-source Swift framework for building AI agent user interfaces that implement the Agent User Interaction Protocol (AG-UI). It provides real-time streaming communication between Swift applications and AI agents.
+
+### Architecture
+```
+AGUICore (Foundation layer - Protocol types • Event models • Message definitions)
+ ↓
+AGUIClient (Low-level client infrastructure and transport implementations - HttpAgent • AbstractAgent • SseParser • EventStreamManager )
+ ↓
+ ├─→ AGUITools (Tool execution framework for extending agent capabilities - ToolExecutor • ToolRegistry • ToolExecutionManager)
+ └─→ AGUIAgentSDK (High-level APIs for common agent interaction patterns - AgUiAgent • StatefulAgUiAgent • Builders)
+ ↓
+ Application
+
+```
+
+### Key Directories
+- `Sources/AGUIAgentSDK/` - High-level APIs for common agent interaction patterns.
+- `Sources/AGUIClient/` - Low-level client infrastructure and transport implementations.
+- `Sources/AGUICore/` - Core framework source, Protocol types, events, and message definitions.
+- `Sources/AGUITools/` - Tool execution framework for extending agent capabilities.
+- `Tests/` - Test suites with mock protocols
+
+## Development Standards
+
+### Swift 6.2 Requirements
+- **Minimum**: iOS 15+, macOS 13+, Swift 5.9
+- **Concurrency**: Use `async/await`, actors, and `@MainActor` isolation by default
+- **Data Safety**: All public types must be `Sendable`; use `@concurrent` for explicit parallel execution
+- **Value Types**: Prefer `struct` over `class`; use actors for shared mutable state
+
+### API Design Principles
+- Protocol-first: Define behavior contracts before implementations
+- Composition over inheritance: Use protocol composition (`Runnable & Observable`)
+- Fluent interfaces: Enable chaining with `@discardableResult` where appropriate
+- Progressive disclosure: Simple defaults, advanced configuration via builders
+
+### Naming Conventions
+- Types/Protocols: `UpperCamelCase` (e.g., `AgentProtocol`, `MemoryStore`)
+- Methods: Verb phrases for mutations (`execute`, `store`), noun phrases for accessors (`configuration`)
+- Boolean properties: Use `is`, `has`, `should` prefixes
+- Generics: Descriptive names (`Element`, `Input`, `Output`) over single letters when unclear
+
+## Workflow
+
+### Verification Commands
+```bash
+swift build # Build framework
+swift test # Run test suite
+swift test --filter AgentTests # Run specific tests
+swift package plugin --allow-writing-to-package-directory swiftformat # Format code
+```
+
+### Before Committing
+1. Run `swift build` - ensure no compilation errors
+2. Run `swift test` - all tests must pass
+3. Run SwiftFormat - code must be formatted (`swift package plugin --allow-writing-to-package-directory swiftformat`)
+4. Run SwiftLint - code must pass linting checks (`swiftlint lint` - install via Homebrew if needed)
+5. Verify `Sendable` conformance on public types
+6. Ensure documentation comments on public APIs
+
+### Branch Naming
+- `feature/agent-memory-system`
+- `fix/orchestration-race-condition`
+- `refactor/protocol-conformance`
+
+## Technical Guidelines
+
+### Protocols
+- Use `associatedtype` for type-safe generic protocols
+- Provide protocol extensions with sensible defaults
+- Mark class-only protocols with `AnyObject` constraint
+- See `Sources/SwiftAgents/Core/Protocols/` for patterns
+
+### Concurrency
+- Use structured concurrency (`TaskGroup`, `async let`) over unstructured
+- Annotate with `@MainActor` for UI-bound code
+- Use `actor` for shared state requiring synchronization
+- Apply `nonisolated` explicitly when needed for cross-isolation calls
+
+### Memory Systems
+- `ConversationMemory`: Short-term, token-limited context
+- `VectorMemory`: Long-term semantic retrieval (requires embedding provider)
+- `SummaryMemory`: Compressed conversation history
+
+### Logging
+- Use `swift-log` for cross-platform compatibility (Apple platforms + Linux servers)
+- Call `Log.bootstrap()` once at application startup to configure logging
+- Never use `print()` statements in production code
+- Use category-specific loggers:
+ - `Log.agents`: Agent lifecycle and execution
+ - `Log.memory`: Memory system operations
+ - `Log.tracing`: Observability and tracing events
+ - `Log.metrics`: Performance and usage metrics
+ - `Log.orchestration`: Multi-agent coordination
+- Choose appropriate log levels: `.trace`, `.debug`, `.info`, `.notice`, `.warning`, `.error`, `.critical`
+- **Privacy Note**: Unlike `os.Logger`, swift-log does not support `privacy:` parameter annotations
+ - Do not log sensitive user data, credentials, or PII in production
+ - Configure log handlers to redact sensitive information at runtime
+ - Default behavior logs all interpolated values as-is
+- Example: `Log.memory.error("Failed to save: \(error.localizedDescription)")`
+- For Apple-only code, `OSLogTracer` is available with privacy annotations wrapped in `#if canImport(os)`
+
+### Testing Strategy (TDD Required)
+- **Test-Driven Development is mandatory** for all new features and bug fixes
+- All protocols have corresponding `Mock*` implementations in test targets
+- Test async code with `XCTestExpectation` or async test methods
+
+### TDD Workflow
+Follow Red-Green-Refactor cycle for all development:
+
+1. **Red**: Write failing tests first
+ - Define expected behavior through test cases
+ - Use `test-specialist` agent to design comprehensive test coverage
+ - Tests must fail initially (verifies test is meaningful)
+
+2. **Green**: Write minimal implementation
+ - Only write enough code to make tests pass
+ - No premature optimization or extra features
+ - Focus on satisfying test assertions
+
+3. **Refactor**: Improve code quality
+ - Clean up implementation while keeping tests green
+ - Apply Swift patterns and conventions
+ - Use `code-reviewer` agent to verify refactoring
+
+**TDD Guidelines**:
+- Never write implementation before tests
+- One test case per behavior/feature
+- Test edge cases and error conditions
+- Mock external dependencies (LLM providers, network)
+- Run `swift test` frequently during development
+
+
+## Sub-Agents
+
+Use specialized agents for focused expertise. Delegate proactively:
+
+| Agent | When to Use |
+|-------|-------------|
+| `context-builder` | **Long-running tasks**, gathering requirements, researching before implementation |
+| `api-designer` | Designing public APIs, naming decisions, fluent interfaces |
+| `protocol-architect` | Type hierarchies, protocol composition, POP patterns |
+| `concurrency-expert` | Async code, actors, Sendable conformance, data-race safety |
+| `macro-engineer` | Implementing or modifying Swift macros |
+| `code-reviewer` | After code changes, before commits |
+| `test-specialist` | Writing tests, creating mocks, test coverage |
+| `framework-architect` | Multi-agent orchestration patterns, coordination design |
+
+### Context Management (Long-Running Tasks)
+**Always use `context-builder` agent** at the start of complex or long-running tasks to:
+- Gather comprehensive requirements before implementation
+- Research existing codebase patterns and conventions
+- Identify dependencies and integration points
+- Create a clear roadmap before delegating to specialist agents
+
+**Context Preservation Workflow**:
+1. **Start**: Use `context-builder` to research and gather context
+2. **Plan**: Document findings and create implementation plan
+3. **Execute**: Delegate to specialist agents with clear context handoffs
+4. **Checkpoint**: Re-invoke `context-builder` for multi-phase tasks
+5. **Update**: Keep plan documents and memory updated throughout
+
+**When to use `context-builder`**:
+- Tasks spanning multiple files or components
+- Features requiring understanding of existing architecture
+- Bug fixes requiring root cause investigation
+- Any task expected to take more than a few interactions
+
+### Delegation Guidelines
+- **Before implementing**: Use `context-builder` to research, then `api-designer` and `protocol-architect`
+- **During implementation**: Use `concurrency-expert` for async code, `macro-engineer` for macros
+- **After changes**: Always run `code-reviewer` before committing
+- **For tests**: Delegate to `test-specialist` for mock patterns and coverage (TDD: tests first!)
+
+**TDD + Sub-Agent Workflow** (for complex features):
+1. `context-builder` → research existing patterns and requirements
+2. `test-specialist` → write failing tests first (Red phase) and keep async/time-sensitive tests deterministic (no flakiness)
+3. `protocol-architect` → design abstractions to satisfy tests
+4. `api-designer` → refine public interface
+5. `concurrency-expert` → verify thread safety
+6. `code-reviewer` → final review (ensure tests pass)
+
+## Quick References
+- API patterns: See `https://github.com/ag-ui-protocol/ag-ui/tree/main/sdks/community/kotlin`
+- Protocol design: See `https://github.com/ag-ui-protocol/ag-ui`
+- Understanding how AG-UI complements and works with MCP and A2A: See `https://docs.ag-ui.com/llms-full.txt`
+
+## Git Commit Guidelines
+
+When creating commits:
+- Follow conventional commit format
+- Keep the first line under 50 characters
+- Do not include AI attribution in commit messages
diff --git a/sdks/community/swift/CONTRIBUTING.md b/sdks/community/swift/CONTRIBUTING.md
new file mode 100644
index 0000000000..462e335d85
--- /dev/null
+++ b/sdks/community/swift/CONTRIBUTING.md
@@ -0,0 +1,275 @@
+# Contributing to AGUISwift
+
+Thank you for contributing to AGUISwift! This guide will help you understand our development workflow.
+
+## Branch Strategy
+
+We use a feature branch workflow to ensure code quality and enable code review:
+
+- **`main`** - Production-ready code. Protected branch, no direct commits.
+- **`feature/*`** - New features (e.g., `feature/agent-memory-system`)
+- **`fix/*`** - Bug fixes (e.g., `fix/parsing-edge-case`)
+- **`refactor/*`** - Code refactoring (e.g., `refactor/transport-layer`)
+- **`docs/*`** - Documentation updates (e.g., `docs/api-examples`)
+
+## Development Workflow
+
+### 1. Create a Feature Branch
+
+Always start from the latest `main`:
+
+```bash
+# Update main branch
+git checkout main
+git pull origin main
+
+# Create and switch to feature branch
+git checkout -b feature/your-feature-name
+
+# Or for bug fixes
+git checkout -b fix/bug-description
+```
+
+### 2. Make Your Changes
+
+Follow our development standards from `CLAUDE.md`:
+
+```bash
+# Make changes to code
+# Run tests frequently
+swift test
+
+# Format code
+swift package plugin --allow-writing-to-package-directory swiftformat
+
+# Check for linting issues
+swiftlint lint
+```
+
+### 3. Commit Your Changes
+
+Use conventional commit messages:
+
+```bash
+git add .
+git commit -m "feat: add user authentication to HttpAgent
+
+- Implement bearer token authentication
+- Add configuration options for auth headers
+- Include tests for auth flows
+
+🤖 Generated with [Claude Code](https://claude.com/claude-code)
+
+Co-Authored-By: Claude Sonnet 4.5 "
+```
+
+**Commit Message Format**:
+- `feat:` - New feature
+- `fix:` - Bug fix
+- `refactor:` - Code refactoring
+- `docs:` - Documentation changes
+- `test:` - Test additions/updates
+- `chore:` - Build/tooling changes
+
+### 4. Push Your Branch
+
+```bash
+# First time pushing the branch
+git push -u origin feature/your-feature-name
+
+# Subsequent pushes
+git push
+```
+
+### 5. Create a Pull Request
+
+**Using GitHub CLI** (recommended):
+
+```bash
+# Create PR with title and description
+gh pr create --title "Add user authentication" --body "$(cat <<'EOF'
+## Summary
+Implements user authentication for HttpAgent with bearer token support.
+
+## Changes
+- Add `AuthenticationProvider` protocol
+- Implement `BearerTokenAuth` provider
+- Update `HttpAgentConfiguration` with auth options
+- Add comprehensive test coverage
+
+## Testing
+- [x] Unit tests pass
+- [x] Integration tests pass
+- [x] SwiftLint passes
+- [x] SwiftFormat applied
+
+## Related Issues
+Closes #42
+
+🤖 Generated with [Claude Code](https://claude.com/claude-code)
+EOF
+)"
+
+# Or create PR interactively
+gh pr create
+```
+
+**Using GitHub Web UI**:
+
+1. Go to https://github.com/paduh/ag-ui
+2. Click "Compare & pull request"
+3. Fill in the PR template
+4. Click "Create pull request"
+
+### 6. Code Review & Merge
+
+- Wait for CI checks to pass
+- Wait for approval from an **approved code reviewer** (required for merge)
+- Address any review feedback by making changes and pushing updates
+- Once approved by a designated reviewer, the PR can be merged
+- Use "Squash and merge" to maintain clean commit history
+
+## Pre-Commit Checklist
+
+Before creating a PR, ensure:
+
+- [ ] All tests pass (`swift test`)
+- [ ] Code is formatted (`swift package plugin swiftformat`)
+- [ ] No SwiftLint violations in production code (`swiftlint lint`)
+- [ ] Documentation is updated
+- [ ] Commit messages follow conventions
+- [ ] Branch is up to date with `main`
+
+## Syncing with Main
+
+Keep your feature branch up to date:
+
+```bash
+# While on your feature branch
+git fetch origin
+git rebase origin/main
+
+# Or merge if you prefer
+git merge origin/main
+```
+
+## PR Best Practices
+
+### PR Title
+Use the same format as commit messages:
+- `feat: add authentication to HttpAgent`
+- `fix: resolve UTF-8 boundary handling in SSE parser`
+- `refactor: simplify state management actor`
+
+### PR Description Template
+
+```markdown
+## Summary
+Brief description of what this PR does.
+
+## Changes
+- List of main changes
+- Each change on a new line
+- Be specific and clear
+
+## Testing
+- [ ] Unit tests added/updated
+- [ ] Integration tests pass
+- [ ] Manual testing performed
+
+## Screenshots (if applicable)
+[Add screenshots for UI changes]
+
+## Related Issues
+Closes #123
+Relates to #456
+
+## Checklist
+- [ ] Tests pass
+- [ ] Code formatted
+- [ ] SwiftLint passes
+- [ ] Documentation updated
+```
+
+## Testing Requirements
+
+All PRs must include tests following TDD principles:
+
+1. **Write tests first** (Red phase)
+2. **Implement feature** (Green phase)
+3. **Refactor** (Clean phase)
+
+See `CLAUDE.md` for detailed testing guidelines.
+
+## Code Review Guidelines
+
+### For Authors
+- Keep PRs focused and small (< 400 lines preferred)
+- Respond to feedback promptly
+- Be open to suggestions
+- Update PR description if scope changes
+
+### For Reviewers
+
+**Note**: Only designated code reviewers with approval permissions can approve PRs for merging.
+
+#### Review Guidelines
+- Review within 24-48 hours
+- Be constructive and respectful
+- Focus on code quality, not personal preferences
+- Check that all checklist items are completed
+- Verify tests pass and cover new functionality
+- Approve only when project standards are met
+
+## Branch Protection Rules
+
+The `main` branch is protected with:
+- **Require pull request before merging** - No direct commits to main
+- **Require status checks to pass** - All CI checks must be green
+- **Require approval from designated code reviewers** - Only approved reviewers can approve PRs
+- **Dismiss stale reviews on new commits** - New changes invalidate previous approvals
+- **No force pushes** - Protects commit history
+- **No deletions** - Branch cannot be deleted
+
+### Setting Up Approved Reviewers
+
+Repository administrators can configure approved reviewers in GitHub:
+
+1. Go to **Settings** → **Branches** → **Branch protection rules**
+2. Edit the `main` branch rule
+3. Enable **"Require a pull request before merging"**
+4. Enable **"Require approvals"** (set to 1 or more)
+5. Enable **"Restrict who can approve pull requests"**
+6. Add designated reviewers to the approval list
+
+This ensures only trusted reviewers with expertise can approve code for production.
+
+## Emergency Hotfixes
+
+For critical production issues:
+
+```bash
+# Create hotfix branch from main
+git checkout main
+git pull origin main
+git checkout -b fix/critical-security-patch
+
+# Make minimal changes
+# Test thoroughly
+# Create PR with "HOTFIX:" prefix
+
+gh pr create --title "HOTFIX: patch XSS vulnerability"
+```
+
+Hotfix PRs can be fast-tracked but still require review.
+
+## Getting Help
+
+- Check `CLAUDE.md` for project standards
+- Review existing PRs for examples
+- Ask in PR comments if unclear
+- Contact maintainers for guidance
+
+## License
+
+By contributing, you agree that your contributions will be licensed under the same license as the project.
diff --git a/sdks/community/swift/Examples/ChatApp/ChatApp.xcodeproj/project.pbxproj b/sdks/community/swift/Examples/ChatApp/ChatApp.xcodeproj/project.pbxproj
new file mode 100644
index 0000000000..cedb06826b
--- /dev/null
+++ b/sdks/community/swift/Examples/ChatApp/ChatApp.xcodeproj/project.pbxproj
@@ -0,0 +1,731 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 56;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ A50000000000000000000001 /* ChatAppApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A40000000000000000000001 /* ChatAppApp.swift */; };
+ A50000000000000000000002 /* AgentConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A40000000000000000000002 /* AgentConfig.swift */; };
+ A50000000000000000000003 /* DisplayMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A40000000000000000000003 /* DisplayMessage.swift */; };
+ A50000000000000000000004 /* ChatUIState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A40000000000000000000004 /* ChatUIState.swift */; };
+ A50000000000000000000005 /* AgentDraft.swift in Sources */ = {isa = PBXBuildFile; fileRef = A40000000000000000000005 /* AgentDraft.swift */; };
+ A50000000000000000000006 /* ChatAppStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A40000000000000000000006 /* ChatAppStore.swift */; };
+ A50000000000000000000007 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A40000000000000000000007 /* RootView.swift */; };
+ A50000000000000000000008 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A40000000000000000000008 /* ChatView.swift */; };
+ A50000000000000000000009 /* MessageBubbleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A40000000000000000000009 /* MessageBubbleView.swift */; };
+ A5000000000000000000000A /* AgentListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4000000000000000000000A /* AgentListView.swift */; };
+ A5000000000000000000000B /* AgentFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4000000000000000000000B /* AgentFormView.swift */; };
+ A5000000000000000000000C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A4000000000000000000000C /* Assets.xcassets */; };
+ A5000000000000000000000D /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = A80000000000000000000003 /* MarkdownUI */; };
+ A5000000000000000000000E /* AGUIAgentSDK in Frameworks */ = {isa = PBXBuildFile; productRef = A80000000000000000000004 /* AGUIAgentSDK */; };
+ A5000000000000000000000F /* MockAgUiAgent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4000000000000000000000D /* MockAgUiAgent.swift */; };
+ A50000000000000000000010 /* ChatAppStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4000000000000000000000E /* ChatAppStoreTests.swift */; };
+ A50000000000000000000011 /* AGUIAgentSDK in Frameworks */ = {isa = PBXBuildFile; productRef = A80000000000000000000005 /* AGUIAgentSDK */; };
+ A50000000000000000000012 /* AGUICore in Frameworks */ = {isa = PBXBuildFile; productRef = A80000000000000000000006 /* AGUICore */; };
+ A50000000000000000000013 /* AGUICore in Frameworks */ = {isa = PBXBuildFile; productRef = A80000000000000000000007 /* AGUICore */; };
+ A50000000000000000000014 /* EphemeralSlot.swift in Sources */ = {isa = PBXBuildFile; fileRef = A40000000000000000000012 /* EphemeralSlot.swift */; };
+ A50000000000000000000015 /* SupplementalMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A40000000000000000000013 /* SupplementalMessage.swift */; };
+ A50000000000000000000016 /* ChatRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A40000000000000000000014 /* ChatRow.swift */; };
+ A50000000000000000000017 /* ChangeBackgroundToolExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A40000000000000000000015 /* ChangeBackgroundToolExecutor.swift */; };
+ A50000000000000000000018 /* ChatAppToolRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = A40000000000000000000016 /* ChatAppToolRegistry.swift */; };
+ A50000000000000000000019 /* ChatAppStore+EventProcessing.swift in Sources */ = {isa = PBXBuildFile; fileRef = A40000000000000000000017 /* ChatAppStore+EventProcessing.swift */; };
+ A5000000000000000000001A /* AGUITools in Frameworks */ = {isa = PBXBuildFile; productRef = A80000000000000000000008 /* AGUITools */; };
+ A5000000000000000000001B /* AGUITools in Frameworks */ = {isa = PBXBuildFile; productRef = A80000000000000000000009 /* AGUITools */; };
+ A5000000000000000000001C /* ChangeBackgroundToolExecutorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A40000000000000000000018 /* ChangeBackgroundToolExecutorTests.swift */; };
+ A5000000000000000000001D /* TypingDotsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A40000000000000000000019 /* TypingDotsView.swift */; };
+ A5000000000000000000001E /* StreamingCursorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4000000000000000000001A /* StreamingCursorView.swift */; };
+ A5000000000000000000001F /* A2UIComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4000000000000000000001B /* A2UIComponent.swift */; };
+ A50000000000000000000020 /* A2UISurfaceStateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4000000000000000000001C /* A2UISurfaceStateManager.swift */; };
+ A50000000000000000000021 /* ChatAppStore+A2UI.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4000000000000000000001D /* ChatAppStore+A2UI.swift */; };
+ A50000000000000000000022 /* A2UIComponentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4000000000000000000001E /* A2UIComponentView.swift */; };
+ A50000000000000000000023 /* A2UISurfaceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4000000000000000000001F /* A2UISurfaceView.swift */; };
+ A50000000000000000000024 /* A2UIComponentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A40000000000000000000020 /* A2UIComponentTests.swift */; };
+ A50000000000000000000025 /* A2UISurfaceStateManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A40000000000000000000021 /* A2UISurfaceStateManagerTests.swift */; };
+ A50000000000000000000026 /* AGUIClient in Frameworks */ = {isa = PBXBuildFile; productRef = A8000000000000000000000A /* AGUIClient */; };
+ A50000000000000000000027 /* ClawgUIPairingState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A40000000000000000000022 /* ClawgUIPairingState.swift */; };
+ A50000000000000000000028 /* ClawgUIDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = A40000000000000000000023 /* ClawgUIDetector.swift */; };
+ A50000000000000000000029 /* ClawgUIPairingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A40000000000000000000024 /* ClawgUIPairingManager.swift */; };
+ A5000000000000000000002A /* ClawgUIPairingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A40000000000000000000025 /* ClawgUIPairingView.swift */; };
+ A5000000000000000000002B /* ClawgUIDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A40000000000000000000026 /* ClawgUIDetectorTests.swift */; };
+ A5000000000000000000002C /* ClawgUIPairingManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A40000000000000000000027 /* ClawgUIPairingManagerTests.swift */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXFileReference section */
+ A40000000000000000000001 /* ChatAppApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatAppApp.swift; sourceTree = ""; };
+ A40000000000000000000002 /* AgentConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentConfig.swift; sourceTree = ""; };
+ A40000000000000000000003 /* DisplayMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayMessage.swift; sourceTree = ""; };
+ A40000000000000000000004 /* ChatUIState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatUIState.swift; sourceTree = ""; };
+ A40000000000000000000005 /* AgentDraft.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentDraft.swift; sourceTree = ""; };
+ A40000000000000000000006 /* ChatAppStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatAppStore.swift; sourceTree = ""; };
+ A40000000000000000000007 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; };
+ A40000000000000000000008 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = ""; };
+ A40000000000000000000009 /* MessageBubbleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageBubbleView.swift; sourceTree = ""; };
+ A4000000000000000000000A /* AgentListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentListView.swift; sourceTree = ""; };
+ A4000000000000000000000B /* AgentFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentFormView.swift; sourceTree = ""; };
+ A4000000000000000000000C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
+ A4000000000000000000000D /* MockAgUiAgent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAgUiAgent.swift; sourceTree = ""; };
+ A4000000000000000000000E /* ChatAppStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatAppStoreTests.swift; sourceTree = ""; };
+ A4000000000000000000000F /* ChatApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ChatApp.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ A40000000000000000000010 /* ChatAppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ChatAppTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+ A40000000000000000000011 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+ A40000000000000000000012 /* EphemeralSlot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EphemeralSlot.swift; sourceTree = ""; };
+ A40000000000000000000013 /* SupplementalMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupplementalMessage.swift; sourceTree = ""; };
+ A40000000000000000000014 /* ChatRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatRow.swift; sourceTree = ""; };
+ A40000000000000000000015 /* ChangeBackgroundToolExecutor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangeBackgroundToolExecutor.swift; sourceTree = ""; };
+ A40000000000000000000016 /* ChatAppToolRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatAppToolRegistry.swift; sourceTree = ""; };
+ A40000000000000000000017 /* ChatAppStore+EventProcessing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ChatAppStore+EventProcessing.swift"; sourceTree = ""; };
+ A40000000000000000000018 /* ChangeBackgroundToolExecutorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangeBackgroundToolExecutorTests.swift; sourceTree = ""; };
+ A40000000000000000000019 /* TypingDotsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingDotsView.swift; sourceTree = ""; };
+ A4000000000000000000001A /* StreamingCursorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamingCursorView.swift; sourceTree = ""; };
+ A4000000000000000000001B /* A2UIComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = A2UIComponent.swift; sourceTree = ""; };
+ A4000000000000000000001C /* A2UISurfaceStateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = A2UISurfaceStateManager.swift; sourceTree = ""; };
+ A4000000000000000000001D /* ChatAppStore+A2UI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ChatAppStore+A2UI.swift"; sourceTree = ""; };
+ A4000000000000000000001E /* A2UIComponentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = A2UIComponentView.swift; sourceTree = ""; };
+ A4000000000000000000001F /* A2UISurfaceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = A2UISurfaceView.swift; sourceTree = ""; };
+ A40000000000000000000020 /* A2UIComponentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = A2UIComponentTests.swift; sourceTree = ""; };
+ A40000000000000000000021 /* A2UISurfaceStateManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = A2UISurfaceStateManagerTests.swift; sourceTree = ""; };
+ A40000000000000000000022 /* ClawgUIPairingState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClawgUIPairingState.swift; sourceTree = ""; };
+ A40000000000000000000023 /* ClawgUIDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClawgUIDetector.swift; sourceTree = ""; };
+ A40000000000000000000024 /* ClawgUIPairingManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClawgUIPairingManager.swift; sourceTree = ""; };
+ A40000000000000000000025 /* ClawgUIPairingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClawgUIPairingView.swift; sourceTree = ""; };
+ A40000000000000000000026 /* ClawgUIDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClawgUIDetectorTests.swift; sourceTree = ""; };
+ A40000000000000000000027 /* ClawgUIPairingManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClawgUIPairingManagerTests.swift; sourceTree = ""; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ A60000000000000000000003 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ A5000000000000000000000D /* MarkdownUI in Frameworks */,
+ A5000000000000000000000E /* AGUIAgentSDK in Frameworks */,
+ A50000000000000000000012 /* AGUICore in Frameworks */,
+ A5000000000000000000001A /* AGUITools in Frameworks */,
+ A50000000000000000000026 /* AGUIClient in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ A60000000000000000000005 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ A50000000000000000000011 /* AGUIAgentSDK in Frameworks */,
+ A50000000000000000000013 /* AGUICore in Frameworks */,
+ A5000000000000000000001B /* AGUITools in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ A30000000000000000000001 /* ChatApp */ = {
+ isa = PBXGroup;
+ children = (
+ A30000000000000000000003 /* Sources */,
+ A30000000000000000000009 /* Tests */,
+ A30000000000000000000002 /* Products */,
+ );
+ sourceTree = "";
+ };
+ A30000000000000000000002 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ A4000000000000000000000F /* ChatApp.app */,
+ A40000000000000000000010 /* ChatAppTests.xctest */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+ A30000000000000000000003 /* Sources */ = {
+ isa = PBXGroup;
+ children = (
+ A30000000000000000000004 /* App */,
+ A30000000000000000000005 /* Models */,
+ A30000000000000000000006 /* Store */,
+ A30000000000000000000007 /* Views */,
+ A30000000000000000000008 /* Resources */,
+ A3000000000000000000000C /* Tools */,
+ A3000000000000000000000D /* A2UI */,
+ A3000000000000000000000F /* ClawgUI */,
+ );
+ path = Sources;
+ sourceTree = "";
+ };
+ A30000000000000000000004 /* App */ = {
+ isa = PBXGroup;
+ children = (
+ A40000000000000000000001 /* ChatAppApp.swift */,
+ );
+ path = App;
+ sourceTree = "";
+ };
+ A30000000000000000000005 /* Models */ = {
+ isa = PBXGroup;
+ children = (
+ A40000000000000000000002 /* AgentConfig.swift */,
+ A40000000000000000000003 /* DisplayMessage.swift */,
+ A40000000000000000000004 /* ChatUIState.swift */,
+ A40000000000000000000005 /* AgentDraft.swift */,
+ A40000000000000000000012 /* EphemeralSlot.swift */,
+ A40000000000000000000013 /* SupplementalMessage.swift */,
+ A40000000000000000000014 /* ChatRow.swift */,
+ );
+ path = Models;
+ sourceTree = "";
+ };
+ A30000000000000000000006 /* Store */ = {
+ isa = PBXGroup;
+ children = (
+ A40000000000000000000006 /* ChatAppStore.swift */,
+ A40000000000000000000017 /* ChatAppStore+EventProcessing.swift */,
+ A4000000000000000000001D /* ChatAppStore+A2UI.swift */,
+ );
+ path = Store;
+ sourceTree = "";
+ };
+ A30000000000000000000007 /* Views */ = {
+ isa = PBXGroup;
+ children = (
+ A40000000000000000000007 /* RootView.swift */,
+ A40000000000000000000008 /* ChatView.swift */,
+ A40000000000000000000009 /* MessageBubbleView.swift */,
+ A4000000000000000000000A /* AgentListView.swift */,
+ A4000000000000000000000B /* AgentFormView.swift */,
+ A40000000000000000000019 /* TypingDotsView.swift */,
+ A4000000000000000000001A /* StreamingCursorView.swift */,
+ A3000000000000000000000E /* A2UI */,
+ A30000000000000000000010 /* ClawgUI */,
+ );
+ path = Views;
+ sourceTree = "";
+ };
+ A30000000000000000000008 /* Resources */ = {
+ isa = PBXGroup;
+ children = (
+ A4000000000000000000000C /* Assets.xcassets */,
+ A40000000000000000000011 /* Info.plist */,
+ );
+ path = Resources;
+ sourceTree = "";
+ };
+ A30000000000000000000009 /* Tests */ = {
+ isa = PBXGroup;
+ children = (
+ A3000000000000000000000A /* ChatAppTests */,
+ );
+ path = Tests;
+ sourceTree = "";
+ };
+ A3000000000000000000000A /* ChatAppTests */ = {
+ isa = PBXGroup;
+ children = (
+ A3000000000000000000000B /* Mocks */,
+ A4000000000000000000000E /* ChatAppStoreTests.swift */,
+ A40000000000000000000018 /* ChangeBackgroundToolExecutorTests.swift */,
+ A40000000000000000000020 /* A2UIComponentTests.swift */,
+ A40000000000000000000021 /* A2UISurfaceStateManagerTests.swift */,
+ A40000000000000000000026 /* ClawgUIDetectorTests.swift */,
+ A40000000000000000000027 /* ClawgUIPairingManagerTests.swift */,
+ );
+ path = ChatAppTests;
+ sourceTree = "";
+ };
+ A3000000000000000000000B /* Mocks */ = {
+ isa = PBXGroup;
+ children = (
+ A4000000000000000000000D /* MockAgUiAgent.swift */,
+ );
+ path = Mocks;
+ sourceTree = "";
+ };
+ A3000000000000000000000C /* Tools */ = {
+ isa = PBXGroup;
+ children = (
+ A40000000000000000000015 /* ChangeBackgroundToolExecutor.swift */,
+ A40000000000000000000016 /* ChatAppToolRegistry.swift */,
+ );
+ path = Tools;
+ sourceTree = "";
+ };
+ A3000000000000000000000D /* A2UI */ = {
+ isa = PBXGroup;
+ children = (
+ A4000000000000000000001B /* A2UIComponent.swift */,
+ A4000000000000000000001C /* A2UISurfaceStateManager.swift */,
+ );
+ path = A2UI;
+ sourceTree = "";
+ };
+ A3000000000000000000000E /* A2UI */ = {
+ isa = PBXGroup;
+ children = (
+ A4000000000000000000001E /* A2UIComponentView.swift */,
+ A4000000000000000000001F /* A2UISurfaceView.swift */,
+ );
+ path = A2UI;
+ sourceTree = "";
+ };
+ A3000000000000000000000F /* ClawgUI */ = {
+ isa = PBXGroup;
+ children = (
+ A40000000000000000000022 /* ClawgUIPairingState.swift */,
+ A40000000000000000000023 /* ClawgUIDetector.swift */,
+ A40000000000000000000024 /* ClawgUIPairingManager.swift */,
+ );
+ path = ClawgUI;
+ sourceTree = "";
+ };
+ A30000000000000000000010 /* ClawgUI */ = {
+ isa = PBXGroup;
+ children = (
+ A40000000000000000000025 /* ClawgUIPairingView.swift */,
+ );
+ path = ClawgUI;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ A70000000000000000000001 /* ChatApp */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = A10000000000000000000002 /* Build configuration list for PBXNativeTarget "ChatApp" */;
+ buildPhases = (
+ A60000000000000000000001 /* Sources */,
+ A60000000000000000000002 /* Resources */,
+ A60000000000000000000003 /* Frameworks */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = ChatApp;
+ packageProductDependencies = (
+ A80000000000000000000003 /* MarkdownUI */,
+ A80000000000000000000004 /* AGUIAgentSDK */,
+ A80000000000000000000006 /* AGUICore */,
+ A80000000000000000000008 /* AGUITools */,
+ A8000000000000000000000A /* AGUIClient */,
+ );
+ productName = ChatApp;
+ productReference = A4000000000000000000000F /* ChatApp.app */;
+ productType = "com.apple.product-type.application";
+ };
+ A70000000000000000000002 /* ChatAppTests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = A10000000000000000000003 /* Build configuration list for PBXNativeTarget "ChatAppTests" */;
+ buildPhases = (
+ A60000000000000000000004 /* Sources */,
+ A60000000000000000000005 /* Frameworks */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = ChatAppTests;
+ packageProductDependencies = (
+ A80000000000000000000005 /* AGUIAgentSDK */,
+ A80000000000000000000007 /* AGUICore */,
+ A80000000000000000000009 /* AGUITools */,
+ );
+ productName = ChatAppTests;
+ productReference = A40000000000000000000010 /* ChatAppTests.xctest */;
+ productType = "com.apple.product-type.bundle.unit-test";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ A00000000000000000000001 /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = 1;
+ LastSwiftUpdateCheck = 1500;
+ LastUpgradeCheck = 1500;
+ TargetAttributes = {
+ A70000000000000000000001 = {
+ CreatedOnToolsVersion = 15.0;
+ };
+ A70000000000000000000002 = {
+ CreatedOnToolsVersion = 15.0;
+ TestTargetID = A70000000000000000000001;
+ };
+ };
+ };
+ buildConfigurationList = A10000000000000000000001 /* Build configuration list for PBXProject "ChatApp" */;
+ compatibilityVersion = "Xcode 14.0";
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = A30000000000000000000001 /* ChatApp */;
+ packageReferences = (
+ A80000000000000000000001 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */,
+ A80000000000000000000002 /* XCLocalSwiftPackageReference "../../" */,
+ );
+ productRefGroup = A30000000000000000000002 /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ A70000000000000000000001 /* ChatApp */,
+ A70000000000000000000002 /* ChatAppTests */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ A60000000000000000000002 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ A5000000000000000000000C /* Assets.xcassets in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ A60000000000000000000001 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ A50000000000000000000001 /* ChatAppApp.swift in Sources */,
+ A50000000000000000000002 /* AgentConfig.swift in Sources */,
+ A50000000000000000000003 /* DisplayMessage.swift in Sources */,
+ A50000000000000000000004 /* ChatUIState.swift in Sources */,
+ A50000000000000000000005 /* AgentDraft.swift in Sources */,
+ A50000000000000000000006 /* ChatAppStore.swift in Sources */,
+ A50000000000000000000007 /* RootView.swift in Sources */,
+ A50000000000000000000008 /* ChatView.swift in Sources */,
+ A50000000000000000000009 /* MessageBubbleView.swift in Sources */,
+ A5000000000000000000000A /* AgentListView.swift in Sources */,
+ A5000000000000000000000B /* AgentFormView.swift in Sources */,
+ A50000000000000000000014 /* EphemeralSlot.swift in Sources */,
+ A50000000000000000000015 /* SupplementalMessage.swift in Sources */,
+ A50000000000000000000016 /* ChatRow.swift in Sources */,
+ A50000000000000000000017 /* ChangeBackgroundToolExecutor.swift in Sources */,
+ A50000000000000000000018 /* ChatAppToolRegistry.swift in Sources */,
+ A50000000000000000000019 /* ChatAppStore+EventProcessing.swift in Sources */,
+ A5000000000000000000001D /* TypingDotsView.swift in Sources */,
+ A5000000000000000000001E /* StreamingCursorView.swift in Sources */,
+ A5000000000000000000001F /* A2UIComponent.swift in Sources */,
+ A50000000000000000000020 /* A2UISurfaceStateManager.swift in Sources */,
+ A50000000000000000000021 /* ChatAppStore+A2UI.swift in Sources */,
+ A50000000000000000000022 /* A2UIComponentView.swift in Sources */,
+ A50000000000000000000023 /* A2UISurfaceView.swift in Sources */,
+ A50000000000000000000027 /* ClawgUIPairingState.swift in Sources */,
+ A50000000000000000000028 /* ClawgUIDetector.swift in Sources */,
+ A50000000000000000000029 /* ClawgUIPairingManager.swift in Sources */,
+ A5000000000000000000002A /* ClawgUIPairingView.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ A60000000000000000000004 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ A5000000000000000000000F /* MockAgUiAgent.swift in Sources */,
+ A50000000000000000000010 /* ChatAppStoreTests.swift in Sources */,
+ A5000000000000000000001C /* ChangeBackgroundToolExecutorTests.swift in Sources */,
+ A50000000000000000000024 /* A2UIComponentTests.swift in Sources */,
+ A50000000000000000000025 /* A2UISurfaceStateManagerTests.swift in Sources */,
+ A5000000000000000000002B /* ClawgUIDetectorTests.swift in Sources */,
+ A5000000000000000000002C /* ClawgUIPairingManagerTests.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin XCBuildConfiguration section */
+ A20000000000000000000001 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.0;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ MTL_FAST_MATH = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = iphoneos;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ };
+ name = Debug;
+ };
+ A20000000000000000000002 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.0;
+ MTL_FAST_MATH = YES;
+ SDKROOT = iphoneos;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_OPTIMIZATION_LEVEL = "-O";
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+ A20000000000000000000003 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = "";
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = Sources/Resources/Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.agui.ChatApp;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 5.9;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ A20000000000000000000004 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = "";
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = Sources/Resources/Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.agui.ChatApp;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 5.9;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Release;
+ };
+ A20000000000000000000005 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ DEVELOPMENT_TEAM = "";
+ GENERATE_INFOPLIST_FILE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.agui.ChatAppTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_VERSION = 5.9;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ChatApp.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ChatApp";
+ };
+ name = Debug;
+ };
+ A20000000000000000000006 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ DEVELOPMENT_TEAM = "";
+ GENERATE_INFOPLIST_FILE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.agui.ChatAppTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_VERSION = 5.9;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ChatApp.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ChatApp";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ A10000000000000000000001 /* Build configuration list for PBXProject "ChatApp" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ A20000000000000000000001 /* Debug */,
+ A20000000000000000000002 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ A10000000000000000000002 /* Build configuration list for PBXNativeTarget "ChatApp" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ A20000000000000000000003 /* Debug */,
+ A20000000000000000000004 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ A10000000000000000000003 /* Build configuration list for PBXNativeTarget "ChatAppTests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ A20000000000000000000005 /* Debug */,
+ A20000000000000000000006 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+
+/* Begin XCLocalSwiftPackageReference section */
+ A80000000000000000000002 /* XCLocalSwiftPackageReference "../../" */ = {
+ isa = XCLocalSwiftPackageReference;
+ relativePath = "../..";
+ };
+/* End XCLocalSwiftPackageReference section */
+
+/* Begin XCRemoteSwiftPackageReference section */
+ A80000000000000000000001 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */ = {
+ isa = XCRemoteSwiftPackageReference;
+ repositoryURL = "https://github.com/gonzalezreal/swift-markdown-ui";
+ requirement = {
+ kind = upToNextMajorVersion;
+ minimumVersion = 2.0.0;
+ };
+ };
+/* End XCRemoteSwiftPackageReference section */
+
+/* Begin XCSwiftPackageProductDependency section */
+ A80000000000000000000003 /* MarkdownUI */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = A80000000000000000000001 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */;
+ productName = MarkdownUI;
+ };
+ A80000000000000000000004 /* AGUIAgentSDK */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = A80000000000000000000002 /* XCLocalSwiftPackageReference "../../" */;
+ productName = AGUIAgentSDK;
+ };
+ A80000000000000000000005 /* AGUIAgentSDK */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = A80000000000000000000002 /* XCLocalSwiftPackageReference "../../" */;
+ productName = AGUIAgentSDK;
+ };
+ A80000000000000000000006 /* AGUICore */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = A80000000000000000000002 /* XCLocalSwiftPackageReference "../../" */;
+ productName = AGUICore;
+ };
+ A80000000000000000000007 /* AGUICore */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = A80000000000000000000002 /* XCLocalSwiftPackageReference "../../" */;
+ productName = AGUICore;
+ };
+ A80000000000000000000008 /* AGUITools */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = A80000000000000000000002 /* XCLocalSwiftPackageReference "../../" */;
+ productName = AGUITools;
+ };
+ A80000000000000000000009 /* AGUITools */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = A80000000000000000000002 /* XCLocalSwiftPackageReference "../../" */;
+ productName = AGUITools;
+ };
+ A8000000000000000000000A /* AGUIClient */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = A80000000000000000000002 /* XCLocalSwiftPackageReference "../../" */;
+ productName = AGUIClient;
+ };
+/* End XCSwiftPackageProductDependency section */
+
+ };
+ rootObject = A00000000000000000000001 /* Project object */;
+}
diff --git a/sdks/community/swift/Examples/ChatApp/ChatApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/sdks/community/swift/Examples/ChatApp/ChatApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000000..919434a625
--- /dev/null
+++ b/sdks/community/swift/Examples/ChatApp/ChatApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/sdks/community/swift/Examples/ChatApp/ChatApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/sdks/community/swift/Examples/ChatApp/ChatApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
new file mode 100644
index 0000000000..49e9382f7c
--- /dev/null
+++ b/sdks/community/swift/Examples/ChatApp/ChatApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -0,0 +1,33 @@
+{
+ "originHash" : "fa5a75934025f6012d8463bd66b93debdc5faa722715526581a6455710de3824",
+ "pins" : [
+ {
+ "identity" : "networkimage",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/gonzalezreal/NetworkImage",
+ "state" : {
+ "revision" : "2849f5323265386e200484b0d0f896e73c3411b9",
+ "version" : "6.0.1"
+ }
+ },
+ {
+ "identity" : "swift-cmark",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/swiftlang/swift-cmark",
+ "state" : {
+ "revision" : "5d9bdaa4228b381639fff09403e39a04926e2dbe",
+ "version" : "0.7.1"
+ }
+ },
+ {
+ "identity" : "swift-markdown-ui",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/gonzalezreal/swift-markdown-ui",
+ "state" : {
+ "revision" : "5f613358148239d0292c0cef674a3c2314737f9e",
+ "version" : "2.4.1"
+ }
+ }
+ ],
+ "version" : 3
+}
diff --git a/sdks/community/swift/Examples/ChatApp/ChatApp.xcodeproj/xcshareddata/xcschemes/ChatApp.xcscheme b/sdks/community/swift/Examples/ChatApp/ChatApp.xcodeproj/xcshareddata/xcschemes/ChatApp.xcscheme
new file mode 100644
index 0000000000..143e491c2e
--- /dev/null
+++ b/sdks/community/swift/Examples/ChatApp/ChatApp.xcodeproj/xcshareddata/xcschemes/ChatApp.xcscheme
@@ -0,0 +1,103 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/sdks/community/swift/Examples/ChatApp/Sources/A2UI/A2UIComponent.swift b/sdks/community/swift/Examples/ChatApp/Sources/A2UI/A2UIComponent.swift
new file mode 100644
index 0000000000..1e4b273576
--- /dev/null
+++ b/sdks/community/swift/Examples/ChatApp/Sources/A2UI/A2UIComponent.swift
@@ -0,0 +1,204 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+// MARK: - TextStyle
+
+/// Optional visual style hints attached to a `.text` component.
+struct TextStyle: Decodable, Sendable {
+ /// Font size in points.
+ var fontSize: CGFloat?
+ /// Font weight descriptor: `"bold"`, `"semibold"`, `"light"`, `"regular"`.
+ var fontWeight: String?
+ /// Foreground color as a 6-character hex string (e.g. `"FF5733"`).
+ var color: String?
+ /// Text alignment: `"leading"`, `"center"`, `"trailing"`.
+ var alignment: String?
+}
+
+// MARK: - A2UIComponent
+
+/// A recursive component tree node decoded from A2UI surface events.
+///
+/// Each case maps to a single AG-UI component type. The discriminator is
+/// a `"type"` key in the JSON payload. Unknown or future component types
+/// decode to `.unknown` so the application never crashes on server-driven
+/// UI that was designed for a newer client version.
+///
+/// Conforms to `Decodable` via a custom `init(from:)` that reads the
+/// `"type"` key first and then decodes the remaining fields per case.
+indirect enum A2UIComponent: Decodable, Sendable {
+ /// Single line or paragraph of styled text.
+ case text(content: String, style: TextStyle?)
+ /// Tappable button that emits `actionId` on press.
+ case button(label: String, actionId: String)
+ /// Vertical stack of child components.
+ case vStack(children: [A2UIComponent])
+ /// Horizontal stack of child components.
+ case hStack(children: [A2UIComponent])
+ /// Remote image loaded from `url`.
+ case image(url: URL, altText: String?)
+ /// Horizontal rule.
+ case divider
+ /// Titled card container with child components.
+ case card(title: String?, children: [A2UIComponent])
+ /// Ordered list of item components.
+ case list(items: [A2UIComponent])
+ /// Colored label badge.
+ case badge(label: String, color: String?)
+ /// Single-line text input bound to `bindingKey`.
+ case textField(placeholder: String, bindingKey: String)
+ /// Boolean toggle bound to `bindingKey`.
+ case toggle(label: String, bindingKey: String, value: Bool)
+ /// Drop-down selector bound to `bindingKey`.
+ case select(label: String, options: [String], bindingKey: String)
+ /// Determinate progress indicator.
+ case progress(value: Double, total: Double)
+ /// Markdown-formatted text rendered with full markup support.
+ case markdown(content: String)
+ /// Fixed-height vertical spacer.
+ case spacer(height: CGFloat?)
+ /// Chart surface — spec is raw JSON encoded as `Data`.
+ case chart(spec: Data)
+ /// Tabular data with column headers and string rows.
+ case table(headers: [String], rows: [[String]])
+ /// Fallback for any unrecognised component type.
+ case unknown
+
+ // MARK: Decodable
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ let type = (try? container.decode(String.self, forKey: .type)) ?? ""
+
+ switch type {
+ case "text":
+ let content = (try? container.decode(String.self, forKey: .content)) ?? ""
+ let style = try? container.decode(TextStyle.self, forKey: .style)
+ self = .text(content: content, style: style)
+
+ case "button":
+ let label = (try? container.decode(String.self, forKey: .label)) ?? ""
+ let actionId = (try? container.decode(String.self, forKey: .actionId)) ?? ""
+ self = .button(label: label, actionId: actionId)
+
+ case "vStack":
+ let children = (try? container.decode([A2UIComponent].self, forKey: .children)) ?? []
+ self = .vStack(children: children)
+
+ case "hStack":
+ let children = (try? container.decode([A2UIComponent].self, forKey: .children)) ?? []
+ self = .hStack(children: children)
+
+ case "image":
+ let urlStr = (try? container.decode(String.self, forKey: .url)) ?? ""
+ let url = URL(string: urlStr) ?? URL(string: "about:blank")!
+ let altText = try? container.decode(String.self, forKey: .altText)
+ self = .image(url: url, altText: altText)
+
+ case "divider":
+ self = .divider
+
+ case "card":
+ let title = try? container.decode(String.self, forKey: .title)
+ let children = (try? container.decode([A2UIComponent].self, forKey: .children)) ?? []
+ self = .card(title: title, children: children)
+
+ case "list":
+ let items = (try? container.decode([A2UIComponent].self, forKey: .items)) ?? []
+ self = .list(items: items)
+
+ case "badge":
+ let label = (try? container.decode(String.self, forKey: .label)) ?? ""
+ let color = try? container.decode(String.self, forKey: .color)
+ self = .badge(label: label, color: color)
+
+ case "textField":
+ let placeholder = (try? container.decode(String.self, forKey: .placeholder)) ?? ""
+ let bindingKey = (try? container.decode(String.self, forKey: .bindingKey)) ?? ""
+ self = .textField(placeholder: placeholder, bindingKey: bindingKey)
+
+ case "toggle":
+ let label = (try? container.decode(String.self, forKey: .label)) ?? ""
+ let bindingKey = (try? container.decode(String.self, forKey: .bindingKey)) ?? ""
+ let value = (try? container.decode(Bool.self, forKey: .value)) ?? false
+ self = .toggle(label: label, bindingKey: bindingKey, value: value)
+
+ case "select":
+ let label = (try? container.decode(String.self, forKey: .label)) ?? ""
+ let options = (try? container.decode([String].self, forKey: .options)) ?? []
+ let bindingKey = (try? container.decode(String.self, forKey: .bindingKey)) ?? ""
+ self = .select(label: label, options: options, bindingKey: bindingKey)
+
+ case "progress":
+ let value = (try? container.decode(Double.self, forKey: .value)) ?? 0
+ let total = (try? container.decode(Double.self, forKey: .total)) ?? 1
+ self = .progress(value: value, total: total)
+
+ case "markdown":
+ let content = (try? container.decode(String.self, forKey: .content)) ?? ""
+ self = .markdown(content: content)
+
+ case "spacer":
+ let height = try? container.decode(CGFloat.self, forKey: .height)
+ self = .spacer(height: height)
+
+ case "chart":
+ if let spec = try? container.decode(JSONPayload.self, forKey: .spec),
+ let specData = try? JSONEncoder().encode(spec) {
+ self = .chart(spec: specData)
+ } else {
+ self = .chart(spec: Data())
+ }
+
+ case "table":
+ let headers = (try? container.decode([String].self, forKey: .headers)) ?? []
+ let rows = (try? container.decode([[String]].self, forKey: .rows)) ?? []
+ self = .table(headers: headers, rows: rows)
+
+ default:
+ self = .unknown
+ }
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case type, content, style, label, actionId, children, url, altText
+ case title, items, color, placeholder, bindingKey, value, options
+ case total, height, spec, headers, rows
+ }
+}
+
+// MARK: - JSONPayload (private chart spec helper)
+
+/// Arbitrary JSON value type used only to round-trip chart spec data through `Codable`.
+private indirect enum JSONPayload: Codable {
+ case string(String)
+ case number(Double)
+ case bool(Bool)
+ case array([JSONPayload])
+ case object([String: JSONPayload])
+ case null
+
+ init(from decoder: Decoder) throws {
+ let c = try decoder.singleValueContainer()
+ if (try? c.decodeNil()) == true { self = .null; return }
+ if let v = try? c.decode(Bool.self) { self = .bool(v); return }
+ if let v = try? c.decode(Double.self) { self = .number(v); return }
+ if let v = try? c.decode(String.self) { self = .string(v); return }
+ if let v = try? c.decode([String: JSONPayload].self) { self = .object(v); return }
+ if let v = try? c.decode([JSONPayload].self) { self = .array(v); return }
+ self = .null
+ }
+
+ func encode(to encoder: Encoder) throws {
+ var c = encoder.singleValueContainer()
+ switch self {
+ case .string(let s): try c.encode(s)
+ case .number(let n): try c.encode(n)
+ case .bool(let b): try c.encode(b)
+ case .array(let a): try c.encode(a)
+ case .object(let o): try c.encode(o)
+ case .null: try c.encodeNil()
+ }
+ }
+}
diff --git a/sdks/community/swift/Examples/ChatApp/Sources/A2UI/A2UISurfaceStateManager.swift b/sdks/community/swift/Examples/ChatApp/Sources/A2UI/A2UISurfaceStateManager.swift
new file mode 100644
index 0000000000..ce5b48871d
--- /dev/null
+++ b/sdks/community/swift/Examples/ChatApp/Sources/A2UI/A2UISurfaceStateManager.swift
@@ -0,0 +1,61 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUIClient
+import AGUICore
+import Foundation
+
+// MARK: - A2UIError
+
+/// Errors produced by `A2UISurfaceStateManager`.
+enum A2UIError: Error, Sendable {
+ /// A delta arrived for a surface that has no snapshot yet.
+ case surfaceNotFound(messageId: String)
+}
+
+// MARK: - A2UISurfaceStateManager
+
+/// Manages the raw JSON state for each A2UI surface by `messageId`.
+///
+/// `applySnapshot` seeds the surface state from an `ActivitySnapshotEvent`.
+/// `applyDelta` applies an RFC 6902 JSON Patch from an `ActivityDeltaEvent`.
+///
+/// Designed as a `@MainActor` class rather than an actor so it shares
+/// the store's isolation domain without actor-hopping overhead.
+@MainActor
+final class A2UISurfaceStateManager {
+
+ // MARK: - Private state
+
+ private var surfaces: [String: Data] = [:]
+
+ // MARK: - API
+
+ /// Replaces (or seeds) the surface state for `event.messageId`.
+ func applySnapshot(_ event: ActivitySnapshotEvent) {
+ surfaces[event.messageId] = event.content
+ }
+
+ /// Applies the RFC 6902 patch from `event` to the stored surface state.
+ ///
+ /// - Returns: The updated surface data.
+ /// - Throws: `A2UIError.surfaceNotFound` if no snapshot exists for this `messageId`,
+ /// or a `PatchError` if the patch is malformed or a path is missing.
+ func applyDelta(_ event: ActivityDeltaEvent) throws -> Data {
+ guard let current = surfaces[event.messageId] else {
+ throw A2UIError.surfaceNotFound(messageId: event.messageId)
+ }
+ let updated = try PatchApplicator().apply(patch: event.patch, to: current)
+ surfaces[event.messageId] = updated
+ return updated
+ }
+
+ /// Returns the current raw JSON data for a surface, or `nil` if not yet seeded.
+ func surfaceData(for messageId: String) -> Data? {
+ surfaces[messageId]
+ }
+
+ /// Removes all surface state. Call when switching agents.
+ func reset() {
+ surfaces.removeAll()
+ }
+}
diff --git a/sdks/community/swift/Examples/ChatApp/Sources/App/ChatAppApp.swift b/sdks/community/swift/Examples/ChatApp/Sources/App/ChatAppApp.swift
new file mode 100644
index 0000000000..d36139d877
--- /dev/null
+++ b/sdks/community/swift/Examples/ChatApp/Sources/App/ChatAppApp.swift
@@ -0,0 +1,15 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import SwiftUI
+
+@main
+struct ChatAppApp: App {
+ @StateObject private var store = ChatAppStore()
+
+ var body: some Scene {
+ WindowGroup {
+ RootView()
+ .environmentObject(store)
+ }
+ }
+}
diff --git a/sdks/community/swift/Examples/ChatApp/Sources/ClawgUI/ClawgUIDetector.swift b/sdks/community/swift/Examples/ChatApp/Sources/ClawgUI/ClawgUIDetector.swift
new file mode 100644
index 0000000000..0a452186f7
--- /dev/null
+++ b/sdks/community/swift/Examples/ChatApp/Sources/ClawgUI/ClawgUIDetector.swift
@@ -0,0 +1,22 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+// MARK: - ClawgUIDetector
+
+/// Identifies whether a URL string points to a ClawgUI enterprise gateway endpoint.
+///
+/// Mirrors the detection logic from the Kotlin SDK reference implementation.
+/// A URL is considered a ClawgUI endpoint when its path contains `/v1/clawg-ui`.
+enum ClawgUIDetector {
+
+ /// Returns `true` when `urlString` contains the `/v1/clawg-ui` path segment.
+ ///
+ /// The check is case-sensitive — URL paths are case-sensitive by convention.
+ static func isClawgUIEndpoint(_ urlString: String) -> Bool {
+ urlString.range(
+ of: #"/v1/clawg-ui"#,
+ options: .regularExpression
+ ) != nil
+ }
+}
diff --git a/sdks/community/swift/Examples/ChatApp/Sources/ClawgUI/ClawgUIPairingManager.swift b/sdks/community/swift/Examples/ChatApp/Sources/ClawgUI/ClawgUIPairingManager.swift
new file mode 100644
index 0000000000..6dc1c4e602
--- /dev/null
+++ b/sdks/community/swift/Examples/ChatApp/Sources/ClawgUI/ClawgUIPairingManager.swift
@@ -0,0 +1,202 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+// MARK: - ClawgUIPairingHTTPClient
+
+/// Abstraction over the ClawgUI gateway HTTP operations.
+///
+/// Inject a mock in tests; use `LiveClawgUIPairingHTTPClient` in production.
+protocol ClawgUIPairingHTTPClient: Sendable {
+ /// Performs the initial pairing handshake.
+ ///
+ /// Expected server behaviour (TBD): `POST agentURL` returns HTTP 403 with a JSON body
+ /// containing an `approvalURL` the user must visit to authorize the connection.
+ ///
+ /// - Throws: Any transport or decoding error.
+ func initiateHandshake(agentURL: URL) async throws -> ClawgUIHandshakeResult
+
+ /// Polls the gateway to check whether the user has approved the pairing.
+ ///
+ /// Expected server behaviour (TBD): `GET agentURL` returns 200 when approved,
+ /// 403 when still pending.
+ ///
+ /// - Returns: `true` when the gateway confirms approval.
+ /// - Throws: Any transport error.
+ func pollApprovalStatus(agentURL: URL) async throws -> Bool
+}
+
+// MARK: - ClawgUIHandshakeResult
+
+struct ClawgUIHandshakeResult: Sendable {
+ let approvalURL: URL
+}
+
+// MARK: - ClawgUIPairingError
+
+enum ClawgUIPairingError: Error, LocalizedError {
+ case handshakeFailed(String)
+ case pollingFailed(String)
+ case noAgentURL
+
+ var errorDescription: String? {
+ switch self {
+ case .handshakeFailed(let r): return "Handshake failed: \(r)"
+ case .pollingFailed(let r): return "Polling failed: \(r)"
+ case .noAgentURL: return "No agent URL configured for retry"
+ }
+ }
+}
+
+// MARK: - LiveClawgUIPairingHTTPClient
+
+/// Production HTTP client for ClawgUI pairing.
+///
+/// - Important: The exact request/response format is pending confirmation
+/// from the ClawgUI server specification. Both methods throw until the
+/// spec is confirmed and implemented.
+struct LiveClawgUIPairingHTTPClient: ClawgUIPairingHTTPClient {
+
+ // TODO: Implement once the ClawgUI gateway pairing protocol is confirmed.
+ // Expected handshake: POST to agentURL → 403 with JSON { "approvalURL": "..." }
+ func initiateHandshake(agentURL: URL) async throws -> ClawgUIHandshakeResult {
+ throw ClawgUIPairingError.handshakeFailed(
+ "Not yet implemented — awaiting ClawgUI server specification"
+ )
+ }
+
+ // TODO: Implement once the ClawgUI gateway polling protocol is confirmed.
+ // Expected poll: GET agentURL → 200 (approved) or 403 (pending)
+ func pollApprovalStatus(agentURL: URL) async throws -> Bool {
+ throw ClawgUIPairingError.pollingFailed(
+ "Not yet implemented — awaiting ClawgUI server specification"
+ )
+ }
+}
+
+// MARK: - ClawgUIPairingManager
+
+/// Drives the ClawgUI enterprise pairing state machine.
+///
+/// Runs on the main actor so it shares the store's isolation domain and can be
+/// accessed from SwiftUI callbacks without actor hops.
+///
+/// The pairing flow is UI-driven:
+/// 1. `initiatePairing(agentURL:)` — performs the gateway handshake.
+/// 2. User opens the `approvalURL` in a browser.
+/// 3. `confirmApproval()` — user taps "I've Authorized", begins polling.
+/// 4. Polling confirms → `onPairingSuccess` fires → store builds the agent.
+/// 5. `reset()` at any point returns to `.idle` and cancels in-flight polling.
+@MainActor
+final class ClawgUIPairingManager {
+
+ // MARK: - Observed state
+
+ /// Current position in the pairing state machine.
+ private(set) var pairingState: ClawgUIPairingState = .idle
+
+ // MARK: - Callbacks
+
+ /// Fired on every state transition. Use to mirror state into `ChatUIState`.
+ var onStateChange: ((ClawgUIPairingState) -> Void)?
+
+ /// Fired exactly once when polling confirms the user approved the pairing.
+ var onPairingSuccess: (() -> Void)?
+
+ // MARK: - Private state
+
+ private let httpClient: any ClawgUIPairingHTTPClient
+ private var pollingTask: Task?
+ private var pairingAgentURL: URL?
+
+ let maxPollingAttempts: Int
+ var pollingInterval: Duration
+
+ // MARK: - Init
+
+ init(
+ httpClient: any ClawgUIPairingHTTPClient = LiveClawgUIPairingHTTPClient(),
+ maxPollingAttempts: Int = 5,
+ pollingInterval: Duration = .seconds(2)
+ ) {
+ self.httpClient = httpClient
+ self.maxPollingAttempts = maxPollingAttempts
+ self.pollingInterval = pollingInterval
+ }
+
+ // MARK: - Public interface
+
+ /// Performs the initial gateway handshake for the given agent URL.
+ ///
+ /// Transitions: `.idle` → `.initiating` → `.pendingApproval` or `.failed`.
+ func initiatePairing(agentURL: URL) async {
+ pairingAgentURL = agentURL
+ transition(to: .initiating)
+ do {
+ let result = try await httpClient.initiateHandshake(agentURL: agentURL)
+ transition(to: .pendingApproval(approvalURL: result.approvalURL))
+ } catch {
+ transition(to: .failed(reason: error.localizedDescription))
+ }
+ }
+
+ /// Called when the user taps "I've Authorized". Starts approval polling.
+ ///
+ /// Transitions: `.pendingApproval` → `.awaitingApproval`
+ func confirmApproval() {
+ transition(to: .awaitingApproval)
+ startPolling()
+ }
+
+ /// Re-initiates the handshake. Intended for the `retryingConnection` and
+ /// `failed` states when the user taps "Try Again".
+ func retryConnection() async {
+ guard let agentURL = pairingAgentURL else {
+ transition(to: .failed(reason: ClawgUIPairingError.noAgentURL.localizedDescription ?? ""))
+ return
+ }
+ await initiatePairing(agentURL: agentURL)
+ }
+
+ /// Cancels all in-flight operations and returns to `.idle`.
+ func reset() {
+ pollingTask?.cancel()
+ pollingTask = nil
+ pairingAgentURL = nil
+ transition(to: .idle)
+ }
+
+ // MARK: - Private helpers
+
+ private func transition(to newState: ClawgUIPairingState) {
+ pairingState = newState
+ onStateChange?(newState)
+ }
+
+ private func startPolling() {
+ guard let agentURL = pairingAgentURL else { return }
+ pollingTask?.cancel()
+ pollingTask = Task { [weak self] in
+ guard let self else { return }
+ var attempts = 0
+ while attempts < self.maxPollingAttempts {
+ if Task.isCancelled { return }
+ try? await Task.sleep(for: self.pollingInterval)
+ if Task.isCancelled { return }
+ do {
+ let approved = try await self.httpClient.pollApprovalStatus(agentURL: agentURL)
+ if approved {
+ self.transition(to: .idle)
+ self.onPairingSuccess?()
+ return
+ }
+ } catch {
+ // Network error during poll — treat as non-approval and continue
+ }
+ attempts += 1
+ }
+ // All polling attempts exhausted — user must explicitly retry
+ self.transition(to: .retryingConnection)
+ }
+ }
+}
diff --git a/sdks/community/swift/Examples/ChatApp/Sources/ClawgUI/ClawgUIPairingState.swift b/sdks/community/swift/Examples/ChatApp/Sources/ClawgUI/ClawgUIPairingState.swift
new file mode 100644
index 0000000000..1d7aeaa4f2
--- /dev/null
+++ b/sdks/community/swift/Examples/ChatApp/Sources/ClawgUI/ClawgUIPairingState.swift
@@ -0,0 +1,38 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+// MARK: - ClawgUIPairingState
+
+/// Six-state machine mirroring the Kotlin SDK's ClawgUI pairing flow.
+///
+/// ```
+/// idle ──[clawg-ui URL detected]──► initiating
+/// initiating ──[403 + pairing info]──► pendingApproval(approvalURL)
+/// initiating ──[HTTP error]──► failed(reason)
+/// pendingApproval ──[user confirms]──► awaitingApproval
+/// awaitingApproval ──[poll 200 OK]──► idle (connected)
+/// awaitingApproval ──[poll timeout]──► retryingConnection
+/// retryingConnection ──[retry succeeds]──► pendingApproval(url)
+/// retryingConnection ──[max retries exhausted]──► failed(reason)
+/// failed ──[user dismisses]──► idle
+/// ```
+enum ClawgUIPairingState: Sendable, Equatable {
+ /// No pairing in progress — default state for non-ClawgUI agents.
+ case idle
+
+ /// Initial handshake request is in flight.
+ case initiating
+
+ /// Handshake returned a 403 with an approval URL; user must open it to authorize.
+ case pendingApproval(approvalURL: URL)
+
+ /// User confirmed they authorized; polling the gateway for confirmation.
+ case awaitingApproval
+
+ /// Polling timed out; user can tap Retry to re-initiate.
+ case retryingConnection
+
+ /// Terminal error state; user must dismiss and reconfigure.
+ case failed(reason: String)
+}
diff --git a/sdks/community/swift/Examples/ChatApp/Sources/Models/AgentConfig.swift b/sdks/community/swift/Examples/ChatApp/Sources/Models/AgentConfig.swift
new file mode 100644
index 0000000000..12652e4f4f
--- /dev/null
+++ b/sdks/community/swift/Examples/ChatApp/Sources/Models/AgentConfig.swift
@@ -0,0 +1,142 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUIAgentSDK
+import Foundation
+
+// MARK: - AgentConfig
+
+struct AgentConfig: Identifiable, Codable, Hashable, Sendable {
+ var id: String
+ var name: String
+ var url: String
+ var description: String?
+ var systemPrompt: String?
+ var authMethod: AuthMethod
+ var customHeaders: [HeaderEntry]
+
+ init(
+ id: String = UUID().uuidString,
+ name: String,
+ url: String,
+ description: String? = nil,
+ systemPrompt: String? = nil,
+ authMethod: AuthMethod = .none,
+ customHeaders: [HeaderEntry] = []
+ ) {
+ self.id = id
+ self.name = name
+ self.url = url
+ self.description = description
+ self.systemPrompt = systemPrompt
+ self.authMethod = authMethod
+ self.customHeaders = customHeaders
+ }
+}
+
+// MARK: - HeaderEntry
+
+struct HeaderEntry: Identifiable, Codable, Hashable, Sendable {
+ var id: String
+ var key: String
+ var value: String
+
+ init(id: String = UUID().uuidString, key: String, value: String) {
+ self.id = id
+ self.key = key
+ self.value = value
+ }
+}
+
+// MARK: - AuthMethod
+
+enum AuthMethod: Codable, Hashable, Sendable {
+ case none
+ case apiKey(key: String, headerName: String)
+ case bearerToken(String)
+ case basicAuth(username: String, password: String)
+ case custom(type: String, entries: [HeaderEntry])
+}
+
+// MARK: - SDK Bridge
+
+extension AgentConfig {
+ /// Builds HTTP headers from the agent's auth method and custom headers.
+ func buildHeaders() -> [String: String] {
+ var headers: [String: String] = [:]
+ for entry in customHeaders {
+ let k = entry.key.trimmingCharacters(in: .whitespacesAndNewlines)
+ let v = entry.value.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !k.isEmpty { headers[k] = v }
+ }
+ switch authMethod {
+ case .none:
+ break
+ case .apiKey(let key, let headerName):
+ headers[headerName] = key
+ case .bearerToken(let token):
+ headers["Authorization"] = "Bearer \(token)"
+ case .basicAuth(let username, let password):
+ let credential = "\(username):\(password)"
+ let encoded = Data(credential.utf8).base64EncodedString()
+ headers["Authorization"] = "Basic \(encoded)"
+ case .custom(_, let entries):
+ for entry in entries {
+ let k = entry.key.trimmingCharacters(in: .whitespacesAndNewlines)
+ let v = entry.value.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !k.isEmpty { headers[k] = v }
+ }
+ }
+ return headers
+ }
+
+ /// Converts this config into a `StatefulAgUiAgentConfig`.
+ ///
+ /// The full URL entered by the user (e.g. `http://localhost:8888/agentic_chat`)
+ /// is split into a transport `baseURL` (scheme + host + port) and an `endpoint`
+ /// path (e.g. `/agentic_chat`), so the SDK doesn't double-append `/run`.
+ ///
+ /// - Throws: `AgentConfigError.invalidURL` when the URL string is malformed.
+ func toStatefulAgentConfig() throws -> StatefulAgUiAgentConfig {
+ let trimmed = url.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard let agentURL = URL(string: trimmed),
+ let scheme = agentURL.scheme,
+ let host = agentURL.host else {
+ throw AgentConfigError.invalidURL(url)
+ }
+
+ // Build a base URL containing only scheme + host + port.
+ var components = URLComponents()
+ components.scheme = scheme
+ components.host = host
+ components.port = agentURL.port
+ guard let baseURL = components.url else {
+ throw AgentConfigError.invalidURL(url)
+ }
+
+ // Use the path as the endpoint; fall back to "/run" if none was given.
+ let path = agentURL.path
+ let endpoint = path.isEmpty ? "/run" : path
+
+ var config = StatefulAgUiAgentConfig(baseURL: baseURL)
+ config.endpoint = endpoint
+ config.systemPrompt = systemPrompt
+ #if DEBUG
+ config.debug = true
+ #endif
+ config.headers = buildHeaders()
+ return config
+ }
+}
+
+// MARK: - AgentConfigError
+
+enum AgentConfigError: LocalizedError {
+ case invalidURL(String)
+
+ var errorDescription: String? {
+ switch self {
+ case .invalidURL(let url):
+ return "Invalid agent URL: \"\(url)\""
+ }
+ }
+}
diff --git a/sdks/community/swift/Examples/ChatApp/Sources/Models/AgentDraft.swift b/sdks/community/swift/Examples/ChatApp/Sources/Models/AgentDraft.swift
new file mode 100644
index 0000000000..aa0a42f087
--- /dev/null
+++ b/sdks/community/swift/Examples/ChatApp/Sources/Models/AgentDraft.swift
@@ -0,0 +1,148 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+// MARK: - AgentDraft
+
+/// Mutable form state for creating or editing an `AgentConfig`.
+struct AgentDraft: Sendable {
+ var name: String = ""
+ var url: String = ""
+ var description: String = ""
+ var systemPrompt: String = ""
+ var customHeaders: [HeaderField] = []
+ var authSelection: AuthMethodSelection = .none
+
+ // API Key
+ var apiKey: String = ""
+ var apiHeaderName: String = "X-API-Key"
+
+ // Bearer token
+ var bearerToken: String = ""
+
+ // Basic auth
+ var basicUsername: String = ""
+ var basicPassword: String = ""
+
+ // Custom auth
+ var customAuthType: String = ""
+ var customConfiguration: [HeaderField] = []
+
+ init() {}
+
+ init(from config: AgentConfig) {
+ name = config.name
+ url = config.url
+ description = config.description ?? ""
+ systemPrompt = config.systemPrompt ?? ""
+ customHeaders = config.customHeaders.map { HeaderField(key: $0.key, value: $0.value) }
+
+ switch config.authMethod {
+ case .none:
+ authSelection = .none
+ case .apiKey(let key, let headerName):
+ authSelection = .apiKey
+ apiKey = key
+ apiHeaderName = headerName
+ case .bearerToken(let token):
+ authSelection = .bearerToken
+ bearerToken = token
+ case .basicAuth(let username, let password):
+ authSelection = .basicAuth
+ basicUsername = username
+ basicPassword = password
+ case .custom(let type, let entries):
+ authSelection = .custom
+ customAuthType = type
+ customConfiguration = entries.map { HeaderField(key: $0.key, value: $0.value) }
+ }
+ }
+
+ func toAgentConfig(existingId: String? = nil) -> AgentConfig {
+ let authMethod: AuthMethod
+ switch authSelection {
+ case .none:
+ authMethod = .none
+ case .apiKey:
+ authMethod = .apiKey(key: apiKey, headerName: apiHeaderName)
+ case .bearerToken:
+ authMethod = .bearerToken(bearerToken)
+ case .basicAuth:
+ authMethod = .basicAuth(username: basicUsername, password: basicPassword)
+ case .custom:
+ authMethod = .custom(
+ type: customAuthType,
+ entries: customConfiguration.compactMap { $0.toHeaderEntry() }
+ )
+ }
+ return AgentConfig(
+ id: existingId ?? UUID().uuidString,
+ name: name.trimmingCharacters(in: .whitespacesAndNewlines),
+ url: url.trimmingCharacters(in: .whitespacesAndNewlines),
+ description: description.isEmpty ? nil : description,
+ systemPrompt: systemPrompt.isEmpty ? nil : systemPrompt,
+ authMethod: authMethod,
+ customHeaders: customHeaders.compactMap { $0.toHeaderEntry() }
+ )
+ }
+
+ var isValid: Bool {
+ !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
+ !url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ }
+}
+
+// MARK: - AuthMethodSelection
+
+enum AuthMethodSelection: String, CaseIterable, Identifiable, Sendable {
+ case none, apiKey, bearerToken, basicAuth, custom
+
+ var id: String { rawValue }
+
+ var title: String {
+ switch self {
+ case .none: return "None"
+ case .apiKey: return "API Key"
+ case .bearerToken: return "Bearer Token"
+ case .basicAuth: return "Basic Auth"
+ case .custom: return "Custom"
+ }
+ }
+}
+
+// MARK: - HeaderField
+
+/// Mutable key-value pair used in form editors.
+struct HeaderField: Identifiable, Hashable, Sendable {
+ // Use a stable id so ForEach($items) bindings work across reorders.
+ let id: UUID = UUID()
+ var key: String = ""
+ var value: String = ""
+
+ init(key: String = "", value: String = "") {
+ self.key = key
+ self.value = value
+ }
+
+ func toHeaderEntry() -> HeaderEntry? {
+ let k = key.trimmingCharacters(in: .whitespacesAndNewlines)
+ let v = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !k.isEmpty, !v.isEmpty else { return nil }
+ return HeaderEntry(key: k, value: v)
+ }
+}
+
+// MARK: - AgentFormMode
+
+enum AgentFormMode: Equatable, Sendable {
+ case create
+ case edit(AgentConfig)
+
+ static func == (lhs: AgentFormMode, rhs: AgentFormMode) -> Bool {
+ switch (lhs, rhs) {
+ case (.create, .create): return true
+ case let (.edit(l), .edit(r)): return l.id == r.id
+ default: return false
+ }
+ }
+}
diff --git a/sdks/community/swift/Examples/ChatApp/Sources/Models/ChatRow.swift b/sdks/community/swift/Examples/ChatApp/Sources/Models/ChatRow.swift
new file mode 100644
index 0000000000..28d48f75a8
--- /dev/null
+++ b/sdks/community/swift/Examples/ChatApp/Sources/Models/ChatRow.swift
@@ -0,0 +1,31 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Unified list-item type for the chat message list.
+///
+/// Merges ``DisplayMessage`` (agent-originated content) and
+/// ``SupplementalMessage`` (system-originated events) into a single
+/// sequence for `ForEach` iteration in `ChatView`.
+enum ChatRow: Identifiable, Sendable {
+ case agent(DisplayMessage)
+ case supplemental(SupplementalMessage)
+
+ // MARK: - Identifiable
+
+ var id: String {
+ switch self {
+ case .agent(let m): return m.id
+ case .supplemental(let s): return s.id
+ }
+ }
+
+ // MARK: - Sorting
+
+ var timestamp: Date {
+ switch self {
+ case .agent(let m): return m.timestamp
+ case .supplemental(let s): return s.timestamp
+ }
+ }
+}
diff --git a/sdks/community/swift/Examples/ChatApp/Sources/Models/ChatUIState.swift b/sdks/community/swift/Examples/ChatApp/Sources/Models/ChatUIState.swift
new file mode 100644
index 0000000000..01213e7e0b
--- /dev/null
+++ b/sdks/community/swift/Examples/ChatApp/Sources/Models/ChatUIState.swift
@@ -0,0 +1,67 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Snapshot of the chat UI that `ChatAppStore` publishes to SwiftUI views.
+struct ChatUIState: Sendable {
+ var messages: [DisplayMessage]
+ /// Active ephemeral banners, keyed by slot type.
+ ///
+ /// Multiple slots can coexist. `.step` dismisses immediately on `StepFinishedEvent`;
+ /// `.toolCall` dismisses 1 second after `ToolCallEndEvent`.
+ var ephemeralSlots: [EphemeralSlot: DisplayMessage]
+ /// System-level messages (connection status, inline errors) injected alongside chat messages.
+ var supplementalMessages: [SupplementalMessage]
+ var isLoading: Bool
+ /// `true` when an active agent is configured and ready to receive messages.
+ var isConnected: Bool
+ var error: String?
+ /// Hex color string for the chat background (e.g. `"FF5733"` or `"FF5733CC"`).
+ var backgroundHex: String?
+ var activeAgent: AgentConfig?
+ /// Phase 4: Raw JSON data for each A2UI surface, keyed by `messageId`.
+ ///
+ /// Populated by `ActivitySnapshotEvent` and updated on each `ActivityDeltaEvent`.
+ /// Surfaces are cleared when the active agent changes.
+ var a2uiSurfaces: [String: Data]
+ /// Phase 5: Current position in the ClawgUI enterprise pairing state machine.
+ ///
+ /// Non-idle values cause `RootView` to present the pairing sheet.
+ /// Reset to `.idle` when the active agent changes or the user cancels.
+ var clawgUIPairingState: ClawgUIPairingState
+
+ init(
+ messages: [DisplayMessage] = [],
+ ephemeralSlots: [EphemeralSlot: DisplayMessage] = [:],
+ supplementalMessages: [SupplementalMessage] = [],
+ isLoading: Bool = false,
+ isConnected: Bool = false,
+ error: String? = nil,
+ backgroundHex: String? = nil,
+ activeAgent: AgentConfig? = nil,
+ a2uiSurfaces: [String: Data] = [:],
+ clawgUIPairingState: ClawgUIPairingState = .idle
+ ) {
+ self.messages = messages
+ self.ephemeralSlots = ephemeralSlots
+ self.supplementalMessages = supplementalMessages
+ self.isLoading = isLoading
+ self.isConnected = isConnected
+ self.error = error
+ self.backgroundHex = backgroundHex
+ self.activeAgent = activeAgent
+ self.a2uiSurfaces = a2uiSurfaces
+ self.clawgUIPairingState = clawgUIPairingState
+ }
+
+ /// Unified list of all rows shown in the chat message list.
+ ///
+ /// Merges agent `messages` and `supplementalMessages`, sorted by timestamp
+ /// so supplemental events (connection confirmation, inline errors) appear
+ /// at the correct point in the conversation timeline.
+ var chatRows: [ChatRow] {
+ let agentRows = messages.map { ChatRow.agent($0) }
+ let suppRows = supplementalMessages.map { ChatRow.supplemental($0) }
+ return (agentRows + suppRows).sorted { $0.timestamp < $1.timestamp }
+ }
+}
diff --git a/sdks/community/swift/Examples/ChatApp/Sources/Models/DisplayMessage.swift b/sdks/community/swift/Examples/ChatApp/Sources/Models/DisplayMessage.swift
new file mode 100644
index 0000000000..3b0b83c9ab
--- /dev/null
+++ b/sdks/community/swift/Examples/ChatApp/Sources/Models/DisplayMessage.swift
@@ -0,0 +1,65 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+// MARK: - DisplayMessage
+
+/// A UI-only model that represents a single row in the chat message list.
+///
+/// Built from AG-UI protocol events in `ChatAppStore`; not persisted.
+struct DisplayMessage: Identifiable, Sendable {
+ let id: String
+ let role: DisplayMessageRole
+ var content: String
+ let timestamp: Date
+ /// `true` while `TextMessageContent` events are still arriving.
+ var isStreaming: Bool
+ /// `true` while the user message is waiting for agent confirmation.
+ ///
+ /// Set on the optimistic message created by `sendMessage`; cleared once the
+ /// agent echoes the message back in a `MessagesSnapshotEvent`, or on
+ /// cancellation / error.
+ var isSending: Bool
+
+ init(
+ id: String = UUID().uuidString,
+ role: DisplayMessageRole,
+ content: String,
+ timestamp: Date = .now,
+ isStreaming: Bool = false,
+ isSending: Bool = false
+ ) {
+ self.id = id
+ self.role = role
+ self.content = content
+ self.timestamp = timestamp
+ self.isStreaming = isStreaming
+ self.isSending = isSending
+ }
+
+ // MARK: - Phase 3: Animation state helpers
+
+ /// `true` when the agent has started responding but no tokens have arrived yet.
+ ///
+ /// Drives the animated typing indicator in `MessageBubbleView`.
+ var showsTypingIndicator: Bool { isStreaming && content.isEmpty }
+
+ /// `true` when tokens are actively streaming in and there is content to show.
+ ///
+ /// Drives the blinking cursor appended to streaming text in `MessageBubbleView`.
+ var showsStreamingCursor: Bool { isStreaming && !content.isEmpty }
+}
+
+// MARK: - DisplayMessageRole
+
+enum DisplayMessageRole: Sendable, Hashable {
+ case user
+ case assistant
+ case system
+ case error
+ case toolCall(name: String)
+ case stepInfo(name: String)
+ /// An A2UI generative-UI surface. The associated `messageId` keys into
+ /// `ChatUIState.a2uiSurfaces` for the raw JSON data.
+ case a2uiSurface(messageId: String)
+}
diff --git a/sdks/community/swift/Examples/ChatApp/Sources/Models/EphemeralSlot.swift b/sdks/community/swift/Examples/ChatApp/Sources/Models/EphemeralSlot.swift
new file mode 100644
index 0000000000..1ee0d7e385
--- /dev/null
+++ b/sdks/community/swift/Examples/ChatApp/Sources/Models/EphemeralSlot.swift
@@ -0,0 +1,39 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Identifies a named slot in the ephemeral banner strip.
+///
+/// Each slot is independent — multiple slots can be visible simultaneously.
+/// Slots differ in their display priority and dismissal behaviour, mirroring
+/// the Kotlin Compose `chatapp` implementation.
+enum EphemeralSlot: Hashable, CaseIterable, Comparable, Sendable {
+ /// Shown while a tool call is in flight.
+ /// Dismissed 1 second after `ToolCallEndEvent` arrives.
+ case toolCall
+ /// Shown while a reasoning step is active.
+ /// Dismissed immediately (synchronously) when `StepFinishedEvent` arrives.
+ case step
+
+ /// Lower value renders closer to the top of the banner strip.
+ var displayPriority: Int {
+ switch self {
+ case .step: return 0
+ case .toolCall: return 1
+ }
+ }
+
+ /// The delay to wait before clearing this slot from the banner strip.
+ ///
+ /// `nil` means dismiss immediately — no `Task.sleep` is scheduled.
+ var dismissDelay: Duration? {
+ switch self {
+ case .toolCall: return .seconds(1)
+ case .step: return nil
+ }
+ }
+
+ static func < (lhs: EphemeralSlot, rhs: EphemeralSlot) -> Bool {
+ lhs.displayPriority < rhs.displayPriority
+ }
+}
diff --git a/sdks/community/swift/Examples/ChatApp/Sources/Models/SupplementalMessage.swift b/sdks/community/swift/Examples/ChatApp/Sources/Models/SupplementalMessage.swift
new file mode 100644
index 0000000000..9980842746
--- /dev/null
+++ b/sdks/community/swift/Examples/ChatApp/Sources/Models/SupplementalMessage.swift
@@ -0,0 +1,22 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// A non-agent system message injected into the chat list for lifecycle events.
+///
+/// Examples: agent connection confirmation, inline error notifications.
+/// Rendered as distinct, non-interactive rows by `SupplementalMessageBubbleView`.
+struct SupplementalMessage: Identifiable, Sendable {
+ let id: String
+ let kind: Kind
+ let timestamp: Date
+
+ // MARK: - Kind
+
+ enum Kind: Sendable {
+ /// Shown when an agent connection is established successfully.
+ case connection(agentName: String)
+ /// Shown inline when a run fails (in addition to the error alert).
+ case error(message: String)
+ }
+}
diff --git a/sdks/community/swift/Examples/ChatApp/Sources/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/sdks/community/swift/Examples/ChatApp/Sources/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000000..13613e3ee1
--- /dev/null
+++ b/sdks/community/swift/Examples/ChatApp/Sources/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,13 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/sdks/community/swift/Examples/ChatApp/Sources/Resources/Assets.xcassets/Contents.json b/sdks/community/swift/Examples/ChatApp/Sources/Resources/Assets.xcassets/Contents.json
new file mode 100644
index 0000000000..73c00596a7
--- /dev/null
+++ b/sdks/community/swift/Examples/ChatApp/Sources/Resources/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/sdks/community/swift/Examples/ChatApp/Sources/Resources/Info.plist b/sdks/community/swift/Examples/ChatApp/Sources/Resources/Info.plist
new file mode 100644
index 0000000000..9eef63f00f
--- /dev/null
+++ b/sdks/community/swift/Examples/ChatApp/Sources/Resources/Info.plist
@@ -0,0 +1,53 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ $(PRODUCT_BUNDLE_PACKAGE_TYPE)
+ CFBundleShortVersionString
+ $(MARKETING_VERSION)
+ CFBundleVersion
+ $(CURRENT_PROJECT_VERSION)
+ LSRequiresIPhoneOS
+
+
+ NSAppTransportSecurity
+
+ NSAllowsLocalNetworking
+
+
+ UIApplicationSceneManifest
+
+ UIApplicationSupportsMultipleScenes
+
+
+ UILaunchScreen
+
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+
+
diff --git a/sdks/community/swift/Examples/ChatApp/Sources/Store/ChatAppStore+A2UI.swift b/sdks/community/swift/Examples/ChatApp/Sources/Store/ChatAppStore+A2UI.swift
new file mode 100644
index 0000000000..758c152853
--- /dev/null
+++ b/sdks/community/swift/Examples/ChatApp/Sources/Store/ChatAppStore+A2UI.swift
@@ -0,0 +1,72 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUICore
+import Foundation
+
+// MARK: - ChatAppStore + A2UI
+
+extension ChatAppStore {
+
+ // MARK: - Event processing
+
+ /// Seeds a new A2UI surface from an activity snapshot and inserts a
+ /// corresponding display message so the surface appears in the chat list.
+ func processA2UISnapshot(_ event: ActivitySnapshotEvent) {
+ surfaceManager.applySnapshot(event)
+ state.a2uiSurfaces[event.messageId] = event.content
+ upsertA2UIMessage(messageId: event.messageId)
+ }
+
+ /// Applies a JSON Patch delta to an existing A2UI surface.
+ ///
+ /// Silently ignores patches that arrive before their snapshot — the surface
+ /// manager will throw, and the store swallows the error without crashing.
+ func processA2UIDelta(_ event: ActivityDeltaEvent) {
+ guard let updated = try? surfaceManager.applyDelta(event) else { return }
+ state.a2uiSurfaces[event.messageId] = updated
+ }
+
+ // MARK: - Action routing
+
+ /// Wire format for an A2UI action sent from client to agent.
+ private struct A2UIActionEnvelope: Encodable {
+ let type = "a2ui_action"
+ let messageId: String
+ let action: String
+ let payload: [String: String]
+ }
+
+ /// Handles a user-initiated A2UI action.
+ ///
+ /// "cancel" stops the in-flight stream locally without contacting the server.
+ /// All other actions are serialized as a typed `A2UIActionEnvelope` JSON
+ /// message and forwarded to the agent via `sendMessage`.
+ func handleA2UIAction(messageId: String, actionId: String, payload: [String: String]) {
+ if actionId == "cancel" {
+ cancelStreaming()
+ return
+ }
+
+ let envelope = A2UIActionEnvelope(messageId: messageId, action: actionId, payload: payload)
+ guard
+ let data = try? JSONEncoder().encode(envelope),
+ let json = String(data: data, encoding: .utf8)
+ else { return }
+ sendMessage(json)
+ }
+
+ // MARK: - Private helpers
+
+ /// Inserts a `.a2uiSurface` display message if one for this `messageId` does not yet exist.
+ private func upsertA2UIMessage(messageId: String) {
+ let compositeId = "a2ui-\(messageId)"
+ guard !state.messages.contains(where: { $0.id == compositeId }) else { return }
+ let msg = DisplayMessage(
+ id: compositeId,
+ role: .a2uiSurface(messageId: messageId),
+ content: "",
+ timestamp: .now
+ )
+ state.messages.append(msg)
+ }
+}
diff --git a/sdks/community/swift/Examples/ChatApp/Sources/Store/ChatAppStore+EventProcessing.swift b/sdks/community/swift/Examples/ChatApp/Sources/Store/ChatAppStore+EventProcessing.swift
new file mode 100644
index 0000000000..7be0a9b0ae
--- /dev/null
+++ b/sdks/community/swift/Examples/ChatApp/Sources/Store/ChatAppStore+EventProcessing.swift
@@ -0,0 +1,216 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUICore
+import Foundation
+
+// MARK: - ChatAppStore + Event Processing
+
+extension ChatAppStore {
+
+ // MARK: - Event dispatcher
+
+ /// Processes a single AG-UI event and updates `state` accordingly.
+ ///
+ /// Marked `internal` so tests can inject events directly without a live agent.
+ func processEvent(_ event: any AGUIEvent) {
+ switch event {
+
+ // MARK: Text messages (streaming assembly)
+
+ case let e as TextMessageStartEvent:
+ let msg = DisplayMessage(
+ id: e.messageId,
+ role: .assistant,
+ content: "",
+ timestamp: .now,
+ isStreaming: true
+ )
+ streamingMessageIndices[e.messageId] = state.messages.count
+ state.messages.append(msg)
+
+ case let e as TextMessageContentEvent:
+ if let idx = streamingMessageIndices[e.messageId] {
+ state.messages[idx].content += e.delta
+ }
+
+ case let e as TextMessageEndEvent:
+ if let idx = streamingMessageIndices[e.messageId] {
+ state.messages[idx].isStreaming = false
+ streamingMessageIndices.removeValue(forKey: e.messageId)
+ }
+
+ // MARK: Tool calls (ephemeral .toolCall slot + args preview)
+
+ case let e as ToolCallStartEvent:
+ toolCallArgBuffer[e.toolCallId] = ""
+ showEphemeral(
+ DisplayMessage(
+ id: e.toolCallId,
+ role: .toolCall(name: e.toolCallName),
+ content: "Calling \(e.toolCallName)…",
+ timestamp: .now
+ ),
+ slot: .toolCall
+ )
+
+ case let e as ToolCallArgsEvent:
+ // Phase 2A: buffer arg deltas and update the ephemeral preview.
+ toolCallArgBuffer[e.toolCallId, default: ""] += e.delta
+ let preview = summarizeArguments(toolCallArgBuffer[e.toolCallId] ?? "")
+ if var msg = state.ephemeralSlots[.toolCall] {
+ msg.content = preview
+ state.ephemeralSlots[.toolCall] = msg
+ }
+
+ case let e as ToolCallEndEvent:
+ toolCallArgBuffer.removeValue(forKey: e.toolCallId)
+ scheduleEphemeralDismissal(for: .toolCall)
+
+ // MARK: Steps (ephemeral .step slot)
+
+ case let e as StepStartedEvent:
+ showEphemeral(
+ DisplayMessage(
+ id: UUID().uuidString,
+ role: .stepInfo(name: e.stepName),
+ content: e.stepName,
+ timestamp: .now
+ ),
+ slot: .step
+ )
+
+ case is StepFinishedEvent:
+ // Step dismisses immediately — no scheduled delay.
+ ephemeralDismissTasks[.step]?.cancel()
+ ephemeralDismissTasks.removeValue(forKey: .step)
+ state.ephemeralSlots[.step] = nil
+
+ // MARK: Run lifecycle
+
+ case let e as RunErrorEvent:
+ state.error = e.message
+ appendSupplemental(SupplementalMessage(
+ id: UUID().uuidString,
+ kind: .error(message: e.message),
+ timestamp: .now
+ ))
+
+ // MARK: Messages snapshot (authoritative history replacement)
+
+ case let e as MessagesSnapshotEvent:
+ rebuildMessages(from: e)
+
+ // MARK: A2UI surfaces (Phase 4)
+
+ case let e as ActivitySnapshotEvent where e.activityType == "a2ui-surface":
+ processA2UISnapshot(e)
+
+ case let e as ActivityDeltaEvent where e.activityType == "a2ui-surface":
+ processA2UIDelta(e)
+
+ // MARK: Custom events (e.g. server-sent change_background)
+
+ case let e as CustomEvent:
+ handleCustomEvent(e)
+
+ default:
+ break
+ }
+ }
+
+ // MARK: - Supplemental messages
+
+ /// Appends a system-level message to the chat list.
+ ///
+ /// `internal` so `buildAgent(from:)` in `ChatAppStore.swift` can call it.
+ func appendSupplemental(_ message: SupplementalMessage) {
+ state.supplementalMessages.append(message)
+ }
+
+ // MARK: - Ephemeral slot management
+
+ /// Sets the ephemeral message for `slot`, cancelling any pending dismissal first.
+ private func showEphemeral(_ message: DisplayMessage, slot: EphemeralSlot) {
+ ephemeralDismissTasks[slot]?.cancel()
+ ephemeralDismissTasks.removeValue(forKey: slot)
+ state.ephemeralSlots[slot] = message
+ }
+
+ /// Schedules dismissal of `slot` after its configured `dismissDelay`.
+ ///
+ /// Slots with `dismissDelay == nil` (i.e. `.step`) are cleared synchronously in
+ /// the `StepFinishedEvent` case — this method is a no-op for those slots.
+ private func scheduleEphemeralDismissal(for slot: EphemeralSlot) {
+ let delay = ephemeralDismissDelayOverrides[slot] ?? slot.dismissDelay
+ guard let delay else { return }
+ ephemeralDismissTasks[slot]?.cancel()
+ ephemeralDismissTasks[slot] = Task { [weak self] in
+ try? await Task.sleep(for: delay)
+ guard !Task.isCancelled else { return }
+ self?.state.ephemeralSlots[slot] = nil
+ self?.ephemeralDismissTasks.removeValue(forKey: slot)
+ }
+ }
+
+ // MARK: - Message snapshot reconstruction
+
+ private func rebuildMessages(from event: MessagesSnapshotEvent) {
+ var rebuilt = event.messages.compactMap { displayMessage(from: $0) }
+
+ // Phase 1C: Correlate the pending optimistic user message.
+ if let pendingId = pendingUserMessageId,
+ let pending = state.messages.first(where: { $0.id == pendingId }) {
+ let isEchoed = rebuilt.contains { $0.role == .user && $0.content == pending.content }
+ if isEchoed {
+ pendingUserMessageId = nil
+ } else {
+ rebuilt.insert(pending, at: 0)
+ }
+ }
+
+ state.messages = rebuilt
+ streamingMessageIndices.removeAll()
+ }
+
+ private func displayMessage(from message: any Message) -> DisplayMessage? {
+ let displayRole: DisplayMessageRole
+ switch message.role {
+ case .user: displayRole = .user
+ case .assistant: displayRole = .assistant
+ case .system: displayRole = .system
+ default: return nil // skip tool/activity/reasoning messages from the display list
+ }
+
+ let content: String
+ if let userMsg = message as? UserMessage {
+ content = userMsg.content ?? ""
+ } else if let assistantMsg = message as? AssistantMessage {
+ content = assistantMsg.content ?? ""
+ } else if let sysMsg = message as? SystemMessage {
+ content = sysMsg.content
+ } else {
+ content = ""
+ }
+
+ return DisplayMessage(id: message.id, role: displayRole, content: content, timestamp: .now)
+ }
+
+ // MARK: - Custom event handling
+
+ private func handleCustomEvent(_ event: CustomEvent) {
+ guard event.name == "change_background",
+ let payload = try? event.parsedData() as? [String: Any],
+ let hex = payload["hex"] as? String ?? payload["color"] as? String
+ else { return }
+ state.backgroundHex = hex
+ }
+
+ // MARK: - Args summary (Phase 2A)
+
+ /// Truncates `json` to 80 characters and appends `…` if longer.
+ private func summarizeArguments(_ json: String) -> String {
+ let trimmed = json.trimmingCharacters(in: .whitespaces)
+ guard trimmed.count > 80 else { return trimmed }
+ return String(trimmed.prefix(80)) + "…"
+ }
+}
diff --git a/sdks/community/swift/Examples/ChatApp/Sources/Store/ChatAppStore.swift b/sdks/community/swift/Examples/ChatApp/Sources/Store/ChatAppStore.swift
new file mode 100644
index 0000000000..4ec65fc153
--- /dev/null
+++ b/sdks/community/swift/Examples/ChatApp/Sources/Store/ChatAppStore.swift
@@ -0,0 +1,359 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUIAgentSDK
+import AGUICore
+import Foundation
+
+// MARK: - ChatAppStore
+
+/// Central store that drives the entire chat UI.
+///
+/// All `@Published` mutations run on the main actor. The streaming
+/// `Task` suspends the main actor only during network waits, so UI
+/// updates remain snappy.
+///
+/// Event processing logic lives in `ChatAppStore+EventProcessing.swift`.
+@MainActor
+final class ChatAppStore: ObservableObject {
+
+ // MARK: - Published state
+
+ @Published var state: ChatUIState = .init()
+ @Published private(set) var agents: [AgentConfig] = []
+ @Published var formMode: AgentFormMode?
+ @Published var draft: AgentDraft = .init()
+ @Published var repositoryError: String?
+
+ // MARK: - Private state
+
+ private var agent: StatefulAgUiAgent?
+ private var streamingTask: Task?
+ /// messageId → index in `state.messages` for O(1) delta updates.
+ var streamingMessageIndices: [String: Int] = [:]
+ /// Per-slot dismiss tasks; keyed so TOOL_CALL and STEP cancel independently.
+ var ephemeralDismissTasks: [EphemeralSlot: Task] = [:]
+ /// Override dismiss delays for testing (avoids real 1-second waits in tests).
+ ///
+ /// Set entries before processing events in tests. Production code never mutates this.
+ var ephemeralDismissDelayOverrides: [EphemeralSlot: Duration] = [:]
+ /// The `id` of the optimistic user message currently awaiting agent confirmation.
+ var pendingUserMessageId: String?
+ /// Buffered tool-call arguments keyed by toolCallId.
+ ///
+ /// Populated on `ToolCallStartEvent`, appended on `ToolCallArgsEvent`,
+ /// removed on `ToolCallEndEvent`. Marked `internal` for test inspection.
+ var toolCallArgBuffer: [String: String] = [:]
+ /// Phase 4: Manages raw JSON state for each A2UI surface.
+ let surfaceManager = A2UISurfaceStateManager()
+ /// Phase 5: Drives the ClawgUI enterprise pairing state machine.
+ let pairingManager: ClawgUIPairingManager
+ /// Phase 5: Config awaiting agent build after successful ClawgUI pairing.
+ private var pendingAgentConfig: AgentConfig?
+
+ // MARK: - Persistence
+
+ private let defaults: UserDefaults
+ private static let agentsKey = "chatapp.agents"
+ private static let activeAgentIdKey = "chatapp.activeAgentId"
+
+ var selectedAgentId: String? { state.activeAgent?.id }
+
+ // MARK: - Init
+
+ init(defaults: UserDefaults = .standard, pairingManager: ClawgUIPairingManager? = nil) {
+ self.defaults = defaults
+ // Default-parameter expressions cannot call @MainActor inits in Swift 6;
+ // create the manager in the body where main-actor isolation is guaranteed.
+ self.pairingManager = pairingManager ?? ClawgUIPairingManager()
+ agents = Self.loadAgents(from: defaults)
+ setupPairingCallbacks()
+ if let id = defaults.string(forKey: Self.activeAgentIdKey),
+ let config = agents.first(where: { $0.id == id }) {
+ startAgentOrPairing(config: config)
+ }
+ }
+
+ // MARK: - Chat actions
+
+ func sendMessage(_ text: String) {
+ guard let agent else { return }
+
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return }
+
+ // Cancel any in-flight stream and clean up ephemeral state.
+ cancelStreaming()
+ streamingMessageIndices.removeAll()
+
+ // Phase 1C: Optimistic user message — shown immediately before agent ack.
+ let messageId = UUID().uuidString
+ let userMsg = DisplayMessage(
+ id: messageId,
+ role: .user,
+ content: trimmed,
+ timestamp: .now,
+ isSending: true
+ )
+ state.messages.append(userMsg)
+ pendingUserMessageId = messageId
+ state.isLoading = true
+
+ streamingTask = Task { [weak self] in
+ guard let self else { return }
+ defer {
+ self.state.isLoading = false
+ // Clear all ephemeral banners and their scheduled dismissals.
+ self.state.ephemeralSlots.removeAll()
+ for task in self.ephemeralDismissTasks.values { task.cancel() }
+ self.ephemeralDismissTasks.removeAll()
+ self.finishStreamingMessages()
+ }
+ do {
+ let stream = try await agent.chat(message: trimmed)
+ for try await event in stream {
+ self.processEvent(event)
+ }
+ } catch is CancellationError {
+ // User cancelled — remove the optimistic message and show no error.
+ if let pendingId = self.pendingUserMessageId {
+ self.state.messages.removeAll { $0.id == pendingId }
+ self.pendingUserMessageId = nil
+ }
+ } catch {
+ // On error: keep the optimistic message but mark it as no longer sending.
+ if let pendingId = self.pendingUserMessageId,
+ let idx = self.state.messages.firstIndex(where: { $0.id == pendingId }) {
+ self.state.messages[idx].isSending = false
+ self.pendingUserMessageId = nil
+ }
+ self.state.error = error.localizedDescription
+ }
+ }
+ }
+
+ func cancelStreaming() {
+ streamingTask?.cancel()
+ streamingTask = nil
+ // Cancel any pending ephemeral dismissal timers.
+ for task in ephemeralDismissTasks.values { task.cancel() }
+ ephemeralDismissTasks.removeAll()
+ state.ephemeralSlots.removeAll()
+ toolCallArgBuffer.removeAll()
+ // Clear the optimistic pending message.
+ if let pendingId = pendingUserMessageId {
+ state.messages.removeAll { $0.id == pendingId }
+ pendingUserMessageId = nil
+ }
+ }
+
+ func dismissError() {
+ state.error = nil
+ }
+
+ // MARK: - Agent management
+
+ func setActiveAgent(id: String?) {
+ guard id != state.activeAgent?.id else { return }
+ cancelStreaming()
+ streamingMessageIndices.removeAll()
+ toolCallArgBuffer.removeAll()
+ surfaceManager.reset()
+ // Phase 5: Cancel any in-flight pairing when switching agents.
+ pairingManager.reset()
+ pendingAgentConfig = nil
+
+ if let id, let config = agents.first(where: { $0.id == id }) {
+ startAgentOrPairing(config: config)
+ } else {
+ agent = nil
+ state = ChatUIState()
+ }
+ saveActiveAgentId(id)
+ }
+
+ func presentCreateAgent() {
+ draft = AgentDraft()
+ formMode = .create
+ }
+
+ func presentEditAgent(_ config: AgentConfig) {
+ draft = AgentDraft(from: config)
+ formMode = .edit(config)
+ }
+
+ func dismissAgentForm() {
+ formMode = nil
+ }
+
+ func saveAgent() {
+ guard let mode = formMode, draft.isValid else { return }
+
+ switch mode {
+ case .create:
+ let config = draft.toAgentConfig()
+ agents.append(config)
+ persistAgents()
+ formMode = nil
+ setActiveAgent(id: config.id)
+
+ case .edit(let existing):
+ let config = draft.toAgentConfig(existingId: existing.id)
+ if let idx = agents.firstIndex(where: { $0.id == existing.id }) {
+ agents[idx] = config
+ persistAgents()
+ }
+ formMode = nil
+ if config.id == state.activeAgent?.id {
+ buildAgent(from: config)
+ }
+ }
+ }
+
+ func deleteAgent(id: String) {
+ agents.removeAll { $0.id == id }
+ persistAgents()
+
+ if state.activeAgent?.id == id {
+ cancelStreaming()
+ pairingManager.reset()
+ pendingAgentConfig = nil
+ if let next = agents.first {
+ buildAgent(from: next)
+ saveActiveAgentId(next.id)
+ } else {
+ agent = nil
+ state = ChatUIState()
+ saveActiveAgentId(nil)
+ }
+ }
+ }
+
+ // MARK: - ClawgUI pairing (Phase 5)
+
+ /// Called when the user taps "I've Authorized" in the pairing sheet.
+ func confirmPairing() {
+ pairingManager.confirmApproval()
+ }
+
+ /// Called when the user taps "Retry" in the pairing sheet.
+ func retryPairing() {
+ Task { await pairingManager.retryConnection() }
+ }
+
+ /// Called when the user cancels the pairing sheet.
+ func resetPairing() {
+ pairingManager.reset()
+ pendingAgentConfig = nil
+ }
+
+ // MARK: - Testing support
+
+ /// Resets state and configures an active agent for unit tests.
+ func setupForTesting(agent config: AgentConfig) {
+ state = ChatUIState(isConnected: true, activeAgent: config)
+ streamingMessageIndices = [:]
+ ephemeralDismissTasks.removeAll()
+ toolCallArgBuffer.removeAll()
+ pendingUserMessageId = nil
+ surfaceManager.reset()
+ pairingManager.reset()
+ pendingAgentConfig = nil
+ }
+
+ /// Injects an optimistic user message for unit tests without going through `sendMessage`.
+ func injectPendingMessageForTesting(content: String) {
+ let id = UUID().uuidString
+ let msg = DisplayMessage(id: id, role: .user, content: content, isSending: true)
+ state.messages.append(msg)
+ pendingUserMessageId = id
+ }
+
+ // MARK: - Private helpers
+
+ /// Routes to the ClawgUI pairing flow or directly builds the agent, depending on the URL.
+ private func startAgentOrPairing(config: AgentConfig) {
+ if ClawgUIDetector.isClawgUIEndpoint(config.url) {
+ pendingAgentConfig = config
+ Task { [weak self] in
+ guard let self, let url = URL(string: config.url) else { return }
+ await self.pairingManager.initiatePairing(agentURL: url)
+ }
+ } else {
+ buildAgent(from: config)
+ }
+ }
+
+ /// Wires up `pairingManager` callbacks so state changes are mirrored into `ChatUIState`
+ /// and a successful pairing triggers agent construction.
+ private func setupPairingCallbacks() {
+ pairingManager.onStateChange = { [weak self] pairingState in
+ self?.state.clawgUIPairingState = pairingState
+ }
+ pairingManager.onPairingSuccess = { [weak self] in
+ guard let self, let config = self.pendingAgentConfig else { return }
+ self.pendingAgentConfig = nil
+ self.buildAgent(from: config)
+ }
+ }
+
+ private func buildAgent(from config: AgentConfig) {
+ do {
+ let baseConfig = try config.toStatefulAgentConfig()
+ // Create the agent immediately (no tool registry yet) so the UI is usable right away.
+ agent = StatefulAgUiAgent(configuration: baseConfig)
+ state = ChatUIState(isConnected: true, activeAgent: config)
+ // Phase 1B: Record the connection as an in-chat supplemental message.
+ appendSupplemental(SupplementalMessage(
+ id: UUID().uuidString,
+ kind: .connection(agentName: config.name),
+ timestamp: .now
+ ))
+
+ // Phase 2B: Asynchronously enhance the agent with client-side tool executors.
+ // The agent remains functional during setup; tools are added once ready.
+ Task { [weak self] in
+ guard let self else { return }
+ do {
+ let registry = try await ChatAppToolRegistry.makeRegistry { [weak self] hex in
+ await MainActor.run { self?.state.backgroundHex = hex } // nil = reset to default
+ }
+ var configWithTools = baseConfig
+ configWithTools.toolRegistry = registry
+ self.agent = StatefulAgUiAgent(configuration: configWithTools)
+ } catch {
+ // Tool registry setup failed — agent continues without client-side tools.
+ }
+ }
+ } catch {
+ state.error = error.localizedDescription
+ }
+ }
+
+ func finishStreamingMessages() {
+ for idx in state.messages.indices where state.messages[idx].isStreaming {
+ state.messages[idx].isStreaming = false
+ }
+ streamingMessageIndices.removeAll()
+ toolCallArgBuffer.removeAll()
+ }
+
+ // MARK: - Persistence helpers
+
+ private static func loadAgents(from defaults: UserDefaults) -> [AgentConfig] {
+ guard
+ let data = defaults.data(forKey: agentsKey),
+ let decoded = try? JSONDecoder().decode([AgentConfig].self, from: data)
+ else { return [] }
+ return decoded
+ }
+
+ private func persistAgents() {
+ if let encoded = try? JSONEncoder().encode(agents) {
+ defaults.set(encoded, forKey: Self.agentsKey)
+ }
+ }
+
+ private func saveActiveAgentId(_ id: String?) {
+ defaults.set(id, forKey: Self.activeAgentIdKey)
+ }
+}
diff --git a/sdks/community/swift/Examples/ChatApp/Sources/Tools/ChangeBackgroundToolExecutor.swift b/sdks/community/swift/Examples/ChatApp/Sources/Tools/ChangeBackgroundToolExecutor.swift
new file mode 100644
index 0000000000..c5d30a8b24
--- /dev/null
+++ b/sdks/community/swift/Examples/ChatApp/Sources/Tools/ChangeBackgroundToolExecutor.swift
@@ -0,0 +1,106 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUICore
+import AGUITools
+import Foundation
+
+// MARK: - ChangeBackgroundToolExecutor
+
+/// Client-side tool executor that updates the chat background color.
+///
+/// Registered with the `StatefulAgUiAgent`'s tool registry so the agent can
+/// call `change_background` locally instead of routing through a server round-trip.
+///
+/// Accepts `"color"`, `"hex"`, or `"background"` keys for the color value.
+/// Pass `"reset": true` to restore the default background.
+actor ChangeBackgroundToolExecutor: ToolExecutor {
+
+ // MARK: - Tool definition
+
+ nonisolated let tool: Tool = Tool(
+ name: "change_background",
+ description: "Changes the chat background color. Pass reset: true to restore the default.",
+ parameters: Data("""
+ {
+ "type": "object",
+ "properties": {
+ "color": {
+ "type": "string",
+ "description": "Hex color string (#RRGGBB / #RRGGBBAA) or CSS gradient"
+ },
+ "description": {
+ "type": "string",
+ "description": "Human-readable description of the background"
+ },
+ "reset": {
+ "type": "boolean",
+ "description": "Set true to restore the default background"
+ }
+ }
+ }
+ """.utf8)
+ )
+
+ // MARK: - Private state
+
+ /// Called when the agent requests a color change (`String`) or a reset (`nil`).
+ private let onBackground: @Sendable (String?) async -> Void
+
+ // MARK: - Init
+
+ init(onBackground: @escaping @Sendable (String?) async -> Void) {
+ self.onBackground = onBackground
+ }
+
+ // MARK: - ToolExecutor
+
+ func execute(context: ToolExecutionContext) async throws -> ToolExecutionResult {
+ let args = context.toolCall.function.arguments
+
+ if isReset(args) {
+ await onBackground(nil)
+ return .success(message: "Background reset to default")
+ }
+
+ guard let colorValue = parseColor(from: args) else {
+ throw ToolExecutionError.validationFailed(
+ message: "Provide a 'color' value or pass 'reset: true'"
+ )
+ }
+ await onBackground(colorValue)
+ return .success(message: "Background updated to \(colorValue)")
+ }
+
+ nonisolated func validate(toolCall: ToolCall) -> ToolValidationResult {
+ let args = toolCall.function.arguments
+ guard isReset(args) || parseColor(from: args) != nil else {
+ return .invalid(errors: ["Provide 'color', 'hex', 'background', or 'reset: true'"])
+ }
+ return .valid
+ }
+
+ // MARK: - Private helpers
+
+ /// Returns `true` when the arguments contain `"reset": true`.
+ private nonisolated func isReset(_ arguments: String) -> Bool {
+ guard
+ let data = arguments.data(using: .utf8),
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
+ else { return false }
+ return json["reset"] as? Bool == true
+ }
+
+ /// Extracts the color value from JSON arguments.
+ ///
+ /// Accepts `"color"`, `"hex"`, or `"background"` keys.
+ /// The value may be a hex string or a CSS gradient/color expression.
+ private nonisolated func parseColor(from arguments: String) -> String? {
+ guard
+ let data = arguments.data(using: .utf8),
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
+ else { return nil }
+ return json["color"] as? String
+ ?? json["hex"] as? String
+ ?? json["background"] as? String
+ }
+}
diff --git a/sdks/community/swift/Examples/ChatApp/Sources/Tools/ChatAppToolRegistry.swift b/sdks/community/swift/Examples/ChatApp/Sources/Tools/ChatAppToolRegistry.swift
new file mode 100644
index 0000000000..740459714c
--- /dev/null
+++ b/sdks/community/swift/Examples/ChatApp/Sources/Tools/ChatAppToolRegistry.swift
@@ -0,0 +1,30 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUITools
+import Foundation
+
+// MARK: - ChatAppToolRegistry
+
+/// Factory for the ChatApp tool registry.
+///
+/// Creates and configures a `DefaultToolRegistry` with all client-side tool
+/// executors registered. Callers provide callbacks for side effects (e.g.
+/// background colour updates) to keep executors decoupled from the store.
+enum ChatAppToolRegistry {
+
+ /// Builds and returns a fully configured tool registry.
+ ///
+ /// - Parameter onBackground: Async closure invoked when the agent calls
+ /// `change_background`. Receives the color string, or `nil` when resetting
+ /// to the default background.
+ /// - Returns: A `ToolRegistry` with `change_background` registered.
+ /// - Throws: `ToolRegistryError` if registration fails.
+ static func makeRegistry(
+ onBackground: @escaping @Sendable (String?) async -> Void
+ ) async throws -> any ToolRegistry {
+ let registry = DefaultToolRegistry()
+ let executor = ChangeBackgroundToolExecutor(onBackground: onBackground)
+ try await registry.register(executor: executor)
+ return registry
+ }
+}
diff --git a/sdks/community/swift/Examples/ChatApp/Sources/Views/A2UI/A2UIComponentView.swift b/sdks/community/swift/Examples/ChatApp/Sources/Views/A2UI/A2UIComponentView.swift
new file mode 100644
index 0000000000..7abc99ba62
--- /dev/null
+++ b/sdks/community/swift/Examples/ChatApp/Sources/Views/A2UI/A2UIComponentView.swift
@@ -0,0 +1,342 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import MarkdownUI
+import SwiftUI
+
+// MARK: - A2UIComponentView
+
+/// Recursively renders a single `A2UIComponent` node.
+///
+/// User interactions (button taps, toggle changes, text input) are reported
+/// through `onAction` rather than stored internally — the call site routes
+/// these back to the agent via `ChatAppStore.handleA2UIAction`.
+struct A2UIComponentView: View {
+
+ let component: A2UIComponent
+ /// `(actionId, payload)` — called on button taps and interactive control changes.
+ let onAction: (String, [String: String]) -> Void
+
+ var body: some View {
+ switch component {
+ case .text(let content, let style):
+ StyledText(content: content, style: style)
+
+ case .button(let label, let actionId):
+ Button(label) { onAction(actionId, [:]) }
+ .buttonStyle(.bordered)
+
+ case .vStack(let children):
+ VStack(alignment: .leading, spacing: 8) {
+ ForEach(Array(children.enumerated()), id: \.offset) { _, child in
+ A2UIComponentView(component: child, onAction: onAction)
+ }
+ }
+
+ case .hStack(let children):
+ HStack(spacing: 8) {
+ ForEach(Array(children.enumerated()), id: \.offset) { _, child in
+ A2UIComponentView(component: child, onAction: onAction)
+ }
+ }
+
+ case .image(let url, let altText):
+ AsyncImage(url: url) { phase in
+ switch phase {
+ case .success(let image):
+ image.resizable().scaledToFit()
+ case .failure:
+ Image(systemName: "photo.badge.exclamationmark")
+ .foregroundStyle(.secondary)
+ default:
+ ProgressView()
+ }
+ }
+ .accessibilityLabel(altText ?? "Image")
+
+ case .divider:
+ Divider()
+
+ case .card(let title, let children):
+ CardView(title: title, children: children, onAction: onAction)
+
+ case .list(let items):
+ VStack(alignment: .leading, spacing: 4) {
+ ForEach(Array(items.enumerated()), id: \.offset) { _, item in
+ HStack(alignment: .top, spacing: 6) {
+ Text("•").foregroundStyle(.secondary)
+ A2UIComponentView(component: item, onAction: onAction)
+ }
+ }
+ }
+
+ case .badge(let label, let color):
+ BadgeView(label: label, hex: color)
+
+ case .textField(let placeholder, let bindingKey):
+ BoundTextField(placeholder: placeholder, bindingKey: bindingKey, onAction: onAction)
+
+ case .toggle(let label, let bindingKey, let initialValue):
+ BoundToggle(label: label, bindingKey: bindingKey, initialValue: initialValue, onAction: onAction)
+
+ case .select(let label, let options, let bindingKey):
+ BoundSelect(label: label, options: options, bindingKey: bindingKey, onAction: onAction)
+
+ case .progress(let value, let total):
+ VStack(alignment: .leading, spacing: 2) {
+ ProgressView(value: value, total: total)
+ Text("\(Int((value / max(total, 1)) * 100))%")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ case .markdown(let content):
+ Markdown(content)
+ .markdownTheme(.basic)
+
+ case .spacer(let height):
+ if let h = height {
+ Spacer().frame(height: h)
+ } else {
+ Spacer()
+ }
+
+ case .chart:
+ // Charts require iOS 16+ Charts framework. Rendered as a placeholder
+ // until a chart rendering library is integrated.
+ HStack {
+ Image(systemName: "chart.bar.xaxis")
+ .foregroundStyle(.secondary)
+ Text("Chart (unsupported)")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ .padding(8)
+ .background(Color(UIColor.secondarySystemBackground))
+ .clipShape(RoundedRectangle(cornerRadius: 8))
+
+ case .table(let headers, let rows):
+ TableView(headers: headers, rows: rows)
+
+ case .unknown:
+ EmptyView()
+ }
+ }
+}
+
+// MARK: - Sub-views
+
+private struct StyledText: View {
+ let content: String
+ let style: TextStyle?
+
+ var body: some View {
+ Text(content)
+ .font(resolvedFont)
+ .foregroundColor(resolvedColor)
+ .multilineTextAlignment(resolvedAlignment)
+ }
+
+ private var resolvedFont: Font {
+ let weight: Font.Weight
+ switch style?.fontWeight {
+ case "bold": weight = .bold
+ case "semibold": weight = .semibold
+ case "light": weight = .light
+ default: weight = .regular
+ }
+ if let size = style?.fontSize {
+ // Fixed size requested: honour it exactly, but still apply the weight.
+ return .system(size: size, weight: weight)
+ }
+ // No explicit size — use .body so Dynamic Type scaling is preserved.
+ return .body.weight(weight)
+ }
+
+ private var resolvedColor: Color? {
+ guard let hex = style?.color else { return nil }
+ return Color(hex: hex)
+ }
+
+ private var resolvedAlignment: TextAlignment {
+ switch style?.alignment {
+ case "center": return .center
+ case "trailing": return .trailing
+ default: return .leading
+ }
+ }
+}
+
+private struct CardView: View {
+ let title: String?
+ let children: [A2UIComponent]
+ let onAction: (String, [String: String]) -> Void
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ if let t = title {
+ Text(t).font(.headline)
+ }
+ ForEach(Array(children.enumerated()), id: \.offset) { _, child in
+ A2UIComponentView(component: child, onAction: onAction)
+ }
+ }
+ .padding(12)
+ .background(Color(UIColor.secondarySystemBackground))
+ .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
+ }
+}
+
+private struct BadgeView: View {
+ let label: String
+ let hex: String?
+
+ private var badgeColor: Color {
+ (hex.flatMap { Color(hex: $0) }) ?? .accentColor
+ }
+
+ var body: some View {
+ Text(label)
+ .font(.caption.weight(.semibold))
+ .padding(.horizontal, 8)
+ .padding(.vertical, 3)
+ .background(badgeColor.opacity(0.15))
+ .foregroundColor(badgeColor)
+ .clipShape(Capsule())
+ }
+}
+
+private struct BoundTextField: View {
+ let placeholder: String
+ let bindingKey: String
+ let onAction: (String, [String: String]) -> Void
+
+ @State private var text: String = ""
+
+ var body: some View {
+ TextField(placeholder, text: $text)
+ .textFieldStyle(.roundedBorder)
+ .onSubmit { onAction("text_submitted", [bindingKey: text]) }
+ }
+}
+
+private struct BoundToggle: View {
+ let label: String
+ let bindingKey: String
+ let initialValue: Bool
+ let onAction: (String, [String: String]) -> Void
+
+ @State private var isOn: Bool
+
+ init(label: String, bindingKey: String, initialValue: Bool, onAction: @escaping (String, [String: String]) -> Void) {
+ self.label = label
+ self.bindingKey = bindingKey
+ self.initialValue = initialValue
+ self.onAction = onAction
+ _isOn = State(initialValue: initialValue)
+ }
+
+ var body: some View {
+ Toggle(label, isOn: $isOn)
+ .onChange(of: isOn) { value in
+ onAction("toggle_changed", [bindingKey: value ? "true" : "false"])
+ }
+ }
+}
+
+private struct BoundSelect: View {
+ let label: String
+ let options: [String]
+ let bindingKey: String
+ let onAction: (String, [String: String]) -> Void
+
+ @State private var selection: String
+
+ init(label: String, options: [String], bindingKey: String, onAction: @escaping (String, [String: String]) -> Void) {
+ self.label = label
+ self.options = options
+ self.bindingKey = bindingKey
+ self.onAction = onAction
+ _selection = State(initialValue: options.first ?? "")
+ }
+
+ var body: some View {
+ Picker(label, selection: $selection) {
+ ForEach(options, id: \.self) { option in
+ Text(option).tag(option)
+ }
+ }
+ .pickerStyle(.menu)
+ .onChange(of: selection) { value in
+ onAction("select_changed", [bindingKey: value])
+ }
+ }
+}
+
+private struct TableView: View {
+ let headers: [String]
+ let rows: [[String]]
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ // Header row
+ HStack(spacing: 0) {
+ ForEach(headers, id: \.self) { header in
+ Text(header)
+ .font(.caption.weight(.semibold))
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(8)
+ .background(Color(UIColor.tertiarySystemBackground))
+ }
+ }
+ Divider()
+ // Data rows
+ ForEach(Array(rows.enumerated()), id: \.offset) { rowIdx, row in
+ HStack(spacing: 0) {
+ ForEach(Array(row.enumerated()), id: \.offset) { _, cell in
+ Text(cell)
+ .font(.caption)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(8)
+ }
+ }
+ .background(rowIdx % 2 == 0 ? Color.clear : Color(UIColor.secondarySystemBackground).opacity(0.5))
+ if rowIdx < rows.count - 1 { Divider() }
+ }
+ }
+ .clipShape(RoundedRectangle(cornerRadius: 8))
+ .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color(UIColor.separator), lineWidth: 0.5))
+ }
+}
+
+// MARK: - Previews
+
+#Preview("Button") {
+ A2UIComponentView(
+ component: .button(label: "Click me", actionId: "action_1"),
+ onAction: { id, _ in print("Action:", id) }
+ )
+ .padding()
+}
+
+#Preview("Card with children") {
+ A2UIComponentView(
+ component: .card(title: "Stats", children: [
+ .text(content: "Score: 100", style: TextStyle(fontWeight: "bold")),
+ .progress(value: 0.7, total: 1.0),
+ .button(label: "View Details", actionId: "view_details")
+ ]),
+ onAction: { _, _ in }
+ )
+ .padding()
+}
+
+#Preview("Table") {
+ A2UIComponentView(
+ component: .table(
+ headers: ["Name", "Score", "Rank"],
+ rows: [["Alice", "100", "1st"], ["Bob", "90", "2nd"], ["Carol", "85", "3rd"]]
+ ),
+ onAction: { _, _ in }
+ )
+ .padding()
+}
diff --git a/sdks/community/swift/Examples/ChatApp/Sources/Views/A2UI/A2UISurfaceView.swift b/sdks/community/swift/Examples/ChatApp/Sources/Views/A2UI/A2UISurfaceView.swift
new file mode 100644
index 0000000000..eb942743b9
--- /dev/null
+++ b/sdks/community/swift/Examples/ChatApp/Sources/Views/A2UI/A2UISurfaceView.swift
@@ -0,0 +1,65 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import SwiftUI
+
+// MARK: - A2UISurfaceView
+
+/// Root renderer for a single A2UI surface.
+///
+/// Decodes `surfaceData` once in `init` and falls back to an empty view on
+/// parse failure, so the layout never breaks during a streaming patch.
+///
+/// User interactions are forwarded through `onAction` so no store reference
+/// is held inside the view hierarchy — this keeps the view pure and testable.
+struct A2UISurfaceView: View {
+
+ let messageId: String
+ private let rootComponent: A2UIComponent?
+ /// `(messageId, actionId, payload)` — called when the user interacts with
+ /// a button, toggle, text field, or select inside this surface.
+ let onAction: (String, String, [String: String]) -> Void
+
+ init(
+ messageId: String,
+ surfaceData: Data?,
+ onAction: @escaping (String, String, [String: String]) -> Void
+ ) {
+ self.messageId = messageId
+ self.rootComponent = surfaceData.flatMap {
+ try? JSONDecoder().decode(A2UIComponent.self, from: $0)
+ }
+ self.onAction = onAction
+ }
+
+ var body: some View {
+ if let root = rootComponent {
+ A2UIComponentView(component: root) { actionId, payload in
+ onAction(messageId, actionId, payload)
+ }
+ .padding(12)
+ .background(Color(UIColor.secondarySystemBackground).opacity(0.6))
+ .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
+ }
+ }
+}
+
+// MARK: - Previews
+
+#Preview("Text surface") {
+ let json = """
+ {"type": "vStack", "children": [
+ {"type": "text", "content": "Hello from A2UI", "style": {"fontWeight": "bold"}},
+ {"type": "divider"},
+ {"type": "button", "label": "Tap me", "actionId": "tap"}
+ ]}
+ """.data(using: .utf8)
+
+ A2UISurfaceView(
+ messageId: "preview-1",
+ surfaceData: json,
+ onAction: { mId, actionId, payload in
+ print("Action: \(actionId) on \(mId), payload: \(payload)")
+ }
+ )
+ .padding()
+}
diff --git a/sdks/community/swift/Examples/ChatApp/Sources/Views/AgentFormView.swift b/sdks/community/swift/Examples/ChatApp/Sources/Views/AgentFormView.swift
new file mode 100644
index 0000000000..e7c6d8896a
--- /dev/null
+++ b/sdks/community/swift/Examples/ChatApp/Sources/Views/AgentFormView.swift
@@ -0,0 +1,205 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import SwiftUI
+
+struct AgentFormView: View {
+ @EnvironmentObject private var store: ChatAppStore
+ @Environment(\.dismiss) private var dismiss
+
+ let mode: AgentFormMode
+
+ private var title: String {
+ switch mode {
+ case .create: return "New Agent"
+ case .edit(let config): return "Edit \(config.name)"
+ }
+ }
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ detailsSection
+ authenticationSection
+ headersSection
+ previewSection
+ }
+ .navigationTitle(title)
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ store.dismissAgentForm()
+ dismiss()
+ }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Save", action: store.saveAgent)
+ .disabled(!store.draft.isValid)
+ }
+ }
+ }
+ .onChange(of: store.formMode) { mode in
+ if mode == nil { dismiss() }
+ }
+ }
+
+ // MARK: - Sections
+
+ private var detailsSection: some View {
+ Section("Details") {
+ TextField("Name", text: bind(\.name))
+ TextField("Endpoint URL", text: bind(\.url))
+ .textContentType(.URL)
+ .keyboardType(.URL)
+ .autocorrectionDisabled()
+ .textInputAutocapitalization(.never)
+ TextField("Description (optional)", text: bind(\.description), axis: .vertical)
+ .lineLimit(1 ... 3)
+ TextField("System Prompt (optional)", text: bind(\.systemPrompt), axis: .vertical)
+ .lineLimit(1 ... 6)
+ }
+ }
+
+ private var authenticationSection: some View {
+ Section("Authentication") {
+ Picker("Method", selection: bind(\.authSelection)) {
+ ForEach(AuthMethodSelection.allCases) { method in
+ Text(method.title).tag(method)
+ }
+ }
+ .pickerStyle(.menu)
+
+ switch store.draft.authSelection {
+ case .none:
+ Text("No authentication headers will be added.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ case .apiKey:
+ SecureField("API Key", text: bind(\.apiKey))
+ .autocorrectionDisabled()
+ .textInputAutocapitalization(.never)
+ TextField("Header Name", text: bind(\.apiHeaderName))
+ .autocorrectionDisabled()
+ .textInputAutocapitalization(.never)
+ case .bearerToken:
+ SecureField("Bearer Token", text: bind(\.bearerToken))
+ case .basicAuth:
+ TextField("Username", text: bind(\.basicUsername))
+ .autocorrectionDisabled()
+ .textInputAutocapitalization(.never)
+ SecureField("Password", text: bind(\.basicPassword))
+ case .custom:
+ TextField("Auth Type", text: bind(\.customAuthType))
+ .autocorrectionDisabled()
+ .textInputAutocapitalization(.never)
+ KeyValueEditor(items: bind(\.customConfiguration))
+ }
+ }
+ }
+
+ private var headersSection: some View {
+ Section("Custom Headers") {
+ KeyValueEditor(items: bind(\.customHeaders))
+ }
+ }
+
+ @ViewBuilder
+ private var previewSection: some View {
+ let hasContent = !store.draft.description.isEmpty
+ || !store.draft.systemPrompt.isEmpty
+ || !store.draft.customHeaders.isEmpty
+
+ Section("Preview") {
+ if !hasContent {
+ Text("Configure a system prompt, description, or headers to preview.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ } else {
+ VStack(alignment: .leading, spacing: 10) {
+ if !store.draft.description.isEmpty {
+ previewBlock(
+ label: "Description",
+ icon: "text.justify",
+ body: store.draft.description
+ )
+ }
+ if !store.draft.systemPrompt.isEmpty {
+ previewBlock(
+ label: "System Prompt",
+ icon: "sparkles",
+ body: store.draft.systemPrompt
+ )
+ }
+ if !store.draft.customHeaders.isEmpty {
+ Label("Headers", systemImage: "tray.full")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ ForEach(store.draft.customHeaders) { header in
+ HStack {
+ Text(header.key).font(.caption)
+ Spacer()
+ Text(header.value)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private func previewBlock(label: String, icon: String, body: String) -> some View {
+ VStack(alignment: .leading, spacing: 4) {
+ Label(label, systemImage: icon)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ Text(body)
+ .font(.callout)
+ }
+ }
+
+ // MARK: - Binding helper
+
+ private func bind(_ keyPath: WritableKeyPath) -> Binding {
+ Binding(
+ get: { store.draft[keyPath: keyPath] },
+ set: { store.draft[keyPath: keyPath] = $0 }
+ )
+ }
+}
+
+// MARK: - KeyValueEditor
+
+private struct KeyValueEditor: View {
+ @Binding var items: [HeaderField]
+
+ var body: some View {
+ ForEach($items) { $item in
+ HStack {
+ TextField("Key", text: $item.key)
+ .autocorrectionDisabled()
+ .textInputAutocapitalization(.never)
+ TextField("Value", text: $item.value)
+ .autocorrectionDisabled()
+ .textInputAutocapitalization(.never)
+ }
+ }
+
+ Button {
+ items.append(HeaderField())
+ } label: {
+ Label("Add Header", systemImage: "plus.circle")
+ }
+ .buttonStyle(.borderless)
+
+ if !items.isEmpty {
+ Button(role: .destructive) {
+ items.removeLast()
+ } label: {
+ Label("Remove Last", systemImage: "trash")
+ }
+ .buttonStyle(.borderless)
+ }
+ }
+}
diff --git a/sdks/community/swift/Examples/ChatApp/Sources/Views/AgentListView.swift b/sdks/community/swift/Examples/ChatApp/Sources/Views/AgentListView.swift
new file mode 100644
index 0000000000..14910db6d1
--- /dev/null
+++ b/sdks/community/swift/Examples/ChatApp/Sources/Views/AgentListView.swift
@@ -0,0 +1,107 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import SwiftUI
+
+struct AgentListView: View {
+ let agents: [AgentConfig]
+ let selectedId: String?
+ let onSelect: (String?) -> Void
+ let onAdd: () -> Void
+ let onEdit: (AgentConfig) -> Void
+ let onDelete: (String) -> Void
+
+ var body: some View {
+ List {
+ ForEach(agents) { agent in
+ AgentRow(
+ agent: agent,
+ isSelected: agent.id == selectedId,
+ onSelect: { onSelect(agent.id) },
+ onEdit: { onEdit(agent) },
+ onDelete: { onDelete(agent.id) }
+ )
+ }
+ }
+ .listStyle(.insetGrouped)
+ .navigationTitle("Agents")
+ .toolbar {
+ ToolbarItem(placement: .navigationBarTrailing) {
+ Button(action: onAdd) {
+ Label("Add Agent", systemImage: "plus")
+ }
+ }
+ }
+ .overlay {
+ if agents.isEmpty {
+ VStack(spacing: 16) {
+ Image(systemName: "person.crop.circle.badge.plus")
+ .font(.system(size: 40))
+ .foregroundStyle(.secondary)
+ Text("No agents")
+ .font(.headline)
+ Text("Tap + to add your first agent.")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ }
+}
+
+// MARK: - AgentRow
+
+private struct AgentRow: View {
+ let agent: AgentConfig
+ let isSelected: Bool
+ let onSelect: () -> Void
+ let onEdit: () -> Void
+ let onDelete: () -> Void
+
+ var body: some View {
+ Button(action: onSelect) {
+ HStack {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(agent.name)
+ .font(.headline)
+ .foregroundStyle(.primary)
+ Group {
+ if let desc = agent.description {
+ Text(desc)
+ } else {
+ Text(agent.url)
+ }
+ }
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ Spacer()
+ if isSelected {
+ Image(systemName: "checkmark.circle.fill")
+ .foregroundStyle(.cyan)
+ }
+ }
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel(agent.name)
+ .accessibilityHint(isSelected ? "Selected. Tap to keep this agent active." : "Tap to select this agent")
+ .swipeActions(edge: .trailing) {
+ Button(role: .destructive, action: onDelete) {
+ Label("Delete", systemImage: "trash")
+ }
+ Button(action: onEdit) {
+ Label("Edit", systemImage: "pencil")
+ }
+ .tint(.blue)
+ }
+ .contextMenu {
+ Button(action: onEdit) {
+ Label("Edit", systemImage: "pencil")
+ }
+ Button(role: .destructive, action: onDelete) {
+ Label("Delete", systemImage: "trash")
+ }
+ }
+ }
+}
diff --git a/sdks/community/swift/Examples/ChatApp/Sources/Views/ChatView.swift b/sdks/community/swift/Examples/ChatApp/Sources/Views/ChatView.swift
new file mode 100644
index 0000000000..317a0dc92e
--- /dev/null
+++ b/sdks/community/swift/Examples/ChatApp/Sources/Views/ChatView.swift
@@ -0,0 +1,269 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import SwiftUI
+
+struct ChatView: View {
+ @EnvironmentObject private var store: ChatAppStore
+
+ let state: ChatUIState
+ let onSend: (String) -> Void
+
+ @State private var messageText: String = ""
+
+ var body: some View {
+ let background = state.backgroundHex.flatMap { Color(hex: $0) }
+ ?? Color(UIColor.systemBackground)
+
+ Group {
+ if state.activeAgent == nil {
+ EmptyAgentView()
+ } else {
+ VStack(spacing: 0) {
+ messageList(background: background)
+
+ // Phase 1A: Render each active ephemeral slot independently.
+ // Slots are sorted by displayPriority so .step appears above .toolCall.
+ let activeSlots = EphemeralSlot.allCases
+ .sorted()
+ .compactMap { slot -> DisplayMessage? in state.ephemeralSlots[slot] }
+
+ ForEach(activeSlots, id: \.id) { ephemeral in
+ EphemeralBanner(message: ephemeral)
+ .transition(.move(edge: .bottom).combined(with: .opacity))
+ }
+
+ Divider()
+ inputArea
+ }
+ // Phase 3C: Animate background color changes smoothly.
+ .background(background.animation(.easeInOut(duration: 0.5), value: state.backgroundHex))
+ }
+ }
+ // Phase 3B: Spring animation for new message rows entering the list.
+ .animation(.spring(response: 0.38, dampingFraction: 0.82), value: state.chatRows.count)
+ .animation(.easeInOut(duration: 0.22), value: state.ephemeralSlots.count)
+ }
+
+ // MARK: - Sub-views
+
+ private func messageList(background: Color) -> some View {
+ ScrollViewReader { proxy in
+ ScrollView {
+ LazyVStack(alignment: .leading, spacing: 12) {
+ // Phase 1B: Iterate chatRows (agent messages + supplemental messages).
+ ForEach(state.chatRows) { row in
+ Group {
+ switch row {
+ case .agent(let message):
+ MessageBubbleView(message: message)
+ case .supplemental(let supplemental):
+ SupplementalMessageBubbleView(message: supplemental)
+ }
+ }
+ .id(row.id)
+ // Phase 3B: New rows slide up from below and fade in.
+ .transition(
+ .asymmetric(
+ insertion: .move(edge: .bottom).combined(with: .opacity),
+ removal: .opacity
+ )
+ )
+ }
+ }
+ .padding(.horizontal)
+ .padding(.vertical, 16)
+ }
+ .background(background.opacity(0.6))
+ .onChange(of: state.chatRows.last?.id) { id in
+ guard let id else { return }
+ withAnimation { proxy.scrollTo(id, anchor: .bottom) }
+ }
+ }
+ }
+
+ private var inputArea: some View {
+ VStack(spacing: 8) {
+ HStack(alignment: .bottom, spacing: 12) {
+ TextField("Type a message", text: $messageText, axis: .vertical)
+ .textFieldStyle(.roundedBorder)
+ .lineLimit(1 ... 6)
+ .disabled(!state.isConnected || state.isLoading)
+
+ Button {
+ let trimmed = messageText.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return }
+ onSend(trimmed)
+ messageText = ""
+ } label: {
+ Image(systemName: "paperplane.fill")
+ .font(.system(size: 18, weight: .semibold))
+ }
+ .buttonStyle(.borderedProminent)
+ .disabled(!state.isConnected || state.isLoading)
+ .accessibilityLabel("Send")
+ .accessibilityHint("Send your message to the agent")
+ }
+
+ if state.isLoading {
+ HStack(spacing: 8) {
+ ProgressView()
+ Text("Waiting for response…")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ Spacer()
+ Button("Cancel", role: .cancel, action: store.cancelStreaming)
+ .accessibilityLabel("Cancel")
+ .accessibilityHint("Stop waiting for the agent response")
+ }
+ }
+ }
+ .padding()
+ .background(Material.bar)
+ }
+}
+
+// MARK: - EphemeralBanner
+
+private struct EphemeralBanner: View {
+ let message: DisplayMessage
+
+ private var icon: String {
+ switch message.role {
+ case .toolCall: return "wrench.adjustable"
+ case .stepInfo: return "bolt.fill"
+ default: return "info.circle"
+ }
+ }
+
+ var body: some View {
+ HStack(spacing: 12) {
+ Image(systemName: icon)
+ .foregroundStyle(.cyan)
+ Text(message.content)
+ .font(.footnote)
+ .foregroundStyle(.cyan)
+ Spacer()
+ }
+ .padding(12)
+ .background(Color.accentColor.opacity(0.1))
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel(accessibilityLabel)
+ }
+
+ private var accessibilityLabel: String {
+ switch message.role {
+ case .toolCall(let name): return "Running tool: \(name)"
+ case .stepInfo(let name): return "Step in progress: \(name)"
+ default: return message.content
+ }
+ }
+}
+
+// MARK: - SupplementalMessageBubbleView
+
+/// Renders system lifecycle events (connection status, inline errors) as chat rows.
+private struct SupplementalMessageBubbleView: View {
+ let message: SupplementalMessage
+
+ var body: some View {
+ HStack(spacing: 8) {
+ Image(systemName: iconName)
+ .font(.caption)
+ .foregroundStyle(iconColor)
+ Text(labelText)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ Spacer()
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 6)
+ }
+
+ private var iconName: String {
+ switch message.kind {
+ case .connection: return "checkmark.circle.fill"
+ case .error: return "exclamationmark.triangle.fill"
+ }
+ }
+
+ private var iconColor: Color {
+ switch message.kind {
+ case .connection: return .green
+ case .error: return .orange
+ }
+ }
+
+ private var labelText: String {
+ switch message.kind {
+ case .connection(let agentName): return "Connected to \(agentName)"
+ case .error(let message): return message
+ }
+ }
+}
+
+// MARK: - EmptyAgentView
+
+private struct EmptyAgentView: View {
+ var body: some View {
+ VStack(spacing: 16) {
+ Image(systemName: "person.crop.circle.badge.questionmark")
+ .font(.system(size: 48))
+ .foregroundStyle(.secondary)
+ Text("Select an agent")
+ .font(.headline)
+ Text("Choose or create an agent to begin chatting.")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ }
+ .padding()
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+}
+
+// MARK: - Color + Hex
+
+extension Color {
+ /// Creates a `Color` from a hex string or a CSS color expression.
+ ///
+ /// Accepts:
+ /// - 6-character hex: `#RRGGBB` or `RRGGBB`
+ /// - 8-character hex: `#RRGGBBAA` or `RRGGBBAA`
+ /// - CSS gradients / expressions: extracts the first `#RRGGBB` hex found in the string
+ init?(hex: String) {
+ let trimmed = hex.trimmingCharacters(in: .whitespacesAndNewlines)
+
+ // Fast path: plain hex string
+ let cleaned = trimmed.replacingOccurrences(of: "#", with: "")
+ if let value = UInt64(cleaned, radix: 16) {
+ switch cleaned.count {
+ case 6:
+ let r = Double((value & 0xFF0000) >> 16) / 255
+ let g = Double((value & 0x00FF00) >> 8) / 255
+ let b = Double(value & 0x0000FF) / 255
+ self.init(red: r, green: g, blue: b)
+ return
+ case 8:
+ let r = Double((value & 0xFF00_0000) >> 24) / 255
+ let g = Double((value & 0x00FF_0000) >> 16) / 255
+ let b = Double((value & 0x0000_FF00) >> 8) / 255
+ let a = Double(value & 0x0000_00FF) / 255
+ self.init(red: r, green: g, blue: b, opacity: a)
+ return
+ default:
+ break
+ }
+ }
+
+ // Fallback: extract first #RRGGBB hex from a CSS gradient/expression
+ if let range = trimmed.range(of: #"#[0-9a-fA-F]{6}"#, options: .regularExpression) {
+ let extracted = String(trimmed[range])
+ if let color = Color(hex: extracted) {
+ self = color
+ return
+ }
+ }
+
+ return nil
+ }
+}
diff --git a/sdks/community/swift/Examples/ChatApp/Sources/Views/ClawgUI/ClawgUIPairingView.swift b/sdks/community/swift/Examples/ChatApp/Sources/Views/ClawgUI/ClawgUIPairingView.swift
new file mode 100644
index 0000000000..1fcc21a4e6
--- /dev/null
+++ b/sdks/community/swift/Examples/ChatApp/Sources/Views/ClawgUI/ClawgUIPairingView.swift
@@ -0,0 +1,223 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import SwiftUI
+
+// MARK: - ClawgUIPairingView
+
+/// Sheet modal for the ClawgUI enterprise pairing flow.
+///
+/// Pure presentational view — all state transitions are handled by callbacks
+/// passed in from the store. No business logic lives here.
+struct ClawgUIPairingView: View {
+ let state: ClawgUIPairingState
+ let onConfirm: () -> Void
+ let onRetry: () -> Void
+ let onDismiss: () -> Void
+
+ var body: some View {
+ NavigationStack {
+ Group {
+ switch state {
+ case .initiating:
+ InitiatingView()
+
+ case .pendingApproval(let url):
+ PendingApprovalView(approvalURL: url, onConfirm: onConfirm)
+
+ case .awaitingApproval:
+ AwaitingApprovalView()
+
+ case .retryingConnection:
+ RetryingConnectionView(onRetry: onRetry)
+
+ case .failed(let reason):
+ FailedView(reason: reason, onRetry: onRetry)
+
+ case .idle:
+ // Sheet is dismissed when state is .idle; nothing to render.
+ EmptyView()
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .navigationTitle("Enterprise Pairing")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .navigationBarLeading) {
+ Button("Cancel", action: onDismiss)
+ }
+ }
+ }
+ .presentationDetents([.medium])
+ }
+}
+
+// MARK: - Sub-views
+
+private struct InitiatingView: View {
+ var body: some View {
+ VStack(spacing: 20) {
+ ProgressView()
+ .controlSize(.large)
+ Text("Connecting to Gateway…")
+ .font(.headline)
+ Text("Initiating the ClawgUI enterprise handshake.")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ }
+ .padding()
+ }
+}
+
+private struct PendingApprovalView: View {
+ let approvalURL: URL
+ let onConfirm: () -> Void
+
+ var body: some View {
+ VStack(spacing: 24) {
+ Image(systemName: "lock.shield.fill")
+ .font(.system(size: 64))
+ .foregroundStyle(.tint)
+
+ Text("Enterprise Authorization Required")
+ .font(.title2)
+ .fontWeight(.semibold)
+ .multilineTextAlignment(.center)
+
+ Text("Open the link below in your browser to authorize access to this agent, then tap Continue.")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+
+ Link(destination: approvalURL) {
+ Label("Open Authorization Page", systemImage: "safari")
+ }
+ .buttonStyle(.bordered)
+
+ Button(action: onConfirm) {
+ Label("Continue — I've Authorized", systemImage: "checkmark.circle.fill")
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.borderedProminent)
+ }
+ .padding()
+ }
+}
+
+private struct AwaitingApprovalView: View {
+ var body: some View {
+ VStack(spacing: 20) {
+ ProgressView()
+ .controlSize(.large)
+ Text("Awaiting Approval")
+ .font(.headline)
+ Text("Checking with the gateway. This may take a moment.")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ }
+ .padding()
+ }
+}
+
+private struct RetryingConnectionView: View {
+ let onRetry: () -> Void
+
+ var body: some View {
+ VStack(spacing: 20) {
+ Image(systemName: "arrow.clockwise.circle.fill")
+ .font(.system(size: 64))
+ .foregroundStyle(.orange)
+
+ Text("Connection Timed Out")
+ .font(.headline)
+
+ Text("The authorization check timed out. Visit the approval page again, then tap Retry.")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+
+ Button(action: onRetry) {
+ Label("Retry", systemImage: "arrow.clockwise")
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.borderedProminent)
+ }
+ .padding()
+ }
+}
+
+private struct FailedView: View {
+ let reason: String
+ let onRetry: () -> Void
+
+ var body: some View {
+ VStack(spacing: 20) {
+ Image(systemName: "xmark.circle.fill")
+ .font(.system(size: 64))
+ .foregroundStyle(.red)
+
+ Text("Pairing Failed")
+ .font(.headline)
+
+ Text(reason)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+
+ Button(action: onRetry) {
+ Label("Try Again", systemImage: "arrow.clockwise")
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.borderedProminent)
+ }
+ .padding()
+ }
+}
+
+// MARK: - Previews
+
+#Preview("Initiating") {
+ ClawgUIPairingView(
+ state: .initiating,
+ onConfirm: {},
+ onRetry: {},
+ onDismiss: {}
+ )
+}
+
+#Preview("Pending Approval") {
+ ClawgUIPairingView(
+ state: .pendingApproval(approvalURL: URL(string: "https://clawg-ui.enterprise.com/approve?token=abc123")!),
+ onConfirm: {},
+ onRetry: {},
+ onDismiss: {}
+ )
+}
+
+#Preview("Awaiting Approval") {
+ ClawgUIPairingView(
+ state: .awaitingApproval,
+ onConfirm: {},
+ onRetry: {},
+ onDismiss: {}
+ )
+}
+
+#Preview("Retrying Connection") {
+ ClawgUIPairingView(
+ state: .retryingConnection,
+ onConfirm: {},
+ onRetry: {},
+ onDismiss: {}
+ )
+}
+
+#Preview("Failed") {
+ ClawgUIPairingView(
+ state: .failed(reason: "Network connection refused by the enterprise gateway."),
+ onConfirm: {},
+ onRetry: {},
+ onDismiss: {}
+ )
+}
diff --git a/sdks/community/swift/Examples/ChatApp/Sources/Views/MessageBubbleView.swift b/sdks/community/swift/Examples/ChatApp/Sources/Views/MessageBubbleView.swift
new file mode 100644
index 0000000000..bbe14791e4
--- /dev/null
+++ b/sdks/community/swift/Examples/ChatApp/Sources/Views/MessageBubbleView.swift
@@ -0,0 +1,120 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import MarkdownUI
+import SwiftUI
+
+struct MessageBubbleView: View {
+ @EnvironmentObject private var store: ChatAppStore
+ let message: DisplayMessage
+
+ private var isUser: Bool { message.role == .user }
+
+ private var bubbleColor: Color {
+ switch message.role {
+ case .user: return .accentColor
+ case .assistant: return Color(UIColor.secondarySystemBackground)
+ case .system: return Color(UIColor.systemGray5)
+ case .error: return Color.red.opacity(0.15)
+ case .toolCall: return Color.yellow.opacity(0.2)
+ case .stepInfo: return Color.blue.opacity(0.12)
+ case .a2uiSurface: return .clear
+ }
+ }
+
+ private var textColor: Color {
+ switch message.role {
+ case .user: return .white
+ case .error: return .red
+ default: return .primary
+ }
+ }
+
+ private var leadingIcon: String? {
+ switch message.role {
+ case .user: return nil
+ case .assistant: return "sparkles"
+ case .system: return "info.circle"
+ case .error: return "exclamationmark.triangle"
+ case .toolCall: return "wrench.adjustable"
+ case .stepInfo: return "bolt.fill"
+ case .a2uiSurface: return nil
+ }
+ }
+
+ var body: some View {
+ // Phase 4: A2UI surfaces render full-width without a standard bubble.
+ if case .a2uiSurface(let messageId) = message.role {
+ A2UISurfaceView(
+ messageId: messageId,
+ surfaceData: store.state.a2uiSurfaces[messageId]
+ ) { mId, actionId, payload in
+ store.handleA2UIAction(messageId: mId, actionId: actionId, payload: payload)
+ }
+ } else {
+ standardBubble
+ }
+ }
+
+ // MARK: - Standard bubble layout
+
+ private var standardBubble: some View {
+ HStack {
+ if isUser { Spacer(minLength: 40) }
+
+ VStack(alignment: isUser ? .trailing : .leading, spacing: 6) {
+ HStack(alignment: .top, spacing: 6) {
+ if let icon = leadingIcon {
+ Image(systemName: icon)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ // Phase 3A: Typing indicator while waiting for first token;
+ // inline blinking cursor once text is arriving.
+ if message.showsTypingIndicator {
+ TypingDotsView()
+ } else {
+ HStack(alignment: .bottom, spacing: 4) {
+ Markdown(message.content)
+ .markdownTheme(.basic)
+ .markdownTextStyle(\.text) {
+ ForegroundColor(textColor)
+ }
+ .markdownTextStyle(\.strong) {
+ FontWeight(.heavy)
+ ForegroundColor(textColor)
+ }
+ .markdownTextStyle(\.link) {
+ ForegroundColor(textColor)
+ }
+ .textSelection(.enabled)
+
+ if message.showsStreamingCursor {
+ StreamingCursorView()
+ }
+ }
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: isUser ? .trailing : .leading)
+ .padding(12)
+ .background(bubbleColor)
+ .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
+
+ HStack(spacing: 4) {
+ Text(message.timestamp.formatted(date: .omitted, time: .shortened))
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ // Phase 1C: Optimistic send indicator — visible until agent confirms.
+ if message.isSending {
+ Image(systemName: "clock")
+ .font(.caption2)
+ .foregroundStyle(.secondary.opacity(0.6))
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: isUser ? .trailing : .leading)
+ }
+
+ if !isUser { Spacer(minLength: 40) }
+ }
+ }
+}
diff --git a/sdks/community/swift/Examples/ChatApp/Sources/Views/RootView.swift b/sdks/community/swift/Examples/ChatApp/Sources/Views/RootView.swift
new file mode 100644
index 0000000000..6ef87ba594
--- /dev/null
+++ b/sdks/community/swift/Examples/ChatApp/Sources/Views/RootView.swift
@@ -0,0 +1,139 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import SwiftUI
+
+struct RootView: View {
+ @EnvironmentObject private var store: ChatAppStore
+ @Environment(\.horizontalSizeClass) private var horizontalSizeClass
+
+ var body: some View {
+ Group {
+ if horizontalSizeClass == .regular {
+ regularLayout
+ } else {
+ compactLayout
+ }
+ }
+ // Agent form sheet
+ .sheet(item: Binding(
+ get: { store.formMode.map(FormSheetItem.init) },
+ set: { newVal in if newVal == nil { store.dismissAgentForm() } }
+ )) { item in
+ AgentFormView(mode: item.mode)
+ .environmentObject(store)
+ }
+ // Phase 5: ClawgUI enterprise pairing sheet.
+ // Presented whenever pairingState != .idle; dismissed on success or user cancel.
+ .sheet(isPresented: Binding(
+ get: { store.state.clawgUIPairingState != .idle },
+ set: { if !$0 { store.resetPairing() } }
+ )) {
+ ClawgUIPairingView(
+ state: store.state.clawgUIPairingState,
+ onConfirm: { store.confirmPairing() },
+ onRetry: { store.retryPairing() },
+ onDismiss: { store.resetPairing() }
+ )
+ }
+ // Repository error alert
+ .alert(
+ "Error",
+ isPresented: Binding(
+ get: { store.repositoryError != nil },
+ set: { _ in store.repositoryError = nil }
+ )
+ ) {
+ Button("OK", role: .cancel) { store.repositoryError = nil }
+ } message: {
+ Text(store.repositoryError ?? "")
+ }
+ // Conversation error alert
+ .alert(
+ "Conversation Error",
+ isPresented: Binding(
+ get: { store.state.error != nil },
+ set: { _ in store.dismissError() }
+ )
+ ) {
+ Button("OK", role: .cancel) { store.dismissError() }
+ } message: {
+ Text(store.state.error ?? "")
+ }
+ }
+
+ // MARK: - Layout variants
+
+ private var regularLayout: some View {
+ NavigationSplitView {
+ AgentListView(
+ agents: store.agents,
+ selectedId: store.selectedAgentId,
+ onSelect: { store.setActiveAgent(id: $0) },
+ onAdd: store.presentCreateAgent,
+ onEdit: store.presentEditAgent,
+ onDelete: { store.deleteAgent(id: $0) }
+ )
+ .frame(minWidth: 280)
+ } detail: {
+ ChatView(state: store.state) { store.sendMessage($0) }
+ .navigationTitle(store.state.activeAgent?.name ?? "Chat")
+ .toolbar {
+ ToolbarItem(placement: .navigationBarTrailing) {
+ Button(action: store.presentCreateAgent) {
+ Label("Add Agent", systemImage: "plus")
+ }
+ }
+ }
+ }
+ }
+
+ private var compactLayout: some View {
+ NavigationStack {
+ ChatView(state: store.state) { store.sendMessage($0) }
+ .navigationTitle(store.state.activeAgent?.name ?? "Chat")
+ .toolbar {
+ ToolbarItem(placement: .navigationBarLeading) {
+ Menu {
+ ForEach(store.agents) { agent in
+ Button {
+ store.setActiveAgent(id: agent.id)
+ } label: {
+ Label(
+ agent.name,
+ systemImage: store.selectedAgentId == agent.id
+ ? "checkmark.circle.fill" : "circle"
+ )
+ }
+ }
+ Divider()
+ Button(action: store.presentCreateAgent) {
+ Label("New Agent", systemImage: "plus.circle")
+ }
+ } label: {
+ Label("Agents", systemImage: "person.3.sequence")
+ }
+ }
+ ToolbarItem(placement: .navigationBarTrailing) {
+ Button(action: store.presentCreateAgent) {
+ Label("Add Agent", systemImage: "plus")
+ }
+ }
+ }
+ }
+ }
+}
+
+// MARK: - Helpers
+
+private struct FormSheetItem: Identifiable, Equatable {
+ let mode: AgentFormMode
+
+ var id: String {
+ switch mode {
+ case .create: return "create"
+ case .edit(let c): return "edit-\(c.id)"
+ }
+ }
+
+ static func == (lhs: FormSheetItem, rhs: FormSheetItem) -> Bool { lhs.id == rhs.id }
+}
diff --git a/sdks/community/swift/Examples/ChatApp/Sources/Views/StreamingCursorView.swift b/sdks/community/swift/Examples/ChatApp/Sources/Views/StreamingCursorView.swift
new file mode 100644
index 0000000000..907ede90da
--- /dev/null
+++ b/sdks/community/swift/Examples/ChatApp/Sources/Views/StreamingCursorView.swift
@@ -0,0 +1,43 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import SwiftUI
+
+// MARK: - StreamingCursorView
+
+/// A blinking vertical bar appended to a streaming message bubble.
+///
+/// Signals that the agent's response is still arriving. Fades out smoothly
+/// when the message's `isStreaming` flag transitions to `false`.
+struct StreamingCursorView: View {
+
+ // MARK: - State
+
+ @State private var visible: Bool = true
+
+ // MARK: - Body
+
+ var body: some View {
+ RoundedRectangle(cornerRadius: 1.5)
+ .fill(Color.secondary.opacity(0.7))
+ .frame(width: 2, height: 14)
+ .opacity(visible ? 1 : 0)
+ .onAppear {
+ withAnimation(
+ .easeInOut(duration: 0.52)
+ .repeatForever(autoreverses: true)
+ ) {
+ visible = false
+ }
+ }
+ }
+}
+
+// MARK: - Previews
+
+#Preview {
+ HStack {
+ Text("Streaming text")
+ StreamingCursorView()
+ }
+ .padding()
+}
diff --git a/sdks/community/swift/Examples/ChatApp/Sources/Views/TypingDotsView.swift b/sdks/community/swift/Examples/ChatApp/Sources/Views/TypingDotsView.swift
new file mode 100644
index 0000000000..47dc5c2b94
--- /dev/null
+++ b/sdks/community/swift/Examples/ChatApp/Sources/Views/TypingDotsView.swift
@@ -0,0 +1,59 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import SwiftUI
+
+// MARK: - TypingDotsView
+
+/// Three animated dots shown while the agent is composing its first token.
+///
+/// Each dot bounces vertically with a staggered delay, matching the typing
+/// indicator style common in modern chat applications.
+struct TypingDotsView: View {
+
+ // MARK: - State
+
+ @State private var phase: Bool = false
+
+ // MARK: - Body
+
+ var body: some View {
+ HStack(spacing: 5) {
+ dot(delay: 0.00)
+ dot(delay: 0.18)
+ dot(delay: 0.36)
+ }
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel("Agent is typing")
+ .onAppear {
+ withAnimation(
+ .easeInOut(duration: 0.54)
+ .repeatForever(autoreverses: true)
+ ) {
+ phase = true
+ }
+ }
+ }
+
+ // MARK: - Private
+
+ private func dot(delay: Double) -> some View {
+ Circle()
+ .fill(Color.secondary.opacity(0.6))
+ .frame(width: 8, height: 8)
+ .scaleEffect(phase ? 1.0 : 0.5)
+ .offset(y: phase ? -4 : 0)
+ .animation(
+ .easeInOut(duration: 0.54)
+ .repeatForever(autoreverses: true)
+ .delay(delay),
+ value: phase
+ )
+ }
+}
+
+// MARK: - Previews
+
+#Preview {
+ TypingDotsView()
+ .padding()
+}
diff --git a/sdks/community/swift/Examples/ChatApp/Tests/ChatAppTests/A2UIComponentTests.swift b/sdks/community/swift/Examples/ChatApp/Tests/ChatAppTests/A2UIComponentTests.swift
new file mode 100644
index 0000000000..19c8fbaccc
--- /dev/null
+++ b/sdks/community/swift/Examples/ChatApp/Tests/ChatAppTests/A2UIComponentTests.swift
@@ -0,0 +1,158 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import ChatApp
+
+// MARK: - Phase 4: A2UIComponent Decoding Tests
+
+final class A2UIComponentTests: XCTestCase {
+
+ // MARK: - Helpers
+
+ private func decode(_ json: String) throws -> A2UIComponent {
+ let data = json.data(using: .utf8)!
+ return try JSONDecoder().decode(A2UIComponent.self, from: data)
+ }
+
+ // MARK: - Tests
+
+ func test_decodesButton() throws {
+ let json = """
+ {"type": "button", "label": "Click me", "actionId": "submit"}
+ """
+ let component = try decode(json)
+ guard case .button(let label, let actionId) = component else {
+ XCTFail("Expected .button, got \(component)")
+ return
+ }
+ XCTAssertEqual(label, "Click me")
+ XCTAssertEqual(actionId, "submit")
+ }
+
+ func test_decodesText() throws {
+ let json = """
+ {"type": "text", "content": "Hello World"}
+ """
+ let component = try decode(json)
+ guard case .text(let content, _) = component else {
+ XCTFail("Expected .text, got \(component)")
+ return
+ }
+ XCTAssertEqual(content, "Hello World")
+ }
+
+ func test_decodesVStack_withChildren() throws {
+ let json = """
+ {"type": "vStack", "children": [
+ {"type": "text", "content": "Child 1"},
+ {"type": "divider"}
+ ]}
+ """
+ let component = try decode(json)
+ guard case .vStack(let children) = component else {
+ XCTFail("Expected .vStack, got \(component)")
+ return
+ }
+ XCTAssertEqual(children.count, 2)
+ if case .text(let c, _) = children[0] { XCTAssertEqual(c, "Child 1") } else { XCTFail() }
+ if case .divider = children[1] { /* pass */ } else { XCTFail() }
+ }
+
+ func test_decodesMarkdown() throws {
+ let json = """
+ {"type": "markdown", "content": "**Bold text**"}
+ """
+ let component = try decode(json)
+ guard case .markdown(let content) = component else {
+ XCTFail("Expected .markdown, got \(component)")
+ return
+ }
+ XCTAssertEqual(content, "**Bold text**")
+ }
+
+ func test_decodesProgress() throws {
+ let json = """
+ {"type": "progress", "value": 0.75, "total": 1.0}
+ """
+ let component = try decode(json)
+ guard case .progress(let value, let total) = component else {
+ XCTFail("Expected .progress, got \(component)")
+ return
+ }
+ XCTAssertEqual(value, 0.75, accuracy: 0.001)
+ XCTAssertEqual(total, 1.0, accuracy: 0.001)
+ }
+
+ func test_decodesTable() throws {
+ let json = """
+ {"type": "table", "headers": ["Name", "Score"], "rows": [["Alice", "100"], ["Bob", "95"]]}
+ """
+ let component = try decode(json)
+ guard case .table(let headers, let rows) = component else {
+ XCTFail("Expected .table, got \(component)")
+ return
+ }
+ XCTAssertEqual(headers, ["Name", "Score"])
+ XCTAssertEqual(rows.count, 2)
+ XCTAssertEqual(rows[0], ["Alice", "100"])
+ }
+
+ func test_unknownType_doesNotThrow() throws {
+ let json = """
+ {"type": "unknown_future_type_v99", "someField": "someValue"}
+ """
+ let component = try decode(json)
+ guard case .unknown = component else {
+ XCTFail("Expected .unknown, got \(component)")
+ return
+ }
+ }
+
+ func test_missingType_fallsBackToUnknown() throws {
+ let json = """
+ {"someField": "someValue"}
+ """
+ let component = try decode(json)
+ guard case .unknown = component else {
+ XCTFail("Expected .unknown for missing type, got \(component)")
+ return
+ }
+ }
+
+ func test_decodesDivider() throws {
+ let json = """
+ {"type": "divider"}
+ """
+ let component = try decode(json)
+ guard case .divider = component else {
+ XCTFail("Expected .divider, got \(component)")
+ return
+ }
+ }
+
+ func test_decodesCard_withTitleAndChildren() throws {
+ let json = """
+ {"type": "card", "title": "My Card", "children": [{"type": "text", "content": "Body"}]}
+ """
+ let component = try decode(json)
+ guard case .card(let title, let children) = component else {
+ XCTFail("Expected .card, got \(component)")
+ return
+ }
+ XCTAssertEqual(title, "My Card")
+ XCTAssertEqual(children.count, 1)
+ }
+
+ func test_decodesBadge() throws {
+ let json = """
+ {"type": "badge", "label": "NEW", "color": "FF0000"}
+ """
+ let component = try decode(json)
+ guard case .badge(let label, let color) = component else {
+ XCTFail("Expected .badge, got \(component)")
+ return
+ }
+ XCTAssertEqual(label, "NEW")
+ XCTAssertEqual(color, "FF0000")
+ }
+}
diff --git a/sdks/community/swift/Examples/ChatApp/Tests/ChatAppTests/A2UISurfaceStateManagerTests.swift b/sdks/community/swift/Examples/ChatApp/Tests/ChatAppTests/A2UISurfaceStateManagerTests.swift
new file mode 100644
index 0000000000..5456eaa61d
--- /dev/null
+++ b/sdks/community/swift/Examples/ChatApp/Tests/ChatAppTests/A2UISurfaceStateManagerTests.swift
@@ -0,0 +1,115 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUICore
+import XCTest
+@testable import ChatApp
+
+// MARK: - Phase 4: A2UISurfaceStateManager Tests
+
+@MainActor
+final class A2UISurfaceStateManagerTests: XCTestCase {
+
+ // MARK: - Helpers
+
+ private func jsonData(_ dict: [String: Any]) -> Data {
+ try! JSONSerialization.data(withJSONObject: dict)
+ }
+
+ private func makeSnapshot(messageId: String, content: [String: Any]) -> ActivitySnapshotEvent {
+ ActivitySnapshotEvent(
+ messageId: messageId,
+ activityType: "a2ui-surface",
+ content: jsonData(content)
+ )
+ }
+
+ private func makeDelta(messageId: String, operations: [[String: Any]]) -> ActivityDeltaEvent {
+ ActivityDeltaEvent(
+ messageId: messageId,
+ activityType: "a2ui-surface",
+ patch: try! JSONSerialization.data(withJSONObject: operations)
+ )
+ }
+
+ // MARK: - Tests
+
+ func test_applySnapshot_storesSurfaceData() {
+ let manager = A2UISurfaceStateManager()
+ let event = makeSnapshot(messageId: "m1", content: ["type": "text", "content": "Hello"])
+
+ manager.applySnapshot(event)
+
+ XCTAssertNotNil(manager.surfaceData(for: "m1"))
+ }
+
+ func test_applySnapshot_storesCorrectContent() throws {
+ let manager = A2UISurfaceStateManager()
+ let event = makeSnapshot(messageId: "m1", content: ["type": "text", "content": "Hello"])
+
+ manager.applySnapshot(event)
+
+ let stored = try XCTUnwrap(manager.surfaceData(for: "m1"))
+ let dict = try JSONSerialization.jsonObject(with: stored) as! [String: Any]
+ XCTAssertEqual(dict["type"] as? String, "text")
+ XCTAssertEqual(dict["content"] as? String, "Hello")
+ }
+
+ func test_applyDelta_patchesCorrectly() throws {
+ let manager = A2UISurfaceStateManager()
+ manager.applySnapshot(makeSnapshot(messageId: "m1", content: ["type": "text", "content": "Hello"]))
+
+ let delta = makeDelta(
+ messageId: "m1",
+ operations: [["op": "replace", "path": "/content", "value": "World"]]
+ )
+ let result = try manager.applyDelta(delta)
+
+ let dict = try JSONSerialization.jsonObject(with: result) as! [String: Any]
+ XCTAssertEqual(dict["content"] as? String, "World")
+ }
+
+ func test_applyDelta_updatesStoredState() throws {
+ let manager = A2UISurfaceStateManager()
+ manager.applySnapshot(makeSnapshot(messageId: "m1", content: ["type": "text", "content": "Hello"]))
+
+ let delta = makeDelta(
+ messageId: "m1",
+ operations: [["op": "replace", "path": "/content", "value": "Updated"]]
+ )
+ _ = try manager.applyDelta(delta)
+
+ let stored = try XCTUnwrap(manager.surfaceData(for: "m1"))
+ let dict = try JSONSerialization.jsonObject(with: stored) as! [String: Any]
+ XCTAssertEqual(dict["content"] as? String, "Updated")
+ }
+
+ func test_applyDelta_throwsWhenNoSnapshot() {
+ let manager = A2UISurfaceStateManager()
+ let delta = makeDelta(
+ messageId: "nonexistent",
+ operations: [["op": "replace", "path": "/content", "value": "World"]]
+ )
+
+ XCTAssertThrowsError(try manager.applyDelta(delta))
+ }
+
+ func test_reset_clearsAllSurfaces() {
+ let manager = A2UISurfaceStateManager()
+ manager.applySnapshot(makeSnapshot(messageId: "m1", content: ["type": "text", "content": "Hello"]))
+ manager.applySnapshot(makeSnapshot(messageId: "m2", content: ["type": "divider"]))
+
+ manager.reset()
+
+ XCTAssertNil(manager.surfaceData(for: "m1"))
+ XCTAssertNil(manager.surfaceData(for: "m2"))
+ }
+
+ func test_multipleSurfaces_coexist() {
+ let manager = A2UISurfaceStateManager()
+ manager.applySnapshot(makeSnapshot(messageId: "s1", content: ["type": "text", "content": "Surface 1"]))
+ manager.applySnapshot(makeSnapshot(messageId: "s2", content: ["type": "button", "label": "OK"]))
+
+ XCTAssertNotNil(manager.surfaceData(for: "s1"))
+ XCTAssertNotNil(manager.surfaceData(for: "s2"))
+ }
+}
diff --git a/sdks/community/swift/Examples/ChatApp/Tests/ChatAppTests/ChangeBackgroundToolExecutorTests.swift b/sdks/community/swift/Examples/ChatApp/Tests/ChatAppTests/ChangeBackgroundToolExecutorTests.swift
new file mode 100644
index 0000000000..91bea0c478
--- /dev/null
+++ b/sdks/community/swift/Examples/ChatApp/Tests/ChatAppTests/ChangeBackgroundToolExecutorTests.swift
@@ -0,0 +1,126 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUICore
+import AGUITools
+import XCTest
+@testable import ChatApp
+
+// MARK: - ChangeBackgroundToolExecutorTests
+
+@MainActor
+final class ChangeBackgroundToolExecutorTests: XCTestCase {
+
+ // MARK: - Helpers
+
+ /// Thread-safe capture box for async callbacks.
+ private final class StringCapture: @unchecked Sendable {
+ var value: String?
+ }
+
+ private func makeContext(arguments: String) -> ToolExecutionContext {
+ ToolExecutionContext(
+ toolCall: ToolCall(
+ id: "tc-test",
+ function: FunctionCall(name: "change_background", arguments: arguments)
+ )
+ )
+ }
+
+ // MARK: - Execute
+
+ func test_execute_colorKey_callsCallbackWithHex() async throws {
+ let capture = StringCapture()
+ let executor = ChangeBackgroundToolExecutor { hex in capture.value = hex }
+
+ let result = try await executor.execute(context: makeContext(arguments: "{\"color\":\"#FF5733\"}"))
+
+ XCTAssertTrue(result.success)
+ XCTAssertEqual(capture.value, "#FF5733")
+ }
+
+ func test_execute_hexKey_callsCallbackWithHex() async throws {
+ let capture = StringCapture()
+ let executor = ChangeBackgroundToolExecutor { hex in capture.value = hex }
+
+ let result = try await executor.execute(context: makeContext(arguments: "{\"hex\":\"#AABBCC\"}"))
+
+ XCTAssertTrue(result.success)
+ XCTAssertEqual(capture.value, "#AABBCC")
+ }
+
+ func test_execute_missingColorKey_throws() async {
+ let executor = ChangeBackgroundToolExecutor { _ in }
+
+ do {
+ _ = try await executor.execute(context: makeContext(arguments: "{}"))
+ XCTFail("Expected an error to be thrown")
+ } catch {
+ // Expected
+ }
+ }
+
+ // MARK: - Validate
+
+ func test_validate_validHex6Digits_returnsValid() {
+ let executor = ChangeBackgroundToolExecutor { _ in }
+ let result = executor.validate(toolCall: ToolCall(
+ id: "tc1",
+ function: FunctionCall(name: "change_background", arguments: "{\"color\":\"#FF5733\"}")
+ ))
+ XCTAssertTrue(result.isValid)
+ }
+
+ func test_validate_validHex8Digits_returnsValid() {
+ let executor = ChangeBackgroundToolExecutor { _ in }
+ let result = executor.validate(toolCall: ToolCall(
+ id: "tc1",
+ function: FunctionCall(name: "change_background", arguments: "{\"color\":\"#FF5733AA\"}")
+ ))
+ XCTAssertTrue(result.isValid)
+ }
+
+ func test_validate_invalidHex_returnsInvalid() {
+ let executor = ChangeBackgroundToolExecutor { _ in }
+ let result = executor.validate(toolCall: ToolCall(
+ id: "tc1",
+ function: FunctionCall(name: "change_background", arguments: "{\"color\":\"not-a-hex\"}")
+ ))
+ XCTAssertFalse(result.isValid)
+ XCTAssertFalse(result.errors.isEmpty)
+ }
+
+ func test_validate_missingColorKey_returnsInvalid() {
+ let executor = ChangeBackgroundToolExecutor { _ in }
+ let result = executor.validate(toolCall: ToolCall(
+ id: "tc1",
+ function: FunctionCall(name: "change_background", arguments: "{}")
+ ))
+ XCTAssertFalse(result.isValid)
+ }
+
+ func test_validate_hexKeyAccepted() {
+ let executor = ChangeBackgroundToolExecutor { _ in }
+ let result = executor.validate(toolCall: ToolCall(
+ id: "tc1",
+ function: FunctionCall(name: "change_background", arguments: "{\"hex\":\"#AABBCC\"}")
+ ))
+ XCTAssertTrue(result.isValid)
+ }
+
+ // MARK: - Registry
+
+ func test_toolRegistry_registersChangeBackground() async throws {
+ let registry = try await ChatAppToolRegistry.makeRegistry { _ in }
+
+ let isRegistered = await registry.isToolRegistered(toolName: "change_background")
+ XCTAssertTrue(isRegistered)
+ }
+
+ func test_toolRegistry_hasCorrectToolName() async throws {
+ let registry = try await ChatAppToolRegistry.makeRegistry { _ in }
+
+ let tools = await registry.allTools()
+ XCTAssertEqual(tools.count, 1)
+ XCTAssertEqual(tools.first?.name, "change_background")
+ }
+}
diff --git a/sdks/community/swift/Examples/ChatApp/Tests/ChatAppTests/ChatAppStoreTests.swift b/sdks/community/swift/Examples/ChatApp/Tests/ChatAppTests/ChatAppStoreTests.swift
new file mode 100644
index 0000000000..da975e1742
--- /dev/null
+++ b/sdks/community/swift/Examples/ChatApp/Tests/ChatAppTests/ChatAppStoreTests.swift
@@ -0,0 +1,521 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUICore
+import XCTest
+@testable import ChatApp
+
+@MainActor
+final class ChatAppStoreTests: XCTestCase {
+
+ // MARK: - Helpers
+
+ private func makeStore() -> ChatAppStore {
+ // Use an isolated UserDefaults suite so tests don't share persisted state.
+ let suite = UUID().uuidString
+ let defaults = UserDefaults(suiteName: suite)!
+ return ChatAppStore(defaults: defaults)
+ }
+
+ private func testConfig() -> AgentConfig {
+ AgentConfig(name: "TestAgent", url: "https://test.local")
+ }
+
+ // MARK: - Text message streaming
+
+ func test_streamingMessageReconstruction() {
+ let store = makeStore()
+ store.setupForTesting(agent: testConfig())
+
+ store.processEvent(TextMessageStartEvent(messageId: "msg1", role: "assistant"))
+ store.processEvent(TextMessageContentEvent(messageId: "msg1", delta: "Hello"))
+ store.processEvent(TextMessageContentEvent(messageId: "msg1", delta: ", world"))
+ store.processEvent(TextMessageEndEvent(messageId: "msg1"))
+
+ XCTAssertEqual(store.state.messages.count, 1)
+ let msg = store.state.messages[0]
+ XCTAssertEqual(msg.content, "Hello, world")
+ XCTAssertFalse(msg.isStreaming)
+ XCTAssertEqual(msg.role, .assistant)
+ }
+
+ func test_multipleStreamingMessages() {
+ let store = makeStore()
+ store.setupForTesting(agent: testConfig())
+
+ store.processEvent(TextMessageStartEvent(messageId: "a", role: "assistant"))
+ store.processEvent(TextMessageContentEvent(messageId: "a", delta: "First"))
+ store.processEvent(TextMessageEndEvent(messageId: "a"))
+
+ store.processEvent(TextMessageStartEvent(messageId: "b", role: "assistant"))
+ store.processEvent(TextMessageContentEvent(messageId: "b", delta: "Second"))
+ store.processEvent(TextMessageEndEvent(messageId: "b"))
+
+ XCTAssertEqual(store.state.messages.count, 2)
+ XCTAssertEqual(store.state.messages[0].content, "First")
+ XCTAssertEqual(store.state.messages[1].content, "Second")
+ }
+
+ func test_streamingIndicator_clears_onEnd() {
+ let store = makeStore()
+ store.setupForTesting(agent: testConfig())
+
+ store.processEvent(TextMessageStartEvent(messageId: "m1", role: "assistant"))
+ XCTAssertTrue(store.state.messages.last?.isStreaming == true)
+
+ store.processEvent(TextMessageEndEvent(messageId: "m1"))
+ XCTAssertFalse(store.state.messages.last?.isStreaming == true)
+ }
+
+ // MARK: - Phase 1A: Tool call ephemeral banner (.toolCall slot)
+
+ func test_toolCallStart_setsToolCallEphemeralSlot() {
+ let store = makeStore()
+ store.setupForTesting(agent: testConfig())
+
+ store.processEvent(ToolCallStartEvent(toolCallId: "tc1", toolCallName: "web_search"))
+
+ let banner = store.state.ephemeralSlots[.toolCall]
+ XCTAssertNotNil(banner)
+ XCTAssertEqual(banner?.content, "Calling web_search…")
+ if case .toolCall(let name) = banner?.role {
+ XCTAssertEqual(name, "web_search")
+ } else {
+ XCTFail("Expected .toolCall role")
+ }
+ }
+
+ func test_toolCallEnd_schedulesEphemeralDismissal() async throws {
+ let store = makeStore()
+ store.setupForTesting(agent: testConfig())
+ // Inject a short delay so the test doesn't wait the production 1 second.
+ store.ephemeralDismissDelayOverrides[.toolCall] = .milliseconds(50)
+
+ store.processEvent(ToolCallStartEvent(toolCallId: "tc1", toolCallName: "search"))
+ XCTAssertNotNil(store.state.ephemeralSlots[.toolCall])
+
+ store.processEvent(ToolCallEndEvent(toolCallId: "tc1"))
+
+ // Wait long enough for the injected 50ms delay to fire.
+ try await Task.sleep(for: .milliseconds(200))
+ XCTAssertNil(store.state.ephemeralSlots[.toolCall])
+ }
+
+ // MARK: - Phase 1A: Step ephemeral banner (.step slot)
+
+ func test_stepStarted_setsStepEphemeralSlot() {
+ let store = makeStore()
+ store.setupForTesting(agent: testConfig())
+
+ store.processEvent(StepStartedEvent(stepName: "Reasoning"))
+
+ let banner = store.state.ephemeralSlots[.step]
+ XCTAssertNotNil(banner)
+ XCTAssertEqual(banner?.content, "Reasoning")
+ if case .stepInfo(let name) = banner?.role {
+ XCTAssertEqual(name, "Reasoning")
+ } else {
+ XCTFail("Expected .stepInfo role")
+ }
+ }
+
+ func test_stepFinished_clearsStepSlotImmediately() {
+ let store = makeStore()
+ store.setupForTesting(agent: testConfig())
+
+ store.processEvent(StepStartedEvent(stepName: "Reasoning"))
+ XCTAssertNotNil(store.state.ephemeralSlots[.step])
+
+ store.processEvent(StepFinishedEvent(stepName: "Reasoning"))
+
+ XCTAssertNil(store.state.ephemeralSlots[.step])
+ }
+
+ func test_stepFinished_doesNotClearToolCallSlot() {
+ let store = makeStore()
+ store.setupForTesting(agent: testConfig())
+
+ store.processEvent(ToolCallStartEvent(toolCallId: "tc1", toolCallName: "search"))
+ store.processEvent(StepStartedEvent(stepName: "Reasoning"))
+ store.processEvent(StepFinishedEvent(stepName: "Reasoning"))
+
+ // .toolCall slot must survive .step dismissal
+ XCTAssertNotNil(store.state.ephemeralSlots[.toolCall])
+ XCTAssertNil(store.state.ephemeralSlots[.step])
+ }
+
+ func test_bothEphemeralSlotsCoexist() {
+ let store = makeStore()
+ store.setupForTesting(agent: testConfig())
+
+ store.processEvent(StepStartedEvent(stepName: "Reasoning"))
+ store.processEvent(ToolCallStartEvent(toolCallId: "tc1", toolCallName: "search"))
+
+ XCTAssertNotNil(store.state.ephemeralSlots[.step])
+ XCTAssertNotNil(store.state.ephemeralSlots[.toolCall])
+ XCTAssertEqual(store.state.ephemeralSlots.count, 2)
+ }
+
+ func test_newToolCall_replacesExistingToolCallSlot() {
+ let store = makeStore()
+ store.setupForTesting(agent: testConfig())
+
+ store.processEvent(ToolCallStartEvent(toolCallId: "tc1", toolCallName: "search"))
+ store.processEvent(ToolCallStartEvent(toolCallId: "tc2", toolCallName: "calculator"))
+
+ let banner = store.state.ephemeralSlots[.toolCall]
+ // The second call replaces the first in the slot
+ XCTAssertEqual(banner?.content, "Calling calculator…")
+ }
+
+ // MARK: - Run error
+
+ func test_runError_setsErrorState() {
+ let store = makeStore()
+ store.setupForTesting(agent: testConfig())
+
+ store.processEvent(RunErrorEvent(message: "Request timed out", code: "TIMEOUT"))
+
+ XCTAssertEqual(store.state.error, "Request timed out")
+ }
+
+ func test_dismissError_clearsError() {
+ let store = makeStore()
+ store.setupForTesting(agent: testConfig())
+ store.processEvent(RunErrorEvent(message: "boom", code: "ERR"))
+
+ store.dismissError()
+
+ XCTAssertNil(store.state.error)
+ }
+
+ // MARK: - Phase 1B: Supplemental messages
+
+ func test_buildAgent_appendsConnectionSupplemental() {
+ let store = makeStore()
+ store.presentCreateAgent()
+ store.draft.name = "TestAgent"
+ store.draft.url = "https://test.local"
+ store.saveAgent()
+
+ XCTAssertFalse(store.state.supplementalMessages.isEmpty)
+ if case .connection(let name) = store.state.supplementalMessages.first?.kind {
+ XCTAssertEqual(name, "TestAgent")
+ } else {
+ XCTFail("Expected .connection supplemental message")
+ }
+ }
+
+ func test_runError_appendsInlineError() {
+ let store = makeStore()
+ store.setupForTesting(agent: testConfig())
+ let initialCount = store.state.supplementalMessages.count
+
+ store.processEvent(RunErrorEvent(message: "Network failed", code: "ERR"))
+
+ XCTAssertEqual(store.state.supplementalMessages.count, initialCount + 1)
+ if case .error(let msg) = store.state.supplementalMessages.last?.kind {
+ XCTAssertEqual(msg, "Network failed")
+ } else {
+ XCTFail("Expected .error supplemental message")
+ }
+ }
+
+ func test_chatRows_includesAgentMessages() {
+ let store = makeStore()
+ store.setupForTesting(agent: testConfig())
+
+ store.processEvent(TextMessageStartEvent(messageId: "m1", role: "assistant"))
+ store.processEvent(TextMessageEndEvent(messageId: "m1"))
+
+ let rows = store.state.chatRows
+ XCTAssertFalse(rows.isEmpty)
+ let hasAgentRow = rows.contains { if case .agent = $0 { return true }; return false }
+ XCTAssertTrue(hasAgentRow)
+ }
+
+ func test_chatRows_mergesAgentAndSupplementalByTimestamp() {
+ let store = makeStore()
+ store.setupForTesting(agent: testConfig())
+
+ // Add a supplemental message
+ store.processEvent(RunErrorEvent(message: "Oops", code: "ERR"))
+ // Add an agent message after
+ store.processEvent(TextMessageStartEvent(messageId: "m1", role: "assistant"))
+ store.processEvent(TextMessageEndEvent(messageId: "m1"))
+
+ let rows = store.state.chatRows
+ // Both rows should be present
+ let agentRows = rows.filter { if case .agent = $0 { return true }; return false }
+ let suppRows = rows.filter { if case .supplemental = $0 { return true }; return false }
+ XCTAssertEqual(agentRows.count, 1)
+ XCTAssertEqual(suppRows.count, 1)
+ }
+
+ // MARK: - Background hex (custom event)
+
+ func test_changeBackgroundCustomEvent_setsHex() throws {
+ let store = makeStore()
+ store.setupForTesting(agent: testConfig())
+
+ let payload = try JSONSerialization.data(withJSONObject: ["hex": "FF5733"])
+ store.processEvent(CustomEvent(name: "change_background", value: payload))
+
+ XCTAssertEqual(store.state.backgroundHex, "FF5733")
+ }
+
+ // MARK: - Phase 1C: Optimistic user messages
+
+ func test_displayMessage_isSendingDefaultsFalse() {
+ let msg = DisplayMessage(role: .user, content: "Hello")
+ XCTAssertFalse(msg.isSending)
+ }
+
+ func test_displayMessage_isSendingCanBeSet() {
+ let msg = DisplayMessage(role: .user, content: "Hello", isSending: true)
+ XCTAssertTrue(msg.isSending)
+ }
+
+ func test_injectPendingMessage_appearsInMessages() {
+ let store = makeStore()
+ store.setupForTesting(agent: testConfig())
+
+ store.injectPendingMessageForTesting(content: "Hello!")
+
+ let pending = store.state.messages.last
+ XCTAssertNotNil(pending)
+ XCTAssertTrue(pending?.isSending == true)
+ XCTAssertEqual(pending?.content, "Hello!")
+ XCTAssertEqual(pending?.role, .user)
+ }
+
+ func test_messagesSnapshot_correlatesAndClearsPending() throws {
+ let store = makeStore()
+ store.setupForTesting(agent: testConfig())
+ store.injectPendingMessageForTesting(content: "Hello!")
+
+ XCTAssertNotNil(store.pendingUserMessageId)
+
+ // Simulate the agent echoing the message in a snapshot
+ let snapshotData = try JSONSerialization.data(withJSONObject: [
+ ["id": "server-msg-1", "role": "user", "content": "Hello!"],
+ ])
+ store.processEvent(MessagesSnapshotEvent(messages: snapshotData))
+
+ // Pending should be cleared once the content is found in the snapshot
+ XCTAssertNil(store.pendingUserMessageId)
+ XCTAssertFalse(store.state.messages.isEmpty)
+ }
+
+ func test_messagesSnapshot_reinjectsPendingIfNotFound() throws {
+ let store = makeStore()
+ store.setupForTesting(agent: testConfig())
+ store.injectPendingMessageForTesting(content: "Pending message")
+
+ // Snapshot that does NOT include the pending message
+ let snapshotData = try JSONSerialization.data(withJSONObject: [
+ ["id": "server-msg-1", "role": "assistant", "content": "I am processing"],
+ ])
+ store.processEvent(MessagesSnapshotEvent(messages: snapshotData))
+
+ // Pending must still be in the message list
+ XCTAssertNotNil(store.pendingUserMessageId)
+ let pendingMsg = store.state.messages.first { $0.role == .user }
+ XCTAssertNotNil(pendingMsg)
+ XCTAssertTrue(pendingMsg?.isSending == true)
+ }
+
+ // MARK: - Phase 2A: ToolCallArgsEvent — args preview in ephemeral slot
+
+ func test_toolCallArgs_updatesEphemeralContent() {
+ let store = makeStore()
+ store.setupForTesting(agent: testConfig())
+
+ store.processEvent(ToolCallStartEvent(toolCallId: "tc1", toolCallName: "search"))
+ store.processEvent(ToolCallArgsEvent(toolCallId: "tc1", delta: "{\"query\":\"swift\"}"))
+
+ let banner = store.state.ephemeralSlots[.toolCall]
+ XCTAssertNotNil(banner)
+ XCTAssertEqual(banner?.content, "{\"query\":\"swift\"}")
+ }
+
+ func test_toolCallArgs_multipleDeltas_concatenate() {
+ let store = makeStore()
+ store.setupForTesting(agent: testConfig())
+
+ store.processEvent(ToolCallStartEvent(toolCallId: "tc1", toolCallName: "search"))
+ store.processEvent(ToolCallArgsEvent(toolCallId: "tc1", delta: "{\"query\":"))
+ store.processEvent(ToolCallArgsEvent(toolCallId: "tc1", delta: "\"swift\""))
+ store.processEvent(ToolCallArgsEvent(toolCallId: "tc1", delta: "}"))
+
+ let banner = store.state.ephemeralSlots[.toolCall]
+ XCTAssertEqual(banner?.content, "{\"query\":\"swift\"}")
+ }
+
+ func test_toolCallArgs_truncatesAt80Chars() {
+ let store = makeStore()
+ store.setupForTesting(agent: testConfig())
+
+ store.processEvent(ToolCallStartEvent(toolCallId: "tc1", toolCallName: "search"))
+ let longArgs = String(repeating: "x", count: 90)
+ store.processEvent(ToolCallArgsEvent(toolCallId: "tc1", delta: longArgs))
+
+ let banner = store.state.ephemeralSlots[.toolCall]
+ XCTAssertEqual(banner?.content, String(repeating: "x", count: 80) + "…")
+ }
+
+ func test_toolCallEnd_clearsArgBuffer() {
+ let store = makeStore()
+ store.setupForTesting(agent: testConfig())
+
+ store.processEvent(ToolCallStartEvent(toolCallId: "tc1", toolCallName: "search"))
+ store.processEvent(ToolCallArgsEvent(toolCallId: "tc1", delta: "{\"query\":\"test\"}"))
+
+ XCTAssertNotNil(store.toolCallArgBuffer["tc1"])
+
+ store.processEvent(ToolCallEndEvent(toolCallId: "tc1"))
+
+ XCTAssertNil(store.toolCallArgBuffer["tc1"])
+ }
+
+ // MARK: - Phase 3A: DisplayMessage animation state helpers
+
+ func test_showsTypingIndicator_trueWhenStreamingWithEmptyContent() {
+ let msg = DisplayMessage(role: .assistant, content: "", isStreaming: true)
+ XCTAssertTrue(msg.showsTypingIndicator)
+ }
+
+ func test_showsTypingIndicator_falseWhenNotStreaming() {
+ let msg = DisplayMessage(role: .assistant, content: "", isStreaming: false)
+ XCTAssertFalse(msg.showsTypingIndicator)
+ }
+
+ func test_showsTypingIndicator_falseWhenStreamingWithContent() {
+ let msg = DisplayMessage(role: .assistant, content: "Hello", isStreaming: true)
+ XCTAssertFalse(msg.showsTypingIndicator)
+ }
+
+ func test_showsStreamingCursor_trueWhenStreamingWithContent() {
+ let msg = DisplayMessage(role: .assistant, content: "Hello", isStreaming: true)
+ XCTAssertTrue(msg.showsStreamingCursor)
+ }
+
+ func test_showsStreamingCursor_falseWhenNotStreaming() {
+ let msg = DisplayMessage(role: .assistant, content: "Hello", isStreaming: false)
+ XCTAssertFalse(msg.showsStreamingCursor)
+ }
+
+ func test_showsStreamingCursor_falseWhenEmptyContent() {
+ let msg = DisplayMessage(role: .assistant, content: "", isStreaming: true)
+ XCTAssertFalse(msg.showsStreamingCursor)
+ }
+
+ func test_streamingMessage_transitionsFromTypingToCursor() {
+ let store = makeStore()
+ store.setupForTesting(agent: testConfig())
+
+ // On TextMessageStart, message has empty content → typing indicator
+ store.processEvent(TextMessageStartEvent(messageId: "m1", role: "assistant"))
+ let streaming = store.state.messages.last
+ XCTAssertTrue(streaming?.showsTypingIndicator == true)
+ XCTAssertFalse(streaming?.showsStreamingCursor == true)
+
+ // After first content delta → cursor indicator
+ store.processEvent(TextMessageContentEvent(messageId: "m1", delta: "Hi"))
+ let withContent = store.state.messages.last
+ XCTAssertFalse(withContent?.showsTypingIndicator == true)
+ XCTAssertTrue(withContent?.showsStreamingCursor == true)
+
+ // After TextMessageEnd → no animation state
+ store.processEvent(TextMessageEndEvent(messageId: "m1"))
+ let finished = store.state.messages.last
+ XCTAssertFalse(finished?.showsTypingIndicator == true)
+ XCTAssertFalse(finished?.showsStreamingCursor == true)
+ }
+
+ // MARK: - Agent lifecycle
+
+ func test_presentCreateAgent_setsDraft() {
+ let store = makeStore()
+ store.presentCreateAgent()
+
+ XCTAssertEqual(store.formMode, .create)
+ XCTAssertTrue(store.draft.name.isEmpty)
+ }
+
+ func test_saveAgent_appendsToList() {
+ let store = makeStore()
+ store.presentCreateAgent()
+ store.draft.name = "My Agent"
+ store.draft.url = "https://agent.example.com"
+
+ store.saveAgent()
+
+ XCTAssertEqual(store.agents.count, 1)
+ XCTAssertEqual(store.agents[0].name, "My Agent")
+ XCTAssertNil(store.formMode)
+ }
+
+ func test_deleteAgent_removesFromList() {
+ let store = makeStore()
+ store.presentCreateAgent()
+ store.draft.name = "Agent A"
+ store.draft.url = "https://a.example.com"
+ store.saveAgent()
+
+ let id = store.agents[0].id
+ store.deleteAgent(id: id)
+
+ XCTAssertTrue(store.agents.isEmpty)
+ }
+
+ // MARK: - Phase 4: A2UI / Generative UI
+
+ func test_activitySnapshot_insertsA2UIDisplayMessage() throws {
+ let store = makeStore()
+ store.setupForTesting(agent: testConfig())
+
+ let content = try JSONSerialization.data(withJSONObject: ["type": "text", "content": "Hello"])
+ let event = ActivitySnapshotEvent(messageId: "act1", activityType: "a2ui-surface", content: content)
+
+ store.processEvent(event)
+
+ XCTAssertNotNil(store.state.a2uiSurfaces["act1"])
+ XCTAssertTrue(store.state.messages.contains { $0.id == "a2ui-act1" })
+ if let msg = store.state.messages.first(where: { $0.id == "a2ui-act1" }) {
+ if case .a2uiSurface(let messageId) = msg.role {
+ XCTAssertEqual(messageId, "act1")
+ } else {
+ XCTFail("Expected .a2uiSurface role")
+ }
+ }
+ }
+
+ func test_activityDelta_updatesA2UIState() throws {
+ let store = makeStore()
+ store.setupForTesting(agent: testConfig())
+
+ // Snapshot first
+ let content = try JSONSerialization.data(withJSONObject: ["type": "text", "content": "Hello"])
+ store.processEvent(ActivitySnapshotEvent(messageId: "act1", activityType: "a2ui-surface", content: content))
+
+ // Then delta
+ let patch = try JSONSerialization.data(withJSONObject: [["op": "replace", "path": "/content", "value": "World"]])
+ store.processEvent(ActivityDeltaEvent(messageId: "act1", activityType: "a2ui-surface", patch: patch))
+
+ let updatedData = try XCTUnwrap(store.state.a2uiSurfaces["act1"])
+ let dict = try XCTUnwrap(try JSONSerialization.jsonObject(with: updatedData) as? [String: Any])
+ XCTAssertEqual(dict["content"] as? String, "World")
+ }
+
+ func test_activitySnapshot_ignoredForNonA2UISurface() throws {
+ let store = makeStore()
+ store.setupForTesting(agent: testConfig())
+
+ let content = try JSONSerialization.data(withJSONObject: ["data": "whatever"])
+ store.processEvent(ActivitySnapshotEvent(messageId: "act1", activityType: "some-other-type", content: content))
+
+ // Non-a2ui-surface activity types should not create A2UI messages
+ XCTAssertNil(store.state.a2uiSurfaces["act1"])
+ XCTAssertFalse(store.state.messages.contains { $0.id == "a2ui-act1" })
+ }
+}
diff --git a/sdks/community/swift/Examples/ChatApp/Tests/ChatAppTests/ClawgUIDetectorTests.swift b/sdks/community/swift/Examples/ChatApp/Tests/ChatAppTests/ClawgUIDetectorTests.swift
new file mode 100644
index 0000000000..9c7dc8bbf6
--- /dev/null
+++ b/sdks/community/swift/Examples/ChatApp/Tests/ChatAppTests/ClawgUIDetectorTests.swift
@@ -0,0 +1,53 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import ChatApp
+
+final class ClawgUIDetectorTests: XCTestCase {
+
+ // MARK: - Positive matches
+
+ func test_matchesStandardClawgUIPath() {
+ XCTAssertTrue(ClawgUIDetector.isClawgUIEndpoint("https://gateway.enterprise.com/v1/clawg-ui"))
+ }
+
+ func test_matchesClawgUIWithSubpath() {
+ XCTAssertTrue(ClawgUIDetector.isClawgUIEndpoint("https://gateway.enterprise.com/v1/clawg-ui/chat"))
+ }
+
+ func test_matchesClawgUIOnLocalhost() {
+ XCTAssertTrue(ClawgUIDetector.isClawgUIEndpoint("http://localhost:8080/v1/clawg-ui"))
+ }
+
+ func test_matchesClawgUIWithPort() {
+ XCTAssertTrue(ClawgUIDetector.isClawgUIEndpoint("https://api.example.com:9443/v1/clawg-ui/agentic_chat"))
+ }
+
+ // MARK: - Negative matches
+
+ func test_rejectsStandardAPIURL() {
+ XCTAssertFalse(ClawgUIDetector.isClawgUIEndpoint("https://api.openai.com/v1/chat/completions"))
+ }
+
+ func test_rejectsRegularAgentURL() {
+ XCTAssertFalse(ClawgUIDetector.isClawgUIEndpoint("https://my-agent.example.com/run"))
+ }
+
+ func test_rejectsEmptyString() {
+ XCTAssertFalse(ClawgUIDetector.isClawgUIEndpoint(""))
+ }
+
+ func test_rejectsNearMissWithoutClawgUI() {
+ XCTAssertFalse(ClawgUIDetector.isClawgUIEndpoint("https://example.com/v1/clawg"))
+ }
+
+ func test_rejectsURLWithClawgUIInHost_notPath() {
+ // Only path segment /v1/clawg-ui matches — not host name
+ XCTAssertFalse(ClawgUIDetector.isClawgUIEndpoint("https://clawg-ui.enterprise.com/v1/chat"))
+ }
+
+ func test_isCaseSensitive() {
+ // URL paths are case-sensitive; uppercase should not match
+ XCTAssertFalse(ClawgUIDetector.isClawgUIEndpoint("https://example.com/V1/CLAWG-UI"))
+ }
+}
diff --git a/sdks/community/swift/Examples/ChatApp/Tests/ChatAppTests/ClawgUIPairingManagerTests.swift b/sdks/community/swift/Examples/ChatApp/Tests/ChatAppTests/ClawgUIPairingManagerTests.swift
new file mode 100644
index 0000000000..74e6d82a6f
--- /dev/null
+++ b/sdks/community/swift/Examples/ChatApp/Tests/ChatAppTests/ClawgUIPairingManagerTests.swift
@@ -0,0 +1,276 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import ChatApp
+
+// MARK: - Mock HTTP Client
+
+/// Controllable HTTP client for pairing manager tests.
+/// @unchecked Sendable because test properties are mutated from the main actor only.
+final class MockClawgUIPairingHTTPClient: ClawgUIPairingHTTPClient, @unchecked Sendable {
+
+ // MARK: - Handshake control
+
+ enum HandshakeResult {
+ case success(approvalURL: URL)
+ case failure(Error)
+ }
+
+ var handshakeResult: HandshakeResult = .success(
+ approvalURL: URL(string: "https://clawg-ui.example.com/approve")!
+ )
+ var handshakeCallCount = 0
+
+ // MARK: - Poll control
+
+ var pollResult = false
+ var pollError: Error?
+ var pollCallCount = 0
+ /// Artificial async delay injected before returning poll result (seconds).
+ var pollDelay: TimeInterval = 0
+
+ // MARK: - ClawgUIPairingHTTPClient
+
+ func initiateHandshake(agentURL: URL) async throws -> ClawgUIHandshakeResult {
+ handshakeCallCount += 1
+ switch handshakeResult {
+ case .success(let url):
+ return ClawgUIHandshakeResult(approvalURL: url)
+ case .failure(let error):
+ throw error
+ }
+ }
+
+ func pollApprovalStatus(agentURL: URL) async throws -> Bool {
+ pollCallCount += 1
+ if pollDelay > 0 {
+ try await Task.sleep(for: .seconds(pollDelay))
+ }
+ if let error = pollError { throw error }
+ return pollResult
+ }
+}
+
+// MARK: - ClawgUIPairingManagerTests
+
+@MainActor
+final class ClawgUIPairingManagerTests: XCTestCase {
+
+ private let agentURL = URL(string: "https://gateway.enterprise.com/v1/clawg-ui")!
+ private let approvalURL = URL(string: "https://clawg-ui.example.com/approve")!
+
+ private func makeManager(client: MockClawgUIPairingHTTPClient) -> ClawgUIPairingManager {
+ ClawgUIPairingManager(
+ httpClient: client,
+ maxPollingAttempts: 3,
+ pollingInterval: .milliseconds(10) // fast for tests
+ )
+ }
+
+ // MARK: - Initial state
+
+ func test_initialState_isIdle() {
+ let manager = makeManager(client: MockClawgUIPairingHTTPClient())
+ XCTAssertEqual(manager.pairingState, .idle)
+ }
+
+ // MARK: - Initiate pairing
+
+ func test_initiatePairing_passesInitiatingStateToCallback() async {
+ let client = MockClawgUIPairingHTTPClient()
+ let manager = makeManager(client: client)
+
+ var observed: [ClawgUIPairingState] = []
+ manager.onStateChange = { observed.append($0) }
+
+ await manager.initiatePairing(agentURL: agentURL)
+
+ XCTAssertTrue(observed.contains(.initiating), "Expected .initiating in \(observed)")
+ }
+
+ func test_initiatePairing_successfulHandshake_transitionsToPendingApproval() async {
+ let client = MockClawgUIPairingHTTPClient()
+ client.handshakeResult = .success(approvalURL: approvalURL)
+ let manager = makeManager(client: client)
+
+ await manager.initiatePairing(agentURL: agentURL)
+
+ guard case .pendingApproval(let url) = manager.pairingState else {
+ return XCTFail("Expected .pendingApproval, got \(manager.pairingState)")
+ }
+ XCTAssertEqual(url, approvalURL)
+ }
+
+ func test_initiatePairing_httpError_transitionsToFailed() async {
+ let client = MockClawgUIPairingHTTPClient()
+ client.handshakeResult = .failure(NSError(domain: "test", code: -1, userInfo: [NSLocalizedDescriptionKey: "Network error"]))
+ let manager = makeManager(client: client)
+
+ await manager.initiatePairing(agentURL: agentURL)
+
+ guard case .failed(let reason) = manager.pairingState else {
+ return XCTFail("Expected .failed, got \(manager.pairingState)")
+ }
+ XCTAssertFalse(reason.isEmpty)
+ }
+
+ func test_initiatePairing_callsHandshakeOnce() async {
+ let client = MockClawgUIPairingHTTPClient()
+ let manager = makeManager(client: client)
+
+ await manager.initiatePairing(agentURL: agentURL)
+
+ XCTAssertEqual(client.handshakeCallCount, 1)
+ }
+
+ // MARK: - Confirm approval
+
+ func test_confirmApproval_transitionsToAwaitingApproval() async {
+ let client = MockClawgUIPairingHTTPClient()
+ client.pollResult = false
+ client.pollDelay = 60 // long delay so polling doesn't complete during test
+ let manager = makeManager(client: client)
+
+ await manager.initiatePairing(agentURL: agentURL)
+ manager.confirmApproval()
+
+ XCTAssertEqual(manager.pairingState, .awaitingApproval)
+ }
+
+ // MARK: - Polling success
+
+ func test_pollingSuccess_transitionsToIdle() async throws {
+ let client = MockClawgUIPairingHTTPClient()
+ client.pollResult = true
+
+ let expectation = expectation(description: "pairing success")
+ let manager = makeManager(client: client)
+ manager.onPairingSuccess = { expectation.fulfill() }
+
+ await manager.initiatePairing(agentURL: agentURL)
+ manager.confirmApproval()
+
+ await fulfillment(of: [expectation], timeout: 2)
+ XCTAssertEqual(manager.pairingState, .idle)
+ }
+
+ func test_pollingSuccess_callsOnPairingSuccessOnce() async throws {
+ let client = MockClawgUIPairingHTTPClient()
+ client.pollResult = true
+
+ let expectation = expectation(description: "pairing success")
+ expectation.assertForOverFulfill = true
+ let manager = makeManager(client: client)
+ manager.onPairingSuccess = { expectation.fulfill() }
+
+ await manager.initiatePairing(agentURL: agentURL)
+ manager.confirmApproval()
+
+ await fulfillment(of: [expectation], timeout: 2)
+ }
+
+ // MARK: - Polling exhaustion
+
+ func test_pollingExhausted_transitionsToRetryingConnection() async throws {
+ let client = MockClawgUIPairingHTTPClient()
+ client.pollResult = false // never approve
+
+ let expectation = expectation(description: "retrying connection")
+ let manager = makeManager(client: client) // maxPollingAttempts = 3
+
+ manager.onStateChange = { state in
+ if state == .retryingConnection { expectation.fulfill() }
+ }
+
+ await manager.initiatePairing(agentURL: agentURL)
+ manager.confirmApproval()
+
+ await fulfillment(of: [expectation], timeout: 5)
+ XCTAssertEqual(manager.pairingState, .retryingConnection)
+ }
+
+ // MARK: - Reset
+
+ func test_reset_setsStateToIdle() async {
+ let client = MockClawgUIPairingHTTPClient()
+ let manager = makeManager(client: client)
+
+ await manager.initiatePairing(agentURL: agentURL)
+ manager.reset()
+
+ XCTAssertEqual(manager.pairingState, .idle)
+ }
+
+ func test_reset_cancelsInFlightPolling() async throws {
+ let client = MockClawgUIPairingHTTPClient()
+ client.pollResult = false
+ client.pollDelay = 60 // long delay
+ let manager = makeManager(client: client)
+
+ await manager.initiatePairing(agentURL: agentURL)
+ manager.confirmApproval()
+ manager.reset()
+
+ // After reset, state is idle and polling is cancelled
+ XCTAssertEqual(manager.pairingState, .idle)
+ // Poll should not have completed (cancelled before delay elapsed)
+ XCTAssertEqual(client.pollCallCount, 0)
+ }
+
+ func test_reset_firesOnStateChangeWithIdle() async {
+ let client = MockClawgUIPairingHTTPClient()
+ let manager = makeManager(client: client)
+
+ var lastState: ClawgUIPairingState?
+ manager.onStateChange = { lastState = $0 }
+
+ await manager.initiatePairing(agentURL: agentURL)
+ manager.reset()
+
+ XCTAssertEqual(lastState, .idle)
+ }
+
+ // MARK: - Retry connection
+
+ func test_retryConnection_performsNewHandshake() async {
+ let client = MockClawgUIPairingHTTPClient()
+ client.handshakeResult = .success(approvalURL: approvalURL)
+ let manager = makeManager(client: client)
+
+ await manager.initiatePairing(agentURL: agentURL)
+ await manager.retryConnection()
+
+ XCTAssertEqual(client.handshakeCallCount, 2)
+ }
+
+ func test_retryConnection_successTransitionsToPendingApproval() async {
+ let client = MockClawgUIPairingHTTPClient()
+ let manager = makeManager(client: client)
+
+ await manager.initiatePairing(agentURL: agentURL)
+ await manager.retryConnection()
+
+ guard case .pendingApproval = manager.pairingState else {
+ return XCTFail("Expected .pendingApproval, got \(manager.pairingState)")
+ }
+ }
+
+ // MARK: - State change callback
+
+ func test_onStateChange_firedForEveryTransition() async {
+ let client = MockClawgUIPairingHTTPClient()
+ let manager = makeManager(client: client)
+
+ var states: [ClawgUIPairingState] = []
+ manager.onStateChange = { states.append($0) }
+
+ await manager.initiatePairing(agentURL: agentURL)
+
+ // Should have seen .initiating, then .pendingApproval
+ XCTAssertEqual(states.count, 2)
+ XCTAssertEqual(states[0], .initiating)
+ if case .pendingApproval = states[1] { } else {
+ XCTFail("Expected .pendingApproval as second state")
+ }
+ }
+}
diff --git a/sdks/community/swift/Examples/ChatApp/Tests/ChatAppTests/Mocks/MockAgUiAgent.swift b/sdks/community/swift/Examples/ChatApp/Tests/ChatAppTests/Mocks/MockAgUiAgent.swift
new file mode 100644
index 0000000000..d453010c3f
--- /dev/null
+++ b/sdks/community/swift/Examples/ChatApp/Tests/ChatAppTests/Mocks/MockAgUiAgent.swift
@@ -0,0 +1,38 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUIAgentSDK
+import AGUICore
+import Foundation
+
+/// A scriptable `AgUiAgent` subclass used in unit tests.
+///
+/// Set `events` before calling any store method that triggers a run.
+/// Set `errorToThrow` to simulate network or protocol errors.
+final class MockAgUiAgent: AgUiAgent {
+ /// Ordered events to emit when `run(input:)` is called.
+ var events: [any AGUIEvent] = []
+ /// When non-nil, this error is thrown instead of emitting events.
+ var errorToThrow: Error?
+
+ init() {
+ // URL is unused — `run(input:)` is fully overridden.
+ super.init(url: URL(string: "https://mock.test.local")!)
+ }
+
+ override func run(input: RunAgentInput) -> AsyncThrowingStream {
+ let events = self.events
+ let error = self.errorToThrow
+ return AsyncThrowingStream { continuation in
+ Task {
+ if let error {
+ continuation.finish(throwing: error)
+ return
+ }
+ for event in events {
+ continuation.yield(event)
+ }
+ continuation.finish()
+ }
+ }
+ }
+}
diff --git a/sdks/community/swift/LICENSE b/sdks/community/swift/LICENSE
new file mode 100644
index 0000000000..8e9fd95cbb
--- /dev/null
+++ b/sdks/community/swift/LICENSE
@@ -0,0 +1,22 @@
+MIT License
+
+Copyright (c) 2025 Perfect Aduh.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
diff --git a/sdks/community/swift/Package.resolved b/sdks/community/swift/Package.resolved
new file mode 100644
index 0000000000..eec9740232
--- /dev/null
+++ b/sdks/community/swift/Package.resolved
@@ -0,0 +1,23 @@
+{
+ "pins" : [
+ {
+ "identity" : "swift-docc-plugin",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-docc-plugin",
+ "state" : {
+ "revision" : "3e4f133a77e644a5812911a0513aeb7288b07d06",
+ "version" : "1.4.5"
+ }
+ },
+ {
+ "identity" : "swift-docc-symbolkit",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/swiftlang/swift-docc-symbolkit",
+ "state" : {
+ "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34",
+ "version" : "1.0.0"
+ }
+ }
+ ],
+ "version" : 2
+}
diff --git a/sdks/community/swift/Package.swift b/sdks/community/swift/Package.swift
new file mode 100644
index 0000000000..1866484352
--- /dev/null
+++ b/sdks/community/swift/Package.swift
@@ -0,0 +1,56 @@
+// swift-tools-version: 5.9
+// The swift-tools-version declares the minimum version of Swift required to build this package.
+
+import PackageDescription
+
+let package = Package(
+ name: "AGUISwift",
+ platforms: [
+ .iOS(.v16),
+ .macOS(.v13),
+ ],
+ products: [
+ .library(
+ name: "AGUICore",
+ targets: ["AGUICore"]),
+ .library(
+ name: "AGUIClient",
+ targets: ["AGUIClient"]),
+ .library(
+ name: "AGUIAgentSDK",
+ targets: ["AGUIAgentSDK"]),
+ .library(
+ name: "AGUITools",
+ targets: ["AGUITools"]),
+ ],
+ dependencies: [
+ .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"),
+ ],
+ targets: [
+ .target(
+ name: "AGUICore",
+ dependencies: []),
+ .target(
+ name: "AGUIClient",
+ dependencies: ["AGUICore"]),
+ .target(
+ name: "AGUIAgentSDK",
+ dependencies: ["AGUICore", "AGUIClient", "AGUITools"]),
+ .target(
+ name: "AGUITools",
+ dependencies: ["AGUICore"]),
+ .testTarget(
+ name: "AGUICoreTests",
+ dependencies: ["AGUICore"]),
+ .testTarget(
+ name: "AGUIClientTests",
+ dependencies: ["AGUIClient"]),
+ .testTarget(
+ name: "AGUIAgentSDKTests",
+ dependencies: ["AGUIAgentSDK", "AGUIClient", "AGUITools"]),
+ .testTarget(
+ name: "AGUIToolsTests",
+ dependencies: ["AGUITools"]),
+ ]
+)
+
diff --git a/sdks/community/swift/README.md b/sdks/community/swift/README.md
new file mode 100644
index 0000000000..0da7ac8b72
--- /dev/null
+++ b/sdks/community/swift/README.md
@@ -0,0 +1,98 @@
+# AGUISwift
+
+[](https://github.com/paduh/ag-ui/actions/workflows/ci.yml)
+
+The AG-UI Swift SDK is a Swift library for building AI agent user interfaces that implement the Agent User Interaction Protocol (AG-UI). It provides real-time streaming communication between Swift applications and AI agents.
+
+## Architecture
+
+The SDK follows a modular architecture with four main components:
+
+### AGUIAgentSDK
+High-level APIs for common agent interaction patterns.
+- **AgUiAgent**: Stateless client for cases where no ongoing context is needed or the agent manages all state server-side
+- **StatefulAgUiAgent**: Stateful client that maintains conversation history and sends it with each request
+- **Builders**: Convenient builder patterns for agent configuration
+
+### AGUIClient
+Low-level client infrastructure and transport implementations.
+- **HttpAgent**: Low-level HTTP transport implementation
+- **AbstractAgent**: Base class for custom agent implementations
+- **SseParser**: Server-Sent Events parser for streaming responses
+- **EventStreamManager**: Event stream management and processing
+
+### AGUICore
+Protocol types, events, and message definitions.
+- **Events**: All AG-UI protocol event types and serialization
+- **Types**: Protocol message types and state management
+- **Domain Layer**: Pure domain value objects and domain events
+- **Infrastructure Layer**: Serialization and adapters
+
+### AGUITools
+Tool execution framework for extending agent capabilities.
+- **ToolExecutor**: Protocol for implementing custom tools
+- **ToolRegistry**: Tool registration and management
+- **ToolExecutionManager**: Tool execution with circuit breaker patterns
+
+## Requirements
+
+- Swift 5.9+
+- iOS 15.0+ / macOS 13.0+
+
+## Documentation
+
+API documentation can be generated locally using the Swift-DocC plugin:
+
+```bash
+swift package generate-documentation
+```
+
+## License
+
+This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
+
+
+## Contributing
+
+We welcome contributions! Please follow our development workflow:
+
+### Quick Start
+
+1. **Create a Feature Branch**:
+ ```bash
+ git checkout -b feature/your-feature-name
+ ```
+
+3. **Make Changes & Test**:
+ ```bash
+ swift test
+ swiftlint lint
+ ```
+
+4. **Create Pull Request**:
+ ```bash
+ git push -u origin feature/your-feature-name
+ gh pr create
+ ```
+
+### Development Workflow
+
+- **No direct commits to `main`** - All changes must go through pull requests
+- **Code review required** - PRs must be approved by designated reviewers before merging
+- **Branch naming**: `feature/`, `fix/`, `refactor/`, `docs/`
+- **Follow TDD**: Write tests first, then implement
+- **Code quality**: Format with SwiftFormat, lint with SwiftLint
+
+See detailed guides:
+- [Contributing Guide](CONTRIBUTING.md) - Full workflow and standards
+- [Workflow Quick Reference](docs/WORKFLOW.md) - Common tasks and commands
+- [Project Standards](CLAUDE.md) - Code conventions and architecture
+
+### Before Submitting PR
+
+- [ ] Tests pass: `swift test`
+- [ ] Code formatted: `swift package plugin swiftformat`
+- [ ] Linting passes: `swiftlint lint`
+- [ ] Documentation updated
+- [ ] Commit messages follow conventions
+
diff --git a/sdks/community/swift/Sources/AGUIAgentSDK/AGUIAgentSDK.swift b/sdks/community/swift/Sources/AGUIAgentSDK/AGUIAgentSDK.swift
new file mode 100644
index 0000000000..2b0ed5abb3
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUIAgentSDK/AGUIAgentSDK.swift
@@ -0,0 +1,21 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUIClient
+import AGUICore
+import Foundation
+
+/// AGUIAgentSDK provides high-level APIs for common agent interaction patterns.
+///
+/// This module offers convenient, easy-to-use APIs for building AI agent user interfaces.
+/// It builds on top of AGUICore and AGUIClient to provide a streamlined developer experience.
+///
+/// Key Components:
+/// - **AgUiAgent**: Stateless client for cases where no ongoing context is needed
+/// - **StatefulAgUiAgent**: Stateful client that maintains conversation history
+/// - **Builders**: Convenient builder patterns for agent configuration
+public struct AGUIAgentSDK {
+ /// AGUIAgentSDK version
+ public static let version = "1.0.0"
+
+ public init() {}
+}
diff --git a/sdks/community/swift/Sources/AGUIAgentSDK/AgUiAgent.swift b/sdks/community/swift/Sources/AGUIAgentSDK/AgUiAgent.swift
new file mode 100644
index 0000000000..68d746fb3b
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUIAgentSDK/AgUiAgent.swift
@@ -0,0 +1,162 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUIClient
+import AGUICore
+import AGUITools
+import Foundation
+
+public final class AgUiAgent: Sendable {
+
+ // MARK: - Public properties
+
+ public let config: AgUiAgentConfig
+
+ // MARK: - Private state
+
+ // Non-nil for URL-based init; provides run/subscribe/dispose and tool responses.
+ // The HttpAgent owns the single URLSession for this init path.
+ private let httpAgent: HttpAgent?
+ // Non-nil for transport-based init; provides run/subscribe/dispose without
+ // needing an HttpAgent (avoids the placeholder-URL dummy agent).
+ private let abstractAgent: AbstractAgent?
+ private let toolExecutionManager: ToolExecutionManager?
+
+ /// Stable thread ID used when the caller does not provide one.
+ /// Generated once at init time so consecutive `sendMessage` calls share a conversation thread.
+ private let defaultThreadId: String = UUID().uuidString
+
+ // MARK: - Initialization
+
+ public init(
+ url: URL,
+ configure: (inout AgUiAgentConfig) -> Void = { _ in }
+ ) {
+ var cfg = AgUiAgentConfig()
+ configure(&cfg)
+ self.config = cfg
+
+ var httpConfig = HttpAgentConfiguration(baseURL: url)
+ httpConfig.timeout = cfg.requestTimeout
+ httpConfig.retryPolicy = cfg.retryPolicy
+ httpConfig.headers = cfg.buildHeaders()
+
+ let agent = HttpAgent(configuration: httpConfig)
+ self.httpAgent = agent
+ self.abstractAgent = nil
+
+ if let registry = cfg.toolRegistry {
+ self.toolExecutionManager = ToolExecutionManager(
+ toolRegistry: registry,
+ responseHandler: ClientToolResponseHandler(httpAgent: agent)
+ )
+ } else {
+ self.toolExecutionManager = nil
+ }
+ }
+
+ public init(
+ transport: any AgentTransport,
+ config: AgUiAgentConfig = AgUiAgentConfig(),
+ toolExecutionManager: ToolExecutionManager? = nil
+ ) {
+ self.config = config
+ self.httpAgent = nil
+ self.abstractAgent = AbstractAgent(transport: transport)
+ self.toolExecutionManager = toolExecutionManager
+ }
+
+ // MARK: - Core run method
+
+ public func run(input: RunAgentInput) -> AsyncThrowingStream {
+ if let agent = httpAgent { return agent.run(input: input) }
+ return abstractAgent!.run(input: input)
+ }
+
+ // MARK: - sendMessage (primary API)
+
+ public func sendMessage(
+ _ message: String,
+ threadId: String? = nil,
+ state: State? = nil,
+ includeSystemPrompt: Bool = true
+ ) -> AsyncThrowingStream {
+ AsyncThrowingStream { continuation in
+ let task = Task {
+ do {
+ let effectiveThreadId = threadId ?? self.defaultThreadId
+
+ var messages: [any Message] = []
+
+ if includeSystemPrompt, let prompt = self.config.systemPrompt {
+ messages.append(SystemMessage(
+ id: "sys_\(UUID().uuidString)",
+ content: prompt
+ ))
+ }
+
+ messages.append(UserMessage(
+ id: "usr_\(UUID().uuidString)",
+ content: message
+ ))
+
+ var tools: [Tool] = []
+ if let registry = self.config.toolRegistry {
+ tools = await registry.allTools()
+ }
+
+ let input = RunAgentInput(
+ threadId: effectiveThreadId,
+ runId: "run_\(UUID().uuidString)",
+ state: state ?? Data("{}".utf8),
+ messages: messages,
+ tools: tools,
+ context: self.config.context,
+ forwardedProps: self.config.forwardedProps
+ )
+
+ let rawStream = self.run(input: input)
+
+ if let manager = self.toolExecutionManager {
+ let managed = await manager.processEventStream(
+ rawStream,
+ threadId: input.threadId,
+ runId: input.runId
+ )
+ for try await event in managed {
+ continuation.yield(event)
+ }
+ } else {
+ for try await event in rawStream {
+ continuation.yield(event)
+ }
+ }
+
+ continuation.finish()
+ } catch {
+ continuation.finish(throwing: error)
+ }
+ }
+ continuation.onTermination = { _ in task.cancel() }
+ }
+ }
+
+ // MARK: - Subscriber
+
+ public func subscribe(_ subscriber: any AgentSubscriber) async -> any AgentSubscription {
+ if let agent = httpAgent { return await agent.subscribe(subscriber) }
+ return await abstractAgent!.subscribe(subscriber)
+ }
+
+ // MARK: - Lifecycle
+
+ public func close() async {
+ if let manager = toolExecutionManager {
+ await manager.cancelAllExecutions()
+ }
+ if let agent = httpAgent {
+ await agent.dispose()
+ } else {
+ await abstractAgent!.dispose()
+ }
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUIAgentSDK/AgUiAgentConfig.swift b/sdks/community/swift/Sources/AGUIAgentSDK/AgUiAgentConfig.swift
new file mode 100644
index 0000000000..944673adaa
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUIAgentSDK/AgUiAgentConfig.swift
@@ -0,0 +1,146 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUIClient
+import AGUICore
+import AGUITools
+import Foundation
+
+/// Configuration for ``AgUiAgent``.
+///
+/// `AgUiAgentConfig` provides all options for a stateless AG-UI agent, including
+/// authentication helpers, tool registry, per-request context, and timeout tuning.
+///
+/// ## Example
+///
+/// ```swift
+/// let agent = AgUiAgent(url: agentURL) { config in
+/// config.bearerToken = "sk-…"
+/// config.systemPrompt = "You are a helpful assistant."
+/// config.toolRegistry = myRegistry
+/// }
+/// ```
+///
+/// ## Auth Convenience
+///
+/// Setting `bearerToken` or `apiKey` automatically merges the corresponding header
+/// into the final header dictionary via ``buildHeaders()``. Explicit entries in
+/// ``headers`` take precedence over auto-generated auth headers.
+///
+/// - SeeAlso: ``AgUiAgent``, ``AgentBuilders``
+public struct AgUiAgentConfig: Sendable {
+
+ // MARK: - Auth
+
+ /// Bearer token for authentication.
+ ///
+ /// When set, ``buildHeaders()`` includes `Authorization: Bearer `.
+ ///
+ /// Default: `nil`
+ public var bearerToken: String?
+
+ /// API key value.
+ ///
+ /// Used together with ``apiKeyHeader`` by ``buildHeaders()``.
+ ///
+ /// Default: `nil`
+ public var apiKey: String?
+
+ /// Header name for the API key.
+ ///
+ /// Default: `"X-API-Key"`
+ public var apiKeyHeader: String
+
+ // MARK: - HTTP
+
+ /// Arbitrary HTTP headers included in every request.
+ ///
+ /// These override auto-generated auth headers when the same key appears.
+ ///
+ /// Default: `[:]`
+ public var headers: [String: String]
+
+ /// Request (read) timeout in seconds.
+ ///
+ /// Default: `600`
+ public var requestTimeout: TimeInterval
+
+ /// Retry policy applied to failed HTTP requests.
+ ///
+ /// Default: `.none`
+ public var retryPolicy: HttpAgentConfiguration.RetryPolicy
+
+ // MARK: - Agent behaviour
+
+ /// Optional system prompt prepended to each call's message list.
+ ///
+ /// Default: `nil`
+ public var systemPrompt: String?
+
+ /// When `true`, enables verbose pipeline logging.
+ ///
+ /// Default: `false`
+ public var debug: Bool
+
+ // MARK: - Tools & Context
+
+ /// Optional tool registry.
+ ///
+ /// When set, tool definitions are included in every `RunAgentInput` and
+ /// tool calls from the agent are executed automatically via
+ /// ``ToolExecutionManager``.
+ ///
+ /// Default: `nil`
+ public var toolRegistry: (any ToolRegistry)?
+
+ /// Persistent user ID that can be used for attribution or routing.
+ ///
+ /// Default: `nil`
+ public var userId: String?
+
+ /// Context items included with every request.
+ ///
+ /// Default: `[]`
+ public var context: [Context]
+
+ /// Forwarded properties included in every `RunAgentInput` as JSON.
+ ///
+ /// Default: `{}`
+ public var forwardedProps: State
+
+ // MARK: - Initialization
+
+ /// Creates a new `AgUiAgentConfig` with default values.
+ public init() {
+ bearerToken = nil
+ apiKey = nil
+ apiKeyHeader = "X-API-Key"
+ headers = [:]
+ requestTimeout = 600
+ retryPolicy = .none
+ systemPrompt = nil
+ debug = false
+ toolRegistry = nil
+ userId = nil
+ context = []
+ forwardedProps = Data("{}".utf8)
+ }
+
+ // MARK: - Header builder
+
+ /// Builds the final HTTP header dictionary by merging auth helpers into ``headers``.
+ ///
+ /// Priority (highest → lowest):
+ /// 1. Entries already in ``headers``
+ /// 2. `bearerToken` → `Authorization: Bearer `
+ /// 3. `apiKey` → `: `
+ ///
+ /// - Returns: Merged header dictionary ready for `HttpAgentConfiguration`.
+ public func buildHeaders() -> [String: String] {
+ AgentHeaderBuilder.buildHeaders(
+ headers: headers,
+ bearerToken: bearerToken,
+ apiKey: apiKey,
+ apiKeyHeader: apiKeyHeader
+ )
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUIAgentSDK/AgentBuilders.swift b/sdks/community/swift/Sources/AGUIAgentSDK/AgentBuilders.swift
new file mode 100644
index 0000000000..e1d77a33d7
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUIAgentSDK/AgentBuilders.swift
@@ -0,0 +1,120 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUICore
+import AGUITools
+import Foundation
+
+/// Convenience factory methods for creating pre-configured AG-UI agents.
+///
+/// `AgentBuilders` mirrors the `AgentBuilders` pattern from the Kotlin SDK,
+/// providing clean one-liner factory calls for the most common agent configurations.
+///
+/// ## Examples
+///
+/// ```swift
+/// // Bearer-token authenticated agent
+/// let agent = AgentBuilders.agentWithBearer(url: agentURL, token: "sk-…")
+///
+/// // API-key authenticated agent
+/// let agent = AgentBuilders.agentWithApiKey(url: agentURL, apiKey: "my-key")
+///
+/// // Agent with custom tool registry
+/// let agent = AgentBuilders.agentWithTools(url: agentURL, registry: myRegistry)
+///
+/// // Stateful chat agent with a system prompt
+/// let agent = AgentBuilders.chatAgent(url: agentURL, systemPrompt: "You are a helpful assistant.")
+///
+/// // Stateful agent with pre-seeded JSON state
+/// let agent = AgentBuilders.statefulAgent(url: agentURL, initialState: Data("{\"mode\":\"creative\"}".utf8))
+///
+/// // Debug agent that logs verbose pipeline output
+/// let agent = AgentBuilders.debugAgent(url: agentURL)
+/// ```
+public enum AgentBuilders {
+
+ // MARK: - Stateless agents
+
+ /// Creates a stateless ``AgUiAgent`` authenticated with a Bearer token.
+ ///
+ /// The token is sent as `Authorization: Bearer ` on every request.
+ ///
+ /// - Parameters:
+ /// - url: Base URL of the AG-UI agent server.
+ /// - token: The Bearer token string.
+ /// - Returns: A configured ``AgUiAgent``.
+ public static func agentWithBearer(url: URL, token: String) -> AgUiAgent {
+ AgUiAgent(url: url) { config in
+ config.bearerToken = token
+ }
+ }
+
+ /// Creates a stateless ``AgUiAgent`` authenticated with an API key.
+ ///
+ /// - Parameters:
+ /// - url: Base URL of the AG-UI agent server.
+ /// - apiKey: The API key value.
+ /// - header: The HTTP header name (default: `"X-API-Key"`).
+ /// - Returns: A configured ``AgUiAgent``.
+ public static func agentWithApiKey(url: URL, apiKey: String, header: String = "X-API-Key") -> AgUiAgent {
+ AgUiAgent(url: url) { config in
+ config.apiKey = apiKey
+ config.apiKeyHeader = header
+ }
+ }
+
+ /// Creates a stateless ``AgUiAgent`` backed by a tool registry.
+ ///
+ /// Tool definitions are included in every `RunAgentInput` and tool calls from
+ /// the agent are executed automatically via ``ToolExecutionManager``.
+ ///
+ /// - Parameters:
+ /// - url: Base URL of the AG-UI agent server.
+ /// - registry: The tool registry to use.
+ /// - Returns: A configured ``AgUiAgent``.
+ public static func agentWithTools(url: URL, registry: any ToolRegistry) -> AgUiAgent {
+ AgUiAgent(url: url) { config in
+ config.toolRegistry = registry
+ }
+ }
+
+ /// Creates a stateless ``AgUiAgent`` with verbose pipeline logging enabled.
+ ///
+ /// - Parameter url: Base URL of the AG-UI agent server.
+ /// - Returns: A configured ``AgUiAgent`` with `debug = true`.
+ public static func debugAgent(url: URL) -> AgUiAgent {
+ AgUiAgent(url: url) { config in
+ config.debug = true
+ }
+ }
+
+ // MARK: - Stateful agents
+
+ /// Creates a ``StatefulAgUiAgent`` with a pre-configured system prompt.
+ ///
+ /// The system prompt is prepended to every new thread's conversation history.
+ ///
+ /// - Parameters:
+ /// - url: Base URL of the AG-UI agent server.
+ /// - systemPrompt: The system prompt text.
+ /// - Returns: A configured ``StatefulAgUiAgent``.
+ public static func chatAgent(url: URL, systemPrompt: String) -> StatefulAgUiAgent {
+ var config = StatefulAgUiAgentConfig(baseURL: url)
+ config.systemPrompt = systemPrompt
+ return StatefulAgUiAgent(configuration: config)
+ }
+
+ /// Creates a ``StatefulAgUiAgent`` with pre-seeded JSON state.
+ ///
+ /// The initial state is sent on the first run and then updated by state events
+ /// from the agent server.
+ ///
+ /// - Parameters:
+ /// - url: Base URL of the AG-UI agent server.
+ /// - initialState: The initial JSON state as `Data`.
+ /// - Returns: A configured ``StatefulAgUiAgent``.
+ public static func statefulAgent(url: URL, initialState: State) -> StatefulAgUiAgent {
+ var config = StatefulAgUiAgentConfig(baseURL: url)
+ config.initialState = initialState
+ return StatefulAgUiAgent(configuration: config)
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUIAgentSDK/AgentHeaderBuilder.swift b/sdks/community/swift/Sources/AGUIAgentSDK/AgentHeaderBuilder.swift
new file mode 100644
index 0000000000..f49c490e31
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUIAgentSDK/AgentHeaderBuilder.swift
@@ -0,0 +1,56 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Shared header-building logic for agent configuration types.
+///
+/// Both `AgUiAgentConfig` and `StatefulAgUiAgentConfig` need identical logic to
+/// merge bearer-token / API-key auth helpers into an explicit headers dictionary
+/// while stripping CRLF characters to prevent header injection.
+///
+/// This is an internal implementation detail — only the config types delegate here.
+enum AgentHeaderBuilder {
+
+ /// Builds the final HTTP header dictionary by merging auth helpers into `headers`.
+ ///
+ /// Priority (highest → lowest):
+ /// 1. Entries already in `headers`
+ /// 2. `bearerToken` → `Authorization: Bearer `
+ /// 3. `apiKey` → `: `
+ ///
+ /// - Parameters:
+ /// - headers: Base header dictionary (highest priority)
+ /// - bearerToken: Optional bearer token
+ /// - apiKey: Optional API key value
+ /// - apiKeyHeader: Header name for the API key (default `"X-API-Key"`)
+ /// - Returns: Merged header dictionary ready for `HttpAgentConfiguration`.
+ static func buildHeaders(
+ headers: [String: String],
+ bearerToken: String?,
+ apiKey: String?,
+ apiKeyHeader: String
+ ) -> [String: String] {
+ var result: [String: String] = [:]
+
+ // Low-priority auth headers first
+ if let key = apiKey {
+ result[apiKeyHeader] = sanitize(key)
+ }
+ if let token = bearerToken {
+ result["Authorization"] = "Bearer \(sanitize(token))"
+ }
+
+ // User-supplied headers override auth helpers
+ for (k, v) in headers {
+ result[k] = sanitize(v)
+ }
+
+ return result
+ }
+
+ /// Strips CR and LF from a header value to prevent CRLF injection.
+ static func sanitize(_ value: String) -> String {
+ value.replacingOccurrences(of: "\r", with: "")
+ .replacingOccurrences(of: "\n", with: "")
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUIAgentSDK/AgentMessage.swift b/sdks/community/swift/Sources/AGUIAgentSDK/AgentMessage.swift
new file mode 100644
index 0000000000..d9c7152e7f
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUIAgentSDK/AgentMessage.swift
@@ -0,0 +1,111 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// A concrete, display-ready representation of a conversation message.
+///
+/// `AgentMessage` is the currency type between the AG-UI event pipeline and SwiftUI
+/// views. Unlike `any Message` (the protocol used inside the networking layer),
+/// `AgentMessage` is a concrete value type that is `Identifiable` and `Equatable`,
+/// making it safe to use directly in `List`, `ForEach`, and `@Observable` properties.
+///
+/// ## Usage in SwiftUI
+///
+/// ```swift
+/// List(viewModel.messages) { message in
+/// MessageBubble(message: message)
+/// }
+/// ```
+///
+/// ## Relationship to the protocol layer
+///
+/// `AgentMessage` is built by ``AgentViewModel`` / ``AgentViewModelCompat`` as events
+/// arrive from the stream. It is not decoded from the wire — it is assembled in the
+/// view model from `TextMessageStartEvent`, `TextMessageContentEvent`, and
+/// `TextMessageEndEvent` events.
+///
+/// - SeeAlso: ``AgentViewModel``, ``AgentViewModelCompat``, ``AgentError``
+public struct AgentMessage: Sendable, Identifiable, Equatable {
+
+ // MARK: - Role
+
+ /// The speaker role for this message.
+ public enum Role: String, Sendable, Equatable, CaseIterable {
+ /// A message from the end-user.
+ case user
+ /// A message from the AI assistant.
+ case assistant
+ /// A system-level instruction (usually not displayed to the user).
+ case system
+ /// The result of a tool call.
+ case tool
+ }
+
+ // MARK: - Properties
+
+ /// Stable identifier for use with SwiftUI `List` / `ForEach` diffing.
+ ///
+ /// When an assistant message is updated with content deltas, the `id` does not
+ /// change — only `content` grows. This lets SwiftUI animate the existing row
+ /// rather than replace it.
+ public let id: String
+
+ /// The speaker role.
+ public let role: Role
+
+ /// The text content of the message.
+ ///
+ /// For assistant messages being streamed, this grows incrementally as
+ /// `TextMessageContentEvent` deltas arrive.
+ public let content: String
+
+ // MARK: - Initialization
+
+ /// Creates an `AgentMessage`.
+ ///
+ /// - Parameters:
+ /// - id: Stable identifier (defaults to a new UUID string).
+ /// - role: The speaker role.
+ /// - content: The message text.
+ public init(
+ id: String = UUID().uuidString,
+ role: Role,
+ content: String
+ ) {
+ self.id = id
+ self.role = role
+ self.content = content
+ }
+}
+
+// MARK: - AgentError
+
+/// Errors surfaced by ``AgentViewModel`` / ``AgentViewModelCompat``.
+///
+/// `AgentError` wraps AG-UI protocol-level errors (from `RunErrorEvent`) so callers
+/// can distinguish them from transport-level failures (e.g., `URLError`, `ClientError`).
+///
+/// ## Example
+///
+/// ```swift
+/// if let error = viewModel.lastError as? AgentError,
+/// case .runError(let message, _) = error {
+/// Text("Agent error: \(message)").foregroundColor(.red)
+/// }
+/// ```
+public enum AgentError: Error, LocalizedError, Sendable {
+
+ /// The agent reported an error via `RunErrorEvent`.
+ ///
+ /// - Parameters:
+ /// - message: Human-readable description from the agent.
+ /// - code: Optional machine-readable error code.
+ case runError(message: String, code: String?)
+
+ public var errorDescription: String? {
+ switch self {
+ case .runError(let message, _):
+ return message
+ }
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUIAgentSDK/AgentViewModel.swift b/sdks/community/swift/Sources/AGUIAgentSDK/AgentViewModel.swift
new file mode 100644
index 0000000000..78861a3295
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUIAgentSDK/AgentViewModel.swift
@@ -0,0 +1,168 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+// Observation framework is available from iOS 17 / macOS 14.
+// The #if canImport guard prevents import failures when compiling against
+// an older SDK; the @available annotation on the class enforces runtime safety.
+#if canImport(Observation)
+
+import AGUICore
+import Foundation
+import Observation
+
+/// `@Observable` view model for AG-UI agent conversations (iOS 17+ / macOS 14+).
+///
+/// `AgentViewModel` uses the Swift `Observation` framework (`@Observable` macro) to
+/// provide automatic, fine-grained dependency tracking without `@Published` boilerplate.
+/// SwiftUI views that read `messages`, `isRunning`, or `lastError` are re-rendered only
+/// when those specific properties change.
+///
+/// For iOS 16 / macOS 13 support use ``AgentViewModelCompat`` (`ObservableObject`).
+///
+/// ## Basic SwiftUI usage
+///
+/// ```swift
+/// @State private var vm = AgentViewModel(
+/// agent: StatefulAgUiAgent(baseURL: agentURL)
+/// )
+///
+/// var body: some View {
+/// VStack {
+/// ScrollView {
+/// ForEach(vm.messages) { message in
+/// MessageBubble(message: message)
+/// }
+/// }
+/// HStack {
+/// TextField("Message", text: $draft)
+/// Button("Send") {
+/// Task { await vm.send(draft) }
+/// }
+/// .disabled(vm.isRunning)
+/// }
+/// if let error = vm.lastError {
+/// Text(error.localizedDescription).foregroundColor(.red)
+/// }
+/// }
+/// }
+/// ```
+///
+/// ## Streaming text
+///
+/// As the agent streams tokens, each `TextMessageContentEvent` delta is appended
+/// to the last assistant `AgentMessage` in place — the message `id` stays the
+/// same so SwiftUI animates the existing row rather than replacing it.
+///
+/// ## Error handling
+///
+/// - `RunErrorEvent` from the agent sets `lastError` as ``AgentError/runError(message:code:)``
+/// - Transport-level failures (network, timeout) set `lastError` as whatever `Error` was thrown
+/// - `lastError` is cleared automatically at the start of each new `send()` call
+///
+/// ## Thread safety
+///
+/// `AgentViewModel` is isolated to `@MainActor`. Call `send()` and `clear()`
+/// from SwiftUI button handlers or `Task { }` blocks — they are already on the main actor.
+///
+/// - SeeAlso: ``AgentViewModelCompat``, ``ChatAgent``, ``AgentMessage``, ``AgentError``
+@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+@Observable
+@MainActor
+public final class AgentViewModel {
+
+ // MARK: - Observable state
+
+ /// The conversation messages in chronological order.
+ ///
+ /// User messages are appended synchronously at the start of `send()`.
+ /// Assistant messages grow token-by-token as the stream delivers content.
+ public private(set) var messages: [AgentMessage] = []
+
+ /// `true` while an agent run is in progress.
+ ///
+ /// Bind this to a loading indicator or use it to disable the send button.
+ public private(set) var isRunning: Bool = false
+
+ /// The last error encountered, or `nil` if the most recent run succeeded.
+ ///
+ /// Set to `nil` automatically at the start of each `send()` call.
+ public private(set) var lastError: Error? = nil
+
+ // MARK: - Configuration
+
+ /// The conversation thread identifier passed to the underlying agent.
+ public let threadId: String
+
+ // MARK: - Private
+
+ private let agent: any ChatAgent
+
+ // MARK: - Initialization
+
+ /// Creates a view model backed by the given agent.
+ ///
+ /// - Parameters:
+ /// - agent: Any ``ChatAgent`` implementation (typically ``StatefulAgUiAgent``).
+ /// - threadId: Conversation thread identifier (default: `"default"`).
+ public init(agent: any ChatAgent, threadId: String = "default") {
+ self.agent = agent
+ self.threadId = threadId
+ }
+
+ // MARK: - Public API
+
+ /// Sends a user message and streams the agent's response into `messages`.
+ ///
+ /// If a run is already in progress this call is a no-op (the guard prevents
+ /// concurrent runs on the same view model instance).
+ ///
+ /// - Parameter text: The user's message text.
+ public func send(_ text: String) async {
+ guard !isRunning else { return }
+ isRunning = true
+ lastError = nil
+
+ messages.append(AgentMessage(role: .user, content: text))
+
+ // Track assistant messages by their protocol messageId so out-of-order
+ // deltas (unlikely but spec-valid) still land in the right message.
+ var assistantIndices: [String: Int] = [:]
+
+ do {
+ let stream = try await agent.chat(message: text, threadId: threadId)
+ for try await event in stream {
+ switch event {
+ case let e as TextMessageStartEvent:
+ messages.append(AgentMessage(role: .assistant, content: ""))
+ assistantIndices[e.messageId] = messages.count - 1
+
+ case let e as TextMessageContentEvent:
+ guard let idx = assistantIndices[e.messageId] else { break }
+ let current = messages[idx]
+ messages[idx] = AgentMessage(
+ id: current.id,
+ role: .assistant,
+ content: current.content + e.delta
+ )
+
+ case let e as RunErrorEvent:
+ lastError = AgentError.runError(message: e.message, code: e.code)
+
+ default:
+ break
+ }
+ }
+ } catch {
+ lastError = error
+ }
+
+ isRunning = false
+ }
+
+ /// Clears all displayed messages and the agent's internal conversation history.
+ public func clear() async {
+ messages = []
+ await agent.clearHistory(threadId: threadId)
+ }
+}
+
+#endif
diff --git a/sdks/community/swift/Sources/AGUIAgentSDK/AgentViewModelCompat.swift b/sdks/community/swift/Sources/AGUIAgentSDK/AgentViewModelCompat.swift
new file mode 100644
index 0000000000..f2fe3b0233
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUIAgentSDK/AgentViewModelCompat.swift
@@ -0,0 +1,157 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUICore
+import Combine
+import Foundation
+
+/// Combine-compatible view model for AG-UI agent conversations.
+///
+/// `AgentViewModelCompat` bridges the AG-UI event stream to SwiftUI on iOS 16 and
+/// macOS 13 (and later) via `ObservableObject` / `@Published`. For iOS 17+ and
+/// macOS 14+, prefer the zero-boilerplate ``AgentViewModel`` which uses the
+/// `@Observable` macro instead.
+///
+/// ## Basic SwiftUI usage
+///
+/// ```swift
+/// @StateObject private var vm = AgentViewModelCompat(
+/// agent: StatefulAgUiAgent(baseURL: agentURL)
+/// )
+///
+/// var body: some View {
+/// VStack {
+/// ScrollView {
+/// ForEach(vm.messages) { message in
+/// MessageBubble(message: message)
+/// }
+/// }
+/// HStack {
+/// TextField("Message", text: $draft)
+/// Button("Send") {
+/// Task { await vm.send(draft) }
+/// }
+/// .disabled(vm.isRunning)
+/// }
+/// if let error = vm.lastError {
+/// Text(error.localizedDescription).foregroundColor(.red)
+/// }
+/// }
+/// }
+/// ```
+///
+/// ## Streaming text
+///
+/// As the agent streams tokens, each `TextMessageContentEvent` delta is appended
+/// to the last assistant `AgentMessage` in place — the message `id` stays the
+/// same so SwiftUI animates the existing row rather than replacing it.
+///
+/// ## Error handling
+///
+/// - `RunErrorEvent` from the agent sets `lastError` as ``AgentError/runError(message:code:)``
+/// - Transport-level failures (network, timeout) set `lastError` as whatever `Error` was thrown
+/// - `lastError` is cleared automatically at the start of each new `send()` call
+///
+/// ## Thread safety
+///
+/// `AgentViewModelCompat` is isolated to `@MainActor`. Call `send()` and `clear()`
+/// from SwiftUI button handlers or `Task { }` blocks — they are already on the main actor.
+///
+/// - SeeAlso: ``AgentViewModel``, ``ChatAgent``, ``AgentMessage``, ``AgentError``
+@MainActor
+public final class AgentViewModelCompat: ObservableObject {
+
+ // MARK: - Published state
+
+ /// The conversation messages in chronological order.
+ ///
+ /// User messages are appended synchronously at the start of `send()`.
+ /// Assistant messages grow token-by-token as the stream delivers content.
+ @Published public private(set) var messages: [AgentMessage] = []
+
+ /// `true` while an agent run is in progress.
+ ///
+ /// Bind this to a loading indicator or use it to disable the send button.
+ @Published public private(set) var isRunning: Bool = false
+
+ /// The last error encountered, or `nil` if the most recent run succeeded.
+ ///
+ /// Set to `nil` automatically at the start of each `send()` call.
+ @Published public private(set) var lastError: Error? = nil
+
+ // MARK: - Configuration
+
+ /// The conversation thread identifier passed to the underlying agent.
+ public let threadId: String
+
+ // MARK: - Private
+
+ private let agent: any ChatAgent
+
+ // MARK: - Initialization
+
+ /// Creates a view model backed by the given agent.
+ ///
+ /// - Parameters:
+ /// - agent: Any ``ChatAgent`` implementation (typically ``StatefulAgUiAgent``).
+ /// - threadId: Conversation thread identifier (default: `"default"`).
+ public init(agent: any ChatAgent, threadId: String = "default") {
+ self.agent = agent
+ self.threadId = threadId
+ }
+
+ // MARK: - Public API
+
+ /// Sends a user message and streams the agent's response into `messages`.
+ ///
+ /// If a run is already in progress this call is a no-op (the guard prevents
+ /// concurrent runs on the same view model instance).
+ ///
+ /// - Parameter text: The user's message text.
+ public func send(_ text: String) async {
+ guard !isRunning else { return }
+ isRunning = true
+ lastError = nil
+
+ messages.append(AgentMessage(role: .user, content: text))
+
+ // Track assistant messages by their protocol messageId so out-of-order
+ // deltas (unlikely but spec-valid) still land in the right message.
+ var assistantIndices: [String: Int] = [:]
+
+ do {
+ let stream = try await agent.chat(message: text, threadId: threadId)
+ for try await event in stream {
+ switch event {
+ case let e as TextMessageStartEvent:
+ messages.append(AgentMessage(role: .assistant, content: ""))
+ assistantIndices[e.messageId] = messages.count - 1
+
+ case let e as TextMessageContentEvent:
+ guard let idx = assistantIndices[e.messageId] else { break }
+ let current = messages[idx]
+ messages[idx] = AgentMessage(
+ id: current.id,
+ role: .assistant,
+ content: current.content + e.delta
+ )
+
+ case let e as RunErrorEvent:
+ lastError = AgentError.runError(message: e.message, code: e.code)
+
+ default:
+ break
+ }
+ }
+ } catch {
+ lastError = error
+ }
+
+ isRunning = false
+ }
+
+ /// Clears all displayed messages and the agent's internal conversation history.
+ public func clear() async {
+ messages = []
+ await agent.clearHistory(threadId: threadId)
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUIAgentSDK/ChatAgent.swift b/sdks/community/swift/Sources/AGUIAgentSDK/ChatAgent.swift
new file mode 100644
index 0000000000..77d7e2deac
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUIAgentSDK/ChatAgent.swift
@@ -0,0 +1,55 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUICore
+import Foundation
+
+/// Defines the minimal surface an agent must expose to be driven by
+/// ``AgentViewModel`` or ``AgentViewModelCompat``.
+///
+/// Conforming to `ChatAgent` decouples the view-model tier from any specific
+/// agent implementation and makes both the view models and any custom agent
+/// implementations fully testable through mocks.
+///
+/// ## Built-in conformances
+///
+/// ``StatefulAgUiAgent`` conforms to `ChatAgent` out of the box via a retroactive
+/// extension in this file.
+///
+/// ## Custom agents
+///
+/// ```swift
+/// struct MockChatAgent: ChatAgent {
+/// func chat(message: String, threadId: String) async throws
+/// -> AsyncThrowingStream
+/// {
+/// // Return test events
+/// }
+///
+/// func clearHistory(threadId: String?) async {}
+/// }
+/// ```
+///
+/// - SeeAlso: ``AgentViewModel``, ``AgentViewModelCompat``, ``StatefulAgUiAgent``
+public protocol ChatAgent: Sendable {
+
+ /// Sends a user message and returns the resulting AG-UI event stream.
+ ///
+ /// - Parameters:
+ /// - message: The user's text.
+ /// - threadId: The conversation thread identifier.
+ /// - Returns: An `AsyncThrowingStream` of AG-UI events for this run.
+ /// - Throws: A transport-level error if the request fails before streaming begins.
+ func chat(
+ message: String,
+ threadId: String
+ ) async throws -> AsyncThrowingStream
+
+ /// Clears the conversation history for the given thread, or all threads when `nil`.
+ ///
+ /// - Parameter threadId: The thread to clear, or `nil` for all threads.
+ func clearHistory(threadId: String?) async
+}
+
+// MARK: - StatefulAgUiAgent conformance
+
+extension StatefulAgUiAgent: ChatAgent {}
diff --git a/sdks/community/swift/Sources/AGUIAgentSDK/ConversationHistoryManager.swift b/sdks/community/swift/Sources/AGUIAgentSDK/ConversationHistoryManager.swift
new file mode 100644
index 0000000000..31dc9acb41
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUIAgentSDK/ConversationHistoryManager.swift
@@ -0,0 +1,137 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUICore
+import Foundation
+
+/// Thread-safe manager for conversation history across multiple threads.
+///
+/// `ConversationHistoryManager` maintains separate message histories for each
+/// conversation thread, providing automatic trimming and thread-safe access.
+///
+/// ## Thread Management
+///
+/// Each thread has its own independent conversation history, identified by
+/// a string thread ID (e.g., "default", "user-123", "session-abc").
+///
+/// ## History Trimming
+///
+/// When trimming, the manager preserves system messages while removing the
+/// oldest user/assistant message pairs to fit within the specified limit.
+///
+/// ## Example
+///
+/// ```swift
+/// let manager = ConversationHistoryManager()
+///
+/// // Add messages to a thread
+/// await manager.append(
+/// message: SystemMessage(id: "sys1", content: "You are helpful"),
+/// to: "chat-1"
+/// )
+/// await manager.append(
+/// message: UserMessage(id: "usr1", content: "Hello"),
+/// to: "chat-1"
+/// )
+///
+/// // Get history
+/// let history = await manager.history(for: "chat-1")
+/// print(history.count) // 2
+///
+/// // Trim to size
+/// await manager.trim(threadId: "chat-1", maxLength: 10)
+/// ```
+actor ConversationHistoryManager {
+ /// Storage for per-thread conversation histories.
+ private var threadHistories: [String: [any Message]] = [:]
+
+ /// Creates a new conversation history manager.
+ public init() {}
+
+ /// Appends a message to a thread's conversation history.
+ ///
+ /// - Parameters:
+ /// - message: The message to append
+ /// - threadId: The thread identifier
+ public func append(message: any Message, to threadId: String) {
+ threadHistories[threadId, default: []].append(message)
+ }
+
+ /// Retrieves the conversation history for a thread.
+ ///
+ /// - Parameter threadId: The thread identifier
+ /// - Returns: Array of messages in chronological order, or empty if no history exists
+ public func history(for threadId: String) -> [any Message] {
+ threadHistories[threadId] ?? []
+ }
+
+ /// Trims conversation history to fit within a maximum length.
+ ///
+ /// This method preserves the system message (if present) and removes the oldest
+ /// messages to fit within `maxLength`. The system message does not count toward
+ /// the limit.
+ ///
+ /// - Parameters:
+ /// - threadId: The thread identifier
+ /// - maxLength: Maximum number of messages to keep (excluding system message)
+ ///
+ /// ## Example
+ ///
+ /// ```swift
+ /// // Given: [SystemMessage, User1, Assistant1, User2, Assistant2, User3]
+ /// await manager.trim(threadId: "chat", maxLength: 3)
+ /// // Result: [SystemMessage, User2, Assistant2, User3]
+ /// ```
+ public func trim(threadId: String, maxLength: Int) {
+ guard let history = threadHistories[threadId], history.count > maxLength else {
+ return
+ }
+
+ // Check for system message at the beginning
+ let hasSystemMessage = history.first is SystemMessage
+ if hasSystemMessage && history.count > 1 {
+ // Keep system message + last maxLength messages (system excluded from count)
+ let systemMessage = history.first!
+ let trimmed = Array(history.dropFirst().suffix(maxLength))
+ threadHistories[threadId] = [systemMessage] + trimmed
+ } else {
+ // No system message, just keep last maxLength messages
+ threadHistories[threadId] = Array(history.suffix(maxLength))
+ }
+ }
+
+ /// Clears conversation history for one or all threads.
+ ///
+ /// - Parameter threadId: The thread ID to clear, or `nil` to clear all threads
+ ///
+ /// ## Example
+ ///
+ /// ```swift
+ /// // Clear specific thread
+ /// await manager.clear(threadId: "chat-1")
+ ///
+ /// // Clear all threads
+ /// await manager.clear(threadId: nil)
+ /// ```
+ public func clear(threadId: String? = nil) {
+ if let threadId = threadId {
+ threadHistories.removeValue(forKey: threadId)
+ } else {
+ threadHistories.removeAll()
+ }
+ }
+
+ /// Returns the number of messages in a thread's history.
+ ///
+ /// - Parameter threadId: The thread identifier
+ /// - Returns: Number of messages, or 0 if thread doesn't exist
+ public func count(for threadId: String) -> Int {
+ threadHistories[threadId]?.count ?? 0
+ }
+
+ /// Returns all active thread IDs.
+ ///
+ /// - Returns: Array of thread IDs that have conversation history
+ public func allThreadIds() -> [String] {
+ Array(threadHistories.keys)
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUIAgentSDK/StatefulAgUiAgent.swift b/sdks/community/swift/Sources/AGUIAgentSDK/StatefulAgUiAgent.swift
new file mode 100644
index 0000000000..82e8704249
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUIAgentSDK/StatefulAgUiAgent.swift
@@ -0,0 +1,512 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUIClient
+import AGUICore
+import AGUITools
+import Foundation
+
+/// Stateful AG-UI agent that automatically manages conversation history.
+///
+/// `StatefulAgUiAgent` provides a high-level, iOS-friendly API for building
+/// conversational AI interfaces. It automatically tracks message history per thread,
+/// manages state updates, and provides convenient methods for common patterns.
+///
+/// ## Basic Usage
+///
+/// ```swift
+/// let agent = StatefulAgUiAgent(baseURL: URL(string: "https://agent.example.com")!)
+///
+/// let stream = try await agent.chat(message: "Hello!")
+/// for try await event in stream {
+/// if let content = event as? TextMessageContentEvent {
+/// print(content.delta, terminator: "")
+/// }
+/// }
+/// ```
+///
+/// ## Advanced Configuration
+///
+/// ```swift
+/// var config = StatefulAgUiAgentConfig(baseURL: agentURL)
+/// config.systemPrompt = "You are a helpful AI assistant."
+/// config.maxHistoryLength = 50
+/// config.timeout = .seconds(60)
+///
+/// let agent = StatefulAgUiAgent(configuration: config)
+///
+/// // Multi-turn conversation
+/// _ = try await agent.chat(message: "What's the weather?")
+/// _ = try await agent.chat(message: "And tomorrow?") // Maintains context
+/// ```
+///
+/// ## Thread Management
+///
+/// Each conversation can have its own thread with independent history:
+///
+/// ```swift
+/// // Conversation 1
+/// let stream1 = try await agent.chat(message: "Hello", threadId: "user-123")
+///
+/// // Conversation 2 (separate history)
+/// let stream2 = try await agent.chat(message: "Hi", threadId: "user-456")
+/// ```
+///
+/// ## Features
+///
+/// - **Automatic History**: User and assistant messages are tracked automatically
+/// - **System Prompts**: Configurable system message for agent behavior
+/// - **History Trimming**: Keeps conversations within token limits
+/// - **State Management**: Tracks and updates agent state from events
+/// - **Thread Safety**: Actor-based concurrency for safe multi-threaded use
+///
+/// - SeeAlso: ``StatefulAgUiAgentConfig``, ``ConversationHistoryManager``
+public final class StatefulAgUiAgent: Sendable {
+ /// The underlying HTTP agent for communication.
+ private let httpAgent: HttpAgent
+
+ /// Injected transport used in place of `httpAgent` when present (test path).
+ private let agentTransport: (any AgentTransport)?
+
+ /// Manager for conversation histories across threads.
+ private let historyManager: ConversationHistoryManager
+
+ /// Configuration for this agent.
+ public let config: StatefulAgUiAgentConfig
+
+ /// Actor for managing current state (thread-safe).
+ private let stateManager: StateManager
+
+ /// Optional tool execution manager. Created when `config.toolRegistry` is set.
+ private let toolExecutionManager: ToolExecutionManager?
+
+ /// Creates a new stateful agent with a base URL.
+ ///
+ /// - Parameter baseURL: The base URL of the AG-UI agent server
+ ///
+ /// ## Example
+ ///
+ /// ```swift
+ /// let agent = StatefulAgUiAgent(baseURL: URL(string: "https://agent.example.com")!)
+ /// ```
+ public convenience init(baseURL: URL) {
+ self.init(configuration: StatefulAgUiAgentConfig(baseURL: baseURL))
+ }
+
+ /// Creates a new stateful agent with custom configuration.
+ ///
+ /// - Parameter configuration: The stateful agent configuration
+ ///
+ /// ## Example
+ ///
+ /// ```swift
+ /// var config = StatefulAgUiAgentConfig(baseURL: agentURL)
+ /// config.systemPrompt = "You are helpful"
+ /// config.maxHistoryLength = 100
+ ///
+ /// let agent = StatefulAgUiAgent(configuration: config)
+ /// ```
+ public init(configuration: StatefulAgUiAgentConfig) {
+ self.config = configuration
+ let agent = HttpAgent(configuration: HttpAgentConfiguration(
+ baseURL: configuration.baseURL,
+ timeout: configuration.timeout,
+ headers: configuration.buildHeaders(),
+ debug: configuration.debug
+ ))
+ self.httpAgent = agent
+ self.agentTransport = nil
+ self.historyManager = ConversationHistoryManager()
+ self.stateManager = StateManager(initialState: configuration.initialState)
+ if let registry = configuration.toolRegistry {
+ self.toolExecutionManager = ToolExecutionManager(
+ toolRegistry: registry,
+ responseHandler: ClientToolResponseHandler(httpAgent: agent, endpoint: configuration.endpoint)
+ )
+ } else {
+ self.toolExecutionManager = nil
+ }
+ }
+
+ /// Creates a stateful agent backed by a custom transport (test path).
+ ///
+ /// Use this initializer in tests to inject a mock transport instead of
+ /// making real HTTP connections.
+ init(transport: any AgentTransport, config: StatefulAgUiAgentConfig) {
+ self.config = config
+ let url = URL(string: "https://placeholder.local")!
+ self.httpAgent = HttpAgent(baseURL: url)
+ self.agentTransport = transport
+ self.historyManager = ConversationHistoryManager()
+ self.stateManager = StateManager(initialState: config.initialState)
+ self.toolExecutionManager = nil
+ }
+
+ /// Sends a chat message with automatic history management.
+ ///
+ /// This is a convenience method that delegates to ``sendMessage(message:threadId:state:includeSystemPrompt:)``
+ /// with sensible defaults for casual chat interactions.
+ ///
+ /// - Parameters:
+ /// - message: The user's message text
+ /// - threadId: The conversation thread ID (default: "default")
+ /// - Returns: Event stream from the agent
+ /// - Throws: `ClientError` if the request fails
+ ///
+ /// ## Example
+ ///
+ /// ```swift
+ /// let stream = try await agent.chat(message: "Hello!")
+ /// for try await event in stream {
+ /// // Process events
+ /// }
+ /// ```
+ public func chat(
+ message: String,
+ threadId: String = "default"
+ ) async throws -> AsyncThrowingStream {
+ let currentState = await stateManager.currentState()
+ return try await sendMessage(
+ message: message,
+ threadId: threadId,
+ state: currentState,
+ includeSystemPrompt: true
+ )
+ }
+
+ /// Sends a message with full control over state and system prompt.
+ ///
+ /// This method provides complete control over the message sending process,
+ /// including custom state and system prompt inclusion.
+ ///
+ /// - Parameters:
+ /// - message: The user's message text
+ /// - threadId: The conversation thread ID
+ /// - state: Custom state to send (defaults to current state)
+ /// - includeSystemPrompt: Whether to add system prompt for new threads (default: true)
+ /// - Returns: Event stream from the agent
+ /// - Throws: `ClientError` if the request fails
+ ///
+ /// ## Example
+ ///
+ /// ```swift
+ /// let customState = Data("{\"mode\":\"creative\"}".utf8)
+ /// let stream = try await agent.sendMessage(
+ /// message: "Tell me a story",
+ /// threadId: "story-session",
+ /// state: customState,
+ /// includeSystemPrompt: true
+ /// )
+ /// ```
+ public func sendMessage(
+ message: String,
+ threadId: String,
+ state: State?,
+ includeSystemPrompt: Bool
+ ) async throws -> AsyncThrowingStream {
+ // Get conversation history for this thread
+ var history = await historyManager.history(for: threadId)
+
+ // Add system prompt if it's the first message and includeSystemPrompt is true
+ if history.isEmpty && includeSystemPrompt, let systemPrompt = config.systemPrompt {
+ let systemMessage = SystemMessage(
+ id: "sys_\(UUID().uuidString)",
+ content: systemPrompt
+ )
+ await historyManager.append(message: systemMessage, to: threadId)
+ history.append(systemMessage)
+ }
+
+ // Create and add the user message
+ let userMessage = UserMessage(
+ id: "usr_\(UUID().uuidString)",
+ content: message
+ )
+ await historyManager.append(message: userMessage, to: threadId)
+ history.append(userMessage)
+
+ // Apply history length limit if configured
+ if config.maxHistoryLength > 0 {
+ await historyManager.trim(threadId: threadId, maxLength: config.maxHistoryLength)
+ history = await historyManager.history(for: threadId)
+ }
+
+ // Use the provided state or the current state
+ let stateToUse: State
+ if let providedState = state {
+ stateToUse = providedState
+ } else {
+ stateToUse = await stateManager.currentState()
+ }
+
+ // Build the base run input
+ let baseInput = try RunAgentInput.builder()
+ .threadId(threadId)
+ .runId("run_\(UUID().uuidString)")
+ .messages(history)
+ .state(stateToUse)
+ .build()
+
+ // Include tool definitions if a registry is configured
+ let inputWithTools: RunAgentInput
+ if let registry = config.toolRegistry {
+ let tools = await registry.allTools()
+ inputWithTools = RunAgentInput(
+ threadId: baseInput.threadId,
+ runId: baseInput.runId,
+ parentRunId: baseInput.parentRunId,
+ state: baseInput.state,
+ messages: baseInput.messages,
+ tools: tools,
+ context: config.context.isEmpty ? baseInput.context : config.context,
+ forwardedProps: baseInput.forwardedProps
+ )
+ } else {
+ inputWithTools = RunAgentInput(
+ threadId: baseInput.threadId,
+ runId: baseInput.runId,
+ parentRunId: baseInput.parentRunId,
+ state: baseInput.state,
+ messages: baseInput.messages,
+ tools: baseInput.tools,
+ context: config.context.isEmpty ? baseInput.context : config.context,
+ forwardedProps: baseInput.forwardedProps
+ )
+ }
+
+ // Execute the run and obtain an event stream
+ let eventStream: AsyncThrowingStream
+
+ if let transport = agentTransport {
+ // Test path: transport yields events directly
+ let rawStream = transport.run(input: inputWithTools)
+ if let manager = toolExecutionManager {
+ eventStream = await manager.processEventStream(
+ rawStream,
+ threadId: inputWithTools.threadId,
+ runId: inputWithTools.runId
+ )
+ } else {
+ eventStream = rawStream
+ }
+ } else {
+ // Production path: httpAgent performs SSE over HTTP
+ let rawStream = try await httpAgent.run(inputWithTools, endpoint: config.endpoint)
+ if let manager = toolExecutionManager {
+ eventStream = await manager.processEventStream(
+ rawStream,
+ threadId: inputWithTools.threadId,
+ runId: inputWithTools.runId
+ )
+ } else {
+ eventStream = AsyncThrowingStream { continuation in
+ let task = Task {
+ do {
+ for try await event in rawStream { continuation.yield(event) }
+ continuation.finish()
+ } catch { continuation.finish(throwing: error) }
+ }
+ continuation.onTermination = { _ in task.cancel() }
+ }
+ }
+ }
+
+ // Wrap the stream to track history and state updates
+ return trackHistoryAndState(stream: eventStream, threadId: threadId)
+ }
+
+ /// Retrieves the conversation history for a thread.
+ ///
+ /// - Parameter threadId: The thread ID (default: "default")
+ /// - Returns: Array of messages in chronological order
+ ///
+ /// ## Example
+ ///
+ /// ```swift
+ /// let history = await agent.history(for: "chat-session")
+ /// print("Conversation has \(history.count) messages")
+ /// ```
+ public func history(for threadId: String = "default") async -> [any Message] {
+ await historyManager.history(for: threadId)
+ }
+
+ /// Clears conversation history for one or all threads.
+ ///
+ /// - Parameter threadId: The thread ID to clear, or `nil` to clear all threads
+ ///
+ /// ## Example
+ ///
+ /// ```swift
+ /// // Clear specific thread
+ /// await agent.clearHistory(threadId: "chat-1")
+ ///
+ /// // Clear all threads
+ /// await agent.clearHistory()
+ /// ```
+ public func clearHistory(threadId: String? = nil) async {
+ await historyManager.clear(threadId: threadId)
+ }
+
+ // MARK: - Private Helpers
+
+ /// Wraps an event stream to track assistant messages, tool calls, and state updates.
+ private func trackHistoryAndState(
+ stream: S,
+ threadId: String
+ ) -> AsyncThrowingStream where S.Element == any AGUIEvent {
+ AsyncThrowingStream { continuation in
+ let task = Task {
+ var currentAssistantMessage: AssistantMessage?
+ let patchApplicator = PatchApplicator()
+
+ do {
+ for try await event in stream {
+ // Yield the event downstream
+ continuation.yield(event)
+
+ // Track assistant messages, tool calls, and state
+ switch event {
+
+ // MARK: Text messages
+
+ case let start as TextMessageStartEvent:
+ currentAssistantMessage = AssistantMessage(
+ id: start.messageId,
+ content: ""
+ )
+
+ case let content as TextMessageContentEvent:
+ if let msg = currentAssistantMessage, msg.id == content.messageId {
+ let updatedContent = (msg.content ?? "") + content.delta
+ currentAssistantMessage = AssistantMessage(
+ id: msg.id,
+ content: updatedContent,
+ name: msg.name,
+ toolCalls: msg.toolCalls
+ )
+ }
+
+ case let end as TextMessageEndEvent:
+ if let msg = currentAssistantMessage, msg.id == end.messageId {
+ await self.historyManager.append(message: msg, to: threadId)
+ currentAssistantMessage = nil
+ }
+
+ // MARK: Tool calls
+
+ case let start as ToolCallStartEvent:
+ let newCall = ToolCall(
+ id: start.toolCallId,
+ function: FunctionCall(name: start.toolCallName, arguments: "")
+ )
+ if let msg = currentAssistantMessage {
+ var calls = msg.toolCalls ?? []
+ calls.append(newCall)
+ currentAssistantMessage = AssistantMessage(
+ id: msg.id,
+ content: msg.content,
+ name: msg.name,
+ toolCalls: calls
+ )
+ } else {
+ currentAssistantMessage = AssistantMessage(
+ id: "asst_\(UUID().uuidString)",
+ content: nil,
+ toolCalls: [newCall]
+ )
+ }
+
+ case let args as ToolCallArgsEvent:
+ if let msg = currentAssistantMessage,
+ let calls = msg.toolCalls,
+ let idx = calls.firstIndex(where: { $0.id == args.toolCallId }) {
+ let call = calls[idx]
+ let updatedCall = ToolCall(
+ id: call.id,
+ function: FunctionCall(
+ name: call.function.name,
+ arguments: call.function.arguments + args.delta
+ )
+ )
+ var updatedCalls = calls
+ updatedCalls[idx] = updatedCall
+ currentAssistantMessage = AssistantMessage(
+ id: msg.id,
+ content: msg.content,
+ name: msg.name,
+ toolCalls: updatedCalls
+ )
+ }
+
+ case is ToolCallEndEvent:
+ // Do NOT append the assistant message here. For multi-tool-call sequences
+ // (ToolCallStart→ToolCallEnd×N), appending at every ToolCallEnd caused N
+ // duplicates in history. The assistant message is flushed at:
+ // • TextMessageEndEvent (text + optional tool calls)
+ // • The first ToolCallResultEvent (tool-call-only turns)
+ break
+
+ case let result as ToolCallResultEvent:
+ // Flush a pending tool-call-only assistant message before recording the
+ // tool result. This handles turns where ToolCallStart/End events fire
+ // without a surrounding TextMessageStart/End envelope.
+ if let msg = currentAssistantMessage {
+ await self.historyManager.append(message: msg, to: threadId)
+ currentAssistantMessage = nil
+ }
+ let toolMessage = ToolMessage(
+ id: result.messageId,
+ content: result.content,
+ toolCallId: result.toolCallId
+ )
+ await self.historyManager.append(message: toolMessage, to: threadId)
+
+ // MARK: State events
+
+ case let snapshot as StateSnapshotEvent:
+ await self.stateManager.updateState(snapshot.snapshot)
+
+ case let delta as StateDeltaEvent:
+ let currentState = await self.stateManager.currentState()
+ if let newState = try? patchApplicator.apply(patch: delta.delta, to: currentState) {
+ await self.stateManager.updateState(newState)
+ }
+
+ case let msgSnapshot as MessagesSnapshotEvent:
+ // Replace the thread history with the authoritative snapshot.
+ await self.historyManager.clear(threadId: threadId)
+ for msg in msgSnapshot.messages {
+ await self.historyManager.append(message: msg, to: threadId)
+ }
+
+ default:
+ break
+ }
+ }
+ continuation.finish()
+ } catch {
+ continuation.finish(throwing: error)
+ }
+ }
+ continuation.onTermination = { _ in task.cancel() }
+ }
+ }
+}
+
+// MARK: - StateManager
+
+/// Thread-safe actor for managing agent state.
+private actor StateManager {
+ private var currentStateValue: State
+
+ init(initialState: State) {
+ self.currentStateValue = initialState
+ }
+
+ func currentState() -> State {
+ currentStateValue
+ }
+
+ func updateState(_ newState: State) {
+ currentStateValue = newState
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUIAgentSDK/StatefulAgUiAgentConfig.swift b/sdks/community/swift/Sources/AGUIAgentSDK/StatefulAgUiAgentConfig.swift
new file mode 100644
index 0000000000..cb198aeafd
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUIAgentSDK/StatefulAgUiAgentConfig.swift
@@ -0,0 +1,158 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUIClient
+import AGUICore
+import AGUITools
+import Foundation
+
+/// Configuration for ``StatefulAgUiAgent``.
+///
+/// This struct provides all configuration options for creating a stateful agent,
+/// including HTTP settings, conversation management, and agent behavior.
+///
+/// ## Example
+///
+/// ```swift
+/// var config = StatefulAgUiAgentConfig(baseURL: agentURL)
+/// config.systemPrompt = "You are a helpful AI assistant."
+/// config.maxHistoryLength = 50
+/// config.timeout = .seconds(60)
+/// config.headers = ["Authorization": "Bearer token"]
+///
+/// let agent = StatefulAgUiAgent(configuration: config)
+/// ```
+public struct StatefulAgUiAgentConfig: Sendable {
+ /// The base URL of the AG-UI agent server.
+ public var baseURL: URL
+
+ /// Initial state for the agent.
+ ///
+ /// This JSON-encoded state is sent with the first run and updated
+ /// automatically based on state events from the agent.
+ public var initialState: State
+
+ /// Maximum number of messages to keep in conversation history.
+ ///
+ /// When the history exceeds this limit, older messages are trimmed
+ /// while preserving the system message. Set to `0` for unlimited history.
+ ///
+ /// Default: `100`
+ public var maxHistoryLength: Int
+
+ /// System prompt automatically added to new conversations.
+ ///
+ /// When set, this message is added as the first message in a new thread's
+ /// conversation history. It provides the agent with behavioral instructions.
+ ///
+ /// Default: `nil`
+ public var systemPrompt: String?
+
+ /// Request timeout in seconds.
+ ///
+ /// Maximum time to wait for the agent server to respond before timing out.
+ ///
+ /// Default: `120.0`
+ public var timeout: TimeInterval
+
+ /// Custom HTTP headers to include in requests.
+ ///
+ /// Common headers include:
+ /// - Authorization: Bearer tokens or API keys
+ /// - Custom tracking or correlation IDs
+ ///
+ /// Default: `[:]`
+ public var headers: [String: String]
+
+ /// Optional tool registry.
+ ///
+ /// When set, tool definitions are automatically included in every
+ /// `RunAgentInput` and tool calls from the agent are executed automatically.
+ ///
+ /// Default: `nil`
+ public var toolRegistry: (any ToolRegistry)?
+
+ /// Persistent user ID for message attribution.
+ ///
+ /// Default: `nil`
+ public var userId: String?
+
+ /// Context items included with every request.
+ ///
+ /// Default: `[]`
+ public var context: [Context]
+
+ /// Bearer token for authentication.
+ ///
+ /// When set, ``buildHeaders()`` includes `Authorization: Bearer `.
+ /// This property does **not** mutate ``headers`` directly — use
+ /// ``buildHeaders()`` to get the merged dictionary for request construction.
+ ///
+ /// Default: `nil`
+ public var bearerToken: String?
+
+ /// API key value.
+ ///
+ /// Used together with ``apiKeyHeader`` to add an API key header to requests.
+ ///
+ /// Default: `nil`
+ public var apiKey: String?
+
+ /// Header name for the API key.
+ ///
+ /// Default: `"X-API-Key"`
+ public var apiKeyHeader: String
+
+ /// When `true`, enables verbose pipeline logging.
+ ///
+ /// Default: `false`
+ public var debug: Bool
+
+ /// The endpoint path appended to `baseURL` for each run request.
+ ///
+ /// Override this when the agent server exposes its AG-UI endpoint at a
+ /// non-standard path. For example, the Claude Agent SDK demo server uses
+ /// `/agentic_chat` instead of the default `/run`.
+ ///
+ /// Default: `"/run"`
+ public var endpoint: String
+
+ // MARK: - Header builder
+
+ /// Returns the final HTTP header dictionary, merging ``bearerToken`` and
+ /// ``apiKey`` into ``headers``.
+ ///
+ /// Priority (highest → lowest):
+ /// 1. Entries already in ``headers``
+ /// 2. `bearerToken` → `Authorization: Bearer `
+ /// 3. `apiKey` → `: `
+ ///
+ /// - Returns: Merged header dictionary ready for `HttpAgentConfiguration`.
+ public func buildHeaders() -> [String: String] {
+ AgentHeaderBuilder.buildHeaders(
+ headers: headers,
+ bearerToken: bearerToken,
+ apiKey: apiKey,
+ apiKeyHeader: apiKeyHeader
+ )
+ }
+
+ /// Creates a new stateful agent configuration.
+ ///
+ /// - Parameter baseURL: The base URL of the AG-UI agent server
+ public init(baseURL: URL) {
+ self.baseURL = baseURL
+ self.initialState = Data("{}".utf8)
+ self.maxHistoryLength = 100
+ self.systemPrompt = nil
+ self.timeout = 120.0
+ self.headers = [:]
+ self.toolRegistry = nil
+ self.userId = nil
+ self.context = []
+ self.bearerToken = nil
+ self.apiKey = nil
+ self.apiKeyHeader = "X-API-Key"
+ self.debug = false
+ self.endpoint = "/run"
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUIAgentSDK/Tools/ClientToolResponseHandler.swift b/sdks/community/swift/Sources/AGUIAgentSDK/Tools/ClientToolResponseHandler.swift
new file mode 100644
index 0000000000..c9705993c3
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUIAgentSDK/Tools/ClientToolResponseHandler.swift
@@ -0,0 +1,65 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUIClient
+import AGUICore
+import AGUITools
+import Foundation
+
+/// HTTP implementation of `ToolResponseHandler`.
+///
+/// Sends tool results back to the agent by initiating a new run containing
+/// only the tool result message. This mirrors the Kotlin `ClientToolResponseHandler`.
+///
+/// ## How it works
+///
+/// When a tool call completes, the result must be delivered back to the agent
+/// so it can continue the conversation. `ClientToolResponseHandler` does this
+/// by constructing a minimal `RunAgentInput` containing the `ToolMessage` and
+/// executing a new run through the same `HttpAgent`. The resulting events are
+/// consumed and discarded — callers receive the results through the ongoing
+/// conversation stream, not here.
+///
+/// ## Example
+///
+/// ```swift
+/// let httpAgent = HttpAgent(baseURL: agentURL)
+/// let handler = ClientToolResponseHandler(httpAgent: httpAgent)
+/// let manager = ToolExecutionManager(
+/// toolRegistry: registry,
+/// responseHandler: handler
+/// )
+/// ```
+public final class ClientToolResponseHandler: ToolResponseHandler, Sendable {
+
+ private let httpAgent: HttpAgent
+ private let endpoint: String?
+
+ /// Creates a handler that routes tool responses through the given agent.
+ ///
+ /// - Parameters:
+ /// - httpAgent: The HTTP agent used to deliver tool results
+ /// - endpoint: The endpoint path to POST tool results to (e.g. `"/agentic_chat"`).
+ /// Defaults to the agent's own default when `nil`.
+ public init(httpAgent: HttpAgent, endpoint: String? = nil) {
+ self.httpAgent = httpAgent
+ self.endpoint = endpoint
+ }
+
+ public func sendToolResponse(
+ _ message: ToolMessage,
+ threadId: String?,
+ runId: String?
+ ) async throws {
+ let input = RunAgentInput(
+ threadId: threadId ?? "tool_\(UUID().uuidString)",
+ runId: runId ?? "run_\(UUID().uuidString)",
+ messages: [message]
+ )
+ // Drive the full pipeline; surface any server-side errors.
+ for try await event in try await httpAgent.run(input, endpoint: endpoint) {
+ if let errorEvent = event as? RunErrorEvent {
+ throw ClientError.streamError(errorEvent.message)
+ }
+ }
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUIClient/AGUIClient.swift b/sdks/community/swift/Sources/AGUIClient/AGUIClient.swift
new file mode 100644
index 0000000000..45d1368c95
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUIClient/AGUIClient.swift
@@ -0,0 +1,29 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUICore
+import Foundation
+
+/// AGUIClient provides HTTP transport and streaming capabilities for AG-UI agents.
+///
+/// This module contains the low-level client infrastructure including:
+/// - HTTP transport with URLSession
+/// - Server-Sent Events (SSE) parsing
+/// - Event stream management
+/// - State synchronization
+///
+/// ## Usage
+///
+/// ```swift
+/// import AGUIClient
+///
+/// let agent = HttpAgent(baseURL: agentURL)
+/// for try await event in try await agent.run(input) {
+/// // Process events
+/// }
+/// ```
+public struct AGUIClient {
+ /// The version of the AGUIClient module.
+ public static let version = "0.1.0"
+
+ private init() {}
+}
diff --git a/sdks/community/swift/Sources/AGUIClient/AbstractAgent.swift b/sdks/community/swift/Sources/AGUIClient/AbstractAgent.swift
new file mode 100644
index 0000000000..f885029025
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUIClient/AbstractAgent.swift
@@ -0,0 +1,200 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUICore
+import Foundation
+
+// MARK: - AgentStorage
+
+internal actor AgentStorage {
+ var messages: [any Message] = []
+ var currentState: State = Data("{}".utf8)
+ var rawEvents: [RawEvent] = []
+ var customEvents: [CustomEvent] = []
+ var currentTask: Task?
+ var isDisposed: Bool = false
+}
+
+// MARK: - AgentStorage Mutation Helpers
+
+internal extension AgentStorage {
+ func setMessages(_ messages: [any Message]) { self.messages = messages }
+ func setState(_ state: State) { self.currentState = state }
+ func setRawEvents(_ rawEvents: [RawEvent]) { self.rawEvents = rawEvents }
+ func setCustomEvents(_ customEvents: [CustomEvent]) { self.customEvents = customEvents }
+ func setCurrentTask(_ task: Task?) { self.currentTask = task }
+ func setDisposed(_ disposed: Bool) { self.isDisposed = disposed }
+}
+
+// MARK: - AbstractAgent
+
+public final class AbstractAgent: Sendable {
+
+ // MARK: - Internal storage
+
+ internal let storage: AgentStorage
+ internal let subscriberManager: SubscriberManager
+
+ // MARK: - Transport
+
+ private let transport: any AgentTransport
+
+ // MARK: - Configuration (immutable after init)
+
+ public let debug: Bool
+
+ // MARK: - Initialization
+
+ public init(transport: any AgentTransport, debug: Bool = false) {
+ self.transport = transport
+ self.storage = AgentStorage()
+ self.subscriberManager = SubscriberManager()
+ self.debug = debug
+ }
+
+ // MARK: - Async state accessors (cross actor boundary)
+
+ public var messages: [any Message] { get async { await storage.messages } }
+
+ public var state: State { get async { await storage.currentState } }
+
+ public var rawEvents: [RawEvent] { get async { await storage.rawEvents } }
+
+ public var customEvents: [CustomEvent] { get async { await storage.customEvents } }
+
+ // MARK: - Run method
+
+ public func run(input: RunAgentInput) -> AsyncThrowingStream {
+ transport.run(input: input)
+ }
+
+ // MARK: - Public pipeline methods
+
+ public func runAgent(
+ parameters: RunAgentParameters? = nil,
+ subscriber: (any AgentSubscriber)? = nil
+ ) async throws {
+ guard await !storage.isDisposed else { return }
+
+ let input = buildInput(from: parameters)
+
+ let registeredSubscribers = await subscriberManager.allSubscribers()
+ var allSubscribers = registeredSubscribers
+ if let s = subscriber { allSubscribers.append(s) }
+
+ let initMutation = await runSubscribersWithMutation(
+ subscribers: allSubscribers,
+ messages: await storage.messages,
+ state: await storage.currentState
+ ) { sub, msgs, st in
+ let params = AgentSubscriberParams(messages: msgs, state: st, input: input)
+ return await sub.onRunInitialized(params: params)
+ }
+ if let msgs = initMutation.messages { await storage.setMessages(msgs) }
+ if let st = initMutation.state { await storage.setState(st) }
+
+ let task = Task {
+ do {
+ let eventStream = self.run(input: input)
+ let processedStream = eventStream
+ .transformChunks()
+ .verifyEvents(debug: self.debug)
+ .applyEvents(input: input, subscribers: allSubscribers)
+
+ for try await agentState in processedStream {
+ await self.applyAgentState(agentState, input: input, subscribers: allSubscribers)
+ }
+
+ let finalMessages = await self.storage.messages
+ let finalState = await self.storage.currentState
+ _ = await runSubscribersWithMutation(
+ subscribers: allSubscribers,
+ messages: finalMessages,
+ state: finalState
+ ) { sub, msgs, st in
+ let params = AgentSubscriberParams(messages: msgs, state: st, input: input)
+ return await sub.onRunFinalized(params: params)
+ }
+ } catch {
+ let currentMsgs = await self.storage.messages
+ let currentSt = await self.storage.currentState
+ _ = await runSubscribersWithMutation(
+ subscribers: allSubscribers,
+ messages: currentMsgs,
+ state: currentSt
+ ) { sub, msgs, st in
+ let params = AgentRunFailureParams(error: error, messages: msgs, state: st, input: input)
+ return await sub.onRunFailed(params: params)
+ }
+ throw error
+ }
+ }
+
+ await storage.setCurrentTask(task)
+ let result = await task.result
+ await storage.setCurrentTask(nil)
+ try result.get()
+ }
+
+ public func runAgentObservable(
+ input: RunAgentInput
+ ) -> AsyncThrowingStream {
+ run(input: input)
+ .transformChunks()
+ .verifyEvents(debug: debug)
+ }
+
+ public func abortRun() async {
+ await storage.currentTask?.cancel()
+ }
+
+ public func dispose() async {
+ await storage.setDisposed(true)
+ }
+
+ public func subscribe(_ subscriber: any AgentSubscriber) async -> any AgentSubscription {
+ let id = await subscriberManager.subscribe(subscriber)
+ return DefaultAgentSubscription {
+ await self.subscriberManager.unsubscribe(id)
+ }
+ }
+
+ // MARK: - Internal helpers
+
+ internal func buildInput(from parameters: RunAgentParameters?) -> RunAgentInput {
+ RunAgentInput(
+ threadId: "default",
+ runId: parameters?.runId ?? "run_\(UUID().uuidString)",
+ tools: parameters?.tools ?? [],
+ context: parameters?.context ?? [],
+ forwardedProps: parameters?.forwardedProps ?? Data("{}".utf8)
+ )
+ }
+
+ internal func applyAgentState(
+ _ agentState: AgentState,
+ input: RunAgentInput,
+ subscribers: [any AgentSubscriber]
+ ) async {
+ if let messages = agentState.messages {
+ await storage.setMessages(messages)
+ let params = AgentStateChangedParams(
+ messages: messages,
+ state: await storage.currentState,
+ input: input
+ )
+ for sub in subscribers { await sub.onMessagesChanged(params: params) }
+ }
+ if let state = agentState.state {
+ await storage.setState(state)
+ let msgs = await storage.messages
+ let params = AgentStateChangedParams(messages: msgs, state: state, input: input)
+ for sub in subscribers { await sub.onStateChanged(params: params) }
+ }
+ if let rawEvents = agentState.rawEvents {
+ await storage.setRawEvents(rawEvents)
+ }
+ if let customEvents = agentState.customEvents {
+ await storage.setCustomEvents(customEvents)
+ }
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUIClient/Errors/ClientError.swift b/sdks/community/swift/Sources/AGUIClient/Errors/ClientError.swift
new file mode 100644
index 0000000000..992fda4858
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUIClient/Errors/ClientError.swift
@@ -0,0 +1,82 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Errors that can occur during client operations.
+public enum ClientError: Error {
+ /// Invalid URL configuration.
+ case invalidURL
+
+ /// Received invalid response from server.
+ case invalidResponse
+
+ /// HTTP error with status code.
+ case httpError(statusCode: Int)
+
+ /// Network error occurred.
+ case networkError(Error)
+
+ /// Failed to encode request body.
+ case encodingError(Error)
+
+ /// Failed to decode event.
+ case decodingError(Error)
+
+ /// Stream processing error.
+ case streamError(String)
+
+ /// Request timed out.
+ case timeout
+
+ /// Request was cancelled.
+ case cancelled
+}
+
+extension ClientError: LocalizedError {
+ public var errorDescription: String? {
+ switch self {
+ case .invalidURL:
+ return "Invalid URL configuration"
+ case .invalidResponse:
+ return "Received invalid response from server"
+ case .httpError(let code):
+ return "HTTP error: \(code)"
+ case .networkError(let error):
+ return "Network error: \(error.localizedDescription)"
+ case .encodingError(let error):
+ return "Failed to encode request: \(error.localizedDescription)"
+ case .decodingError(let error):
+ return "Failed to decode event: \(error.localizedDescription)"
+ case .streamError(let message):
+ return "Stream error: \(message)"
+ case .timeout:
+ return "Request timed out"
+ case .cancelled:
+ return "Request was cancelled"
+ }
+ }
+}
+
+extension ClientError: Equatable {
+ public static func == (lhs: ClientError, rhs: ClientError) -> Bool {
+ switch (lhs, rhs) {
+ case (.invalidURL, .invalidURL),
+ (.invalidResponse, .invalidResponse),
+ (.timeout, .timeout),
+ (.cancelled, .cancelled):
+ return true
+ case (.httpError(let lcode), .httpError(let rcode)):
+ return lcode == rcode
+ case (.streamError(let lmsg), .streamError(let rmsg)):
+ return lmsg == rmsg
+ case (.networkError(let lerr), .networkError(let rerr)):
+ return lerr.localizedDescription == rerr.localizedDescription
+ case (.encodingError(let lerr), .encodingError(let rerr)):
+ return lerr.localizedDescription == rerr.localizedDescription
+ case (.decodingError(let lerr), .decodingError(let rerr)):
+ return lerr.localizedDescription == rerr.localizedDescription
+ default:
+ return false
+ }
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUIClient/HttpAgent.swift b/sdks/community/swift/Sources/AGUIClient/HttpAgent.swift
new file mode 100644
index 0000000000..c996901ac9
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUIClient/HttpAgent.swift
@@ -0,0 +1,98 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUICore
+import Foundation
+
+public final class HttpAgent: Sendable {
+ private let abstractAgent: AbstractAgent
+ private let agentTransport: HttpAgentTransport
+ private let decoder: AGUIEventDecoder
+ private let defaultEndpoint: String
+
+ public init(baseURL: URL) {
+ let config = HttpAgentConfiguration(baseURL: baseURL)
+ let agentTransport = HttpAgentTransport(configuration: config)
+ self.agentTransport = agentTransport
+ self.abstractAgent = AbstractAgent(transport: agentTransport, debug: config.debug)
+ self.decoder = AGUIEventDecoder()
+ self.defaultEndpoint = "/run"
+ }
+
+ public init(configuration: HttpAgentConfiguration) {
+ let agentTransport = HttpAgentTransport(configuration: configuration)
+ self.agentTransport = agentTransport
+ self.abstractAgent = AbstractAgent(transport: agentTransport, debug: configuration.debug)
+ self.decoder = AGUIEventDecoder()
+ self.defaultEndpoint = "/run"
+ }
+
+ public init(
+ configuration: HttpAgentConfiguration,
+ httpClient: any HTTPClient
+ ) {
+ let agentTransport = HttpAgentTransport(configuration: configuration, httpClient: httpClient)
+ self.agentTransport = agentTransport
+ self.abstractAgent = AbstractAgent(transport: agentTransport, debug: configuration.debug)
+ self.decoder = AGUIEventDecoder()
+ self.defaultEndpoint = "/run"
+ }
+
+ public func run(
+ _ input: RunAgentInput,
+ endpoint: String? = nil
+ ) async throws -> EventStream> {
+ let bytes = try await agentTransport.execute(
+ endpoint: endpoint ?? defaultEndpoint,
+ input: input
+ )
+ return EventStream(bytes: bytes, decoder: decoder)
+ }
+
+ public func run(
+ threadId: String,
+ runId: String,
+ endpoint: String? = nil,
+ configure: (RunAgentInputBuilder) -> RunAgentInputBuilder = { $0 }
+ ) async throws -> EventStream> {
+ let input = try configure(
+ RunAgentInput.builder()
+ .threadId(threadId)
+ .runId(runId)
+ ).build()
+ return try await run(input, endpoint: endpoint)
+ }
+
+ public func run(input: RunAgentInput) -> AsyncThrowingStream {
+ abstractAgent.run(input: input)
+ }
+
+ public func runAgent(
+ parameters: RunAgentParameters? = nil,
+ subscriber: (any AgentSubscriber)? = nil
+ ) async throws {
+ try await abstractAgent.runAgent(parameters: parameters, subscriber: subscriber)
+ }
+
+ public func runAgentObservable(
+ input: RunAgentInput
+ ) -> AsyncThrowingStream {
+ abstractAgent.runAgentObservable(input: input)
+ }
+
+ public var messages: [any Message] { get async { await abstractAgent.messages } }
+ public var state: State { get async { await abstractAgent.state } }
+ public var rawEvents: [RawEvent] { get async { await abstractAgent.rawEvents } }
+ public var customEvents: [CustomEvent] { get async { await abstractAgent.customEvents } }
+
+ public func abortRun() async {
+ await abstractAgent.abortRun()
+ }
+
+ public func dispose() async {
+ await abstractAgent.dispose()
+ }
+
+ public func subscribe(_ subscriber: any AgentSubscriber) async -> any AgentSubscription {
+ await abstractAgent.subscribe(subscriber)
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUIClient/RunAgentParameters.swift b/sdks/community/swift/Sources/AGUIClient/RunAgentParameters.swift
new file mode 100644
index 0000000000..44f76c57eb
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUIClient/RunAgentParameters.swift
@@ -0,0 +1,26 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUICore
+import Foundation
+
+// MARK: - RunAgentParameters
+
+/// Parameters for a single agent run.
+public struct RunAgentParameters: Sendable {
+ public var runId: String?
+ public var tools: [Tool]?
+ public var context: [Context]?
+ public var forwardedProps: State?
+
+ public init(
+ runId: String? = nil,
+ tools: [Tool]? = nil,
+ context: [Context]? = nil,
+ forwardedProps: State? = nil
+ ) {
+ self.runId = runId
+ self.tools = tools
+ self.context = context
+ self.forwardedProps = forwardedProps
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUIClient/State/AgentState.swift b/sdks/community/swift/Sources/AGUIClient/State/AgentState.swift
new file mode 100644
index 0000000000..fafce4fe7e
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUIClient/State/AgentState.swift
@@ -0,0 +1,34 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUICore
+import Foundation
+
+/// Represents a snapshot of changes to agent state after processing an event.
+///
+/// Each emission from `applyEvents()` carries only the fields that changed.
+/// Fields not changed in this emission are `nil`.
+///
+/// This design mirrors Kotlin's AgentState: callers accumulate values from
+/// successive emissions rather than replacing the entire state on each event.
+public struct AgentState: Sendable {
+ /// Updated message list, or `nil` if messages did not change.
+ public var messages: [any Message]?
+ /// Updated JSON state, or `nil` if state did not change.
+ public var state: State?
+ /// Updated raw events list, or `nil` if raw events did not change.
+ public var rawEvents: [RawEvent]?
+ /// Updated custom events list, or `nil` if custom events did not change.
+ public var customEvents: [CustomEvent]?
+
+ public init(
+ messages: [any Message]? = nil,
+ state: State? = nil,
+ rawEvents: [RawEvent]? = nil,
+ customEvents: [CustomEvent]? = nil
+ ) {
+ self.messages = messages
+ self.state = state
+ self.rawEvents = rawEvents
+ self.customEvents = customEvents
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUIClient/State/DefaultApplyEvents.swift b/sdks/community/swift/Sources/AGUIClient/State/DefaultApplyEvents.swift
new file mode 100644
index 0000000000..a45fef1167
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUIClient/State/DefaultApplyEvents.swift
@@ -0,0 +1,194 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUICore
+import Foundation
+
+// MARK: - AsyncSequence Extension
+
+extension AsyncSequence where Element == any AGUIEvent {
+ /// Transforms an AG-UI event stream into a stream of `AgentState` emissions.
+ ///
+ /// Each emission carries only the fields that changed in response to the
+ /// triggering event. Callers should accumulate values from successive emissions
+ /// to build the complete agent state.
+ ///
+ /// ## Example
+ ///
+ /// ```swift
+ /// var currentMessages: [any Message] = []
+ /// var currentState: State = Data("{}".utf8)
+ ///
+ /// for try await agentState in eventStream.applyEvents(input: input) {
+ /// if let messages = agentState.messages {
+ /// currentMessages = messages
+ /// }
+ /// if let state = agentState.state {
+ /// currentState = state
+ /// }
+ /// }
+ /// ```
+ ///
+ /// - Parameters:
+ /// - input: The `RunAgentInput` that seeded this run, providing initial messages and state.
+ /// - subscribers: Optional list of subscribers to notify of events (reserved for future use).
+ /// - Returns: An `AsyncThrowingStream` of `AgentState` emissions.
+ public func applyEvents(
+ input: RunAgentInput,
+ subscribers: [any AgentSubscriber] = []
+ ) -> AsyncThrowingStream {
+ AsyncThrowingStream { continuation in
+ Task {
+ // Mutable state — all access is serialized within this single Task
+ var messages: [any Message] = input.messages
+ var currentState: State = input.state
+ var rawEvents: [RawEvent] = []
+ var customEvents: [CustomEvent] = []
+ var initialMessagesEmitted: Bool = false
+
+ do {
+ for try await event in self {
+ // Emit initial messages on first event if present
+ if !initialMessagesEmitted {
+ initialMessagesEmitted = true
+ if !messages.isEmpty {
+ continuation.yield(AgentState(messages: messages))
+ }
+ }
+
+ switch event {
+ case is RunStartedEvent:
+ break
+
+ case let e as TextMessageStartEvent:
+ let newMessage = AssistantMessage(id: e.messageId, content: "")
+ messages.append(newMessage)
+ continuation.yield(AgentState(messages: messages))
+
+ case let e as TextMessageContentEvent:
+ let id = e.messageId
+ if let idx = messages.lastIndex(where: { $0.id == id }),
+ let assistantMsg = messages[idx] as? AssistantMessage {
+ messages[idx] = assistantMsg.withContent(
+ (assistantMsg.content ?? "") + e.delta
+ )
+ continuation.yield(AgentState(messages: messages))
+ }
+
+ case is TextMessageEndEvent:
+ // No-op: no state emission for end event
+ break
+
+ case let e as ToolCallStartEvent:
+ let toolCall = ToolCall(
+ id: e.toolCallId,
+ function: FunctionCall(name: e.toolCallName, arguments: "")
+ )
+ if let parentId = e.parentMessageId,
+ let idx = messages.lastIndex(where: { $0.id == parentId }),
+ let assistantMsg = messages[idx] as? AssistantMessage {
+ messages[idx] = assistantMsg.withAppendedToolCall(toolCall)
+ } else if let idx = messages.lastIndex(where: { $0 is AssistantMessage }),
+ let assistantMsg = messages[idx] as? AssistantMessage {
+ messages[idx] = assistantMsg.withAppendedToolCall(toolCall)
+ } else {
+ // Create a new AssistantMessage to hold this tool call
+ let newMsg = AssistantMessage(
+ id: e.parentMessageId ?? e.toolCallId,
+ content: nil,
+ toolCalls: [toolCall]
+ )
+ messages.append(newMsg)
+ }
+ continuation.yield(AgentState(messages: messages))
+
+ case let e as ToolCallArgsEvent:
+ let id = e.toolCallId
+ for idx in messages.indices {
+ if let assistantMsg = messages[idx] as? AssistantMessage,
+ assistantMsg.toolCalls?.contains(where: { $0.id == id }) == true {
+ messages[idx] = assistantMsg.withUpdatedToolCallArguments(
+ toolCallId: id,
+ appendDelta: e.delta
+ )
+ break
+ }
+ }
+ continuation.yield(AgentState(messages: messages))
+
+ case is ToolCallEndEvent:
+ // No-op: no state emission for end event
+ break
+
+ case let e as ToolCallResultEvent:
+ let toolMsg = ToolMessage(
+ id: e.messageId,
+ content: e.content,
+ toolCallId: e.toolCallId
+ )
+ messages.append(toolMsg)
+ continuation.yield(AgentState(messages: messages))
+
+ case let e as MessagesSnapshotEvent:
+ messages = e.messages
+ continuation.yield(AgentState(messages: messages))
+
+ case let e as StateSnapshotEvent:
+ currentState = e.snapshot
+ continuation.yield(AgentState(state: currentState))
+
+ case let e as StateDeltaEvent:
+ let applicator = PatchApplicator()
+ currentState = try applicator.apply(patch: e.delta, to: currentState)
+ continuation.yield(AgentState(state: currentState))
+
+ case let e as RawEvent:
+ rawEvents.append(e)
+ continuation.yield(AgentState(rawEvents: rawEvents))
+
+ case let e as CustomEvent:
+ customEvents.append(e)
+ continuation.yield(AgentState(customEvents: customEvents))
+
+ default:
+ break
+ }
+ }
+ continuation.finish()
+ } catch {
+ continuation.finish(throwing: error)
+ }
+ }
+ }
+ }
+}
+
+// MARK: - AssistantMessage Mutation Helpers
+
+private extension AssistantMessage {
+ func withContent(_ newContent: String) -> AssistantMessage {
+ AssistantMessage(id: id, content: newContent, name: name, toolCalls: toolCalls)
+ }
+
+ func withAppendedToolCall(_ toolCall: ToolCall) -> AssistantMessage {
+ var calls = toolCalls ?? []
+ calls.append(toolCall)
+ return AssistantMessage(id: id, content: content, name: name, toolCalls: calls)
+ }
+
+ func withUpdatedToolCallArguments(toolCallId: String, appendDelta: String) -> AssistantMessage {
+ guard let calls = toolCalls else { return self }
+ let updated = calls.map { call in
+ if call.id == toolCallId {
+ return ToolCall(
+ id: call.id,
+ function: FunctionCall(
+ name: call.function.name,
+ arguments: call.function.arguments + appendDelta
+ )
+ )
+ }
+ return call
+ }
+ return AssistantMessage(id: id, content: content, name: name, toolCalls: updated)
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUIClient/State/PatchApplicator.swift b/sdks/community/swift/Sources/AGUIClient/State/PatchApplicator.swift
new file mode 100644
index 0000000000..d91cd8f76e
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUIClient/State/PatchApplicator.swift
@@ -0,0 +1,505 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Applies RFC 6902 JSON Patch operations to JSON documents.
+///
+/// PatchApplicator implements the JSON Patch specification (RFC 6902) for
+/// applying incremental changes to JSON documents. It supports all standard
+/// operations: add, remove, replace, move, copy, and test.
+///
+/// ## Usage
+///
+/// ```swift
+/// let applicator = PatchApplicator()
+///
+/// let state = Data("{\"count\":5}".utf8)
+/// let patch = Data("""
+/// [{"op":"replace","path":"/count","value":10}]
+/// """.utf8)
+///
+/// let newState = try applicator.apply(patch: patch, to: state)
+/// ```
+///
+/// ## Supported Operations
+///
+/// - `add`: Adds a value to an object or inserts into an array
+/// - `remove`: Removes a value from an object or array
+/// - `replace`: Replaces a value
+/// - `move`: Moves a value from one location to another
+/// - `copy`: Copies a value from one location to another
+/// - `test`: Tests that a value equals the specified value
+///
+/// ## Path Format (RFC 6901)
+///
+/// Paths use JSON Pointer format:
+/// - `/foo` - Root-level field "foo"
+/// - `/foo/bar` - Nested field "bar" in object "foo"
+/// - `/items/0` - First element of array "items"
+/// - `/items/-` - Append to array "items"
+/// - `/a~0b` - Field "a~b" (~ encoded as ~0)
+/// - `/a~1b` - Field "a/b" (/ encoded as ~1)
+///
+/// - SeeAlso: [RFC 6902 - JSON Patch](https://tools.ietf.org/html/rfc6902)
+/// - SeeAlso: [RFC 6901 - JSON Pointer](https://tools.ietf.org/html/rfc6901)
+public struct PatchApplicator: Sendable {
+ /// Errors that can occur during patch application.
+ public enum PatchError: Error, LocalizedError {
+ case invalidJSON(String)
+ case invalidPatch(String)
+ case invalidOperation(String)
+ case pathNotFound(String)
+ case testFailed(String)
+
+ public var errorDescription: String? {
+ switch self {
+ case .invalidJSON(let message):
+ return "Invalid JSON: \(message)"
+ case .invalidPatch(let message):
+ return "Invalid patch: \(message)"
+ case .invalidOperation(let message):
+ return "Invalid operation: \(message)"
+ case .pathNotFound(let path):
+ return "Path not found: \(path)"
+ case .testFailed(let message):
+ return "Test operation failed: \(message)"
+ }
+ }
+ }
+
+ /// Patch operation decoded from JSON.
+ private struct PatchOperation: Decodable {
+ let op: String
+ let path: String
+ let value: AnyCodable?
+ let from: String?
+
+ enum CodingKeys: String, CodingKey {
+ case op, path, value, from
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ op = try container.decode(String.self, forKey: .op)
+ path = try container.decode(String.self, forKey: .path)
+
+ // Use contains check to distinguish between missing field (nil) and explicit null (NSNull)
+ if container.contains(.value) {
+ value = try container.decode(AnyCodable.self, forKey: .value)
+ } else {
+ value = nil
+ }
+
+ from = try container.decodeIfPresent(String.self, forKey: .from)
+ }
+ }
+
+ public init() {}
+
+ /// Applies JSON Patch operations to a JSON document.
+ ///
+ /// - Parameters:
+ /// - patch: JSON Patch document (array of operations)
+ /// - state: Current JSON document
+ /// - Returns: Updated JSON document
+ /// - Throws: `PatchError` if patch is invalid or cannot be applied
+ public func apply(patch: Data, to state: Data) throws -> Data {
+ // Parse state JSON
+ guard let stateObject = try? JSONSerialization.jsonObject(with: state) else {
+ throw PatchError.invalidJSON("Unable to parse state JSON")
+ }
+
+ // Parse patch operations
+ let operations: [PatchOperation]
+ do {
+ operations = try JSONDecoder().decode([PatchOperation].self, from: patch)
+ } catch {
+ throw PatchError.invalidPatch("Unable to parse patch JSON: \(error.localizedDescription)")
+ }
+
+ // Apply operations sequentially
+ var mutableState = stateObject
+
+ for operation in operations {
+ mutableState = try applyOperation(operation, to: mutableState)
+ }
+
+ // Serialize back to JSON
+ do {
+ return try JSONSerialization.data(withJSONObject: mutableState)
+ } catch {
+ throw PatchError.invalidJSON("Unable to serialize result: \(error.localizedDescription)")
+ }
+ }
+
+ // MARK: - Operation Application
+
+ private func applyOperation(_ operation: PatchOperation, to state: Any) throws -> Any {
+ switch operation.op {
+ case "add":
+ guard let wrapper = operation.value else {
+ throw PatchError.invalidOperation("add operation missing value")
+ }
+ return try applyAdd(path: operation.path, value: wrapper.value, to: state)
+
+ case "remove":
+ return try applyRemove(path: operation.path, from: state)
+
+ case "replace":
+ guard let wrapper = operation.value else {
+ throw PatchError.invalidOperation("replace operation missing value")
+ }
+ return try applyReplace(path: operation.path, value: wrapper.value, to: state)
+
+ case "move":
+ guard let from = operation.from else {
+ throw PatchError.invalidOperation("move operation missing from")
+ }
+ return try applyMove(from: from, to: operation.path, in: state)
+
+ case "copy":
+ guard let from = operation.from else {
+ throw PatchError.invalidOperation("copy operation missing from")
+ }
+ return try applyCopy(from: from, to: operation.path, in: state)
+
+ case "test":
+ guard let wrapper = operation.value else {
+ throw PatchError.invalidOperation("test operation missing value")
+ }
+ return try applyTest(path: operation.path, value: wrapper.value, to: state)
+
+ default:
+ throw PatchError.invalidOperation("Unknown operation: \(operation.op)")
+ }
+ }
+
+ // MARK: - Individual Operations
+
+ private func applyAdd(path: String, value: Any, to state: Any) throws -> Any {
+ let tokens = try parsePath(path)
+
+ // Special case: root path replacement
+ if tokens.isEmpty {
+ return value
+ }
+
+ var current = state
+ let lastToken = tokens.last!
+ let parentTokens = tokens.dropLast()
+
+ // Navigate to parent
+ if !parentTokens.isEmpty {
+ current = try navigate(to: Array(parentTokens), in: current)
+ }
+
+ // Add to parent
+ if var dict = current as? [String: Any] {
+ dict[lastToken] = value
+ return try updateParent(state, at: Array(parentTokens), with: dict)
+ } else if var array = current as? [Any] {
+ if lastToken == "-" {
+ array.append(value)
+ } else if let index = Int(lastToken) {
+ guard index >= 0 && index <= array.count else {
+ throw PatchError.pathNotFound("Array index out of bounds: \(index)")
+ }
+ array.insert(value, at: index)
+ } else {
+ throw PatchError.invalidOperation("Invalid array index: \(lastToken)")
+ }
+ return try updateParent(state, at: Array(parentTokens), with: array)
+ } else {
+ throw PatchError.invalidOperation("Cannot add to non-object/non-array")
+ }
+ }
+
+ private func applyRemove(path: String, from state: Any) throws -> Any {
+ let tokens = try parsePath(path)
+ guard !tokens.isEmpty else {
+ throw PatchError.invalidOperation("Cannot remove root")
+ }
+
+ var current = state
+ let lastToken = tokens.last!
+ let parentTokens = tokens.dropLast()
+
+ // Navigate to parent
+ if !parentTokens.isEmpty {
+ current = try navigate(to: Array(parentTokens), in: current)
+ }
+
+ // Remove from parent
+ if var dict = current as? [String: Any] {
+ guard dict[lastToken] != nil else {
+ throw PatchError.pathNotFound(path)
+ }
+ dict.removeValue(forKey: lastToken)
+ return try updateParent(state, at: Array(parentTokens), with: dict)
+ } else if var array = current as? [Any] {
+ guard let index = Int(lastToken), index >= 0 && index < array.count else {
+ throw PatchError.pathNotFound(path)
+ }
+ array.remove(at: index)
+ return try updateParent(state, at: Array(parentTokens), with: array)
+ } else {
+ throw PatchError.invalidOperation("Cannot remove from non-object/non-array")
+ }
+ }
+
+ private func applyReplace(path: String, value: Any, to state: Any) throws -> Any {
+ let tokens = try parsePath(path)
+ guard !tokens.isEmpty else {
+ return value // Replace root
+ }
+
+ var current = state
+ let lastToken = tokens.last!
+ let parentTokens = tokens.dropLast()
+
+ // Navigate to parent
+ if !parentTokens.isEmpty {
+ current = try navigate(to: Array(parentTokens), in: current)
+ }
+
+ // Replace in parent
+ if var dict = current as? [String: Any] {
+ guard dict[lastToken] != nil else {
+ throw PatchError.pathNotFound(path)
+ }
+ dict[lastToken] = value
+ return try updateParent(state, at: Array(parentTokens), with: dict)
+ } else if var array = current as? [Any] {
+ guard let index = Int(lastToken), index >= 0 && index < array.count else {
+ throw PatchError.pathNotFound(path)
+ }
+ array[index] = value
+ return try updateParent(state, at: Array(parentTokens), with: array)
+ } else {
+ throw PatchError.invalidOperation("Cannot replace in non-object/non-array")
+ }
+ }
+
+ private func applyMove(from: String, to: String, in state: Any) throws -> Any {
+ // Get value at 'from' path
+ let value = try getValue(at: from, in: state)
+
+ // Remove from 'from' path
+ var intermediate = try applyRemove(path: from, from: state)
+
+ // Add to 'to' path
+ return try applyAdd(path: to, value: value, to: intermediate)
+ }
+
+ private func applyCopy(from: String, to: String, in state: Any) throws -> Any {
+ // Get value at 'from' path
+ let value = try getValue(at: from, in: state)
+
+ // Add to 'to' path
+ return try applyAdd(path: to, value: value, to: state)
+ }
+
+ private func applyTest(path: String, value: Any, to state: Any) throws -> Any {
+ let actualValue = try getValue(at: path, in: state)
+
+ // Compare values
+ guard areEqual(actualValue, value) else {
+ throw PatchError.testFailed("Value at \(path) does not match expected value")
+ }
+
+ return state // Test doesn't modify state
+ }
+
+ // MARK: - Helper Methods
+
+ private func parsePath(_ path: String) throws -> [String] {
+ guard path.hasPrefix("/") else {
+ throw PatchError.invalidOperation("Path must start with /: \(path)")
+ }
+
+ // Per RFC 6901: "/" means the empty-string key "". Root is represented by "".
+ if path == "/" {
+ return [""]
+ }
+
+ return path.dropFirst().split(separator: "/", omittingEmptySubsequences: false).map { token in
+ let decoded = String(token)
+ .replacingOccurrences(of: "~1", with: "/")
+ .replacingOccurrences(of: "~0", with: "~")
+ return decoded
+ }
+ }
+
+ private func navigate(to tokens: [String], in state: Any) throws -> Any {
+ var current = state
+
+ for token in tokens {
+ if let dict = current as? [String: Any] {
+ guard let next = dict[token] else {
+ throw PatchError.pathNotFound("/\(tokens.joined(separator: "/"))")
+ }
+ current = next
+ } else if let array = current as? [Any] {
+ guard let index = Int(token), index >= 0 && index < array.count else {
+ throw PatchError.pathNotFound("/\(tokens.joined(separator: "/"))")
+ }
+ current = array[index]
+ } else {
+ throw PatchError.pathNotFound("/\(tokens.joined(separator: "/"))")
+ }
+ }
+
+ return current
+ }
+
+ private func getValue(at path: String, in state: Any) throws -> Any {
+ let tokens = try parsePath(path)
+
+ if tokens.isEmpty {
+ return state
+ }
+
+ return try navigate(to: tokens, in: state)
+ }
+
+ private func updateParent(_ state: Any, at tokens: [String], with value: Any) throws -> Any {
+ if tokens.isEmpty {
+ return value
+ }
+
+ var current = state
+ var components: [(Any, String)] = []
+
+ // Navigate and collect components
+ for token in tokens {
+ components.append((current, token))
+
+ if let dict = current as? [String: Any] {
+ guard let next = dict[token] else {
+ throw PatchError.pathNotFound("/\(tokens.joined(separator: "/"))")
+ }
+ current = next
+ } else if let array = current as? [Any] {
+ guard let index = Int(token), index >= 0 && index < array.count else {
+ throw PatchError.pathNotFound("/\(tokens.joined(separator: "/"))")
+ }
+ current = array[index]
+ }
+ }
+
+ // Rebuild from bottom up
+ var updated = value
+
+ for (parent, token) in components.reversed() {
+ if var dict = parent as? [String: Any] {
+ dict[token] = updated
+ updated = dict
+ } else if var array = parent as? [Any] {
+ guard let index = Int(token), index >= 0 && index < array.count else {
+ throw PatchError.pathNotFound("/\(tokens.joined(separator: "/"))")
+ }
+ array[index] = updated
+ updated = array
+ }
+ }
+
+ return updated
+ }
+
+ private func areEqual(_ lhs: Any, _ rhs: Any) -> Bool {
+ // Handle NSNull
+ if lhs is NSNull && rhs is NSNull {
+ return true
+ }
+
+ // Compare dictionaries
+ if let lhsDict = lhs as? [String: Any], let rhsDict = rhs as? [String: Any] {
+ guard lhsDict.count == rhsDict.count else { return false }
+ for (key, value) in lhsDict {
+ guard let rhsValue = rhsDict[key], areEqual(value, rhsValue) else {
+ return false
+ }
+ }
+ return true
+ }
+
+ // Compare arrays
+ if let lhsArray = lhs as? [Any], let rhsArray = rhs as? [Any] {
+ guard lhsArray.count == rhsArray.count else { return false }
+ for (lhsElement, rhsElement) in zip(lhsArray, rhsArray) {
+ guard areEqual(lhsElement, rhsElement) else {
+ return false
+ }
+ }
+ return true
+ }
+
+ // Compare primitives using NSObject comparison
+ if let lhsObj = lhs as? NSObject, let rhsObj = rhs as? NSObject {
+ return lhsObj == rhsObj
+ }
+
+ return false
+ }
+}
+
+// MARK: - AnyCodable
+
+/// Type-erased Codable wrapper for decoding arbitrary JSON values.
+private struct AnyCodable: Codable {
+ let value: Any
+
+ init(_ value: Any) {
+ self.value = value
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.singleValueContainer()
+
+ if container.decodeNil() {
+ value = NSNull()
+ } else if let bool = try? container.decode(Bool.self) {
+ value = bool
+ } else if let int = try? container.decode(Int.self) {
+ value = int
+ } else if let double = try? container.decode(Double.self) {
+ value = double
+ } else if let string = try? container.decode(String.self) {
+ value = string
+ } else if let array = try? container.decode([AnyCodable].self) {
+ value = array.map { $0.value }
+ } else if let dict = try? container.decode([String: AnyCodable].self) {
+ value = dict.mapValues { $0.value }
+ } else {
+ throw DecodingError.dataCorruptedError(
+ in: container,
+ debugDescription: "Unable to decode value"
+ )
+ }
+ }
+
+ func encode(to encoder: Encoder) throws {
+ var container = encoder.singleValueContainer()
+
+ if value is NSNull {
+ try container.encodeNil()
+ } else if let bool = value as? Bool {
+ try container.encode(bool)
+ } else if let int = value as? Int {
+ try container.encode(int)
+ } else if let double = value as? Double {
+ try container.encode(double)
+ } else if let string = value as? String {
+ try container.encode(string)
+ } else if let array = value as? [Any] {
+ try container.encode(array.map { AnyCodable($0) })
+ } else if let dict = value as? [String: Any] {
+ try container.encode(dict.mapValues { AnyCodable($0) })
+ } else {
+ throw EncodingError.invalidValue(
+ value,
+ EncodingError.Context(codingPath: container.codingPath, debugDescription: "Unable to encode value")
+ )
+ }
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUIClient/State/StateManager.swift b/sdks/community/swift/Sources/AGUIClient/State/StateManager.swift
new file mode 100644
index 0000000000..d05ccc93f4
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUIClient/State/StateManager.swift
@@ -0,0 +1,146 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUICore
+import Foundation
+
+/// Manages application state with snapshot and delta synchronization.
+///
+/// `StateManager` provides thread-safe state management with support for:
+/// - Full state snapshots (`STATE_SNAPSHOT` events)
+/// - Incremental updates using JSON Patch (`STATE_DELTA` events)
+/// - State retrieval and reset
+///
+/// ## Usage
+///
+/// ```swift
+/// let manager = StateManager()
+///
+/// // Handle snapshot event
+/// await manager.handleSnapshot(snapshotEvent)
+///
+/// // Handle delta event
+/// try await manager.handleDelta(deltaEvent)
+///
+/// // Get current state
+/// let state = await manager.getState()
+/// ```
+///
+/// ## Thread Safety
+///
+/// `StateManager` is an actor, providing automatic thread safety for all
+/// state operations. All methods can be safely called from multiple
+/// concurrent tasks.
+///
+/// ## State Lifecycle
+///
+/// 1. Initialize with empty state `{}`
+/// 2. Receive `STATE_SNAPSHOT` → full state replacement
+/// 3. Receive `STATE_DELTA` → apply JSON Patch operations
+/// 4. Query current state with `getState()`
+/// 5. Reset to empty state with `reset()`
+///
+/// - SeeAlso: `PatchApplicator`, `StateSnapshotEvent`, `StateDeltaEvent`
+public actor StateManager {
+ /// Current application state as raw JSON data.
+ private var currentState: Data
+
+ /// Patch applicator for applying delta updates.
+ private let patchApplicator: PatchApplicator
+
+ /// Creates a new state manager with empty initial state.
+ ///
+ /// The initial state is an empty JSON object `{}`.
+ public init() {
+ self.currentState = Data("{}".utf8)
+ self.patchApplicator = PatchApplicator()
+ }
+
+ /// Handles a state snapshot event by replacing the current state.
+ ///
+ /// This method replaces the entire current state with the snapshot
+ /// provided in the event. Use this for initial state or full resets.
+ ///
+ /// - Parameter event: The state snapshot event
+ ///
+ /// ## Example
+ ///
+ /// ```swift
+ /// let snapshot = StateSnapshotEvent(
+ /// snapshot: Data("{\"users\":[],\"count\":0}".utf8)
+ /// )
+ /// await manager.handleSnapshot(snapshot)
+ /// ```
+ public func handleSnapshot(_ event: StateSnapshotEvent) {
+ currentState = event.snapshot
+ }
+
+ /// Handles a state delta event by applying JSON Patch operations.
+ ///
+ /// This method applies the JSON Patch operations from the delta event
+ /// to the current state, producing an updated state. If the patch
+ /// cannot be applied (invalid operation, path not found, etc.), an
+ /// error is thrown and the state remains unchanged.
+ ///
+ /// - Parameter event: The state delta event containing JSON Patch operations
+ /// - Throws: `PatchApplicator.PatchError` if the patch cannot be applied
+ ///
+ /// ## Example
+ ///
+ /// ```swift
+ /// let delta = StateDeltaEvent(
+ /// delta: Data("[{\"op\":\"add\",\"path\":\"/name\",\"value\":\"alice\"}]".utf8)
+ /// )
+ /// try await manager.handleDelta(delta)
+ /// ```
+ ///
+ /// ## Error Handling
+ ///
+ /// If the patch fails to apply, the state is not modified and the error
+ /// is propagated to the caller. Common errors include:
+ /// - Invalid JSON in patch operations
+ /// - Operations referencing non-existent paths
+ /// - Type mismatches (e.g., removing from a non-object)
+ /// - Test operations that fail
+ public func handleDelta(_ event: StateDeltaEvent) throws {
+ currentState = try patchApplicator.apply(patch: event.delta, to: currentState)
+ }
+
+ /// Returns the current application state.
+ ///
+ /// The state is returned as raw JSON data. You can parse it using
+ /// `JSONSerialization` or decode it into a specific type using
+ /// `JSONDecoder`.
+ ///
+ /// - Returns: The current state as JSON data
+ ///
+ /// ## Example
+ ///
+ /// ```swift
+ /// let state = await manager.getState()
+ ///
+ /// // Parse as generic JSON
+ /// let json = try JSONSerialization.jsonObject(with: state) as! [String: Any]
+ ///
+ /// // Or decode as specific type
+ /// struct AppState: Decodable { /* ... */ }
+ /// let appState = try JSONDecoder().decode(AppState.self, from: state)
+ /// ```
+ public func getState() -> Data {
+ currentState
+ }
+
+ /// Resets the state to an empty JSON object.
+ ///
+ /// This method clears the current state and resets it to `{}`.
+ /// Use this when starting a new session or clearing application data.
+ ///
+ /// ## Example
+ ///
+ /// ```swift
+ /// await manager.reset()
+ /// let state = await manager.getState() // Returns Data("{}".utf8)
+ /// ```
+ public func reset() {
+ currentState = Data("{}".utf8)
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUIClient/Streaming/AsyncSequence+Buffering.swift b/sdks/community/swift/Sources/AGUIClient/Streaming/AsyncSequence+Buffering.swift
new file mode 100644
index 0000000000..7968901601
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUIClient/Streaming/AsyncSequence+Buffering.swift
@@ -0,0 +1,426 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+// MARK: - Buffering Extension
+
+extension AsyncSequence where Self: Sendable, Element: Sendable {
+ /// Applies bounded buffering with overflow handling.
+ ///
+ /// Creates a bounded buffer between producer and consumer. When the buffer
+ /// fills, the specified strategy determines which elements to keep.
+ ///
+ /// - Parameters:
+ /// - limit: Maximum number of elements in buffer
+ /// - strategy: Strategy for handling overflow
+ /// - Returns: Buffered async sequence
+ ///
+ /// ## Example
+ ///
+ /// ```swift
+ /// let buffered = eventStream.buffered(limit: 100, strategy: .dropOldest)
+ ///
+ /// for try await event in buffered {
+ /// // Buffer ensures max 100 events in memory
+ /// await slowProcessing(event)
+ /// }
+ /// ```
+ ///
+ /// ## Memory Safety
+ ///
+ /// The buffer guarantees bounded memory usage: `limit * sizeof(Element)`.
+ ///
+ /// ## Performance
+ ///
+ /// - `.dropOldest`: O(1) append, O(n) drop (shifts elements)
+ /// - `.dropNewest`: O(1) append and drop
+ /// - `.suspend`: Natural backpressure (no buffer overhead)
+ public func buffered(
+ limit: Int,
+ strategy: BufferingStrategy
+ ) -> AsyncThrowingStream {
+ // Map our strategy to AsyncThrowingStream's built-in buffer policy.
+ // bufferingNewest keeps the newest `limit` elements (drops oldest on overflow).
+ // bufferingOldest keeps the oldest `limit` elements (drops newest on overflow).
+ let policy: AsyncThrowingStream.Continuation.BufferingPolicy
+ switch strategy {
+ case .dropOldest: policy = .bufferingNewest(limit)
+ case .dropNewest: policy = .bufferingOldest(limit)
+ }
+
+ return AsyncThrowingStream(bufferingPolicy: policy) { continuation in
+ let task = Task {
+ do {
+ for try await element in self {
+ continuation.yield(element) // yield immediately — no accumulation
+ }
+ continuation.finish()
+ } catch {
+ continuation.finish(throwing: error)
+ }
+ }
+
+ continuation.onTermination = { _ in
+ task.cancel()
+ }
+ }
+ }
+}
+
+// MARK: - Batching Extension
+
+extension AsyncSequence where Self: Sendable {
+ /// Batches elements by count.
+ ///
+ /// Groups elements into arrays of the specified size. The last batch
+ /// may contain fewer than `count` elements if the sequence ends.
+ ///
+ /// - Parameter count: Number of elements per batch
+ /// - Returns: Batched async sequence
+ ///
+ /// ## Example
+ ///
+ /// ```swift
+ /// let batched = eventStream.batched(count: 10)
+ ///
+ /// for try await batch in batched {
+ /// // Process 10 events at once
+ /// await processBatch(batch) // batch.count <= 10
+ /// }
+ /// ```
+ public func batched(count: Int) -> BatchedAsyncSequence {
+ BatchedAsyncSequence(base: self, batchSize: count)
+ }
+}
+
+extension AsyncSequence where Self: Sendable, Self.Element: Sendable {
+ /// Batches elements by time window.
+ ///
+ /// Groups elements arriving within a time window. Emits batches
+ /// when the window expires or the sequence ends.
+ ///
+ /// - Parameter timeWindow: Duration in seconds
+ /// - Returns: Time-batched async sequence
+ ///
+ /// ## Example
+ ///
+ /// ```swift
+ /// let batched = textChunks.batched(timeWindow: 0.05) // 50ms
+ ///
+ /// for try await batch in batched {
+ /// // Batch of all chunks received in 50ms window
+ /// let combined = batch.joined()
+ /// await updateUI(combined)
+ /// }
+ /// ```
+ public func batched(timeWindow: TimeInterval) -> TimeBatchedAsyncSequence {
+ TimeBatchedAsyncSequence(base: self, timeWindow: timeWindow)
+ }
+}
+
+/// Async sequence that batches elements by count.
+public struct BatchedAsyncSequence: AsyncSequence where Base: Sendable {
+ public typealias Element = [Base.Element]
+
+ private let base: Base
+ private let batchSize: Int
+
+ init(base: Base, batchSize: Int) {
+ self.base = base
+ self.batchSize = batchSize
+ }
+
+ public func makeAsyncIterator() -> AsyncIterator {
+ AsyncIterator(base: base.makeAsyncIterator(), batchSize: batchSize)
+ }
+
+ public struct AsyncIterator: AsyncIteratorProtocol {
+ private var baseIterator: Base.AsyncIterator
+ private let batchSize: Int
+
+ init(base: Base.AsyncIterator, batchSize: Int) {
+ self.baseIterator = base
+ self.batchSize = batchSize
+ }
+
+ public mutating func next() async throws -> [Base.Element]? {
+ var batch: [Base.Element] = []
+ batch.reserveCapacity(batchSize)
+
+ while batch.count < batchSize {
+ guard let element = try await baseIterator.next() else {
+ // End of sequence - return partial batch if any
+ return batch.isEmpty ? nil : batch
+ }
+ batch.append(element)
+ }
+
+ return batch
+ }
+ }
+}
+
+// MARK: - TimeBatchProducer
+
+/// Actor that buffers elements from a concurrent producer task so that a
+/// time-windowed consumer can safely collect them without races.
+///
+/// The producer task feeds elements into this actor as fast as the upstream
+/// allows. The consumer calls `dequeue()` at each window boundary to drain
+/// whatever arrived during that window. Because actor isolation serialises
+/// all reads and writes to the buffer, no element is ever lost at window
+/// edges — an element added after one `dequeue()` call simply appears in the
+/// next window's batch.
+fileprivate actor TimeBatchProducer {
+ private var buffer: [Element] = []
+ private(set) var isExhausted = false
+ private var storedError: Error?
+
+ func enqueue(_ element: Element) {
+ buffer.append(element)
+ }
+
+ func markExhausted() {
+ isExhausted = true
+ }
+
+ func markFailed(_ error: Error) {
+ storedError = error
+ isExhausted = true
+ }
+
+ /// Drains the buffer and returns it with the exhaustion flag.
+ /// Throws if the upstream ended with an error.
+ func dequeue() throws -> ([Element], Bool) {
+ if let e = storedError { throw e }
+ let batch = buffer
+ buffer = []
+ return (batch, isExhausted)
+ }
+}
+
+/// Cancels its wrapped `Task` on deinit, ensuring the background producer is
+/// torn down if the `TimeBatchedAsyncSequence.AsyncIterator` is dropped before
+/// the upstream sequence is exhausted (e.g. `break` out of a `for await` loop).
+fileprivate final class ProducerTaskHandle: Sendable {
+ let task: Task
+ init(_ task: Task) { self.task = task }
+ deinit { task.cancel() }
+}
+
+/// Async sequence that batches elements by time window.
+///
+/// A dedicated producer task pulls from the upstream sequence as fast as it
+/// can, feeding every element into a `TimeBatchProducer` actor. The consumer
+/// (`next()`) sleeps for `timeWindow` seconds, then drains whatever arrived
+/// during that window and returns it as an array.
+///
+/// Because actor isolation serialises the producer's `enqueue` and the
+/// consumer's `dequeue`, no element can be lost at a window boundary: an
+/// element that arrives after one drain is simply included in the next.
+///
+/// Empty windows (upstream alive but idle) are silently skipped — the
+/// consumer starts a new window automatically and only yields once it has
+/// collected at least one element.
+///
+/// ## Example
+///
+/// ```swift
+/// let batched = textChunks.batched(timeWindow: 0.05) // 50 ms windows
+/// for try await batch in batched {
+/// await render(batch.joined())
+/// }
+/// ```
+///
+/// - Note: Requires `Base.Element: Sendable` because elements must cross
+/// the actor boundary between the producer task and the consumer.
+public struct TimeBatchedAsyncSequence: AsyncSequence
+ where Base: Sendable, Base.Element: Sendable
+{
+ public typealias Element = [Base.Element]
+
+ private let base: Base
+ private let timeWindow: TimeInterval
+
+ init(base: Base, timeWindow: TimeInterval) {
+ self.base = base
+ self.timeWindow = timeWindow
+ }
+
+ public func makeAsyncIterator() -> AsyncIterator {
+ let producer = TimeBatchProducer()
+ let rawTask = Task { [base, producer] in
+ do {
+ for try await element in base {
+ await producer.enqueue(element)
+ }
+ await producer.markExhausted()
+ } catch {
+ await producer.markFailed(error)
+ }
+ }
+ return AsyncIterator(
+ producer: producer,
+ handle: ProducerTaskHandle(rawTask),
+ timeWindow: timeWindow
+ )
+ }
+
+ public struct AsyncIterator: AsyncIteratorProtocol {
+ private let producer: TimeBatchProducer
+ /// Class reference so `deinit` cancels the producer when the iterator
+ /// is dropped (e.g. after a `break` in the consumer's `for await`).
+ private let handle: ProducerTaskHandle
+ private let timeWindow: TimeInterval
+ private var isLocallyExhausted = false
+
+ fileprivate init(
+ producer: TimeBatchProducer,
+ handle: ProducerTaskHandle,
+ timeWindow: TimeInterval
+ ) {
+ self.producer = producer
+ self.handle = handle
+ self.timeWindow = timeWindow
+ }
+
+ public mutating func next() async throws -> [Base.Element]? {
+ guard !isLocallyExhausted else { return nil }
+
+ while true {
+ // Sleep for the full window duration before collecting.
+ try await Task.sleep(for: .seconds(timeWindow))
+
+ let (batch, exhausted) = try await producer.dequeue()
+
+ if exhausted {
+ isLocallyExhausted = true
+ handle.task.cancel()
+ return batch.isEmpty ? nil : batch
+ }
+
+ if !batch.isEmpty {
+ return batch
+ }
+ // Empty window — upstream is alive but idle. Start next window.
+ }
+ }
+ }
+}
+
+// MARK: - Throttling Extension
+
+extension AsyncSequence where Self: Sendable, Element: Sendable {
+ /// Throttles element emission to a maximum rate.
+ ///
+ /// Emits the first element immediately, then drops subsequent elements
+ /// until the time window expires. This ensures a maximum emission rate.
+ ///
+ /// - Parameter interval: Minimum time between emitted elements (seconds)
+ /// - Returns: Throttled async sequence
+ ///
+ /// ## Example
+ ///
+ /// ```swift
+ /// let throttled = rapidEvents.throttled(interval: 0.1) // Max 10/sec
+ ///
+ /// for try await event in throttled {
+ /// // At most 1 event per 100ms
+ /// await processEvent(event)
+ /// }
+ /// ```
+ public func throttled(interval: TimeInterval) -> AsyncThrowingStream {
+ AsyncThrowingStream { continuation in
+ let task = Task {
+ do {
+ var lastEmitTime: Date?
+
+ for try await element in self {
+ let now = Date()
+
+ // Check if enough time has passed since last emit
+ if let lastTime = lastEmitTime {
+ let elapsed = now.timeIntervalSince(lastTime)
+ if elapsed < interval {
+ // Drop this element - too soon
+ continue
+ }
+ }
+
+ // Emit element and update timestamp
+ continuation.yield(element)
+ lastEmitTime = now
+ }
+
+ continuation.finish()
+ } catch {
+ continuation.finish(throwing: error)
+ }
+ }
+
+ continuation.onTermination = { _ in
+ task.cancel()
+ }
+ }
+ }
+
+ /// Samples every Nth element.
+ ///
+ /// Emits only every Nth element, discarding others.
+ ///
+ /// - Parameter stride: Sample interval (emit every Nth element)
+ /// - Returns: Sampled async sequence
+ ///
+ /// ## Example
+ ///
+ /// ```swift
+ /// let sampled = videoFrames.sampled(every: 30) // 1 per second at 30fps
+ ///
+ /// for try await frame in sampled {
+ /// await processFrame(frame)
+ /// }
+ /// ```
+ public func sampled(every stride: Int) -> SampledAsyncSequence {
+ SampledAsyncSequence(base: self, stride: stride)
+ }
+}
+
+/// Async sequence that samples every Nth element.
+public struct SampledAsyncSequence: AsyncSequence where Base: Sendable {
+ public typealias Element = Base.Element
+
+ private let base: Base
+ private let stride: Int
+
+ init(base: Base, stride: Int) {
+ self.base = base
+ self.stride = stride
+ }
+
+ public func makeAsyncIterator() -> AsyncIterator {
+ AsyncIterator(base: base.makeAsyncIterator(), stride: stride)
+ }
+
+ public struct AsyncIterator: AsyncIteratorProtocol {
+ private var baseIterator: Base.AsyncIterator
+ private let stride: Int
+ private var count = 0
+
+ init(base: Base.AsyncIterator, stride: Int) {
+ self.baseIterator = base
+ self.stride = stride
+ }
+
+ public mutating func next() async throws -> Element? {
+ while let element = try await baseIterator.next() {
+ count += 1
+
+ if count % stride == 0 {
+ return element
+ }
+ }
+
+ return nil
+ }
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUIClient/Streaming/BufferingStrategy.swift b/sdks/community/swift/Sources/AGUIClient/Streaming/BufferingStrategy.swift
new file mode 100644
index 0000000000..5a0d76b4d8
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUIClient/Streaming/BufferingStrategy.swift
@@ -0,0 +1,48 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Strategy for handling buffer overflow.
+///
+/// When a bounded buffer fills up and new elements arrive, the strategy
+/// determines which elements to keep and which to drop.
+///
+/// ## Usage
+///
+/// ```swift
+/// let buffered = stream.buffered(limit: 100, strategy: .dropOldest)
+/// ```
+public enum BufferingStrategy: Sendable {
+ /// Drop the oldest elements when buffer is full.
+ ///
+ /// This strategy keeps the most recent elements, which is useful when
+ /// you want the latest state and older data can be safely discarded.
+ ///
+ /// **Use case**: Real-time monitoring where only current values matter.
+ ///
+ /// ## Example
+ ///
+ /// ```
+ /// Buffer: [1, 2, 3, 4, 5] (limit: 5)
+ /// New: 6
+ /// Result: [2, 3, 4, 5, 6] // Dropped 1
+ /// ```
+ case dropOldest
+
+ /// Drop the newest elements when buffer is full.
+ ///
+ /// This strategy preserves the oldest elements, which is useful when
+ /// historical data is important and newer data can be discarded.
+ ///
+ /// **Use case**: Processing a queue where order matters and you want
+ /// to ensure early events are handled.
+ ///
+ /// ## Example
+ ///
+ /// ```
+ /// Buffer: [1, 2, 3, 4, 5] (limit: 5)
+ /// New: 6
+ /// Result: [1, 2, 3, 4, 5] // Dropped 6
+ /// ```
+ case dropNewest
+}
diff --git a/sdks/community/swift/Sources/AGUIClient/Streaming/ChunkTransformer.swift b/sdks/community/swift/Sources/AGUIClient/Streaming/ChunkTransformer.swift
new file mode 100644
index 0000000000..84418408b2
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUIClient/Streaming/ChunkTransformer.swift
@@ -0,0 +1,328 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUICore
+import Foundation
+
+// MARK: - ChunkTransformError
+
+/// Errors that can occur during chunk transformation.
+public enum ChunkTransformError: Error, Sendable {
+ /// A text chunk is missing the required messageId.
+ case missingMessageId
+
+ /// A tool call chunk is missing required information (toolCallId or toolCallName).
+ case missingToolCallInfo
+}
+
+// MARK: - ChunkTransformer
+
+/// Transforms chunk events into structured start/content/end sequences.
+///
+/// `ChunkTransformer` converts `TEXT_MESSAGE_CHUNK` and `TOOL_CALL_CHUNK` events
+/// into complete protocol sequences with explicit start, content, and end events.
+/// This ensures downstream processing can rely on standard event sequences regardless
+/// of the upstream stream shape.
+///
+/// ## Behavior
+///
+/// - **Text Chunks**: Transformed into TextMessageStart → TextMessageContent(s) → TextMessageEnd
+/// - **Tool Chunks**: Transformed into ToolCallStart → ToolCallArgs(s) → ToolCallEnd
+/// - **Existing Events**: Pass through unchanged
+/// - **Mode Switching**: Automatically closes pending sequences when switching between text/tool modes
+///
+/// ## Usage
+///
+/// ```swift
+/// let transformed = events.transformChunks()
+/// for try await event in transformed {
+/// // Process structured events
+/// }
+/// ```
+///
+/// - SeeAlso: ``ChunkTransformError``
+public struct ChunkTransformer {
+ /// Creates a new chunk transformer.
+ public init() {}
+
+ /// Transforms a stream of events, converting chunks to structured sequences.
+ ///
+ /// - Parameter events: The source event stream
+ /// - Returns: Transformed event stream with structured sequences
+ /// - Throws: ``ChunkTransformError`` if chunks are malformed
+ public func transform(
+ _ events: S
+ ) -> AsyncThrowingStream where S.Element == any AGUIEvent {
+ AsyncThrowingStream { continuation in
+ let task = Task {
+ let transformer = EventTransformer(continuation: continuation)
+ await transformer.processEvents(events)
+ }
+ continuation.onTermination = { _ in task.cancel() }
+ }
+ }
+}
+
+// MARK: - EventTransformer
+
+/// Internal transformer that maintains state and processes events.
+private actor EventTransformer {
+ private var mode: ChunkMode?
+ private var textState: TextState?
+ private var toolState: ToolState?
+ private let continuation: AsyncThrowingStream.Continuation
+
+ init(continuation: AsyncThrowingStream.Continuation) {
+ self.continuation = continuation
+ }
+
+ func processEvents(_ events: S) async where S.Element == any AGUIEvent {
+ do {
+ for try await event in events {
+ try await handleEvent(event)
+ }
+ closeAllPendingState()
+ continuation.finish()
+ } catch {
+ continuation.finish(throwing: error)
+ }
+ }
+
+ private func handleEvent(_ event: any AGUIEvent) async throws {
+ switch event {
+ case let chunk as TextMessageChunkEvent:
+ try handleTextChunk(chunk)
+ case let chunk as ToolCallChunkEvent:
+ try handleToolChunk(chunk)
+ case is TextMessageStartEvent, is TextMessageContentEvent, is TextMessageEndEvent:
+ handleTextEvent(event)
+ case is ToolCallStartEvent, is ToolCallArgsEvent, is ToolCallEndEvent:
+ handleToolEvent(event)
+ default:
+ handleOtherEvent(event)
+ }
+ }
+
+ private func handleTextChunk(_ chunk: TextMessageChunkEvent) throws {
+ let messageId = chunk.messageId
+
+ // Check if we need to start a new message
+ if mode != .text || (messageId != nil && messageId != textState?.messageId) {
+ closePending(chunk)
+
+ guard let id = messageId else {
+ throw ChunkTransformError.missingMessageId
+ }
+
+ continuation.yield(TextMessageStartEvent(
+ messageId: id,
+ role: chunk.role ?? "assistant",
+ timestamp: chunk.timestamp,
+ rawEvent: chunk.rawEvent
+ ))
+
+ mode = .text
+ textState = TextState(messageId: id, fromChunk: true)
+ }
+
+ // Emit content if delta is present and non-empty
+ if let delta = chunk.delta, !delta.isEmpty {
+ continuation.yield(TextMessageContentEvent(
+ messageId: textState!.messageId,
+ delta: delta,
+ timestamp: chunk.timestamp,
+ rawEvent: chunk.rawEvent
+ ))
+ }
+ }
+
+ private func handleToolChunk(_ chunk: ToolCallChunkEvent) throws {
+ let toolId = chunk.toolCallId
+ let toolName = chunk.toolCallName
+
+ // Check if we need to start a new tool call
+ if mode != .tool || (toolId != nil && toolId != toolState?.toolCallId) {
+ closePending(chunk)
+
+ guard let id = toolId, let name = toolName else {
+ throw ChunkTransformError.missingToolCallInfo
+ }
+
+ continuation.yield(ToolCallStartEvent(
+ toolCallId: id,
+ toolCallName: name,
+ parentMessageId: chunk.parentMessageId,
+ timestamp: chunk.timestamp,
+ rawEvent: chunk.rawEvent
+ ))
+
+ mode = .tool
+ toolState = ToolState(toolCallId: id, fromChunk: true)
+ }
+
+ // Emit args if delta is present and non-empty
+ if let delta = chunk.delta, !delta.isEmpty {
+ continuation.yield(ToolCallArgsEvent(
+ toolCallId: toolState!.toolCallId,
+ delta: delta,
+ timestamp: chunk.timestamp,
+ rawEvent: chunk.rawEvent
+ ))
+ }
+ }
+
+ private func handleTextEvent(_ event: any AGUIEvent) {
+ switch event {
+ case let start as TextMessageStartEvent:
+ closePending(event)
+ mode = .text
+ textState = TextState(messageId: start.messageId, fromChunk: false)
+ continuation.yield(event)
+
+ case let content as TextMessageContentEvent:
+ mode = .text
+ textState = TextState(messageId: content.messageId, fromChunk: false)
+ continuation.yield(event)
+
+ case is TextMessageEndEvent:
+ textState = nil
+ if mode == .text {
+ mode = nil
+ }
+ continuation.yield(event)
+
+ default:
+ break
+ }
+ }
+
+ private func handleToolEvent(_ event: any AGUIEvent) {
+ switch event {
+ case let start as ToolCallStartEvent:
+ closePending(event)
+ mode = .tool
+ toolState = ToolState(toolCallId: start.toolCallId, fromChunk: false)
+ continuation.yield(event)
+
+ case let args as ToolCallArgsEvent:
+ mode = .tool
+ if toolState?.toolCallId == args.toolCallId {
+ toolState?.fromChunk = false
+ } else {
+ toolState = ToolState(toolCallId: args.toolCallId, fromChunk: false)
+ }
+ continuation.yield(event)
+
+ case is ToolCallEndEvent:
+ toolState = nil
+ if mode == .tool {
+ mode = nil
+ }
+ continuation.yield(event)
+
+ default:
+ break
+ }
+ }
+
+ private func handleOtherEvent(_ event: any AGUIEvent) {
+ closePending(event)
+ continuation.yield(event)
+ }
+
+ private func closeText(_ event: any AGUIEvent) {
+ if let state = textState, state.fromChunk {
+ continuation.yield(TextMessageEndEvent(
+ messageId: state.messageId,
+ timestamp: event.timestamp,
+ rawEvent: event.rawEvent
+ ))
+ }
+ textState = nil
+ if mode == .text {
+ mode = nil
+ }
+ }
+
+ private func closeTool(_ event: any AGUIEvent) {
+ if let state = toolState, state.fromChunk {
+ continuation.yield(ToolCallEndEvent(
+ toolCallId: state.toolCallId,
+ timestamp: event.timestamp,
+ rawEvent: event.rawEvent
+ ))
+ }
+ toolState = nil
+ if mode == .tool {
+ mode = nil
+ }
+ }
+
+ private func closePending(_ event: any AGUIEvent) {
+ closeText(event)
+ closeTool(event)
+ }
+
+ private func closeAllPendingState() {
+ if textState != nil || toolState != nil {
+ let finalEvent = RunFinishedEvent(
+ threadId: "",
+ runId: "",
+ timestamp: nil,
+ rawEvent: nil
+ )
+ closePending(finalEvent)
+ }
+ }
+}
+
+// MARK: - Internal State Types
+
+private enum ChunkMode {
+ case text
+ case tool
+}
+
+private struct TextState {
+ let messageId: String
+ var fromChunk: Bool
+}
+
+private struct ToolState {
+ let toolCallId: String
+ var fromChunk: Bool
+}
+
+// MARK: - AsyncSequence Extension
+
+extension AsyncSequence where Element == any AGUIEvent {
+ /// Apply chunk transformation to the event stream.
+ ///
+ /// This method transforms `TEXT_MESSAGE_CHUNK` and `TOOL_CALL_CHUNK` events
+ /// into structured start/content/end sequences.
+ ///
+ /// ## Example
+ ///
+ /// ```swift
+ /// let eventStream = httpAgent.run(input)
+ /// let transformed = eventStream.transformChunks()
+ ///
+ /// for try await event in transformed {
+ /// switch event {
+ /// case let start as TextMessageStartEvent:
+ /// print("Message started: \(start.messageId)")
+ /// case let content as TextMessageContentEvent:
+ /// print("Content: \(content.delta)")
+ /// case let end as TextMessageEndEvent:
+ /// print("Message ended: \(end.messageId)")
+ /// default:
+ /// break
+ /// }
+ /// }
+ /// ```
+ ///
+ /// - Returns: Transformed event stream with structured sequences
+ /// - Throws: ``ChunkTransformError`` if chunks are malformed
+ public func transformChunks() -> AsyncThrowingStream {
+ ChunkTransformer().transform(self)
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUIClient/Streaming/EventStream.swift b/sdks/community/swift/Sources/AGUIClient/Streaming/EventStream.swift
new file mode 100644
index 0000000000..2413ccb995
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUIClient/Streaming/EventStream.swift
@@ -0,0 +1,244 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUICore
+import Foundation
+
+/// AsyncSequence of AG-UI events from a streaming HTTP response.
+///
+/// `EventStream` integrates the complete event streaming pipeline:
+/// 1. Reads bytes from HTTP response (any AsyncSequence)
+/// 2. Parses Server-Sent Events (SSE) using SseParser
+/// 3. Decodes AG-UI events using AGUIEventDecoder
+/// 4. Handles errors gracefully
+///
+/// ## Usage
+///
+/// ```swift
+/// let transport = HttpTransport(configuration: config)
+/// let bytes = try await transport.execute(endpoint: "/run", input: input)
+/// let decoder = AGUIEventDecoder()
+/// let stream = EventStream(bytes: bytes, decoder: decoder)
+///
+/// for try await event in stream {
+/// switch event.eventType {
+/// case .textMessageChunk:
+/// let chunk = event as! TextMessageChunkEvent
+/// print(chunk.delta, terminator: "")
+/// case .runFinished:
+/// print("\nDone!")
+/// default:
+/// break
+/// }
+/// }
+/// ```
+///
+/// ## Error Handling
+///
+/// - Malformed JSON (`EventDecodingError.invalidJSON`) is skipped — bytes may arrive
+/// truncated and that is not a protocol violation.
+/// - Protocol violations — `unknownEventType`, `missingTypeField`, `decodingFailed`,
+/// `unsupportedEventType` — are re-thrown, terminating the stream. This matches the
+/// TypeScript reference implementation which calls `eventSubject.error(err)` for all
+/// decode failures.
+/// - In tolerant mode (`.returnUnknown` strategy), unknown event types are wrapped in
+/// `UnknownEvent` by the decoder before reaching this layer, so `unknownEventType` is
+/// never thrown.
+/// - Network errors propagate to the caller.
+///
+/// ## Last-Event-ID tracking
+///
+/// `lastEventId` exposes the most recent `id:` field seen in the SSE stream.
+/// It is updated as events arrive and can be read after a mid-stream failure
+/// to resume from the correct position on reconnect.
+///
+/// ## Thread Safety
+///
+/// `EventStream` is Sendable and can be used across concurrency domains.
+/// Each iteration creates a new iterator with isolated state.
+public struct EventStream: AsyncSequence where Bytes.Element == UInt8 {
+ public typealias Element = any AGUIEvent
+
+ // MARK: - Shared last-event-id box
+
+ /// Reference box that lets the iterator write the last SSE id back to the stream.
+ ///
+ /// Using a class (reference semantics) means every iterator created from the same
+ /// stream updates the same location, so callers can read `lastEventId` on the
+ /// stream value after an iterator throws.
+ ///
+ /// Declared as an `actor` so the compiler synthesises `Sendable` automatically
+ /// and serialises all reads and writes without manual locking.
+ actor LastEventIdBox {
+ var value: String?
+
+ func set(_ newValue: String?) {
+ value = newValue
+ }
+ }
+
+ // MARK: - Stored properties
+
+ /// The source byte stream from HTTP response.
+ private let bytes: Bytes
+
+ /// The AG-UI event decoder.
+ private let decoder: AGUIEventDecoder
+
+ /// Shared box updated by the iterator whenever a non-nil SSE `id:` field is seen.
+ private let lastEventIdBox = LastEventIdBox()
+
+ // MARK: - Public surface
+
+ /// The most recent SSE event `id:` field received, or `nil` if none has arrived yet.
+ ///
+ /// Read this after a mid-stream failure to obtain the resume cursor for
+ /// `Last-Event-ID` on reconnect.
+ public var lastEventId: String? { get async { await lastEventIdBox.value } }
+
+ // MARK: - Initialization
+
+ /// Creates a new event stream.
+ ///
+ /// - Parameters:
+ /// - bytes: Async sequence of bytes from HTTP streaming response
+ /// - decoder: AG-UI event decoder
+ public init(bytes: Bytes, decoder: AGUIEventDecoder) {
+ self.bytes = bytes
+ self.decoder = decoder
+ }
+
+ /// Creates an async iterator for streaming events.
+ public func makeAsyncIterator() -> AsyncIterator {
+ AsyncIterator(bytes: bytes.makeAsyncIterator(), decoder: decoder, lastEventIdBox: lastEventIdBox)
+ }
+
+ /// Iterator that processes streaming bytes into AG-UI events.
+ ///
+ /// The iterator maintains internal state for:
+ /// - UTF-8 byte accumulation
+ /// - SSE event parsing
+ /// - Event queue management
+ /// - Last-Event-ID tracking (via shared `LastEventIdBox`)
+ public struct AsyncIterator: AsyncIteratorProtocol {
+ /// Source byte iterator.
+ private var bytesIterator: Bytes.AsyncIterator
+
+ /// AG-UI event decoder.
+ private let decoder: AGUIEventDecoder
+
+ /// SSE parser for extracting events from stream.
+ private var sseParser = SseParser()
+
+ /// Queue of decoded events ready to return.
+ private var eventQueue: [any AGUIEvent] = []
+
+ /// Buffer for accumulating UTF-8 bytes.
+ private var utf8Buffer: [UInt8] = []
+
+ /// Shared reference that is updated with the most recent SSE `id:` value.
+ private let lastEventIdBox: LastEventIdBox
+
+ /// Creates a new iterator.
+ ///
+ /// - Parameters:
+ /// - bytes: Source byte iterator
+ /// - decoder: AG-UI event decoder
+ /// - lastEventIdBox: Shared box written to whenever a non-nil SSE id is seen
+ init(bytes: Bytes.AsyncIterator, decoder: AGUIEventDecoder, lastEventIdBox: LastEventIdBox) {
+ self.bytesIterator = bytes
+ self.decoder = decoder
+ self.lastEventIdBox = lastEventIdBox
+ }
+
+ /// Returns the next AG-UI event from the stream.
+ ///
+ /// This method:
+ /// 1. Returns queued events first
+ /// 2. Reads and accumulates bytes until valid UTF-8
+ /// 3. Parses SSE events from UTF-8 strings
+ /// 4. Decodes AG-UI events from SSE data
+ /// 5. Returns the next available event
+ ///
+ /// - Returns: Next event, or nil when stream ends
+ /// - Throws: Network errors or critical decoding failures
+ public mutating func next() async throws -> (any AGUIEvent)? {
+ // Return queued events first.
+ // Yield before returning a buffered event so callers on the main actor
+ // give the run loop a chance to render UI between events. Without this,
+ // a burst of SSE data arriving in one network packet fills the queue and
+ // all events are consumed synchronously — SwiftUI never sees the
+ // intermediate streaming states (typing dots, streaming cursor).
+ guard eventQueue.isEmpty else {
+ await Task.yield()
+ return eventQueue.removeFirst()
+ }
+
+ // Read and process bytes until we have events
+ while let byte = try await bytesIterator.next() {
+ utf8Buffer.append(byte)
+
+ // Try to decode accumulated bytes as UTF-8
+ if let string = String(bytes: utf8Buffer, encoding: .utf8) {
+ // Successful decode - process the chunk
+ utf8Buffer.removeAll()
+
+ // Parse SSE events from the string chunk.
+ // Throws SseParserError.bufferOverflow if the buffer exceeds
+ // maxBufferByteCount — treat as a fatal stream error.
+ let sseEvents = try sseParser.parse(string)
+
+ // Decode AG-UI events from SSE data
+ for sseEvent in sseEvents {
+ // Track the last-event-id for reconnection support.
+ // The id is captured before decoding so it is available even
+ // when the accompanying data fails to decode.
+ if let id = sseEvent.id {
+ await lastEventIdBox.set(id)
+ }
+
+ guard let data = sseEvent.data.data(using: .utf8) else {
+ // Skip events with invalid UTF-8 data
+ continue
+ }
+
+ do {
+ let event = try decoder.decode(data)
+ eventQueue.append(event)
+ } catch let error as EventDecodingError {
+ switch error {
+ case .invalidJSON:
+ // Malformed bytes are not a protocol violation — the packet
+ // may have arrived truncated. Skip and keep the stream alive.
+ #if DEBUG
+ print("[EventStream] ⚠ Malformed JSON — skipping: \(sseEvent.data.prefix(200))")
+ #endif
+ case .unknownEventType, .missingTypeField, .decodingFailed, .unsupportedEventType:
+ // Protocol violations: the TypeScript reference implementation
+ // calls eventSubject.error(err) for all of these — the stream
+ // terminates. Re-throw to match that behaviour.
+ throw error
+ }
+ }
+ }
+
+ // Return first decoded event if available
+ guard eventQueue.isEmpty else {
+ return eventQueue.removeFirst()
+ }
+ } else if utf8Buffer.count > 4 {
+ // Invalid UTF-8 sequence (UTF-8 characters are max 4 bytes)
+ // Skip the first byte and try to resynchronize
+ utf8Buffer.removeFirst()
+ }
+ }
+
+ // Stream ended - return any remaining queued events
+ guard eventQueue.isEmpty else {
+ return eventQueue.removeFirst()
+ }
+
+ // No more events
+ return nil
+ }
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUIClient/Streaming/EventVerifier.swift b/sdks/community/swift/Sources/AGUIClient/Streaming/EventVerifier.swift
new file mode 100644
index 0000000000..2e8ca77e7e
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUIClient/Streaming/EventVerifier.swift
@@ -0,0 +1,217 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUICore
+import Foundation
+
+// MARK: - AGUIProtocolError
+
+/// An error thrown when the AG-UI protocol event sequence is violated.
+public struct AGUIProtocolError: Error, Sendable {
+ /// A human-readable description of the protocol violation.
+ public let message: String
+
+ public init(message: String) {
+ self.message = message
+ }
+}
+
+// MARK: - EventVerifier
+
+/// Internal state machine that validates AG-UI protocol event sequences.
+///
+/// All access to this class is serialized within a single Task, so no
+/// actor isolation is needed.
+private final class EventVerifier {
+ var firstEventReceived: Bool = false
+ var runStarted: Bool = false
+ var runFinished: Bool = false
+ var runError: Bool = false
+ var activeMessages: [String: Bool] = [:]
+ var activeToolCalls: [String: Bool] = [:]
+ var activeSteps: [String: Bool] = [:]
+
+ let debug: Bool
+
+ init(debug: Bool) {
+ self.debug = debug
+ }
+
+ func verify(_ event: any AGUIEvent) throws {
+ let type = event.eventType.rawValue
+
+ // Handle RUN_STARTED as a special case first — it resets state for multi-run support
+ if let _ = event as? RunStartedEvent {
+ if runFinished {
+ // Multi-run: reset state for new run
+ activeMessages.removeAll()
+ activeToolCalls.removeAll()
+ activeSteps.removeAll()
+ runFinished = false
+ }
+ runStarted = true
+ firstEventReceived = true
+ return
+ }
+
+ // Handle RUN_ERROR as a special case — allowed as first event
+ if let _ = event as? RunErrorEvent {
+ if runError {
+ throw AGUIProtocolError(
+ message: "Cannot send event type '\(type)': The run has already errored"
+ )
+ }
+ runError = true
+ firstEventReceived = true
+ return
+ }
+
+ // First event must be RUN_STARTED or RUN_ERROR
+ if !firstEventReceived {
+ throw AGUIProtocolError(message: "First event must be 'RUN_STARTED'")
+ }
+
+ // After RUN_ERROR, no further events
+ if runError {
+ throw AGUIProtocolError(
+ message: "Cannot send event type '\(type)': The run has already errored"
+ )
+ }
+
+ // After RUN_FINISHED (and no new RUN_STARTED has arrived), no events
+ if runFinished {
+ throw AGUIProtocolError(
+ message: "Cannot send event type '\(type)': The run has already finished"
+ )
+ }
+
+ // Validate each event type
+ switch event {
+ case let e as TextMessageStartEvent:
+ let id = e.messageId
+ if activeMessages[id] == true {
+ throw AGUIProtocolError(
+ message: "A text message with ID '\(id)' is already in progress"
+ )
+ }
+ activeMessages[id] = true
+
+ case let e as TextMessageContentEvent:
+ let id = e.messageId
+ guard activeMessages[id] == true else {
+ throw AGUIProtocolError(
+ message: "No active text message found with ID '\(id)'"
+ )
+ }
+
+ case let e as TextMessageEndEvent:
+ let id = e.messageId
+ guard activeMessages[id] == true else {
+ throw AGUIProtocolError(
+ message: "No active text message found with ID '\(id)'"
+ )
+ }
+ activeMessages.removeValue(forKey: id)
+
+ case let e as ToolCallStartEvent:
+ let id = e.toolCallId
+ if activeToolCalls[id] == true {
+ throw AGUIProtocolError(
+ message: "A tool call with ID '\(id)' is already in progress"
+ )
+ }
+ activeToolCalls[id] = true
+
+ case let e as ToolCallArgsEvent:
+ let id = e.toolCallId
+ guard activeToolCalls[id] == true else {
+ throw AGUIProtocolError(
+ message: "No active tool call found with ID '\(id)'"
+ )
+ }
+
+ case let e as ToolCallEndEvent:
+ let id = e.toolCallId
+ guard activeToolCalls[id] == true else {
+ throw AGUIProtocolError(
+ message: "No active tool call found with ID '\(id)'"
+ )
+ }
+ activeToolCalls.removeValue(forKey: id)
+
+ case let e as StepStartedEvent:
+ activeSteps[e.stepName] = true
+
+ case let e as StepFinishedEvent:
+ let name = e.stepName
+ guard activeSteps[name] == true else {
+ throw AGUIProtocolError(
+ message: "Cannot send 'STEP_FINISHED' for step '\(name)' that was not started"
+ )
+ }
+ activeSteps.removeValue(forKey: name)
+
+ case is RunFinishedEvent:
+ if !activeMessages.isEmpty {
+ throw AGUIProtocolError(
+ message: "Cannot send 'RUN_FINISHED' while messages are still active"
+ )
+ }
+ if !activeToolCalls.isEmpty {
+ throw AGUIProtocolError(
+ message: "Cannot send 'RUN_FINISHED' while tool calls are still active"
+ )
+ }
+ if !activeSteps.isEmpty {
+ throw AGUIProtocolError(
+ message: "Cannot send 'RUN_FINISHED' while steps are still active"
+ )
+ }
+ runFinished = true
+
+ default:
+ break
+ }
+
+ if debug {
+ print("[EventVerifier] Verified: \(type)")
+ }
+ }
+}
+
+// MARK: - AsyncSequence Extension
+
+extension AsyncSequence where Element == any AGUIEvent {
+ /// Validates that the event stream conforms to the AG-UI protocol state machine.
+ ///
+ /// Events are passed through unchanged if they are valid. If a protocol violation
+ /// is detected, the stream throws an `AGUIProtocolError` and terminates.
+ ///
+ /// ## Example
+ ///
+ /// ```swift
+ /// let verified = eventStream.verifyEvents()
+ /// for try await event in verified {
+ /// // Only valid events reach here
+ /// }
+ /// ```
+ ///
+ /// - Parameter debug: When `true`, logs each verified event to stdout.
+ /// - Returns: A throwing stream of verified events.
+ public func verifyEvents(debug: Bool = false) -> AsyncThrowingStream {
+ AsyncThrowingStream { continuation in
+ let task = Task {
+ let verifier = EventVerifier(debug: debug)
+ do {
+ for try await event in self {
+ try verifier.verify(event)
+ continuation.yield(event)
+ }
+ continuation.finish()
+ } catch {
+ continuation.finish(throwing: error)
+ }
+ }
+ continuation.onTermination = { _ in task.cancel() }
+ }
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUIClient/Streaming/SseEvent.swift b/sdks/community/swift/Sources/AGUIClient/Streaming/SseEvent.swift
new file mode 100644
index 0000000000..57230f9e96
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUIClient/Streaming/SseEvent.swift
@@ -0,0 +1,75 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Represents a parsed Server-Sent Event.
+///
+/// Server-Sent Events (SSE) is a standard for server-to-client streaming
+/// over HTTP. Each event can contain data, an ID for reconnection, and an
+/// event type.
+///
+/// ## SSE Format
+///
+/// ```
+/// event: notification
+/// id: 123
+/// data: {"message":"hello"}
+///
+/// ```
+///
+/// ## Example
+///
+/// ```swift
+/// let event = SseEvent(
+/// data: "{\"type\":\"MESSAGE\"}",
+/// id: "123",
+/// event: "message"
+/// )
+/// ```
+///
+/// ## Reference
+///
+/// SSE specification: https://html.spec.whatwg.org/multipage/server-sent-events.html
+public struct SseEvent: Sendable, Equatable {
+ /// The event data payload.
+ ///
+ /// Multiple `data:` fields in an SSE event are concatenated with newlines.
+ /// This field contains the raw string data, typically JSON for AG-UI events.
+ public let data: String
+
+ /// Optional event ID for reconnection.
+ ///
+ /// The `id:` field in SSE events is used to track the last received event.
+ /// When reconnecting, the client can send `Last-Event-ID` header to resume
+ /// from the last processed event.
+ public let id: String?
+
+ /// Event type name.
+ ///
+ /// Defaults to `"message"` if not specified in the SSE stream.
+ /// The `event:` field allows distinguishing different event types.
+ public let event: String
+
+ /// Reconnection interval in milliseconds.
+ ///
+ /// Set by the server via the `retry:` field. This is a connection-level
+ /// directive telling the client how long to wait before reconnecting after
+ /// a dropped connection. `nil` means no interval was specified in this event.
+ ///
+ /// Per WHATWG SSE spec §9.2.6, values that are not ASCII integers are ignored.
+ public let retry: Int?
+
+ /// Creates a new SSE event.
+ ///
+ /// - Parameters:
+ /// - data: The event data payload
+ /// - id: Optional event ID (default: nil)
+ /// - event: Event type name (default: "message")
+ /// - retry: Optional reconnection interval in milliseconds (default: nil)
+ public init(data: String, id: String? = nil, event: String = "message", retry: Int? = nil) {
+ self.data = data
+ self.id = id
+ self.event = event
+ self.retry = retry
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUIClient/Streaming/SseParser.swift b/sdks/community/swift/Sources/AGUIClient/Streaming/SseParser.swift
new file mode 100644
index 0000000000..28b691a550
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUIClient/Streaming/SseParser.swift
@@ -0,0 +1,232 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Incremental parser for Server-Sent Events (SSE) streams.
+///
+/// `SseParser` is a stateful parser that handles SSE data arriving in
+/// arbitrary chunks. It maintains an internal buffer for incomplete events
+/// and returns complete events as they become available.
+///
+/// ## Usage
+///
+/// ```swift
+/// var parser = SseParser()
+///
+/// // Parse chunks as they arrive
+/// for chunk in streamChunks {
+/// let events = parser.parse(chunk)
+/// for event in events {
+/// print("Received: \(event.data)")
+/// }
+/// }
+/// ```
+///
+/// ## SSE Format
+///
+/// Server-Sent Events use a line-based format:
+/// - Lines starting with `data:` contain the payload
+/// - Lines starting with `id:` specify the event ID
+/// - Lines starting with `event:` specify the event type
+/// - Lines starting with `:` are comments (ignored)
+/// - Empty line (double newline) signals end of event
+/// - Multiple `data:` lines are concatenated with newlines
+///
+/// ## Example Input
+///
+/// ```
+/// data: {"type":"MESSAGE","content":"Hello"}
+///
+/// event: notification
+/// id: 123
+/// data: {"alert":"New message"}
+///
+/// ```
+///
+/// ## Thread Safety
+///
+/// `SseParser` is a mutable struct and not thread-safe. Each thread
+/// should maintain its own parser instance.
+///
+/// ## Reference
+///
+/// SSE specification: https://html.spec.whatwg.org/multipage/server-sent-events.html
+/// Errors thrown by ``SseParser``.
+public enum SseParserError: Error, Sendable {
+ /// The internal buffer exceeded the maximum byte limit.
+ ///
+ /// All buffered data has been discarded. This indicates a broken or malicious
+ /// connection that is sending data without the double-newline event delimiter.
+ /// The stream should be treated as unrecoverable and terminated.
+ ///
+ /// - Parameter limit: The byte limit that was exceeded (``SseParser/maxBufferByteCount``).
+ case bufferOverflow(limit: Int)
+}
+
+public struct SseParser {
+ /// Maximum number of UTF-8 bytes the internal buffer may hold.
+ ///
+ /// If a stream sends data faster than complete events arrive — or sends a
+ /// pathologically large payload without a double-newline terminator — the
+ /// buffer is reset and a ``SseParserError/bufferOverflow(limit:)`` error is
+ /// thrown. This prevents unbounded memory growth from malformed or malicious
+ /// streams.
+ public static let maxBufferByteCount = 10 * 1_048_576 // 10 MB
+
+ /// Internal buffer for incomplete events.
+ private var buffer: String = ""
+
+ /// Creates a new SSE parser.
+ public init() {}
+
+ /// Parses a chunk of SSE data and returns complete events.
+ ///
+ /// This method is designed for incremental parsing of streaming data.
+ /// Incomplete events are buffered internally and will be completed
+ /// when subsequent chunks arrive.
+ ///
+ /// - Parameter chunk: A chunk of SSE text data
+ /// - Returns: Array of complete events parsed from this chunk
+ ///
+ /// ## Example
+ ///
+ /// ```swift
+ /// var parser = SseParser()
+ ///
+ /// // First chunk: incomplete event
+ /// var events = parser.parse("data: {\"te")
+ /// // returns: []
+ ///
+ /// // Second chunk: completes the event
+ /// events = parser.parse("st\":\"value\"}\n\n")
+ /// // returns: [SseEvent(data: "{\"test\":\"value\"}")]
+ /// ```
+ ///
+ /// ## Edge Cases
+ ///
+ /// - Empty chunks are handled gracefully
+ /// - Partial UTF-8 sequences are preserved in buffer
+ /// - Very long lines are supported
+ /// - Multiple events in one chunk are all returned
+ public mutating func parse(_ chunk: String) throws -> [SseEvent] {
+ // Normalize all line endings to \n per SSE spec (WHATWG):
+ // \r\n and \r are both valid line ending sequences.
+ let normalized = chunk.replacingOccurrences(of: "\r\n", with: "\n")
+ .replacingOccurrences(of: "\r", with: "\n")
+ buffer += normalized
+
+ // Guard against unbounded buffer growth from malformed/malicious streams.
+ // Discard buffered data and throw — a 10 MB buffer without an event
+ // delimiter indicates a broken or malicious connection.
+ guard buffer.utf8.count <= Self.maxBufferByteCount else {
+ buffer = ""
+ throw SseParserError.bufferOverflow(limit: Self.maxBufferByteCount)
+ }
+
+ var events: [SseEvent] = []
+
+ // Split on double newline (event separator — handles \n\n after normalization)
+ let parts = buffer.components(separatedBy: "\n\n")
+
+ // Keep the last part in buffer (might be incomplete)
+ buffer = parts.last ?? ""
+
+ // Process complete events (all parts except the last)
+ for part in parts.dropLast() where !part.isEmpty {
+ if let event = parseEvent(part) {
+ events.append(event)
+ }
+ }
+
+ return events
+ }
+
+ /// Parses a single complete event from its text representation.
+ ///
+ /// - Parameter text: The event text (between double newlines)
+ /// - Returns: Parsed event, or nil if no data field present
+ private func parseEvent(_ text: String) -> SseEvent? {
+ var dataLines: [String] = []
+ var id: String?
+ var eventType: String?
+ var retry: Int?
+
+ // Process each line in the event
+ for line in text.components(separatedBy: "\n") {
+ // Skip empty lines
+ guard !line.isEmpty else { continue }
+
+ // Comments start with ':'
+ if line.hasPrefix(":") {
+ continue
+ }
+
+ // Find the colon separator
+ guard let colonIndex = line.firstIndex(of: ":") else {
+ // Lines without colon are ignored per spec
+ continue
+ }
+
+ let field = String(line[.. AgentStateMutation? {
+/// var updatedMessages = params.messages
+/// updatedMessages.append(SystemMessage(
+/// id: "custom-prompt",
+/// content: "Be concise and helpful."
+/// ))
+/// return AgentStateMutation(messages: updatedMessages)
+/// }
+/// ```
+///
+/// - SeeAlso: ``AgentSubscriber``
+public struct AgentStateMutation: Sendable {
+ /// Replacement messages for the agent's conversation history.
+ ///
+ /// When non-nil, these messages replace the current message list.
+ public let messages: [any Message]?
+
+ /// Updated state for the agent.
+ ///
+ /// When non-nil, this state replaces the current agent state.
+ public let state: State?
+
+ /// Whether to prevent default handlers from processing this event.
+ ///
+ /// When `true`, the agent's default event handlers will skip their
+ /// normal processing, allowing the subscriber to fully control behavior.
+ public let stopPropagation: Bool
+
+ /// Creates a new agent state mutation.
+ ///
+ /// - Parameters:
+ /// - messages: Optional replacement messages
+ /// - state: Optional replacement state
+ /// - stopPropagation: Whether to stop default handler processing (default: false)
+ public init(
+ messages: [any Message]? = nil,
+ state: State? = nil,
+ stopPropagation: Bool = false
+ ) {
+ self.messages = messages
+ self.state = state
+ self.stopPropagation = stopPropagation
+ }
+}
+
+// MARK: - Parameter Types
+
+/// Common parameters shared across subscriber callbacks.
+public struct AgentSubscriberParams: Sendable {
+ /// Current conversation messages.
+ public let messages: [any Message]
+
+ /// Current agent state.
+ public let state: State
+
+ /// Input that started this run.
+ public let input: RunAgentInput
+
+ /// Creates new subscriber parameters.
+ ///
+ /// - Parameters:
+ /// - messages: Current conversation messages
+ /// - state: Current agent state
+ /// - input: Input that started this run
+ public init(
+ messages: [any Message],
+ state: State,
+ input: RunAgentInput
+ ) {
+ self.messages = messages
+ self.state = state
+ self.input = input
+ }
+}
+
+/// Parameters delivered when subscribers observe a raw event.
+public struct AgentEventParams: Sendable {
+ /// The raw event from the event stream.
+ public let event: any AGUIEvent
+
+ /// Current conversation messages.
+ public let messages: [any Message]
+
+ /// Current agent state.
+ public let state: State
+
+ /// Input that started this run.
+ public let input: RunAgentInput
+
+ /// Creates new event parameters.
+ ///
+ /// - Parameters:
+ /// - event: The raw event from the stream
+ /// - messages: Current conversation messages
+ /// - state: Current agent state
+ /// - input: Input that started this run
+ public init(
+ event: any AGUIEvent,
+ messages: [any Message],
+ state: State,
+ input: RunAgentInput
+ ) {
+ self.event = event
+ self.messages = messages
+ self.state = state
+ self.input = input
+ }
+}
+
+/// Parameters passed when the run fails with an exception.
+public struct AgentRunFailureParams: Sendable {
+ /// The error that caused the run to fail.
+ public let error: Error
+
+ /// Current conversation messages.
+ public let messages: [any Message]
+
+ /// Current agent state.
+ public let state: State
+
+ /// Input that started this run.
+ public let input: RunAgentInput
+
+ /// Creates new run failure parameters.
+ ///
+ /// - Parameters:
+ /// - error: The error that caused the failure
+ /// - messages: Current conversation messages
+ /// - state: Current agent state
+ /// - input: Input that started this run
+ public init(
+ error: Error,
+ messages: [any Message],
+ state: State,
+ input: RunAgentInput
+ ) {
+ self.error = error
+ self.messages = messages
+ self.state = state
+ self.input = input
+ }
+}
+
+/// Parameters used when notifying subscribers of state or message changes.
+public struct AgentStateChangedParams: Sendable {
+ /// Current conversation messages.
+ public let messages: [any Message]
+
+ /// Current agent state.
+ public let state: State
+
+ /// Input that started this run.
+ public let input: RunAgentInput
+
+ /// Creates new state changed parameters.
+ ///
+ /// - Parameters:
+ /// - messages: Current conversation messages
+ /// - state: Current agent state
+ /// - input: Input that started this run
+ public init(
+ messages: [any Message],
+ state: State,
+ input: RunAgentInput
+ ) {
+ self.messages = messages
+ self.state = state
+ self.input = input
+ }
+}
+
+// MARK: - AgentSubscription
+
+/// Subscription handle returned by agent subscribe methods.
+///
+/// Use this to unsubscribe when you no longer want to receive callbacks.
+///
+/// ## Example
+///
+/// ```swift
+/// let subscription = agent.subscribe(mySubscriber)
+///
+/// // Later, when done observing
+/// await subscription.unsubscribe()
+/// ```
+public protocol AgentSubscription: Sendable {
+ /// Unsubscribes from the agent, stopping all future callbacks.
+ func unsubscribe() async
+}
+
+// MARK: - AgentSubscriber
+
+/// Contract for observers that want to intercept lifecycle or event updates.
+///
+/// All callbacks are optional. Returning ``AgentStateMutation/stopPropagation`` = `true`
+/// prevents the default handlers from mutating the agent state for that event.
+///
+/// ## Lifecycle Hooks
+///
+/// The subscriber provides six optional hooks:
+///
+/// - ``onRunInitialized(params:)``: Called when a run starts
+/// - ``onEvent(params:)``: Called for each event in the stream
+/// - ``onRunFinalized(params:)``: Called when a run completes successfully
+/// - ``onRunFailed(params:)``: Called when a run fails with an error
+/// - ``onMessagesChanged(params:)``: Called when messages are modified
+/// - ``onStateChanged(params:)``: Called when state is modified
+///
+/// ## Example
+///
+/// ```swift
+/// struct LoggingSubscriber: AgentSubscriber {
+/// func onRunInitialized(params: AgentSubscriberParams) async -> AgentStateMutation? {
+/// print("Run started with \(params.messages.count) messages")
+/// return nil
+/// }
+///
+/// func onEvent(params: AgentEventParams) async -> AgentStateMutation? {
+/// print("Received event: \(type(of: params.event))")
+/// return nil
+/// }
+///
+/// func onRunFinalized(params: AgentSubscriberParams) async -> AgentStateMutation? {
+/// print("Run completed successfully")
+/// return nil
+/// }
+/// }
+///
+/// let agent = HttpAgent(configuration: config)
+/// let subscription = agent.subscribe(LoggingSubscriber())
+/// ```
+///
+/// - SeeAlso: ``AgentStateMutation``, ``AgentSubscription``
+public protocol AgentSubscriber: Sendable {
+ /// Called when a run is initialized.
+ ///
+ /// This is called before the agent starts processing the input, allowing
+ /// subscribers to inspect or modify the initial state and messages.
+ ///
+ /// - Parameter params: Common subscriber parameters
+ /// - Returns: Optional mutation to apply before processing
+ func onRunInitialized(params: AgentSubscriberParams) async -> AgentStateMutation?
+
+ /// Called when a run fails with an error.
+ ///
+ /// This allows subscribers to log errors, implement retry logic, or
+ /// modify the agent state in response to failures.
+ ///
+ /// - Parameter params: Failure parameters including the error
+ /// - Returns: Optional mutation to apply after failure
+ func onRunFailed(params: AgentRunFailureParams) async -> AgentStateMutation?
+
+ /// Called when a run completes successfully.
+ ///
+ /// This is called after all events have been processed and the run
+ /// has finished without errors.
+ ///
+ /// - Parameter params: Common subscriber parameters
+ /// - Returns: Optional mutation to apply after completion
+ func onRunFinalized(params: AgentSubscriberParams) async -> AgentStateMutation?
+
+ /// Called for each event in the event stream.
+ ///
+ /// This allows subscribers to observe or react to individual events
+ /// as they occur during the run.
+ ///
+ /// - Parameter params: Event parameters including the raw event
+ /// - Returns: Optional mutation to apply for this event
+ func onEvent(params: AgentEventParams) async -> AgentStateMutation?
+
+ /// Called when the message list changes.
+ ///
+ /// This is a notification-only callback (does not return mutations).
+ ///
+ /// - Parameter params: State changed parameters with new messages
+ func onMessagesChanged(params: AgentStateChangedParams) async
+
+ /// Called when the agent state changes.
+ ///
+ /// This is a notification-only callback (does not return mutations).
+ ///
+ /// - Parameter params: State changed parameters with new state
+ func onStateChanged(params: AgentStateChangedParams) async
+}
+
+// MARK: - Default Implementations
+
+extension AgentSubscriber {
+ /// Default implementation returns no mutation.
+ public func onRunInitialized(params: AgentSubscriberParams) async -> AgentStateMutation? {
+ nil
+ }
+
+ /// Default implementation returns no mutation.
+ public func onRunFailed(params: AgentRunFailureParams) async -> AgentStateMutation? {
+ nil
+ }
+
+ /// Default implementation returns no mutation.
+ public func onRunFinalized(params: AgentSubscriberParams) async -> AgentStateMutation? {
+ nil
+ }
+
+ /// Default implementation returns no mutation.
+ public func onEvent(params: AgentEventParams) async -> AgentStateMutation? {
+ nil
+ }
+
+ /// Default implementation does nothing.
+ public func onMessagesChanged(params: AgentStateChangedParams) async {}
+
+ /// Default implementation does nothing.
+ public func onStateChanged(params: AgentStateChangedParams) async {}
+}
diff --git a/sdks/community/swift/Sources/AGUIClient/Subscriber/SubscriberManager.swift b/sdks/community/swift/Sources/AGUIClient/Subscriber/SubscriberManager.swift
new file mode 100644
index 0000000000..65500e4a0b
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUIClient/Subscriber/SubscriberManager.swift
@@ -0,0 +1,152 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUICore
+import Foundation
+
+// MARK: - DefaultAgentSubscription
+
+/// Default implementation of ``AgentSubscription``.
+///
+/// This subscription maintains a reference to the subscriber manager
+/// and its own ID to enable unsubscription.
+actor DefaultAgentSubscription: AgentSubscription {
+ private var isActive: Bool = true
+ private let onUnsubscribe: @Sendable () async -> Void
+
+ /// Creates a new subscription.
+ ///
+ /// - Parameter onUnsubscribe: Closure to call when unsubscribing
+ init(onUnsubscribe: @escaping @Sendable () async -> Void) {
+ self.onUnsubscribe = onUnsubscribe
+ }
+
+ /// Unsubscribes from the agent.
+ ///
+ /// After calling this method, no further callbacks will be delivered
+ /// to the subscriber.
+ public func unsubscribe() async {
+ guard isActive else { return }
+ isActive = false
+ await onUnsubscribe()
+ }
+}
+
+// MARK: - SubscriberManager
+
+/// Thread-safe manager for agent subscribers.
+///
+/// This actor maintains the list of active subscribers and provides
+/// methods to execute them in sequence with mutation chaining.
+///
+/// Subscribers are returned in **registration order** — the order in which
+/// `subscribe(_:)` was called. This is guaranteed even after interleaved
+/// `unsubscribe` calls. A parallel `insertionOrder` array tracks the sequence
+/// separately from the dictionary so that `Dictionary.values` (undefined order)
+/// is never used as an execution order.
+public actor SubscriberManager {
+ private var subscribers: [UUID: any AgentSubscriber] = [:]
+ /// Tracks the order in which subscribers were registered.
+ private var insertionOrder: [UUID] = []
+
+ public init() {}
+
+ /// Subscribes a new subscriber.
+ ///
+ /// - Parameter subscriber: The subscriber to add
+ /// - Returns: The unique ID for this subscription
+ public func subscribe(_ subscriber: any AgentSubscriber) -> UUID {
+ let id = UUID()
+ subscribers[id] = subscriber
+ insertionOrder.append(id)
+ return id
+ }
+
+ /// Unsubscribes a subscriber by ID.
+ ///
+ /// - Parameter id: The subscription ID to remove
+ public func unsubscribe(_ id: UUID) {
+ subscribers.removeValue(forKey: id)
+ insertionOrder.removeAll { $0 == id }
+ }
+
+ /// Returns all active subscribers in registration order.
+ ///
+ /// - Returns: Array of all subscribers ordered by `subscribe(_:)` call sequence
+ public func allSubscribers() -> [any AgentSubscriber] {
+ insertionOrder.compactMap { subscribers[$0] }
+ }
+}
+
+// MARK: - Mutation Execution
+
+/// Executes subscribers sequentially, feeding the latest message/state snapshot.
+///
+/// This function implements mutation chaining: each subscriber receives the
+/// messages and state as modified by previous subscribers. If any subscriber
+/// returns `stopPropagation: true`, execution stops immediately.
+///
+/// ## Example
+///
+/// ```swift
+/// let mutation = await runSubscribersWithMutation(
+/// subscribers: [subscriber1, subscriber2],
+/// messages: currentMessages,
+/// state: currentState
+/// ) { subscriber, messages, state in
+/// let params = AgentSubscriberParams(
+/// messages: messages,
+/// state: state,
+/// input: input
+/// )
+/// return await subscriber.onRunInitialized(params: params)
+/// }
+///
+/// if let updatedMessages = mutation.messages {
+/// currentMessages = updatedMessages
+/// }
+/// ```
+///
+/// - Parameters:
+/// - subscribers: Array of subscribers to execute
+/// - messages: Initial messages
+/// - state: Initial state
+/// - executor: Closure that calls the specific subscriber method
+/// - Returns: Aggregated mutation from all subscribers
+public func runSubscribersWithMutation(
+ subscribers: [any AgentSubscriber],
+ messages: [any Message],
+ state: State,
+ executor: @Sendable (any AgentSubscriber, [any Message], State) async -> AgentStateMutation?
+) async -> AgentStateMutation {
+ var currentMessages = messages
+ var currentState = state
+ var aggregatedMessages: [any Message]?
+ var aggregatedState: State?
+ var stopPropagation = false
+
+ for subscriber in subscribers {
+ // Swift structs have value semantics, so copying is automatic
+ let mutation = await executor(subscriber, currentMessages, currentState)
+
+ if let mutation = mutation {
+ if let newMessages = mutation.messages {
+ currentMessages = newMessages
+ aggregatedMessages = newMessages
+ }
+ if let newState = mutation.state {
+ currentState = newState
+ aggregatedState = newState
+ }
+ if mutation.stopPropagation {
+ stopPropagation = true
+ break
+ }
+ }
+ }
+
+ return AgentStateMutation(
+ messages: aggregatedMessages,
+ state: aggregatedState,
+ stopPropagation: stopPropagation
+ )
+}
diff --git a/sdks/community/swift/Sources/AGUIClient/Transport/AgentTransport.swift b/sdks/community/swift/Sources/AGUIClient/Transport/AgentTransport.swift
new file mode 100644
index 0000000000..ca97c701eb
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUIClient/Transport/AgentTransport.swift
@@ -0,0 +1,8 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUICore
+import Foundation
+
+public protocol AgentTransport: Sendable {
+ func run(input: RunAgentInput) -> AsyncThrowingStream
+}
diff --git a/sdks/community/swift/Sources/AGUIClient/Transport/HTTPClient.swift b/sdks/community/swift/Sources/AGUIClient/Transport/HTTPClient.swift
new file mode 100644
index 0000000000..8d85957ee9
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUIClient/Transport/HTTPClient.swift
@@ -0,0 +1,75 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Protocol for HTTP client operations.
+///
+/// `HTTPClient` abstracts HTTP networking, enabling dependency injection
+/// and testability. Implementations can use URLSession, mock responses,
+/// or custom networking stacks.
+///
+/// ## Example Implementation
+///
+/// ```swift
+/// actor URLSessionHTTPClient: HTTPClient {
+/// private let session: URLSession
+///
+/// init(session: URLSession) {
+/// self.session = session
+/// }
+///
+/// func execute(_ request: URLRequest) async throws -> HTTPResponse {
+/// let (bytes, urlResponse) = try await session.bytes(for: request)
+/// guard let httpResponse = urlResponse as? HTTPURLResponse else {
+/// throw ClientError.invalidResponse
+/// }
+/// return HTTPResponse(bytes: bytes, httpResponse: httpResponse)
+/// }
+/// }
+/// ```
+public protocol HTTPClient: Sendable {
+ /// Executes an HTTP request and returns the response.
+ ///
+ /// - Parameter request: The URL request to execute
+ /// - Returns: An HTTP response containing streaming bytes and metadata
+ /// - Throws: `ClientError` if the request fails
+ func execute(_ request: URLRequest) async throws -> HTTPResponse
+}
+
+/// HTTP response containing streaming bytes and metadata.
+///
+/// `bytes` is typed as `AsyncThrowingStream` rather than
+/// `URLSession.AsyncBytes` so that:
+/// - Mock `HTTPClient` implementations can produce byte streams without a live URLSession
+/// - The transport layer is testable in isolation
+/// - Consumers of `HTTPClient` are decoupled from URLSession internals
+public struct HTTPResponse: Sendable {
+ /// Streaming response bytes.
+ ///
+ /// Conforms to `AsyncSequence` producing `UInt8` values, allowing
+ /// incremental consumption of the response body.
+ public let bytes: AsyncThrowingStream
+
+ /// HTTP response metadata.
+ public let httpResponse: HTTPURLResponse
+
+ /// HTTP status code.
+ public var statusCode: Int {
+ httpResponse.statusCode
+ }
+
+ /// Response headers.
+ public var headers: [AnyHashable: Any] {
+ httpResponse.allHeaderFields
+ }
+
+ /// Creates a new HTTP response.
+ ///
+ /// - Parameters:
+ /// - bytes: Streaming response bytes as an `AsyncThrowingStream`
+ /// - httpResponse: HTTP response metadata
+ public init(bytes: AsyncThrowingStream, httpResponse: HTTPURLResponse) {
+ self.bytes = bytes
+ self.httpResponse = httpResponse
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUIClient/Transport/HttpAgentConfiguration.swift b/sdks/community/swift/Sources/AGUIClient/Transport/HttpAgentConfiguration.swift
new file mode 100644
index 0000000000..49a0c600c0
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUIClient/Transport/HttpAgentConfiguration.swift
@@ -0,0 +1,132 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Configuration for HTTP agent client.
+public struct HttpAgentConfiguration: Sendable {
+ /// Base URL for the agent endpoint.
+ public var baseURL: URL
+
+ /// Timeout interval for requests in seconds.
+ public var timeout: TimeInterval
+
+ /// Retry policy for failed requests.
+ public var retryPolicy: RetryPolicy
+
+ /// Additional HTTP headers to include in requests.
+ public var headers: [String: String]
+
+ /// When `true`, enables verbose pipeline logging.
+ ///
+ /// Default: `false`
+ public var debug: Bool
+
+ /// Bearer token for authentication.
+ ///
+ /// When set, ``buildHeaders()`` includes `Authorization: Bearer `.
+ /// This property does **not** mutate ``headers`` directly — call
+ /// ``buildHeaders()`` to get the merged header dictionary for requests.
+ ///
+ /// Default: `nil`
+ public var bearerToken: String?
+
+ /// API key value.
+ ///
+ /// When set, ``buildHeaders()`` includes the key under ``apiKeyHeader``.
+ /// This property does **not** mutate ``headers`` directly — call
+ /// ``buildHeaders()`` to get the merged header dictionary for requests.
+ ///
+ /// Default: `nil`
+ public var apiKey: String?
+
+ /// Header name used when adding the ``apiKey``.
+ ///
+ /// Default: `"X-API-Key"`
+ public var apiKeyHeader: String
+
+ // MARK: - Header builder
+
+ /// Returns the final HTTP header dictionary, merging ``bearerToken`` and
+ /// ``apiKey`` into ``headers``.
+ ///
+ /// Priority (highest → lowest):
+ /// 1. Entries already in ``headers`` (override everything)
+ /// 2. `bearerToken` → `Authorization: Bearer `
+ /// 3. `apiKey` → `: `
+ ///
+ /// - Returns: Merged header dictionary ready for request construction.
+ public func buildHeaders() -> [String: String] {
+ var result: [String: String] = [:]
+ if let key = apiKey {
+ result[apiKeyHeader] = sanitizeHeaderValue(key)
+ }
+ if let token = bearerToken {
+ result["Authorization"] = "Bearer \(sanitizeHeaderValue(token))"
+ }
+ for (k, v) in headers {
+ result[k] = sanitizeHeaderValue(v)
+ }
+ return result
+ }
+
+ /// Strips CR and LF characters from a header value to prevent CRLF injection.
+ ///
+ /// URLSession rejects headers containing `\r\n` on Apple platforms, but custom
+ /// `HTTPClient` implementations may not. Sanitizing at the source protects all
+ /// transport implementations uniformly.
+ private func sanitizeHeaderValue(_ value: String) -> String {
+ value.replacingOccurrences(of: "\r", with: "")
+ .replacingOccurrences(of: "\n", with: "")
+ }
+
+ /// Retry policy options.
+ public enum RetryPolicy: Sendable {
+ /// No retry on failure.
+ case none
+
+ /// Fixed retry with maximum attempts and delay.
+ case fixed(maxAttempts: Int, delay: TimeInterval)
+
+ /// Exponential backoff retry.
+ case exponentialBackoff(maxAttempts: Int, baseDelay: TimeInterval)
+ }
+
+ /// Creates a new HTTP agent configuration.
+ ///
+ /// - Parameters:
+ /// - baseURL: The base URL for the agent endpoint
+ /// - timeout: Request timeout in seconds (default: 120.0)
+ /// - retryPolicy: Retry policy for failures (default: .none)
+ /// - headers: Additional HTTP headers (default: empty)
+ /// - debug: Enable verbose pipeline logging (default: false)
+ public init(
+ baseURL: URL,
+ timeout: TimeInterval = 120.0,
+ retryPolicy: RetryPolicy = .none,
+ headers: [String: String] = [:],
+ debug: Bool = false
+ ) {
+ self.baseURL = baseURL
+ self.timeout = timeout
+ self.retryPolicy = retryPolicy
+ self.headers = headers
+ self.debug = debug
+ self.bearerToken = nil
+ self.apiKey = nil
+ self.apiKeyHeader = "X-API-Key"
+ }
+}
+
+extension HttpAgentConfiguration {
+ /// Creates a configuration with the specified base URL string.
+ ///
+ /// - Parameter baseURLString: The base URL string
+ /// - Throws: `ClientError.invalidURL` if the URL string is invalid
+ /// - Returns: A new configuration instance
+ public static func create(baseURLString: String) throws -> HttpAgentConfiguration {
+ guard let url = URL(string: baseURLString) else {
+ throw ClientError.invalidURL
+ }
+ return HttpAgentConfiguration(baseURL: url)
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUIClient/Transport/HttpAgentTransport.swift b/sdks/community/swift/Sources/AGUIClient/Transport/HttpAgentTransport.swift
new file mode 100644
index 0000000000..ddc494e625
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUIClient/Transport/HttpAgentTransport.swift
@@ -0,0 +1,130 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUICore
+import Foundation
+
+public struct HttpAgentTransport: AgentTransport {
+ private let transport: HttpTransport
+ private let decoder: AGUIEventDecoder
+ private let endpoint: String
+ private let configuration: HttpAgentConfiguration
+
+ public init(configuration: HttpAgentConfiguration, endpoint: String = "/run") {
+ self.configuration = configuration
+ self.transport = HttpTransport(configuration: configuration)
+ self.decoder = AGUIEventDecoder()
+ self.endpoint = endpoint
+ }
+
+ public init(configuration: HttpAgentConfiguration, httpClient: any HTTPClient, endpoint: String = "/run") {
+ self.configuration = configuration
+ self.transport = HttpTransport(configuration: configuration, httpClient: httpClient)
+ self.decoder = AGUIEventDecoder()
+ self.endpoint = endpoint
+ }
+
+ /// Executes a raw HTTP request and returns the byte stream.
+ ///
+ /// Exposes the underlying transport's execute method so callers can share
+ /// the same URLSession without creating a duplicate `HttpTransport`.
+ ///
+ /// - Parameters:
+ /// - endpoint: API endpoint path
+ /// - input: The run agent input
+ /// - lastEventId: Optional last SSE event ID for reconnection
+ /// - Returns: Raw byte stream from the HTTP response
+ public func execute(
+ endpoint: String,
+ input: RunAgentInput,
+ lastEventId: String? = nil
+ ) async throws -> AsyncThrowingStream {
+ try await transport.execute(
+ endpoint: endpoint,
+ input: input,
+ lastEventId: lastEventId
+ )
+ }
+
+ public func run(input: RunAgentInput) -> AsyncThrowingStream {
+ let transport = self.transport
+ let decoder = self.decoder
+ let endpoint = self.endpoint
+ let configuration = self.configuration
+
+ return AsyncThrowingStream { continuation in
+ let task = Task {
+ var lastEventId: String? = nil
+ var attempt = 0
+ var currentStream: EventStream>? = nil
+
+ while true {
+ do {
+ let bytes = try await transport.execute(
+ endpoint: endpoint,
+ input: input,
+ lastEventId: lastEventId
+ )
+ let eventStream = EventStream(bytes: bytes, decoder: decoder)
+ currentStream = eventStream
+ for try await event in eventStream {
+ continuation.yield(event)
+ }
+ continuation.finish()
+ return
+ } catch {
+ if let stream = currentStream {
+ lastEventId = await stream.lastEventId ?? lastEventId
+ }
+ currentStream = nil
+
+ guard shouldRetry(error: error, attempt: attempt, configuration: configuration) else {
+ continuation.finish(throwing: error)
+ return
+ }
+
+ let delay = retryDelay(for: attempt, configuration: configuration)
+ attempt += 1
+
+ if delay > 0 {
+ try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
+ }
+ }
+ }
+ }
+ continuation.onTermination = { _ in task.cancel() }
+ }
+ }
+
+ private func shouldRetry(error: Error, attempt: Int, configuration: HttpAgentConfiguration) -> Bool {
+ guard isRetryable(error) else { return false }
+ switch configuration.retryPolicy {
+ case .none:
+ return false
+ case .fixed(let maxAttempts, _):
+ return attempt < maxAttempts
+ case .exponentialBackoff(let maxAttempts, _):
+ return attempt < maxAttempts
+ }
+ }
+
+ private func isRetryable(_ error: Error) -> Bool {
+ guard let clientError = error as? ClientError else { return false }
+ switch clientError {
+ case .timeout, .networkError:
+ return true
+ default:
+ return false
+ }
+ }
+
+ private func retryDelay(for attempt: Int, configuration: HttpAgentConfiguration) -> TimeInterval {
+ switch configuration.retryPolicy {
+ case .none:
+ return 0
+ case .fixed(_, let delay):
+ return delay
+ case .exponentialBackoff(_, let baseDelay):
+ return min(baseDelay * pow(2.0, Double(attempt)), 60.0)
+ }
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUIClient/Transport/HttpTransport.swift b/sdks/community/swift/Sources/AGUIClient/Transport/HttpTransport.swift
new file mode 100644
index 0000000000..b00f784962
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUIClient/Transport/HttpTransport.swift
@@ -0,0 +1,164 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUICore
+import Foundation
+
+/// Low-level HTTP transport for AG-UI agent communication.
+///
+/// `HttpTransport` handles HTTP communication with AG-UI agents using
+/// dependency injection for testability and flexibility. It accepts any
+/// `HTTPClient` implementation, defaulting to `URLSessionHTTPClient`.
+///
+/// ## Example
+///
+/// ```swift
+/// // Default usage with URLSession
+/// let config = HttpAgentConfiguration(baseURL: agentURL)
+/// let transport = HttpTransport(configuration: config)
+///
+/// // With custom HTTP client (e.g., for testing)
+/// let mockClient = MockHTTPClient()
+/// let transport = HttpTransport(
+/// configuration: config,
+/// httpClient: mockClient
+/// )
+///
+/// // Execute request
+/// let bytes = try await transport.execute(endpoint: "/run", input: input)
+/// ```
+public actor HttpTransport {
+ private let httpClient: any HTTPClient
+ private let configuration: HttpAgentConfiguration
+
+ /// Creates a new HTTP transport with dependency injection.
+ ///
+ /// - Parameters:
+ /// - configuration: HTTP agent configuration
+ /// - httpClient: HTTP client implementation (optional)
+ ///
+ /// If no HTTP client is provided, creates a default `URLSessionHTTPClient`
+ /// configured according to the provided configuration.
+ ///
+ /// ## Default Client Configuration
+ ///
+ /// When using the default client, URLSession is configured with:
+ /// - Request timeout from configuration
+ /// - Resource timeout from configuration
+ /// - Custom headers from configuration
+ /// - User-Agent header set to "AGUISwift/1.0"
+ ///
+ /// ## Dependency Injection
+ ///
+ /// For testing or custom networking, inject a custom HTTPClient:
+ ///
+ /// ```swift
+ /// let mockClient = MockHTTPClient()
+ /// let transport = HttpTransport(
+ /// configuration: config,
+ /// httpClient: mockClient
+ /// )
+ /// ```
+ public init(
+ configuration: HttpAgentConfiguration,
+ httpClient: (any HTTPClient)? = nil
+ ) {
+ self.configuration = configuration
+
+ if let httpClient = httpClient {
+ self.httpClient = httpClient
+ } else {
+ // Create default URLSession-based client
+ let sessionConfig = URLSessionConfiguration.default
+ // timeoutIntervalForRequest: max idle time between consecutive bytes (per-chunk).
+ // For AI streaming, the inference step can take 30-120 s before the first
+ // token arrives; use at least 5 minutes so we don't cut off mid-inference.
+ // timeoutIntervalForResource: total wall-clock cap for the full stream.
+ // Cap at 1 hour so even long agent runs complete without being killed.
+ sessionConfig.timeoutIntervalForRequest = max(configuration.timeout, 300)
+ sessionConfig.timeoutIntervalForResource = max(configuration.timeout, 3600)
+
+ // Merge auth + explicit headers, then add User-Agent.
+ // buildHeaders() unifies bearerToken, apiKey, and headers in one call
+ // so auth is always applied even when set after init.
+ var headers = configuration.buildHeaders()
+ headers["User-Agent"] = "AGUISwift/1.0"
+ sessionConfig.httpAdditionalHeaders = headers
+
+ let session = URLSession(configuration: sessionConfig)
+ self.httpClient = URLSessionHTTPClient(session: session)
+ }
+ }
+
+ /// Executes an HTTP request and returns streaming bytes.
+ ///
+ /// - Parameters:
+ /// - endpoint: The endpoint path (e.g., "/run")
+ /// - input: The run agent input to send
+ /// - lastEventId: When provided, sets the `Last-Event-ID` header so the server
+ /// can resume the stream from the last processed event (SSE reconnection).
+ /// Pass `nil` (the default) for a fresh stream with no resume cursor.
+ /// - Returns: An async sequence of bytes from the server
+ /// - Throws: `ClientError` if the request fails
+ ///
+ /// ## Request Format
+ ///
+ /// The request is constructed as:
+ /// - Method: POST
+ /// - URL: `baseURL` + `endpoint`
+ /// - Headers:
+ /// - Content-Type: application/json
+ /// - Accept: text/event-stream
+ /// - Last-Event-ID: `lastEventId` (when non-nil)
+ /// - Body: JSON-encoded `RunAgentInput`
+ ///
+ /// ## Response Validation
+ ///
+ /// Validates the HTTP response:
+ /// - Status code must be 200-299
+ /// - Response must be HTTPURLResponse
+ ///
+ /// ## Error Handling
+ ///
+ /// Throws `ClientError` for:
+ /// - Encoding failures → `.encodingError`
+ /// - Non-2xx status codes → `.httpError(statusCode:)`
+ /// - Invalid responses → `.invalidResponse`
+ /// - Network errors → `.networkError`, `.timeout`, `.cancelled`
+ public func execute(
+ endpoint: String,
+ input: RunAgentInput,
+ lastEventId: String? = nil
+ ) async throws -> AsyncThrowingStream {
+ // Construct URL
+ let url = configuration.baseURL.appendingPathComponent(endpoint)
+
+ // Create request
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.setValue("text/event-stream", forHTTPHeaderField: "Accept")
+
+ // SSE reconnection resume cursor — only set when the caller has a prior event id
+ if let lastEventId {
+ request.setValue(lastEventId, forHTTPHeaderField: "Last-Event-ID")
+ }
+
+ // Encode RunAgentInput to JSON
+ let encoder = JSONEncoder()
+ do {
+ request.httpBody = try encoder.encode(input)
+ } catch {
+ throw ClientError.encodingError(error)
+ }
+
+ // Execute request via injected HTTP client
+ let response = try await httpClient.execute(request)
+
+ // Validate HTTP status code
+ guard (200...299).contains(response.statusCode) else {
+ throw ClientError.httpError(statusCode: response.statusCode)
+ }
+
+ return response.bytes
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUIClient/Transport/URLSessionHTTPClient.swift b/sdks/community/swift/Sources/AGUIClient/Transport/URLSessionHTTPClient.swift
new file mode 100644
index 0000000000..75e0b86344
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUIClient/Transport/URLSessionHTTPClient.swift
@@ -0,0 +1,135 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// URLSession-based HTTP client implementation.
+///
+/// `URLSessionHTTPClient` is the default HTTP client that uses Apple's
+/// URLSession for networking. It supports full URLSession configuration
+/// and can be injected with a custom session for testing.
+///
+/// ## Example
+///
+/// ```swift
+/// // Default usage
+/// let client = URLSessionHTTPClient.create()
+///
+/// // Custom configuration
+/// let config = URLSessionConfiguration.default
+/// config.timeoutIntervalForRequest = 30
+/// let session = URLSession(configuration: config)
+/// let client = URLSessionHTTPClient(session: session)
+///
+/// // Execute request
+/// let request = URLRequest(url: url)
+/// let response = try await client.execute(request)
+/// ```
+public actor URLSessionHTTPClient: HTTPClient {
+ private let session: URLSession
+
+ /// Creates a new URLSession HTTP client with the specified session.
+ ///
+ /// This is the primary initializer that accepts a URLSession instance,
+ /// enabling full control over session configuration and injection of
+ /// mock sessions for testing.
+ ///
+ /// - Parameter session: The URLSession to use for requests
+ ///
+ /// ## Example
+ ///
+ /// ```swift
+ /// let config = URLSessionConfiguration.default
+ /// config.timeoutIntervalForRequest = 60
+ /// let session = URLSession(configuration: config)
+ /// let client = URLSessionHTTPClient(session: session)
+ /// ```
+ public init(session: URLSession) {
+ self.session = session
+ }
+
+ deinit {
+ // Release OS-level socket pool and free the session delegate.
+ // invalidateAndCancel() is synchronous and safe to call from deinit.
+ session.invalidateAndCancel()
+ }
+
+ /// Creates a new URLSession HTTP client with the specified configuration.
+ ///
+ /// This factory method provides a convenient way to create a client
+ /// with custom URLSession configuration.
+ ///
+ /// - Parameter configuration: URLSession configuration (default: .default)
+ /// - Returns: A new URLSession HTTP client
+ ///
+ /// ## Example
+ ///
+ /// ```swift
+ /// let config = URLSessionConfiguration.ephemeral
+ /// config.timeoutIntervalForRequest = 30
+ /// let client = URLSessionHTTPClient.create(configuration: config)
+ /// ```
+ public static func create(
+ configuration: URLSessionConfiguration = .default
+ ) -> URLSessionHTTPClient {
+ let session = URLSession(configuration: configuration)
+ return URLSessionHTTPClient(session: session)
+ }
+
+ /// Executes an HTTP request using URLSession.
+ ///
+ /// - Parameter request: The URL request to execute
+ /// - Returns: An HTTP response containing streaming bytes and metadata
+ /// - Throws: `ClientError` if the request fails
+ ///
+ /// ## Error Mapping
+ ///
+ /// URLErrors are mapped to ClientError:
+ /// - `.timedOut` → `.timeout`
+ /// - `.cancelled` → `.cancelled`
+ /// - Other errors → `.networkError`
+ public func execute(_ request: URLRequest) async throws -> HTTPResponse {
+ let (bytes, response): (URLSession.AsyncBytes, URLResponse)
+
+ do {
+ (bytes, response) = try await session.bytes(for: request)
+ } catch let error as URLError {
+ throw mapURLError(error)
+ } catch {
+ throw ClientError.networkError(error)
+ }
+
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw ClientError.invalidResponse
+ }
+
+ // Bridge URLSession.AsyncBytes → AsyncThrowingStream so that
+ // HTTPResponse is decoupled from URLSession and can be mocked in tests.
+ let stream = AsyncThrowingStream { continuation in
+ let task = Task {
+ do {
+ for try await byte in bytes {
+ continuation.yield(byte)
+ }
+ continuation.finish()
+ } catch {
+ continuation.finish(throwing: error)
+ }
+ }
+ continuation.onTermination = { _ in task.cancel() }
+ }
+
+ return HTTPResponse(bytes: stream, httpResponse: httpResponse)
+ }
+
+ /// Maps URLError to ClientError.
+ private func mapURLError(_ error: URLError) -> ClientError {
+ switch error.code {
+ case .timedOut:
+ return .timeout
+ case .cancelled:
+ return .cancelled
+ default:
+ return .networkError(error)
+ }
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/AGUICore.swift b/sdks/community/swift/Sources/AGUICore/AGUICore.swift
new file mode 100644
index 0000000000..11cb3778e7
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/AGUICore.swift
@@ -0,0 +1 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
diff --git a/sdks/community/swift/Sources/AGUICore/Decoding/AGUIEventDecoder.swift b/sdks/community/swift/Sources/AGUICore/Decoding/AGUIEventDecoder.swift
new file mode 100644
index 0000000000..2d2557a368
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Decoding/AGUIEventDecoder.swift
@@ -0,0 +1,434 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Decoder for AG-UI protocol events with polymorphic deserialization.
+///
+/// `AGUIEventDecoder` decodes JSON event data into strongly-typed event objects based on
+/// the "type" field in the JSON. It uses a registry-based architecture that allows you to
+/// customize which event types are supported and how unknown events are handled.
+///
+/// ## Basic Usage
+///
+/// ```swift
+/// // Create a decoder with default settings (strict mode)
+/// let decoder = AGUIEventDecoder()
+///
+/// // Decode an event from JSON data
+/// let event = try decoder.decode(jsonData)
+///
+/// // Pattern match on the event type
+/// switch event.eventType {
+/// case .runStarted:
+/// let runStarted = event as! RunStartedEvent
+/// print("Run started: \(runStarted.runId)")
+/// case .runFinished:
+/// let runFinished = event as! RunFinishedEvent
+/// print("Run finished: \(runFinished.runId)")
+/// default:
+/// print("Other event: \(event.eventType)")
+/// }
+/// ```
+///
+/// ## Configuration Modes
+///
+/// ### Strict Mode (Default)
+///
+/// In strict mode, unknown or unsupported events throw errors:
+///
+/// ```swift
+/// let decoder = AGUIEventDecoder() // Default: .throwError
+/// // Throws EventDecodingError.unknownEventType for unrecognized types
+/// ```
+///
+/// ### Tolerant Mode
+///
+/// In tolerant mode, unknown events are returned as `UnknownEvent`:
+///
+/// ```swift
+/// var config = AGUIEventDecoder.Configuration()
+/// config.unknownEventStrategy = .returnUnknown
+/// let decoder = AGUIEventDecoder(config: config)
+///
+/// let event = try decoder.decode(data)
+/// if let unknown = event as? UnknownEvent {
+/// print("Unknown event type: \(unknown.typeRaw)")
+/// // Can still access raw JSON for forwarding or logging
+/// }
+/// ```
+///
+/// ## Custom Registries
+///
+/// You can provide a custom registry to control which event types are supported:
+///
+/// ```swift
+/// let customRegistry: [EventType: AGUIEventDecoder.DecodeHandler] = [
+/// .runStarted: { data, decoder in
+/// try decoder.decode(RunStartedEventDTO.self, from: data).toDomain(rawEvent: data)
+/// }
+/// // Add more handlers as needed
+/// ]
+///
+/// let decoder = AGUIEventDecoder(registry: customRegistry)
+/// ```
+///
+/// ## Error Handling
+///
+/// The decoder throws `EventDecodingError` for various failure scenarios:
+///
+/// - `.missingTypeField`: The JSON is missing the required "type" field
+/// - `.invalidJSON`: The JSON data is malformed or invalid
+/// - `.unknownEventType(String)`: The event type is not recognized (strict mode only)
+/// - `.unsupportedEventType(EventType)`: The event type is known but has no handler (strict mode only)
+/// - `.decodingFailed(String)`: Field-level decoding errors with detailed messages
+///
+/// ## Thread Safety
+///
+/// `AGUIEventDecoder` maintains mutable state for the THINKING→REASONING backward-compat
+/// remap (tracking IDs across event sequences). It must be used **serially** — one
+/// `decode(_:)` call at a time. Create one decoder per agent run; do not share a decoder
+/// across concurrent tasks. SSE streams guarantee serial delivery, so this is satisfied
+/// automatically when decoding streamed events.
+///
+/// - SeeAlso: `AGUIEvent`, `EventType`, `EventDecodingError`, `UnknownEvent`
+public struct AGUIEventDecoder: Sendable {
+
+ /// Handler function type for decoding a specific event type.
+ ///
+ /// Each handler receives the raw JSON data and a `JSONDecoder`, and returns
+ /// a decoded `AGUIEvent` instance. Handlers are responsible for:
+ ///
+ /// 1. Decoding the event-specific DTO from the JSON data
+ /// 2. Converting the DTO to the domain event type
+ /// 3. Preserving the raw event data for debugging/forwarding
+ ///
+ /// - Parameters:
+ /// - data: The raw JSON data for the event
+ /// - decoder: A `JSONDecoder` instance for decoding
+ /// - Returns: A decoded `AGUIEvent` instance
+ /// - Throws: `EventDecodingError` or `DecodingError` if decoding fails
+ public typealias DecodeHandler = @Sendable (_ data: Data, _ decoder: JSONDecoder) throws -> any AGUIEvent
+
+ /// Configuration options for the decoder.
+ ///
+ /// Use `Configuration` to customize decoder behavior, particularly how unknown
+ /// or unsupported events are handled.
+ ///
+ /// ```swift
+ /// var config = AGUIEventDecoder.Configuration()
+ /// config.unknownEventStrategy = .returnUnknown
+ /// let decoder = AGUIEventDecoder(config: config)
+ /// ```
+ public struct Configuration: Sendable {
+ /// Strategy for handling unknown or unsupported event types.
+ ///
+ /// Defaults to `.throwError` (strict mode).
+ public var unknownEventStrategy: UnknownEventStrategy = .throwError
+
+ /// Creates a new configuration with default settings.
+ public init() {}
+ }
+
+ /// Strategy for handling unknown or unsupported event types.
+ ///
+ /// - `.throwError`: Throw `EventDecodingError` when encountering unknown/unsupported events (strict mode)
+ /// - `.returnUnknown`: Return `UnknownEvent` instances for unknown/unsupported events (tolerant mode)
+ ///
+ /// Tolerant mode is useful for:
+ /// - Forward compatibility with future protocol extensions
+ /// - Graceful degradation when some event types aren't implemented
+ /// - Logging or forwarding events you don't understand yet
+ public enum UnknownEventStrategy: Sendable {
+ /// Throw an error when encountering unknown or unsupported events.
+ ///
+ /// This is the default behavior and ensures type safety by requiring
+ /// all events to be properly decoded.
+ case throwError
+
+ /// Return `UnknownEvent` instances for unknown or unsupported events.
+ ///
+ /// Enables forward compatibility and graceful handling of events
+ /// that aren't yet implemented or recognized.
+ case returnUnknown
+ }
+
+ private let config: Configuration
+ private let makeDecoder: @Sendable () -> JSONDecoder
+ private let registry: [EventType: DecodeHandler]
+
+ /// Mutable state for the THINKING→REASONING backward-compat remap.
+ ///
+ /// Stored as a reference type so the `Sendable` struct can hold mutable state.
+ /// `AGUIEventDecoder` must be used serially (one decode at a time), which is
+ /// guaranteed by SSE streams — events arrive sequentially on a single task.
+ private let remapState: ThinkingRemapState
+
+ // MARK: - Initialization
+
+ /// Creates a new `AGUIEventDecoder`.
+ ///
+ /// - Parameters:
+ /// - config: Configuration options for the decoder (defaults to strict mode)
+ /// - makeDecoder: Factory function for creating `JSONDecoder` instances (defaults to standard `JSONDecoder()`)
+ /// - registry: Dictionary mapping event types to their decode handlers (defaults to `defaultRegistry()`)
+ ///
+ /// The decoder uses the provided registry to determine which event types can be decoded.
+ /// If no registry is provided, it uses `defaultRegistry()` which includes all lifecycle events.
+ ///
+ /// The AG-UI wire protocol uses camelCase keys throughout, so the default `JSONDecoder`
+ /// requires no key decoding strategy. Supply a custom `makeDecoder` only when you need
+ /// additional configuration (e.g. a specific date decoding strategy).
+ ///
+ /// Example with custom JSON decoder:
+ /// ```swift
+ /// let decoder = AGUIEventDecoder(
+ /// makeDecoder: {
+ /// let d = JSONDecoder()
+ /// d.dateDecodingStrategy = .millisecondsSince1970
+ /// return d
+ /// }
+ /// )
+ /// ```
+ public init(
+ config: Configuration = .init(),
+ makeDecoder: @escaping @Sendable () -> JSONDecoder = { JSONDecoder() },
+ registry: [EventType: DecodeHandler] = AGUIEventDecoder.defaultRegistry()
+ ) {
+ self.config = config
+ self.makeDecoder = makeDecoder
+ self.registry = registry
+ self.remapState = ThinkingRemapState()
+ }
+
+ // MARK: - Decoding
+
+ /// Decodes JSON data into an `AGUIEvent` instance.
+ ///
+ /// The decoder performs polymorphic deserialization by:
+ /// 1. Extracting the "type" field from the JSON
+ /// 2. Looking up the appropriate decode handler in the registry
+ /// 3. Invoking the handler to decode the event-specific data
+ ///
+ /// - Parameter data: The JSON data to decode
+ /// - Returns: A decoded `AGUIEvent` instance (specific type depends on the "type" field)
+ /// - Throws: `EventDecodingError` if decoding fails or the event type is unknown/unsupported (in strict mode)
+ ///
+ /// Example:
+ /// ```swift
+ /// let jsonData = """
+ /// {
+ /// "type": "RUN_STARTED",
+ /// "threadId": "thread-123",
+ /// "runId": "run-456"
+ /// }
+ /// """.data(using: .utf8)!
+ ///
+ /// let decoder = AGUIEventDecoder()
+ /// let event = try decoder.decode(jsonData)
+ ///
+ /// if let runStarted = event as? RunStartedEvent {
+ /// print("Run \(runStarted.runId) started in thread \(runStarted.threadId)")
+ /// }
+ /// ```
+ public func decode(_ data: Data) throws -> any AGUIEvent {
+ let decoder = makeDecoder()
+
+ let disc = try decodeTypeDiscriminator(from: data, decoder: decoder)
+
+ // Transparent backward compat: remap legacy THINKING_* wire events to REASONING_*,
+ // mirroring the TypeScript SDK's BackwardCompatibility_0_0_45 middleware.
+ if let (remappedData, remappedType) = remapThinkingEvent(data: data, typeRaw: disc.typeRaw) {
+ guard let handler = registry[remappedType] else {
+ return try handleMissingHandler(for: remappedType, typeRaw: disc.typeRaw, rawEvent: data)
+ }
+ return try executeHandler(handler, data: remappedData, decoder: decoder)
+ }
+
+ guard let type = EventType(rawValue: disc.typeRaw) else {
+ return try handleUnknownEventType(typeRaw: disc.typeRaw, rawEvent: data)
+ }
+
+ guard let handler = registry[type] else {
+ return try handleMissingHandler(for: type, typeRaw: disc.typeRaw, rawEvent: data)
+ }
+
+ return try executeHandler(handler, data: data, decoder: decoder)
+ }
+
+ /// Rewrites a legacy `THINKING_*` wire event to its `REASONING_*` equivalent.
+ ///
+ /// Agents built against protocol versions prior to 0.0.46 emit `THINKING_*` events.
+ /// Rather than keeping deprecated event types in the public API, the decoder silently
+ /// upgrades them — matching how the TypeScript SDK's `BackwardCompatibility_0_0_45`
+ /// middleware handles the same transition.
+ ///
+ /// IDs are stable across all events in the same sequence:
+ /// - `currentReasoningId` is shared by `THINKING_START` and `THINKING_END`.
+ /// - `currentMessageId` is shared by `THINKING_TEXT_MESSAGE_START`, `_CONTENT`, and `_END`.
+ ///
+ /// This decoder must be used serially (one decode call at a time), which is
+ /// guaranteed by SSE streams — events arrive sequentially on a single task.
+ ///
+ /// - Parameters:
+ /// - data: Raw JSON bytes from the SSE stream.
+ /// - typeRaw: The `"type"` discriminator string already extracted from `data`.
+ /// - Returns: Rewritten JSON + the target `EventType`, or `nil` if no remapping is needed.
+ private func remapThinkingEvent(data: Data, typeRaw: String) -> (Data, EventType)? {
+ let targetType: EventType
+ var extraFields: [String: Any]
+
+ switch typeRaw {
+ case "THINKING_START":
+ let id = UUID().uuidString
+ remapState.currentReasoningId = id
+ targetType = .reasoningStart
+ extraFields = ["messageId": id]
+
+ case "THINKING_END":
+ let id = remapState.currentReasoningId ?? UUID().uuidString
+ remapState.currentReasoningId = nil
+ targetType = .reasoningEnd
+ extraFields = ["messageId": id]
+
+ case "THINKING_TEXT_MESSAGE_START":
+ let id = UUID().uuidString
+ remapState.currentMessageId = id
+ targetType = .reasoningMessageStart
+ extraFields = ["messageId": id, "role": "assistant"]
+
+ case "THINKING_TEXT_MESSAGE_CONTENT":
+ let id = remapState.currentMessageId ?? UUID().uuidString
+ targetType = .reasoningMessageContent
+ extraFields = ["messageId": id]
+
+ case "THINKING_TEXT_MESSAGE_END":
+ let id = remapState.currentMessageId ?? UUID().uuidString
+ remapState.currentMessageId = nil
+ targetType = .reasoningMessageEnd
+ extraFields = ["messageId": id]
+
+ default:
+ return nil
+ }
+
+ guard var jsonObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
+ return nil
+ }
+
+ jsonObject["type"] = targetType.rawValue
+ for (key, value) in extraFields {
+ jsonObject[key] = value
+ }
+
+ guard let remappedData = try? JSONSerialization.data(withJSONObject: jsonObject) else {
+ return nil
+ }
+
+ return (remappedData, targetType)
+ }
+
+ private func decodeTypeDiscriminator(from data: Data, decoder: JSONDecoder) throws -> TypeDiscriminator {
+ do {
+ return try decoder.decode(TypeDiscriminator.self, from: data)
+ } catch let error as DecodingError {
+ throw mapDecodingError(error)
+ } catch {
+ throw EventDecodingError.invalidJSON
+ }
+ }
+
+ private func handleUnknownEventType(typeRaw: String, rawEvent: Data) throws -> any AGUIEvent {
+ switch config.unknownEventStrategy {
+ case .throwError:
+ throw EventDecodingError.unknownEventType(typeRaw)
+ case .returnUnknown:
+ return UnknownEvent(typeRaw: typeRaw, rawEvent: rawEvent)
+ }
+ }
+
+ private func handleMissingHandler(
+ for type: EventType,
+ typeRaw: String,
+ rawEvent: Data
+ ) throws -> any AGUIEvent {
+ switch config.unknownEventStrategy {
+ case .throwError:
+ throw EventDecodingError.unsupportedEventType(type)
+ case .returnUnknown:
+ return UnknownEvent(typeRaw: typeRaw, rawEvent: rawEvent)
+ }
+ }
+
+ private func executeHandler(
+ _ handler: DecodeHandler,
+ data: Data,
+ decoder: JSONDecoder
+ ) throws -> any AGUIEvent {
+ do {
+ return try handler(data, decoder)
+ } catch let error as EventDecodingError {
+ throw error
+ } catch let error as DecodingError {
+ throw mapDecodingError(error)
+ } catch {
+ throw EventDecodingError.decodingFailed(error.localizedDescription)
+ }
+ }
+
+ // MARK: - Registry Management
+
+ /// Returns the default registry of event type handlers.
+ ///
+ /// The default registry includes handlers for all lifecycle events:
+ /// - `runStarted`, `runFinished`, `runError`
+ /// - `stepStarted`, `stepFinished`
+ ///
+ /// Additional event categories (text messages, tool calls, state management, etc.)
+ /// can be added by composing multiple registries together.
+ ///
+ /// - Returns: A dictionary mapping `EventType` to `DecodeHandler` functions
+ ///
+ /// Example of composing custom registries:
+ /// ```swift
+ /// let customRegistry = RegistryComposer.compose(
+ /// AGUIEventDecoder.defaultRegistry(),
+ /// MyCustomEventRegistry.registry()
+ /// )
+ /// let decoder = AGUIEventDecoder(registry: customRegistry)
+ /// ```
+ public static func defaultRegistry() -> [EventType: DecodeHandler] {
+ RegistryComposer.compose(
+ LifecycleEventRegistry.registry(),
+ TextMessageEventRegistry.registry(),
+ ToolCallEventRegistry.registry(),
+ StateEventRegistry.registry(),
+ SpecialEventRegistry.registry(),
+ ReasoningEventRegistry.registry(),
+ ActivityEventRegistry.registry()
+ )
+ }
+
+ // MARK: - Error mapping
+
+ private func mapDecodingError(_ error: DecodingError) -> EventDecodingError {
+ func path(_ codingPath: [CodingKey]) -> String {
+ let pathString = codingPath.map(\.stringValue).joined(separator: ".")
+ return pathString.isEmpty ? "root" : pathString
+ }
+
+ switch error {
+ case .keyNotFound(let key, _) where key.stringValue == "type":
+ return .missingTypeField
+ case .dataCorrupted:
+ return .invalidJSON
+ case .keyNotFound(let key, let ctx):
+ return .decodingFailed("Missing key '\(key.stringValue)' at \(path(ctx.codingPath))")
+ case .typeMismatch(let type, let ctx):
+ return .decodingFailed("Type mismatch '\(type)' at \(path(ctx.codingPath))")
+ case .valueNotFound(let type, let ctx):
+ return .decodingFailed("Missing value '\(type)' at \(path(ctx.codingPath))")
+ @unknown default:
+ return .decodingFailed(String(describing: error))
+ }
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/ActivityEventsDTO/ActivityDeltaEventDTO.swift b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/ActivityEventsDTO/ActivityDeltaEventDTO.swift
new file mode 100644
index 0000000000..4f2a7f49e0
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/ActivityEventsDTO/ActivityDeltaEventDTO.swift
@@ -0,0 +1,77 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+struct ActivityDeltaEventDTO {
+ let messageId: String
+ let activityType: String
+ let patch: Data
+ let timestamp: Int64?
+
+ static func decode(from data: Data, decoder: JSONDecoder) throws -> ActivityDeltaEventDTO {
+ // Parse the entire JSON to extract fields
+ guard let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else {
+ throw DecodingError.dataCorrupted(
+ DecodingError.Context(codingPath: [], debugDescription: "Expected JSON object at root")
+ )
+ }
+
+ guard let messageId = jsonObject["messageId"] as? String ?? jsonObject["message_id"] as? String else {
+ throw DecodingError.keyNotFound(
+ CodingKeys.messageId,
+ DecodingError.Context(codingPath: [], debugDescription: "Missing messageId field")
+ )
+ }
+
+ guard let activityType = jsonObject["activityType"] as? String ?? jsonObject["activity_type"] as? String else {
+ throw DecodingError.keyNotFound(
+ CodingKeys.activityType,
+ DecodingError.Context(codingPath: [], debugDescription: "Missing activityType field")
+ )
+ }
+
+ guard let patchValue = jsonObject["patch"] else {
+ throw DecodingError.keyNotFound(
+ CodingKeys.patch,
+ DecodingError.Context(codingPath: [], debugDescription: "Missing patch field")
+ )
+ }
+
+ // Extract timestamp using shared helper
+ let timestamp = try EventDecodingHelpers.extractTimestamp(from: jsonObject)
+
+ // Convert patch value to JSON data (must be an array)
+ guard patchValue is [Any] else {
+ throw DecodingError.typeMismatch(
+ [Any].self,
+ DecodingError.Context(codingPath: [], debugDescription: "Patch must be a JSON array")
+ )
+ }
+
+ let patchData = try JSONSerialization.data(withJSONObject: patchValue, options: [])
+
+ return ActivityDeltaEventDTO(
+ messageId: messageId,
+ activityType: activityType,
+ patch: patchData,
+ timestamp: timestamp
+ )
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case messageId
+ case activityType
+ case patch
+ case timestamp
+ }
+
+ func toDomain(rawEvent: Data? = nil) -> ActivityDeltaEvent {
+ ActivityDeltaEvent(
+ messageId: messageId,
+ activityType: activityType,
+ patch: patch,
+ timestamp: timestamp,
+ rawEvent: rawEvent
+ )
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/ActivityEventsDTO/ActivitySnapshotEventDTO.swift b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/ActivityEventsDTO/ActivitySnapshotEventDTO.swift
new file mode 100644
index 0000000000..3799e33fbc
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/ActivityEventsDTO/ActivitySnapshotEventDTO.swift
@@ -0,0 +1,90 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+struct ActivitySnapshotEventDTO {
+ let messageId: String
+ let activityType: String
+ let content: Data
+ let replace: Bool
+ let timestamp: Int64?
+
+ static func decode(from data: Data, decoder: JSONDecoder) throws -> ActivitySnapshotEventDTO {
+ // Parse the entire JSON to extract fields
+ guard let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else {
+ throw DecodingError.dataCorrupted(
+ DecodingError.Context(codingPath: [], debugDescription: "Expected JSON object at root")
+ )
+ }
+
+ guard let messageId = jsonObject["messageId"] as? String ?? jsonObject["message_id"] as? String else {
+ throw DecodingError.keyNotFound(
+ CodingKeys.messageId,
+ DecodingError.Context(codingPath: [], debugDescription: "Missing messageId field")
+ )
+ }
+
+ guard let activityType = jsonObject["activityType"] as? String ?? jsonObject["activity_type"] as? String else {
+ throw DecodingError.keyNotFound(
+ CodingKeys.activityType,
+ DecodingError.Context(codingPath: [], debugDescription: "Missing activityType field")
+ )
+ }
+
+ guard let contentValue = jsonObject["content"] else {
+ throw DecodingError.keyNotFound(
+ CodingKeys.content,
+ DecodingError.Context(codingPath: [], debugDescription: "Missing content field")
+ )
+ }
+
+ // Extract replace field (defaults to true if not present)
+ let replace = (jsonObject["replace"] as? Bool) ?? true
+
+ // Extract timestamp using shared helper
+ let timestamp = try EventDecodingHelpers.extractTimestamp(from: jsonObject)
+
+ // Convert content value to JSON data.
+ // When the server sends content as a JSON string (e.g. Python SDK), re-parse it
+ // so downstream consumers receive the unwrapped JSON object/array bytes.
+ let contentData: Data
+ if contentValue is NSNull {
+ contentData = Data("null".utf8)
+ } else if contentValue is [Any] || contentValue is [String: Any] {
+ contentData = try JSONSerialization.data(withJSONObject: contentValue, options: [])
+ } else if let jsonString = contentValue as? String, let stringData = jsonString.data(using: .utf8) {
+ // Content was double-encoded as a JSON string — unwrap it.
+ contentData = stringData
+ } else {
+ let encoder = JSONEncoder()
+ contentData = try encoder.encode(JSONPrimitiveWrapper(value: contentValue))
+ }
+
+ return ActivitySnapshotEventDTO(
+ messageId: messageId,
+ activityType: activityType,
+ content: contentData,
+ replace: replace,
+ timestamp: timestamp
+ )
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case messageId
+ case activityType
+ case content
+ case replace
+ case timestamp
+ }
+
+ func toDomain(rawEvent: Data? = nil) -> ActivitySnapshotEvent {
+ ActivitySnapshotEvent(
+ messageId: messageId,
+ activityType: activityType,
+ content: content,
+ replace: replace,
+ timestamp: timestamp,
+ rawEvent: rawEvent
+ )
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/EventDecodingHelpers.swift b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/EventDecodingHelpers.swift
new file mode 100644
index 0000000000..cde84330c9
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/EventDecodingHelpers.swift
@@ -0,0 +1,49 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Helper utilities for decoding event DTOs.
+///
+/// This enum provides shared decoding logic to reduce code duplication across event DTOs
+/// and ensure consistent error handling.
+enum EventDecodingHelpers {
+
+ /// Extracts and validates a timestamp field from a JSON object.
+ ///
+ /// This method handles various timestamp representations:
+ /// - Missing field: returns `nil`
+ /// - NSNull value: returns `nil`
+ /// - Int64 value: returns the value
+ /// - Int value: converts to Int64 and returns
+ /// - Other types: throws a type mismatch error
+ ///
+ /// - Parameter jsonObject: The JSON dictionary containing the timestamp field
+ /// - Returns: The timestamp as Int64, or nil if not present or null
+ /// - Throws: `DecodingError.typeMismatch` if timestamp has an invalid type
+ static func extractTimestamp(from jsonObject: [String: Any]) throws -> Int64? {
+ guard let timestampValue = jsonObject["timestamp"] else {
+ return nil
+ }
+
+ if timestampValue is NSNull {
+ return nil
+ } else if let timestampValue = timestampValue as? Int64 {
+ return timestampValue
+ } else if let timestampValue = timestampValue as? Int {
+ return Int64(timestampValue)
+ } else {
+ throw DecodingError.typeMismatch(
+ Int64.self,
+ DecodingError.Context(
+ codingPath: [CodingKeys.timestamp],
+ debugDescription: "Expected Int64 for timestamp, got \(type(of: timestampValue))"
+ )
+ )
+ }
+ }
+
+ /// Helper CodingKey for constructing error contexts.
+ private enum CodingKeys: String, CodingKey {
+ case timestamp
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/LifeCycleEventsDTO/RunErrorEventDTO.swift b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/LifeCycleEventsDTO/RunErrorEventDTO.swift
new file mode 100644
index 0000000000..43d47ed405
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/LifeCycleEventsDTO/RunErrorEventDTO.swift
@@ -0,0 +1,37 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+struct RunErrorEventDTO {
+ let message: String
+ let code: String?
+ let timestamp: Int64?
+
+ static func decode(from data: Data, decoder: JSONDecoder = JSONDecoder()) throws -> RunErrorEventDTO {
+ guard let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
+ throw DecodingError.dataCorrupted(
+ DecodingError.Context(codingPath: [], debugDescription: "Expected JSON object at root")
+ )
+ }
+
+ guard let message = jsonObject["message"] as? String else {
+ throw DecodingError.keyNotFound(
+ CodingKeys.message,
+ DecodingError.Context(codingPath: [], debugDescription: "Missing required field: message")
+ )
+ }
+
+ let code = jsonObject["code"] as? String
+ let timestamp = try EventDecodingHelpers.extractTimestamp(from: jsonObject)
+
+ return RunErrorEventDTO(message: message, code: code, timestamp: timestamp)
+ }
+
+ func toDomain(rawEvent: Data? = nil) -> RunErrorEvent {
+ RunErrorEvent(message: message, code: code, timestamp: timestamp, rawEvent: rawEvent)
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case message, code, timestamp
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/LifeCycleEventsDTO/RunFinishedEventDTO.swift b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/LifeCycleEventsDTO/RunFinishedEventDTO.swift
new file mode 100644
index 0000000000..2dc92faec6
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/LifeCycleEventsDTO/RunFinishedEventDTO.swift
@@ -0,0 +1,105 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+struct RunFinishedEventDTO {
+ let threadId: String
+ let runId: String
+ let outcome: RunFinishedOutcome?
+ let result: Data?
+ let timestamp: Int64?
+
+ static func decode(from data: Data, decoder: JSONDecoder = JSONDecoder()) throws -> RunFinishedEventDTO {
+ guard let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
+ throw DecodingError.dataCorrupted(
+ DecodingError.Context(codingPath: [], debugDescription: "Expected JSON object at root")
+ )
+ }
+
+ let threadId: String
+ if let raw = jsonObject["threadId"] {
+ guard let value = raw as? String else {
+ throw DecodingError.typeMismatch(
+ String.self,
+ DecodingError.Context(codingPath: [CodingKeys.threadId], debugDescription: "Type mismatch for 'threadId': expected String")
+ )
+ }
+ threadId = value
+ } else {
+ throw DecodingError.keyNotFound(
+ CodingKeys.threadId,
+ DecodingError.Context(codingPath: [], debugDescription: "Missing required field: threadId")
+ )
+ }
+
+ let runId: String
+ if let raw = jsonObject["runId"] {
+ guard let value = raw as? String else {
+ throw DecodingError.typeMismatch(
+ String.self,
+ DecodingError.Context(codingPath: [CodingKeys.runId], debugDescription: "Type mismatch for 'runId': expected String")
+ )
+ }
+ runId = value
+ } else {
+ throw DecodingError.keyNotFound(
+ CodingKeys.runId,
+ DecodingError.Context(codingPath: [], debugDescription: "Missing required field: runId")
+ )
+ }
+
+ // The AG-UI wire protocol sends `outcome` as a discriminated union object:
+ // { "type": "success" }
+ // { "type": "interrupt", "interrupts": [ { "id": "...", "reason": "..." }, ... ] }
+ //
+ // A null or missing field means no outcome was provided (legacy producer).
+ // An unrecognised "type" value is treated as nil for forward compatibility.
+ let outcome: RunFinishedOutcome? = try decodeOutcome(from: jsonObject)
+
+ let timestamp = try EventDecodingHelpers.extractTimestamp(from: jsonObject)
+
+ var resultData: Data?
+ if let resultValue = jsonObject["result"], !(resultValue is NSNull) {
+ resultData = try? JSONSerialization.data(withJSONObject: resultValue)
+ }
+
+ return RunFinishedEventDTO(threadId: threadId, runId: runId, outcome: outcome, result: resultData, timestamp: timestamp)
+ }
+
+ func toDomain(rawEvent: Data? = nil) -> RunFinishedEvent {
+ RunFinishedEvent(threadId: threadId, runId: runId, outcome: outcome, result: result, timestamp: timestamp, rawEvent: rawEvent)
+ }
+
+ // MARK: - Private
+
+ private static func decodeOutcome(from jsonObject: [String: Any]) throws -> RunFinishedOutcome? {
+ guard let outcomeValue = jsonObject["outcome"],
+ !(outcomeValue is NSNull),
+ let outcomeObj = outcomeValue as? [String: Any],
+ let type = outcomeObj["type"] as? String
+ else {
+ return nil
+ }
+
+ switch type {
+ case "success":
+ return .success
+
+ case "interrupt":
+ let rawInterrupts = outcomeObj["interrupts"] as? [[String: Any]] ?? []
+ let interrupts = try rawInterrupts.map { try Interrupt.decode(from: $0) }
+ // The TypeScript schema requires at least one interrupt for this outcome type.
+ // An empty array is treated as nil (malformed payload) for safety.
+ guard !interrupts.isEmpty else { return nil }
+ return .interrupt(interrupts)
+
+ default:
+ // Forward-compatible: unknown future outcome types are treated as nil.
+ return nil
+ }
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case threadId, runId, outcome, result, timestamp
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/LifeCycleEventsDTO/RunStartedEventDTO.swift b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/LifeCycleEventsDTO/RunStartedEventDTO.swift
new file mode 100644
index 0000000000..b873ad229f
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/LifeCycleEventsDTO/RunStartedEventDTO.swift
@@ -0,0 +1,84 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+struct RunStartedEventDTO {
+ let threadId: String
+ let runId: String
+ let parentRunId: String?
+ // `input` stored as raw JSON — the full RunAgentInput schema includes messages
+ // which are not auto-Decodable, so we preserve the wire bytes and let callers parse.
+ let input: Data?
+ let timestamp: Int64?
+
+ static func decode(from data: Data, decoder: JSONDecoder = JSONDecoder()) throws -> RunStartedEventDTO {
+ guard let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
+ throw DecodingError.dataCorrupted(
+ DecodingError.Context(codingPath: [], debugDescription: "Expected JSON object at root")
+ )
+ }
+
+ let threadId: String
+ if let raw = jsonObject["threadId"] {
+ guard let value = raw as? String else {
+ throw DecodingError.typeMismatch(
+ String.self,
+ DecodingError.Context(codingPath: [CodingKeys.threadId], debugDescription: "Type mismatch for 'threadId': expected String")
+ )
+ }
+ threadId = value
+ } else {
+ throw DecodingError.keyNotFound(
+ CodingKeys.threadId,
+ DecodingError.Context(codingPath: [], debugDescription: "Missing required field: threadId")
+ )
+ }
+
+ let runId: String
+ if let raw = jsonObject["runId"] {
+ guard let value = raw as? String else {
+ throw DecodingError.typeMismatch(
+ String.self,
+ DecodingError.Context(codingPath: [CodingKeys.runId], debugDescription: "Type mismatch for 'runId': expected String")
+ )
+ }
+ runId = value
+ } else {
+ throw DecodingError.keyNotFound(
+ CodingKeys.runId,
+ DecodingError.Context(codingPath: [], debugDescription: "Missing required field: runId")
+ )
+ }
+
+ let parentRunId = jsonObject["parentRunId"] as? String
+ let timestamp = try EventDecodingHelpers.extractTimestamp(from: jsonObject)
+
+ var inputData: Data?
+ if let inputValue = jsonObject["input"], !(inputValue is NSNull) {
+ inputData = try? JSONSerialization.data(withJSONObject: inputValue)
+ }
+
+ return RunStartedEventDTO(
+ threadId: threadId,
+ runId: runId,
+ parentRunId: parentRunId,
+ input: inputData,
+ timestamp: timestamp
+ )
+ }
+
+ func toDomain(rawEvent: Data? = nil) -> RunStartedEvent {
+ RunStartedEvent(
+ threadId: threadId,
+ runId: runId,
+ parentRunId: parentRunId,
+ input: input,
+ timestamp: timestamp,
+ rawEvent: rawEvent
+ )
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case threadId, runId, parentRunId, timestamp, input
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/LifeCycleEventsDTO/StepFinishedEventDTO.swift b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/LifeCycleEventsDTO/StepFinishedEventDTO.swift
new file mode 100644
index 0000000000..df593e7a2a
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/LifeCycleEventsDTO/StepFinishedEventDTO.swift
@@ -0,0 +1,12 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+struct StepFinishedEventDTO: Decodable {
+ let stepName: String
+ let timestamp: Int64?
+
+ func toDomain(rawEvent: Data? = nil) -> StepFinishedEvent {
+ StepFinishedEvent(stepName: stepName, timestamp: timestamp, rawEvent: rawEvent)
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/LifeCycleEventsDTO/StepStartedEventDTO.swift b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/LifeCycleEventsDTO/StepStartedEventDTO.swift
new file mode 100644
index 0000000000..00085caec7
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/LifeCycleEventsDTO/StepStartedEventDTO.swift
@@ -0,0 +1,12 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+struct StepStartedEventDTO: Decodable {
+ let stepName: String
+ let timestamp: Int64?
+
+ func toDomain(rawEvent: Data? = nil) -> StepStartedEvent {
+ StepStartedEvent(stepName: stepName, timestamp: timestamp, rawEvent: rawEvent)
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/ReasoningEventsDTO/ReasoningEncryptedValueEventDTO.swift b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/ReasoningEventsDTO/ReasoningEncryptedValueEventDTO.swift
new file mode 100644
index 0000000000..dcae98c4b4
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/ReasoningEventsDTO/ReasoningEncryptedValueEventDTO.swift
@@ -0,0 +1,20 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+struct ReasoningEncryptedValueEventDTO: Decodable {
+ let subtype: ReasoningEncryptedValueSubtype
+ let entityId: String
+ let encryptedValue: String
+ let timestamp: Int64?
+
+ func toDomain(rawEvent: Data? = nil) -> ReasoningEncryptedValueEvent {
+ ReasoningEncryptedValueEvent(
+ subtype: subtype,
+ entityId: entityId,
+ encryptedValue: encryptedValue,
+ timestamp: timestamp,
+ rawEvent: rawEvent
+ )
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/ReasoningEventsDTO/ReasoningEndEventDTO.swift b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/ReasoningEventsDTO/ReasoningEndEventDTO.swift
new file mode 100644
index 0000000000..9e5f82abdb
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/ReasoningEventsDTO/ReasoningEndEventDTO.swift
@@ -0,0 +1,12 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+struct ReasoningEndEventDTO: Decodable {
+ let messageId: String
+ let timestamp: Int64?
+
+ func toDomain(rawEvent: Data? = nil) -> ReasoningEndEvent {
+ ReasoningEndEvent(messageId: messageId, timestamp: timestamp, rawEvent: rawEvent)
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/ReasoningEventsDTO/ReasoningMessageChunkEventDTO.swift b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/ReasoningEventsDTO/ReasoningMessageChunkEventDTO.swift
new file mode 100644
index 0000000000..673e3d2799
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/ReasoningEventsDTO/ReasoningMessageChunkEventDTO.swift
@@ -0,0 +1,13 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+struct ReasoningMessageChunkEventDTO: Decodable {
+ let messageId: String?
+ let delta: String?
+ let timestamp: Int64?
+
+ func toDomain(rawEvent: Data? = nil) -> ReasoningMessageChunkEvent {
+ ReasoningMessageChunkEvent(messageId: messageId, delta: delta, timestamp: timestamp, rawEvent: rawEvent)
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/ReasoningEventsDTO/ReasoningMessageContentEventDTO.swift b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/ReasoningEventsDTO/ReasoningMessageContentEventDTO.swift
new file mode 100644
index 0000000000..8aff30ba12
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/ReasoningEventsDTO/ReasoningMessageContentEventDTO.swift
@@ -0,0 +1,13 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+struct ReasoningMessageContentEventDTO: Decodable {
+ let messageId: String
+ let delta: String
+ let timestamp: Int64?
+
+ func toDomain(rawEvent: Data? = nil) -> ReasoningMessageContentEvent {
+ ReasoningMessageContentEvent(messageId: messageId, delta: delta, timestamp: timestamp, rawEvent: rawEvent)
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/ReasoningEventsDTO/ReasoningMessageEndEventDTO.swift b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/ReasoningEventsDTO/ReasoningMessageEndEventDTO.swift
new file mode 100644
index 0000000000..907a84f1f3
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/ReasoningEventsDTO/ReasoningMessageEndEventDTO.swift
@@ -0,0 +1,12 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+struct ReasoningMessageEndEventDTO: Decodable {
+ let messageId: String
+ let timestamp: Int64?
+
+ func toDomain(rawEvent: Data? = nil) -> ReasoningMessageEndEvent {
+ ReasoningMessageEndEvent(messageId: messageId, timestamp: timestamp, rawEvent: rawEvent)
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/ReasoningEventsDTO/ReasoningMessageStartEventDTO.swift b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/ReasoningEventsDTO/ReasoningMessageStartEventDTO.swift
new file mode 100644
index 0000000000..e156511659
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/ReasoningEventsDTO/ReasoningMessageStartEventDTO.swift
@@ -0,0 +1,13 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+struct ReasoningMessageStartEventDTO: Decodable {
+ let messageId: String
+ let role: String
+ let timestamp: Int64?
+
+ func toDomain(rawEvent: Data? = nil) -> ReasoningMessageStartEvent {
+ ReasoningMessageStartEvent(messageId: messageId, role: role, timestamp: timestamp, rawEvent: rawEvent)
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/ReasoningEventsDTO/ReasoningStartEventDTO.swift b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/ReasoningEventsDTO/ReasoningStartEventDTO.swift
new file mode 100644
index 0000000000..73805664f3
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/ReasoningEventsDTO/ReasoningStartEventDTO.swift
@@ -0,0 +1,12 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+struct ReasoningStartEventDTO: Decodable {
+ let messageId: String
+ let timestamp: Int64?
+
+ func toDomain(rawEvent: Data? = nil) -> ReasoningStartEvent {
+ ReasoningStartEvent(messageId: messageId, timestamp: timestamp, rawEvent: rawEvent)
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/SpecialEventsDTO/CustomEventDTO.swift b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/SpecialEventsDTO/CustomEventDTO.swift
new file mode 100644
index 0000000000..31e52b52e9
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/SpecialEventsDTO/CustomEventDTO.swift
@@ -0,0 +1,72 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+struct CustomEventDTO {
+ let customType: String
+ let data: Data
+ let timestamp: Int64?
+
+ static func decode(from data: Data, decoder: JSONDecoder) throws -> CustomEventDTO {
+ // Parse the entire JSON to extract the customType and data fields
+ guard let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else {
+ throw DecodingError.dataCorrupted(
+ DecodingError.Context(codingPath: [], debugDescription: "Expected JSON object at root")
+ )
+ }
+
+ // AG-UI protocol wire format uses "name" (not "customType")
+ guard let customType = jsonObject["name"] as? String else {
+ if jsonObject["name"] == nil {
+ throw DecodingError.keyNotFound(
+ CodingKeys.customType,
+ DecodingError.Context(codingPath: [], debugDescription: "Missing key 'name' at root")
+ )
+ } else {
+ throw DecodingError.typeMismatch(
+ String.self,
+ DecodingError.Context(codingPath: [CodingKeys.customType], debugDescription: "Expected String for name")
+ )
+ }
+ }
+
+ // AG-UI protocol wire format uses "value" (not "data"); value is optional
+ let dataValue = jsonObject["value"]
+
+ // Extract timestamp using shared helper
+ let timestamp = try EventDecodingHelpers.extractTimestamp(from: jsonObject)
+
+ // Convert value to JSON data; treat absent/null value as empty object {}
+ let eventData: Data
+ if let dataValue {
+ if dataValue is NSNull {
+ eventData = Data("null".utf8)
+ } else if dataValue is [Any] || dataValue is [String: Any] {
+ eventData = try JSONSerialization.data(withJSONObject: dataValue, options: [])
+ } else {
+ let encoder = JSONEncoder()
+ eventData = try encoder.encode(JSONPrimitiveWrapper(value: dataValue))
+ }
+ } else {
+ eventData = Data("{}".utf8)
+ }
+
+ return CustomEventDTO(customType: customType, data: eventData, timestamp: timestamp)
+ }
+
+ // Wire keys: "name" maps to customType, "value" maps to data
+ enum CodingKeys: String, CodingKey {
+ case customType = "name"
+ case data = "value"
+ case timestamp
+ }
+
+ func toDomain(rawEvent: Data? = nil) -> CustomEvent {
+ CustomEvent(
+ name: customType,
+ value: data,
+ timestamp: timestamp,
+ rawEvent: rawEvent
+ )
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/SpecialEventsDTO/RawEventDTO.swift b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/SpecialEventsDTO/RawEventDTO.swift
new file mode 100644
index 0000000000..a49fdd03c3
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/SpecialEventsDTO/RawEventDTO.swift
@@ -0,0 +1,64 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+struct RawEventDTO {
+ let data: Data
+ let source: String?
+ let timestamp: Int64?
+
+ static func decode(from data: Data, decoder: JSONDecoder) throws -> RawEventDTO {
+ // Parse the entire JSON to extract the event field
+ guard let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else {
+ throw DecodingError.dataCorrupted(
+ DecodingError.Context(codingPath: [], debugDescription: "Expected JSON object at root")
+ )
+ }
+
+ // Protocol wire field is "event" (not "data") per AG-UI spec
+ guard let eventValue = jsonObject["event"] else {
+ throw DecodingError.keyNotFound(
+ CodingKeys.event,
+ DecodingError.Context(codingPath: [], debugDescription: "Missing event field")
+ )
+ }
+
+ // Extract optional source field
+ let source = jsonObject["source"] as? String
+
+ // Extract timestamp using shared helper
+ let timestamp = try EventDecodingHelpers.extractTimestamp(from: jsonObject)
+
+ // Convert event value to JSON data
+ // Use JSONEncoder for primitives, JSONSerialization for collections
+ let eventData: Data
+ if eventValue is NSNull {
+ // NSNull needs special handling - encode as null JSON
+ eventData = Data("null".utf8)
+ } else if eventValue is [Any] || eventValue is [String: Any] {
+ // Collections can use JSONSerialization
+ eventData = try JSONSerialization.data(withJSONObject: eventValue, options: [])
+ } else {
+ // Primitives need JSONEncoder
+ let encoder = JSONEncoder()
+ eventData = try encoder.encode(JSONPrimitiveWrapper(value: eventValue))
+ }
+
+ return RawEventDTO(data: eventData, source: source, timestamp: timestamp)
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case event
+ case source
+ case timestamp
+ }
+
+ func toDomain(rawEvent: Data? = nil) -> RawEvent {
+ RawEvent(
+ data: data,
+ source: source,
+ timestamp: timestamp,
+ rawEvent: rawEvent
+ )
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/StateEventsDTO/MessagesSnapshotEventDTO.swift b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/StateEventsDTO/MessagesSnapshotEventDTO.swift
new file mode 100644
index 0000000000..618326adf1
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/StateEventsDTO/MessagesSnapshotEventDTO.swift
@@ -0,0 +1,46 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+struct MessagesSnapshotEventDTO {
+ let messages: [any Message]
+ let timestamp: Int64?
+
+ static func decode(from data: Data, decoder: JSONDecoder) throws -> MessagesSnapshotEventDTO {
+ guard let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else {
+ throw DecodingError.dataCorrupted(
+ DecodingError.Context(codingPath: [], debugDescription: "Expected JSON object at root")
+ )
+ }
+
+ guard let messagesValue = jsonObject["messages"] as? [[String: Any]] else {
+ throw DecodingError.keyNotFound(
+ CodingKeys.messages,
+ DecodingError.Context(codingPath: [], debugDescription: "Missing or non-array messages field")
+ )
+ }
+
+ let timestamp = try EventDecodingHelpers.extractTimestamp(from: jsonObject)
+
+ let messageDecoder = MessageDecoder()
+ let messages: [any Message] = try messagesValue.compactMap { dict in
+ let msgData = try JSONSerialization.data(withJSONObject: dict)
+ return try? messageDecoder.decode(msgData)
+ }
+
+ return MessagesSnapshotEventDTO(messages: messages, timestamp: timestamp)
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case messages
+ case timestamp
+ }
+
+ func toDomain(rawEvent: Data? = nil) -> MessagesSnapshotEvent {
+ MessagesSnapshotEvent(
+ messages: messages,
+ timestamp: timestamp,
+ rawEvent: rawEvent
+ )
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/StateEventsDTO/StateDeltaEventDTO.swift b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/StateEventsDTO/StateDeltaEventDTO.swift
new file mode 100644
index 0000000000..4435ae6f00
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/StateEventsDTO/StateDeltaEventDTO.swift
@@ -0,0 +1,73 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+struct StateDeltaEventDTO {
+ let delta: Data
+ let timestamp: Int64?
+
+ static func decode(from data: Data, decoder: JSONDecoder) throws -> StateDeltaEventDTO {
+ // Parse the entire JSON to extract the delta field
+ guard let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else {
+ throw DecodingError.dataCorrupted(
+ DecodingError.Context(codingPath: [], debugDescription: "Expected JSON object at root")
+ )
+ }
+
+ guard let deltaValue = jsonObject["delta"] else {
+ throw DecodingError.keyNotFound(
+ CodingKeys.delta,
+ DecodingError.Context(codingPath: [], debugDescription: "Missing delta field")
+ )
+ }
+
+ // Extract timestamp using shared helper
+ let timestamp = try EventDecodingHelpers.extractTimestamp(from: jsonObject)
+
+ // Convert delta value to JSON data
+ // Delta must be an array of JSON Patch operations (RFC 6902)
+ let deltaData = try serializeDelta(deltaValue)
+
+ return StateDeltaEventDTO(delta: deltaData, timestamp: timestamp)
+ }
+
+ /// Serializes the delta value to JSON data.
+ ///
+ /// The delta must be an array of JSON Patch operations per RFC 6902,
+ /// or NSNull in edge cases.
+ ///
+ /// - Parameter deltaValue: The delta value from the JSON object
+ /// - Returns: JSON data representation of the delta
+ /// - Throws: `DecodingError.typeMismatch` if delta is not an array or NSNull
+ private static func serializeDelta(_ deltaValue: Any) throws -> Data {
+ if deltaValue is NSNull {
+ // NSNull needs special handling - encode as null JSON
+ return Data("null".utf8)
+ } else if deltaValue is [Any] {
+ // Array can use JSONSerialization
+ return try JSONSerialization.data(withJSONObject: deltaValue, options: [])
+ } else {
+ // Delta must be an array per RFC 6902
+ throw DecodingError.typeMismatch(
+ [Any].self,
+ DecodingError.Context(
+ codingPath: [CodingKeys.delta],
+ debugDescription: "Delta must be an array of JSON Patch operations per RFC 6902, got \(type(of: deltaValue))"
+ )
+ )
+ }
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case delta
+ case timestamp
+ }
+
+ func toDomain(rawEvent: Data? = nil) -> StateDeltaEvent {
+ StateDeltaEvent(
+ delta: delta,
+ timestamp: timestamp,
+ rawEvent: rawEvent
+ )
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/StateEventsDTO/StateSnapshotEventDTO.swift b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/StateEventsDTO/StateSnapshotEventDTO.swift
new file mode 100644
index 0000000000..0272abd9f9
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/StateEventsDTO/StateSnapshotEventDTO.swift
@@ -0,0 +1,57 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+struct StateSnapshotEventDTO {
+ let snapshot: Data
+ let timestamp: Int64?
+
+ static func decode(from data: Data, decoder: JSONDecoder) throws -> StateSnapshotEventDTO {
+ // Parse the entire JSON to extract the snapshot field
+ guard let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else {
+ throw DecodingError.dataCorrupted(
+ DecodingError.Context(codingPath: [], debugDescription: "Expected JSON object at root")
+ )
+ }
+
+ guard let snapshotValue = jsonObject["snapshot"] else {
+ throw DecodingError.keyNotFound(
+ CodingKeys.snapshot,
+ DecodingError.Context(codingPath: [], debugDescription: "Missing snapshot field")
+ )
+ }
+
+ // Extract timestamp using shared helper
+ let timestamp = try EventDecodingHelpers.extractTimestamp(from: jsonObject)
+
+ // Convert snapshot value to JSON data
+ // Use JSONEncoder for primitives, JSONSerialization for collections
+ let snapshotData: Data
+ if snapshotValue is NSNull {
+ // NSNull needs special handling - encode as null JSON
+ snapshotData = Data("null".utf8)
+ } else if snapshotValue is [Any] || snapshotValue is [String: Any] {
+ // Collections can use JSONSerialization
+ snapshotData = try JSONSerialization.data(withJSONObject: snapshotValue, options: [])
+ } else {
+ // Primitives need JSONEncoder
+ let encoder = JSONEncoder()
+ snapshotData = try encoder.encode(JSONPrimitiveWrapper(value: snapshotValue))
+ }
+
+ return StateSnapshotEventDTO(snapshot: snapshotData, timestamp: timestamp)
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case snapshot
+ case timestamp
+ }
+
+ func toDomain(rawEvent: Data? = nil) -> StateSnapshotEvent {
+ StateSnapshotEvent(
+ snapshot: snapshot,
+ timestamp: timestamp,
+ rawEvent: rawEvent
+ )
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/TextMessageEventsDTO/TextMessageChunkEventDTO.swift b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/TextMessageEventsDTO/TextMessageChunkEventDTO.swift
new file mode 100644
index 0000000000..4ea83be153
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/TextMessageEventsDTO/TextMessageChunkEventDTO.swift
@@ -0,0 +1,22 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+struct TextMessageChunkEventDTO: Decodable {
+ let messageId: String?
+ let role: String?
+ let name: String?
+ let delta: String?
+ let timestamp: Int64?
+
+ func toDomain(rawEvent: Data? = nil) -> TextMessageChunkEvent {
+ TextMessageChunkEvent(
+ messageId: messageId,
+ role: role,
+ name: name,
+ delta: delta,
+ timestamp: timestamp,
+ rawEvent: rawEvent
+ )
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/TextMessageEventsDTO/TextMessageEndEventDTO.swift b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/TextMessageEventsDTO/TextMessageEndEventDTO.swift
new file mode 100644
index 0000000000..5950ad7578
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/TextMessageEventsDTO/TextMessageEndEventDTO.swift
@@ -0,0 +1,16 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+struct TextMessageEndEventDTO: Decodable {
+ let messageId: String
+ let timestamp: Int64?
+
+ func toDomain(rawEvent: Data? = nil) -> TextMessageEndEvent {
+ TextMessageEndEvent(
+ messageId: messageId,
+ timestamp: timestamp,
+ rawEvent: rawEvent
+ )
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/ToolCallEventsDTO/ToolCallChunkEventDTO.swift b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/ToolCallEventsDTO/ToolCallChunkEventDTO.swift
new file mode 100644
index 0000000000..8c0fdc7ff0
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/ToolCallEventsDTO/ToolCallChunkEventDTO.swift
@@ -0,0 +1,22 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+struct ToolCallChunkEventDTO: Decodable {
+ let toolCallId: String?
+ let toolCallName: String?
+ let delta: String?
+ let parentMessageId: String?
+ let timestamp: Int64?
+
+ func toDomain(rawEvent: Data? = nil) -> ToolCallChunkEvent {
+ ToolCallChunkEvent(
+ toolCallId: toolCallId,
+ toolCallName: toolCallName,
+ delta: delta,
+ parentMessageId: parentMessageId,
+ timestamp: timestamp,
+ rawEvent: rawEvent
+ )
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/ToolCallEventsDTO/ToolCallEndEventDTO.swift b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/ToolCallEventsDTO/ToolCallEndEventDTO.swift
new file mode 100644
index 0000000000..ef41bfa7c3
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/ToolCallEventsDTO/ToolCallEndEventDTO.swift
@@ -0,0 +1,16 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+struct ToolCallEndEventDTO: Decodable {
+ let toolCallId: String
+ let timestamp: Int64?
+
+ func toDomain(rawEvent: Data? = nil) -> ToolCallEndEvent {
+ ToolCallEndEvent(
+ toolCallId: toolCallId,
+ timestamp: timestamp,
+ rawEvent: rawEvent
+ )
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/ToolCallEventsDTO/ToolCallResultEventDTO.swift b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/ToolCallEventsDTO/ToolCallResultEventDTO.swift
new file mode 100644
index 0000000000..9dfc239180
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/ToolCallEventsDTO/ToolCallResultEventDTO.swift
@@ -0,0 +1,22 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+struct ToolCallResultEventDTO: Decodable {
+ let messageId: String
+ let toolCallId: String
+ let content: String
+ let role: String?
+ let timestamp: Int64?
+
+ func toDomain(rawEvent: Data? = nil) -> ToolCallResultEvent {
+ ToolCallResultEvent(
+ messageId: messageId,
+ toolCallId: toolCallId,
+ content: content,
+ role: role,
+ timestamp: timestamp,
+ rawEvent: rawEvent
+ )
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/TypeDiscriminator.swift b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/TypeDiscriminator.swift
new file mode 100644
index 0000000000..59ec5821e5
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Decoding/EventDTO/TypeDiscriminator.swift
@@ -0,0 +1,16 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Internal structure for reading the "type" field during polymorphic decoding.
+
+struct TypeDiscriminator: Decodable {
+ let typeRaw: String
+
+ enum CodingKeys: String, CodingKey { case type }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ self.typeRaw = try container.decode(String.self, forKey: .type)
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Decoding/EventDecodingError.swift b/sdks/community/swift/Sources/AGUICore/Decoding/EventDecodingError.swift
new file mode 100644
index 0000000000..6fb1ebbe36
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Decoding/EventDecodingError.swift
@@ -0,0 +1,52 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Errors that can occur during event decoding.
+public enum EventDecodingError: Error, LocalizedError, Equatable {
+ /// The event type in the JSON is unknown or unsupported
+ case unknownEventType(String)
+
+ /// The JSON data is invalid or malformed
+ case invalidJSON
+
+ /// The required "type" field is missing from the JSON
+ case missingTypeField
+
+ /// Decoding failed with an underlying error
+ case decodingFailed(String)
+
+ case unsupportedEventType(EventType)
+
+ public var errorDescription: String? {
+ switch self {
+ case .unknownEventType(let type):
+ return "Unknown event type: '\(type)'. This event type is not supported by this version of AGUISwift."
+ case .invalidJSON:
+ return "Invalid JSON data. The provided data could not be parsed as valid JSON."
+ case .missingTypeField:
+ return "Missing 'type' field. All AG-UI events must have a 'type' field."
+ case .decodingFailed(let message):
+ return "Event decoding failed: \(message)"
+ case .unsupportedEventType(let type):
+ return "Unsupported event type: '\(type.rawValue)'. " +
+ "This SDK knows about it but doesn't implement decoding for it."
+ }
+ }
+
+ public var recoverySuggestion: String? {
+ switch self {
+ case .unknownEventType:
+ return "Check if you're using the latest version of AGUISwift, " +
+ "or inspect the raw JSON to see if it's a custom event type."
+ case .invalidJSON:
+ return "Verify that the JSON data is well-formed and complete."
+ case .missingTypeField:
+ return "Ensure the JSON contains a 'type' field at the root level."
+ case .decodingFailed:
+ return "Check the event JSON structure against the AG-UI protocol specification."
+ case .unsupportedEventType:
+ return "Ensure the correct module/registry is linked, or implement/register a decoder for this event type."
+ }
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Decoding/InputContentDTO/AudioInputContentDTO.swift b/sdks/community/swift/Sources/AGUICore/Decoding/InputContentDTO/AudioInputContentDTO.swift
new file mode 100644
index 0000000000..790cfcc461
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Decoding/InputContentDTO/AudioInputContentDTO.swift
@@ -0,0 +1,60 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Data Transfer Object for AudioInputContent decoding.
+struct AudioInputContentDTO {
+ let url: String?
+ let data: String?
+ let format: String?
+ let mimeType: String?
+
+ static func decode(from data: Data, decoder: JSONDecoder = JSONDecoder()) throws -> AudioInputContentDTO {
+ guard let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else {
+ throw DecodingError.dataCorrupted(
+ DecodingError.Context(codingPath: [], debugDescription: "Expected JSON object at root")
+ )
+ }
+
+ if let type = jsonObject["type"] as? String, type != "audio" {
+ throw DecodingError.typeMismatch(
+ AudioInputContent.self,
+ DecodingError.Context(
+ codingPath: [CodingKeys.type],
+ debugDescription: "Expected type 'audio' but got '\(type)'"
+ )
+ )
+ }
+
+ let url = jsonObject["url"] as? String
+ let dataStr = jsonObject["data"] as? String
+
+ guard url != nil || dataStr != nil else {
+ throw DecodingError.dataCorrupted(
+ DecodingError.Context(
+ codingPath: [],
+ debugDescription: "AudioInputContent requires at least one of: url or data"
+ )
+ )
+ }
+
+ return AudioInputContentDTO(
+ url: url,
+ data: dataStr,
+ format: jsonObject["format"] as? String,
+ mimeType: jsonObject["mimeType"] as? String
+ )
+ }
+
+ func toDomain() -> AudioInputContent {
+ if let url = url {
+ return AudioInputContent(url: url, format: format, mimeType: mimeType)
+ } else {
+ return AudioInputContent(data: data!, format: format, mimeType: mimeType)
+ }
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case type, url, data, format, mimeType
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Decoding/InputContentDTO/BinaryInputContentDTO.swift b/sdks/community/swift/Sources/AGUICore/Decoding/InputContentDTO/BinaryInputContentDTO.swift
new file mode 100644
index 0000000000..3d5a996106
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Decoding/InputContentDTO/BinaryInputContentDTO.swift
@@ -0,0 +1,82 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Data Transfer Object for BinaryInputContent decoding.
+struct BinaryInputContentDTO {
+ let mimeType: String
+ let id: String?
+ let url: String?
+ let data: String?
+ let filename: String?
+
+ static func decode(from data: Data, decoder: JSONDecoder = JSONDecoder()) throws -> BinaryInputContentDTO {
+ guard let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else {
+ throw DecodingError.dataCorrupted(
+ DecodingError.Context(codingPath: [], debugDescription: "Expected JSON object at root")
+ )
+ }
+
+ // Validate type field
+ if let type = jsonObject["type"] as? String, type != "binary" {
+ throw DecodingError.typeMismatch(
+ BinaryInputContent.self,
+ DecodingError.Context(
+ codingPath: [CodingKeys.type],
+ debugDescription: "Expected type 'binary' but got '\(type)'"
+ )
+ )
+ }
+
+ // Extract required mimeType field
+ guard let mimeType = jsonObject["mimeType"] as? String else {
+ throw DecodingError.keyNotFound(
+ CodingKeys.mimeType,
+ DecodingError.Context(codingPath: [], debugDescription: "Missing mimeType field")
+ )
+ }
+
+ // Extract optional fields
+ let id = jsonObject["id"] as? String
+ let url = jsonObject["url"] as? String
+ let data = jsonObject["data"] as? String
+ let filename = jsonObject["filename"] as? String
+
+ // Validate that at least one source is provided
+ guard id != nil || url != nil || data != nil else {
+ throw DecodingError.dataCorrupted(
+ DecodingError.Context(
+ codingPath: [],
+ debugDescription: "BinaryInputContent requires at least one of: id, url, or data"
+ )
+ )
+ }
+
+ return BinaryInputContentDTO(
+ mimeType: mimeType,
+ id: id,
+ url: url,
+ data: data,
+ filename: filename
+ )
+ }
+
+ func toDomain() throws -> BinaryInputContent {
+ try BinaryInputContent(
+ mimeType: mimeType,
+ id: id,
+ url: url,
+ data: data,
+ filename: filename
+ )
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case type
+ case mimeType
+ case id
+ case url
+ case data
+ case filename
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Decoding/InputContentDTO/DocumentInputContentDTO.swift b/sdks/community/swift/Sources/AGUICore/Decoding/InputContentDTO/DocumentInputContentDTO.swift
new file mode 100644
index 0000000000..f48989017b
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Decoding/InputContentDTO/DocumentInputContentDTO.swift
@@ -0,0 +1,60 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Data Transfer Object for DocumentInputContent decoding.
+struct DocumentInputContentDTO {
+ let url: String?
+ let data: String?
+ let mimeType: String?
+ let title: String?
+
+ static func decode(from data: Data, decoder: JSONDecoder = JSONDecoder()) throws -> DocumentInputContentDTO {
+ guard let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else {
+ throw DecodingError.dataCorrupted(
+ DecodingError.Context(codingPath: [], debugDescription: "Expected JSON object at root")
+ )
+ }
+
+ if let type = jsonObject["type"] as? String, type != "document" {
+ throw DecodingError.typeMismatch(
+ DocumentInputContent.self,
+ DecodingError.Context(
+ codingPath: [CodingKeys.type],
+ debugDescription: "Expected type 'document' but got '\(type)'"
+ )
+ )
+ }
+
+ let url = jsonObject["url"] as? String
+ let dataStr = jsonObject["data"] as? String
+
+ guard url != nil || dataStr != nil else {
+ throw DecodingError.dataCorrupted(
+ DecodingError.Context(
+ codingPath: [],
+ debugDescription: "DocumentInputContent requires at least one of: url or data"
+ )
+ )
+ }
+
+ return DocumentInputContentDTO(
+ url: url,
+ data: dataStr,
+ mimeType: jsonObject["mimeType"] as? String,
+ title: jsonObject["title"] as? String
+ )
+ }
+
+ func toDomain() -> DocumentInputContent {
+ if let url = url {
+ return DocumentInputContent(url: url, mimeType: mimeType, title: title)
+ } else {
+ return DocumentInputContent(data: data!, mimeType: mimeType, title: title)
+ }
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case type, url, data, mimeType, title
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Decoding/InputContentDTO/ImageInputContentDTO.swift b/sdks/community/swift/Sources/AGUICore/Decoding/InputContentDTO/ImageInputContentDTO.swift
new file mode 100644
index 0000000000..e422f04a6a
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Decoding/InputContentDTO/ImageInputContentDTO.swift
@@ -0,0 +1,60 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Data Transfer Object for ImageInputContent decoding.
+struct ImageInputContentDTO {
+ let url: String?
+ let data: String?
+ let detail: String?
+ let mimeType: String?
+
+ static func decode(from data: Data, decoder: JSONDecoder = JSONDecoder()) throws -> ImageInputContentDTO {
+ guard let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else {
+ throw DecodingError.dataCorrupted(
+ DecodingError.Context(codingPath: [], debugDescription: "Expected JSON object at root")
+ )
+ }
+
+ if let type = jsonObject["type"] as? String, type != "image" {
+ throw DecodingError.typeMismatch(
+ ImageInputContent.self,
+ DecodingError.Context(
+ codingPath: [CodingKeys.type],
+ debugDescription: "Expected type 'image' but got '\(type)'"
+ )
+ )
+ }
+
+ let url = jsonObject["url"] as? String
+ let dataStr = jsonObject["data"] as? String
+
+ guard url != nil || dataStr != nil else {
+ throw DecodingError.dataCorrupted(
+ DecodingError.Context(
+ codingPath: [],
+ debugDescription: "ImageInputContent requires at least one of: url or data"
+ )
+ )
+ }
+
+ return ImageInputContentDTO(
+ url: url,
+ data: dataStr,
+ detail: jsonObject["detail"] as? String,
+ mimeType: jsonObject["mimeType"] as? String
+ )
+ }
+
+ func toDomain() -> ImageInputContent {
+ if let url = url {
+ return ImageInputContent(url: url, detail: detail, mimeType: mimeType)
+ } else {
+ return ImageInputContent(data: data!, detail: detail, mimeType: mimeType)
+ }
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case type, url, data, detail, mimeType
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Decoding/InputContentDTO/TextInputContentDTO.swift b/sdks/community/swift/Sources/AGUICore/Decoding/InputContentDTO/TextInputContentDTO.swift
new file mode 100644
index 0000000000..60a5ecb5ce
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Decoding/InputContentDTO/TextInputContentDTO.swift
@@ -0,0 +1,46 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Data Transfer Object for TextInputContent decoding.
+struct TextInputContentDTO {
+ let text: String
+
+ static func decode(from data: Data, decoder: JSONDecoder = JSONDecoder()) throws -> TextInputContentDTO {
+ guard let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else {
+ throw DecodingError.dataCorrupted(
+ DecodingError.Context(codingPath: [], debugDescription: "Expected JSON object at root")
+ )
+ }
+
+ // Validate type field
+ if let type = jsonObject["type"] as? String, type != "text" {
+ throw DecodingError.typeMismatch(
+ TextInputContent.self,
+ DecodingError.Context(
+ codingPath: [CodingKeys.type],
+ debugDescription: "Expected type 'text' but got '\(type)'"
+ )
+ )
+ }
+
+ // Extract required text field
+ guard let text = jsonObject["text"] as? String else {
+ throw DecodingError.keyNotFound(
+ CodingKeys.text,
+ DecodingError.Context(codingPath: [], debugDescription: "Missing text field")
+ )
+ }
+
+ return TextInputContentDTO(text: text)
+ }
+
+ func toDomain() -> TextInputContent {
+ TextInputContent(text: text)
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case type
+ case text
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Decoding/InputContentDTO/VideoInputContentDTO.swift b/sdks/community/swift/Sources/AGUICore/Decoding/InputContentDTO/VideoInputContentDTO.swift
new file mode 100644
index 0000000000..c4b9fffd6c
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Decoding/InputContentDTO/VideoInputContentDTO.swift
@@ -0,0 +1,54 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Data Transfer Object for VideoInputContent decoding.
+struct VideoInputContentDTO {
+ let url: String?
+ let data: String?
+ let mimeType: String?
+
+ static func decode(from data: Data, decoder: JSONDecoder = JSONDecoder()) throws -> VideoInputContentDTO {
+ guard let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else {
+ throw DecodingError.dataCorrupted(
+ DecodingError.Context(codingPath: [], debugDescription: "Expected JSON object at root")
+ )
+ }
+
+ if let type = jsonObject["type"] as? String, type != "video" {
+ throw DecodingError.typeMismatch(
+ VideoInputContent.self,
+ DecodingError.Context(
+ codingPath: [CodingKeys.type],
+ debugDescription: "Expected type 'video' but got '\(type)'"
+ )
+ )
+ }
+
+ let url = jsonObject["url"] as? String
+ let dataStr = jsonObject["data"] as? String
+
+ guard url != nil || dataStr != nil else {
+ throw DecodingError.dataCorrupted(
+ DecodingError.Context(
+ codingPath: [],
+ debugDescription: "VideoInputContent requires at least one of: url or data"
+ )
+ )
+ }
+
+ return VideoInputContentDTO(url: url, data: dataStr, mimeType: jsonObject["mimeType"] as? String)
+ }
+
+ func toDomain() -> VideoInputContent {
+ if let url = url {
+ return VideoInputContent(url: url, mimeType: mimeType)
+ } else {
+ return VideoInputContent(data: data!, mimeType: mimeType)
+ }
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case type, url, data, mimeType
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Decoding/MessageDTO/ActivityMessageDTO.swift b/sdks/community/swift/Sources/AGUICore/Decoding/MessageDTO/ActivityMessageDTO.swift
new file mode 100644
index 0000000000..19d7ab9600
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Decoding/MessageDTO/ActivityMessageDTO.swift
@@ -0,0 +1,59 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Data Transfer Object for ActivityMessage decoding.
+struct ActivityMessageDTO {
+ let id: String
+ let activityType: String
+ let activityContent: Data
+
+ static func decode(from data: Data, decoder: JSONDecoder = JSONDecoder()) throws -> ActivityMessageDTO {
+ guard let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else {
+ throw DecodingError.dataCorrupted(
+ DecodingError.Context(codingPath: [], debugDescription: "Expected JSON object at root")
+ )
+ }
+
+ // Validate role
+ let role = try MessageDecodingHelpers.extractRole(from: jsonObject)
+ try MessageDecodingHelpers.validateRole(role, expected: .activity)
+
+ // Extract required fields
+ let id = try MessageDecodingHelpers.extractRequiredString(from: jsonObject, key: "id")
+ let activityType = try MessageDecodingHelpers.extractRequiredString(from: jsonObject, key: "activityType")
+
+ // Extract content as JSON object (wire format key is "content")
+ guard let activityContentValue = jsonObject["content"] else {
+ throw DecodingError.keyNotFound(
+ CodingKeys.content,
+ DecodingError.Context(
+ codingPath: [],
+ debugDescription: "Missing content field"
+ )
+ )
+ }
+
+ // Convert activityContent to Data
+ let activityContent: Data
+ if activityContentValue is NSNull {
+ activityContent = Data("null".utf8)
+ } else if activityContentValue is [Any] || activityContentValue is [String: Any] {
+ activityContent = try JSONSerialization.data(withJSONObject: activityContentValue, options: [])
+ } else {
+ // Primitive value - wrap in encoder
+ let encoder = JSONEncoder()
+ activityContent = try encoder.encode(JSONPrimitiveWrapper(value: activityContentValue))
+ }
+
+ return ActivityMessageDTO(id: id, activityType: activityType, activityContent: activityContent)
+ }
+
+ func toDomain() -> ActivityMessage {
+ ActivityMessage(id: id, activityType: activityType, content: activityContent)
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case content
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Decoding/MessageDTO/MessageDecodingHelpers.swift b/sdks/community/swift/Sources/AGUICore/Decoding/MessageDTO/MessageDecodingHelpers.swift
new file mode 100644
index 0000000000..cec7822628
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Decoding/MessageDTO/MessageDecodingHelpers.swift
@@ -0,0 +1,107 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Helper utilities for decoding message DTOs.
+///
+/// This enum provides shared decoding logic to reduce code duplication across message DTOs
+/// and ensure consistent error handling.
+enum MessageDecodingHelpers {
+
+ /// Extracts a required string field from a JSON object.
+ ///
+ /// - Parameters:
+ /// - jsonObject: The JSON dictionary containing the field
+ /// - key: The key to extract
+ /// - Returns: The string value
+ /// - Throws: `DecodingError` if field is missing or wrong type
+ static func extractRequiredString(from jsonObject: [String: Any], key: String) throws -> String {
+ guard let value = jsonObject[key] else {
+ throw DecodingError.keyNotFound(
+ AnyCodingKey(stringValue: key),
+ DecodingError.Context(
+ codingPath: [],
+ debugDescription: "Missing required field: \(key)"
+ )
+ )
+ }
+
+ guard let stringValue = value as? String else {
+ throw DecodingError.typeMismatch(
+ String.self,
+ DecodingError.Context(
+ codingPath: [AnyCodingKey(stringValue: key)],
+ debugDescription: "Expected String for \(key), got \(type(of: value))"
+ )
+ )
+ }
+
+ return stringValue
+ }
+
+ /// Extracts an optional string field from a JSON object.
+ ///
+ /// - Parameters:
+ /// - jsonObject: The JSON dictionary containing the field
+ /// - key: The key to extract
+ /// - Returns: The string value or nil if not present
+ static func extractOptionalString(from jsonObject: [String: Any], key: String) -> String? {
+ guard let value = jsonObject[key], !(value is NSNull) else {
+ return nil
+ }
+ return value as? String
+ }
+
+ /// Extracts role from JSON and validates it.
+ ///
+ /// - Parameter jsonObject: The JSON dictionary containing the role field
+ /// - Returns: The validated Role
+ /// - Throws: `DecodingError` if role is missing or invalid
+ static func extractRole(from jsonObject: [String: Any]) throws -> Role {
+ let roleString = try extractRequiredString(from: jsonObject, key: "role")
+
+ guard let role = Role(rawValue: roleString) else {
+ throw DecodingError.dataCorrupted(
+ DecodingError.Context(
+ codingPath: [AnyCodingKey(stringValue: "role")],
+ debugDescription: "Invalid role value: \(roleString)"
+ )
+ )
+ }
+
+ return role
+ }
+
+ /// Validates that the role matches the expected value.
+ ///
+ /// - Parameters:
+ /// - role: The actual role
+ /// - expected: The expected role
+ /// - Throws: `DecodingError` if roles don't match
+ static func validateRole(_ role: Role, expected: Role) throws {
+ guard role == expected else {
+ throw DecodingError.dataCorrupted(
+ DecodingError.Context(
+ codingPath: [AnyCodingKey(stringValue: "role")],
+ debugDescription: "Expected role \(expected.rawValue), got \(role.rawValue)"
+ )
+ )
+ }
+ }
+
+ /// Helper CodingKey for constructing error contexts.
+ private struct AnyCodingKey: CodingKey {
+ var stringValue: String
+ var intValue: Int?
+
+ init(stringValue: String) {
+ self.stringValue = stringValue
+ self.intValue = nil
+ }
+
+ init?(intValue: Int) {
+ self.stringValue = String(intValue)
+ self.intValue = intValue
+ }
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Decoding/MessageDTO/ReasoningMessageDTO.swift b/sdks/community/swift/Sources/AGUICore/Decoding/MessageDTO/ReasoningMessageDTO.swift
new file mode 100644
index 0000000000..b8d4f38446
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Decoding/MessageDTO/ReasoningMessageDTO.swift
@@ -0,0 +1,35 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Data Transfer Object for ReasoningMessage decoding.
+struct ReasoningMessageDTO {
+ let id: String
+ let content: String
+ let encryptedValue: String?
+
+ static func decode(from data: Data, decoder: JSONDecoder = JSONDecoder()) throws -> ReasoningMessageDTO {
+ guard let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else {
+ throw DecodingError.dataCorrupted(
+ DecodingError.Context(codingPath: [], debugDescription: "Expected JSON object at root")
+ )
+ }
+
+ // Validate role
+ let role = try MessageDecodingHelpers.extractRole(from: jsonObject)
+ try MessageDecodingHelpers.validateRole(role, expected: .reasoning)
+
+ // Extract required fields
+ let id = try MessageDecodingHelpers.extractRequiredString(from: jsonObject, key: "id")
+ let content = try MessageDecodingHelpers.extractRequiredString(from: jsonObject, key: "content")
+
+ // Extract optional fields
+ let encryptedValue = MessageDecodingHelpers.extractOptionalString(from: jsonObject, key: "encryptedValue")
+
+ return ReasoningMessageDTO(id: id, content: content, encryptedValue: encryptedValue)
+ }
+
+ func toDomain() -> ReasoningMessage {
+ ReasoningMessage(id: id, content: content, encryptedValue: encryptedValue)
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Decoding/MessageDTO/ToolMessageDTO.swift b/sdks/community/swift/Sources/AGUICore/Decoding/MessageDTO/ToolMessageDTO.swift
new file mode 100644
index 0000000000..f5202776e6
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Decoding/MessageDTO/ToolMessageDTO.swift
@@ -0,0 +1,45 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Data Transfer Object for ToolMessage decoding.
+struct ToolMessageDTO {
+ let id: String
+ let toolCallId: String
+ let content: String?
+ let name: String?
+ let error: String?
+ let encryptedValue: String?
+
+ static func decode(from data: Data, decoder: JSONDecoder = JSONDecoder()) throws -> ToolMessageDTO {
+ guard let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else {
+ throw DecodingError.dataCorrupted(
+ DecodingError.Context(codingPath: [], debugDescription: "Expected JSON object at root")
+ )
+ }
+
+ // Validate role
+ let role = try MessageDecodingHelpers.extractRole(from: jsonObject)
+ try MessageDecodingHelpers.validateRole(role, expected: .tool)
+
+ // Extract required fields
+ let id = try MessageDecodingHelpers.extractRequiredString(from: jsonObject, key: "id")
+ let toolCallId = try MessageDecodingHelpers.extractRequiredString(from: jsonObject, key: "toolCallId")
+
+ // Extract optional fields
+ let content = MessageDecodingHelpers.extractOptionalString(from: jsonObject, key: "content")
+ let name = MessageDecodingHelpers.extractOptionalString(from: jsonObject, key: "name")
+ let error = MessageDecodingHelpers.extractOptionalString(from: jsonObject, key: "error")
+ let encryptedValue = MessageDecodingHelpers.extractOptionalString(from: jsonObject, key: "encryptedValue")
+
+ return ToolMessageDTO(id: id, toolCallId: toolCallId, content: content, name: name, error: error, encryptedValue: encryptedValue)
+ }
+
+ /// Converts this DTO to a domain `ToolMessage`.
+ ///
+ /// `content` defaults to an empty string when the JSON field is absent,
+ /// matching the AG-UI protocol where tool result content is optional.
+ func toDomain() -> ToolMessage {
+ ToolMessage(id: id, content: content ?? "", toolCallId: toolCallId, name: name, error: error, encryptedValue: encryptedValue)
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Decoding/MessageDTO/UserMessageDTO.swift b/sdks/community/swift/Sources/AGUICore/Decoding/MessageDTO/UserMessageDTO.swift
new file mode 100644
index 0000000000..4e80a58605
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Decoding/MessageDTO/UserMessageDTO.swift
@@ -0,0 +1,146 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Data Transfer Object for UserMessage decoding.
+struct UserMessageDTO {
+ let id: String
+ let content: String
+ let name: String?
+ let contentParts: [any InputContent]?
+ let encryptedValue: String?
+
+ static func decode(from data: Data, decoder: JSONDecoder = JSONDecoder()) throws -> UserMessageDTO {
+ guard let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else {
+ throw DecodingError.dataCorrupted(
+ DecodingError.Context(codingPath: [], debugDescription: "Expected JSON object at root")
+ )
+ }
+
+ // Validate role
+ let role = try MessageDecodingHelpers.extractRole(from: jsonObject)
+ try MessageDecodingHelpers.validateRole(role, expected: .user)
+
+ // Extract required fields
+ let id = try MessageDecodingHelpers.extractRequiredString(from: jsonObject, key: "id")
+
+ // Extract optional fields
+ let name = MessageDecodingHelpers.extractOptionalString(from: jsonObject, key: "name")
+ let encryptedValue = MessageDecodingHelpers.extractOptionalString(from: jsonObject, key: "encryptedValue")
+
+ // Handle polymorphic content (String or Array of InputContent)
+ guard let contentValue = jsonObject["content"] else {
+ throw DecodingError.keyNotFound(
+ CodingKeys.content,
+ DecodingError.Context(codingPath: [], debugDescription: "Missing content field")
+ )
+ }
+
+ let content: String
+ let contentParts: [any InputContent]?
+
+ if let contentString = contentValue as? String {
+ // Text-only message
+ content = contentString
+ contentParts = nil
+ } else if let contentArray = contentValue as? [[String: Any]] {
+ // Multimodal message
+ content = ""
+ contentParts = try decodeInputContentArray(contentArray, decoder: decoder)
+ } else {
+ throw DecodingError.typeMismatch(
+ String.self,
+ DecodingError.Context(
+ codingPath: [CodingKeys.content],
+ debugDescription: "Expected String or Array for content"
+ )
+ )
+ }
+
+ return UserMessageDTO(id: id, content: content, name: name, contentParts: contentParts, encryptedValue: encryptedValue)
+ }
+
+ /// Decodes an array of InputContent from JSON dictionaries using DTOs.
+ private static func decodeInputContentArray(
+ _ array: [[String: Any]],
+ decoder: JSONDecoder
+ ) throws -> [any InputContent] {
+ var result: [any InputContent] = []
+
+ for (index, item) in array.enumerated() {
+ guard let type = item["type"] as? String else {
+ throw DecodingError.keyNotFound(
+ CodingKeys.type,
+ DecodingError.Context(
+ codingPath: [ArrayIndex(index: index)],
+ debugDescription: "Missing type field in content array item"
+ )
+ )
+ }
+
+ let itemData = try JSONSerialization.data(withJSONObject: item)
+
+ switch type {
+ case "text":
+ let dto = try TextInputContentDTO.decode(from: itemData, decoder: decoder)
+ result.append(dto.toDomain())
+ case "binary":
+ let dto = try BinaryInputContentDTO.decode(from: itemData, decoder: decoder)
+ result.append(try dto.toDomain())
+ case "image":
+ let dto = try ImageInputContentDTO.decode(from: itemData, decoder: decoder)
+ result.append(dto.toDomain())
+ case "audio":
+ let dto = try AudioInputContentDTO.decode(from: itemData, decoder: decoder)
+ result.append(dto.toDomain())
+ case "video":
+ let dto = try VideoInputContentDTO.decode(from: itemData, decoder: decoder)
+ result.append(dto.toDomain())
+ case "document":
+ let dto = try DocumentInputContentDTO.decode(from: itemData, decoder: decoder)
+ result.append(dto.toDomain())
+ default:
+ throw DecodingError.dataCorrupted(
+ DecodingError.Context(
+ codingPath: [ArrayIndex(index: index), CodingKeys.type],
+ debugDescription: "Unknown InputContent type: \(type)"
+ )
+ )
+ }
+ }
+
+ return result
+ }
+
+ func toDomain() -> UserMessage {
+ if let parts = contentParts {
+ return UserMessage.multimodal(id: id, parts: parts, name: name, encryptedValue: encryptedValue)
+ } else {
+ return UserMessage(id: id, content: content, name: name, encryptedValue: encryptedValue)
+ }
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case content
+ case type
+ }
+
+ private struct ArrayIndex: CodingKey {
+ var intValue: Int?
+ var stringValue: String
+
+ init(index: Int) {
+ self.intValue = index
+ self.stringValue = "Index \(index)"
+ }
+
+ init?(intValue: Int) {
+ self.intValue = intValue
+ self.stringValue = "Index \(intValue)"
+ }
+
+ init?(stringValue: String) {
+ nil
+ }
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Decoding/MessageDecoder.swift b/sdks/community/swift/Sources/AGUICore/Decoding/MessageDecoder.swift
new file mode 100644
index 0000000000..4583424bd6
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Decoding/MessageDecoder.swift
@@ -0,0 +1,258 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Decoder for AG-UI protocol messages with polymorphic deserialization.
+///
+/// `MessageDecoder` decodes JSON message data into strongly-typed message objects based on
+/// the "role" field in the JSON. It uses a registry-based architecture matching the pattern
+/// used by `AGUIEventDecoder`.
+///
+/// ## Basic Usage
+///
+/// ```swift
+/// // Create a decoder with default registry
+/// let decoder = MessageDecoder()
+///
+/// // Decode a message from JSON data
+/// let message = try decoder.decode(jsonData)
+///
+/// // Pattern match on the message role
+/// switch message.role {
+/// case .user:
+/// let userMessage = message as! UserMessage
+/// print("User said: \(userMessage.content)")
+/// case .assistant:
+/// let assistantMessage = message as! AssistantMessage
+/// print("Assistant replied: \(assistantMessage.content ?? "")")
+/// default:
+/// print("Other message type: \(message.role)")
+/// }
+/// ```
+///
+/// ## Custom Registries
+///
+/// You can provide a custom registry to control which message types are supported:
+///
+/// ```swift
+/// let customRegistry: [Role: MessageDecoder.DecodeHandler] = [
+/// .user: { data, decoder in
+/// try UserMessageDTO.decode(from: data, decoder: decoder).toDomain()
+/// }
+/// // Add more handlers as needed
+/// ]
+///
+/// let decoder = MessageDecoder(registry: customRegistry)
+/// ```
+///
+/// ## Error Handling
+///
+/// The decoder throws `MessageDecodingError` for various failure scenarios:
+///
+/// - `.missingRoleField`: The JSON is missing the required "role" field
+/// - `.invalidJSON`: The JSON data is malformed or invalid
+/// - `.unknownRole(String)`: The role value is not recognized
+/// - `.unsupportedRole(Role)`: The role is known but has no handler
+/// - `.decodingFailed(String)`: Field-level decoding errors with detailed messages
+///
+/// ## Thread Safety
+///
+/// `MessageDecoder` is thread-safe and can be used concurrently. The decoder itself
+/// is immutable after initialization.
+///
+/// - SeeAlso: `Message`, `Role`, `MessageDecodingError`
+public struct MessageDecoder: Sendable {
+
+ /// Handler function type for decoding a specific message type.
+ ///
+ /// Each handler receives the raw JSON data and a `JSONDecoder`, and returns
+ /// a decoded `Message` instance.
+ ///
+ /// - Parameters:
+ /// - data: The raw JSON data for the message
+ /// - decoder: A `JSONDecoder` instance for decoding
+ /// - Returns: A decoded `Message` instance
+ /// - Throws: `MessageDecodingError` or `DecodingError` if decoding fails
+ public typealias DecodeHandler = @Sendable (_ data: Data, _ decoder: JSONDecoder) throws -> any Message
+
+ private let makeDecoder: @Sendable () -> JSONDecoder
+ private let registry: [Role: DecodeHandler]
+
+ // MARK: - Initialization
+
+ /// Creates a new `MessageDecoder`.
+ ///
+ /// - Parameters:
+ /// - makeDecoder: Factory function for creating `JSONDecoder` instances (defaults to standard `JSONDecoder()`)
+ /// - registry: Dictionary mapping roles to their decode handlers (defaults to `defaultRegistry()`)
+ ///
+ /// The decoder uses the provided registry to determine which message types can be decoded.
+ /// If no registry is provided, it uses `defaultRegistry()` which includes all 6 message types.
+ public init(
+ makeDecoder: @escaping @Sendable () -> JSONDecoder = { JSONDecoder() },
+ registry: [Role: DecodeHandler] = MessageDecoder.defaultRegistry()
+ ) {
+ self.makeDecoder = makeDecoder
+ self.registry = registry
+ }
+
+ // MARK: - Decoding
+
+ /// Decodes JSON data into a `Message` instance.
+ ///
+ /// The decoder performs polymorphic deserialization by:
+ /// 1. Extracting the "role" field from the JSON
+ /// 2. Looking up the appropriate decode handler in the registry
+ /// 3. Invoking the handler to decode the message-specific data
+ ///
+ /// - Parameter data: The JSON data to decode
+ /// - Returns: A decoded `Message` instance (specific type depends on the "role" field)
+ /// - Throws: `MessageDecodingError` if decoding fails or the role is unknown/unsupported
+ ///
+ /// Example:
+ /// ```swift
+ /// let jsonData = """
+ /// {
+ /// "id": "msg-1",
+ /// "role": "user",
+ /// "content": "Hello!"
+ /// }
+ /// """.data(using: .utf8)!
+ ///
+ /// let decoder = MessageDecoder()
+ /// let message = try decoder.decode(jsonData)
+ ///
+ /// if let userMessage = message as? UserMessage {
+ /// print("User said: \(userMessage.content)")
+ /// }
+ /// ```
+ public func decode(_ data: Data) throws -> any Message {
+ let decoder = makeDecoder()
+
+ let role = try decodeRole(from: data, decoder: decoder)
+
+ guard let handler = registry[role] else {
+ throw MessageDecodingError.unsupportedRole(role)
+ }
+
+ return try executeHandler(handler, data: data, decoder: decoder)
+ }
+
+ private func decodeRole(from data: Data, decoder: JSONDecoder) throws -> Role {
+ // Extract just the role field
+ guard let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
+ throw MessageDecodingError.invalidJSON
+ }
+
+ guard let roleString = jsonObject["role"] as? String else {
+ throw MessageDecodingError.missingRoleField
+ }
+
+ guard let role = Role(rawValue: roleString) else {
+ throw MessageDecodingError.unknownRole(roleString)
+ }
+
+ return role
+ }
+
+ private func executeHandler(
+ _ handler: DecodeHandler,
+ data: Data,
+ decoder: JSONDecoder
+ ) throws -> any Message {
+ do {
+ return try handler(data, decoder)
+ } catch let error as MessageDecodingError {
+ throw error
+ } catch let error as DecodingError {
+ throw MessageDecodingError.decodingFailed(error.localizedDescription)
+ } catch {
+ throw MessageDecodingError.decodingFailed(error.localizedDescription)
+ }
+ }
+
+ // MARK: - Default Registry
+
+ /// Returns the default registry with handlers for all 7 message types.
+ ///
+ /// The default registry includes:
+ /// - `.developer` → `DeveloperMessage`
+ /// - `.system` → `SystemMessage`
+ /// - `.user` → `UserMessage`
+ /// - `.assistant` → `AssistantMessage`
+ /// - `.tool` → `ToolMessage`
+ /// - `.activity` → `ActivityMessage`
+ /// - `.reasoning` → `ReasoningMessage`
+ ///
+ /// - Returns: Dictionary mapping each role to its decode handler
+ public static func defaultRegistry() -> [Role: DecodeHandler] {
+ [
+ .developer: { data, decoder in
+ try decoder.decode(DeveloperMessage.self, from: data)
+ },
+ .system: { data, decoder in
+ try decoder.decode(SystemMessage.self, from: data)
+ },
+ .user: { data, decoder in
+ try UserMessageDTO.decode(from: data, decoder: decoder).toDomain()
+ },
+ .assistant: { data, decoder in
+ try decoder.decode(AssistantMessage.self, from: data)
+ },
+ .tool: { data, decoder in
+ try ToolMessageDTO.decode(from: data, decoder: decoder).toDomain()
+ },
+ .activity: { data, decoder in
+ try ActivityMessageDTO.decode(from: data, decoder: decoder).toDomain()
+ },
+ .reasoning: { data, decoder in
+ try ReasoningMessageDTO.decode(from: data, decoder: decoder).toDomain()
+ }
+ ]
+ }
+}
+
+// MARK: - Message Decoding Error
+
+/// Errors that can occur during message decoding.
+public enum MessageDecodingError: Error, Sendable {
+ /// The JSON data is invalid or malformed.
+ case invalidJSON
+
+ /// The required "role" field is missing from the JSON.
+ case missingRoleField
+
+ /// The role value is not a recognized Role case.
+ ///
+ /// - Parameter roleString: The unrecognized role value
+ case unknownRole(String)
+
+ /// The role is recognized but has no registered decode handler.
+ ///
+ /// This typically indicates the message type isn't supported by the current registry.
+ ///
+ /// - Parameter role: The unsupported role
+ case unsupportedRole(Role)
+
+ /// Decoding failed due to an error in the message data.
+ ///
+ /// - Parameter message: Description of the decoding failure
+ case decodingFailed(String)
+}
+
+extension MessageDecodingError: LocalizedError {
+ public var errorDescription: String? {
+ switch self {
+ case .invalidJSON:
+ return "Invalid JSON data"
+ case .missingRoleField:
+ return "Missing required 'role' field"
+ case .unknownRole(let roleString):
+ return "Unknown role: '\(roleString)'"
+ case .unsupportedRole(let role):
+ return "Unsupported message role: \(role.rawValue)"
+ case .decodingFailed(let message):
+ return "Message decoding failed: \(message)"
+ }
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Decoding/Registry/ActivityEventRegistry.swift b/sdks/community/swift/Sources/AGUICore/Decoding/Registry/ActivityEventRegistry.swift
new file mode 100644
index 0000000000..dfab18d637
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Decoding/Registry/ActivityEventRegistry.swift
@@ -0,0 +1,18 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+enum ActivityEventRegistry {
+ typealias DecodeHandler = AGUIEventDecoder.DecodeHandler
+
+ static func registry() -> [EventType: DecodeHandler] {
+ [
+ .activitySnapshot: { data, decoder in
+ try ActivitySnapshotEventDTO.decode(from: data, decoder: decoder).toDomain(rawEvent: data)
+ },
+ .activityDelta: { data, decoder in
+ try ActivityDeltaEventDTO.decode(from: data, decoder: decoder).toDomain(rawEvent: data)
+ }
+ ]
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Decoding/Registry/LifecycleEventRegistry.swift b/sdks/community/swift/Sources/AGUICore/Decoding/Registry/LifecycleEventRegistry.swift
new file mode 100644
index 0000000000..555ee3218b
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Decoding/Registry/LifecycleEventRegistry.swift
@@ -0,0 +1,27 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+enum LifecycleEventRegistry {
+ typealias DecodeHandler = AGUIEventDecoder.DecodeHandler
+
+ static func registry() -> [EventType: DecodeHandler] {
+ [
+ .runStarted: { data, decoder in
+ try RunStartedEventDTO.decode(from: data, decoder: decoder).toDomain(rawEvent: data)
+ },
+ .runFinished: { data, decoder in
+ try RunFinishedEventDTO.decode(from: data, decoder: decoder).toDomain(rawEvent: data)
+ },
+ .runError: { data, decoder in
+ try RunErrorEventDTO.decode(from: data, decoder: decoder).toDomain(rawEvent: data)
+ },
+ .stepStarted: { data, decoder in
+ try decoder.decode(StepStartedEventDTO.self, from: data).toDomain(rawEvent: data)
+ },
+ .stepFinished: { data, decoder in
+ try decoder.decode(StepFinishedEventDTO.self, from: data).toDomain(rawEvent: data)
+ }
+ ]
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Decoding/Registry/ReasoningEventRegistry.swift b/sdks/community/swift/Sources/AGUICore/Decoding/Registry/ReasoningEventRegistry.swift
new file mode 100644
index 0000000000..f41a6fed28
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Decoding/Registry/ReasoningEventRegistry.swift
@@ -0,0 +1,33 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+enum ReasoningEventRegistry {
+ typealias DecodeHandler = AGUIEventDecoder.DecodeHandler
+
+ static func registry() -> [EventType: DecodeHandler] {
+ [
+ .reasoningStart: { data, decoder in
+ try decoder.decode(ReasoningStartEventDTO.self, from: data).toDomain(rawEvent: data)
+ },
+ .reasoningMessageStart: { data, decoder in
+ try decoder.decode(ReasoningMessageStartEventDTO.self, from: data).toDomain(rawEvent: data)
+ },
+ .reasoningMessageContent: { data, decoder in
+ try decoder.decode(ReasoningMessageContentEventDTO.self, from: data).toDomain(rawEvent: data)
+ },
+ .reasoningMessageEnd: { data, decoder in
+ try decoder.decode(ReasoningMessageEndEventDTO.self, from: data).toDomain(rawEvent: data)
+ },
+ .reasoningMessageChunk: { data, decoder in
+ try decoder.decode(ReasoningMessageChunkEventDTO.self, from: data).toDomain(rawEvent: data)
+ },
+ .reasoningEnd: { data, decoder in
+ try decoder.decode(ReasoningEndEventDTO.self, from: data).toDomain(rawEvent: data)
+ },
+ .reasoningEncryptedValue: { data, decoder in
+ try decoder.decode(ReasoningEncryptedValueEventDTO.self, from: data).toDomain(rawEvent: data)
+ }
+ ]
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Decoding/Registry/SpecialEventRegistry.swift b/sdks/community/swift/Sources/AGUICore/Decoding/Registry/SpecialEventRegistry.swift
new file mode 100644
index 0000000000..a7d10566bc
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Decoding/Registry/SpecialEventRegistry.swift
@@ -0,0 +1,18 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+enum SpecialEventRegistry {
+ typealias DecodeHandler = AGUIEventDecoder.DecodeHandler
+
+ static func registry() -> [EventType: DecodeHandler] {
+ [
+ .raw: { data, decoder in
+ try RawEventDTO.decode(from: data, decoder: decoder).toDomain(rawEvent: data)
+ },
+ .custom: { data, decoder in
+ try CustomEventDTO.decode(from: data, decoder: decoder).toDomain(rawEvent: data)
+ }
+ ]
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Decoding/Registry/StateEventRegistry.swift b/sdks/community/swift/Sources/AGUICore/Decoding/Registry/StateEventRegistry.swift
new file mode 100644
index 0000000000..a242f2ef46
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Decoding/Registry/StateEventRegistry.swift
@@ -0,0 +1,21 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+enum StateEventRegistry {
+ typealias DecodeHandler = AGUIEventDecoder.DecodeHandler
+
+ static func registry() -> [EventType: DecodeHandler] {
+ [
+ .stateSnapshot: { data, decoder in
+ try StateSnapshotEventDTO.decode(from: data, decoder: decoder).toDomain(rawEvent: data)
+ },
+ .stateDelta: { data, decoder in
+ try StateDeltaEventDTO.decode(from: data, decoder: decoder).toDomain(rawEvent: data)
+ },
+ .messagesSnapshot: { data, decoder in
+ try MessagesSnapshotEventDTO.decode(from: data, decoder: decoder).toDomain(rawEvent: data)
+ }
+ ]
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Decoding/Registry/TextMessageEventRegistry.swift b/sdks/community/swift/Sources/AGUICore/Decoding/Registry/TextMessageEventRegistry.swift
new file mode 100644
index 0000000000..901eeaf962
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Decoding/Registry/TextMessageEventRegistry.swift
@@ -0,0 +1,24 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+enum TextMessageEventRegistry {
+ typealias DecodeHandler = AGUIEventDecoder.DecodeHandler
+
+ static func registry() -> [EventType: DecodeHandler] {
+ [
+ .textMessageStart: { data, decoder in
+ try decoder.decode(TextMessageStartEvent.self, from: data).withRawEvent(data)
+ },
+ .textMessageContent: { data, decoder in
+ try decoder.decode(TextMessageContentEvent.self, from: data).withRawEvent(data)
+ },
+ .textMessageEnd: { data, decoder in
+ try decoder.decode(TextMessageEndEventDTO.self, from: data).toDomain(rawEvent: data)
+ },
+ .textMessageChunk: { data, decoder in
+ try decoder.decode(TextMessageChunkEventDTO.self, from: data).toDomain(rawEvent: data)
+ }
+ ]
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Decoding/Registry/ToolCallEventRegistry.swift b/sdks/community/swift/Sources/AGUICore/Decoding/Registry/ToolCallEventRegistry.swift
new file mode 100644
index 0000000000..9aa5267952
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Decoding/Registry/ToolCallEventRegistry.swift
@@ -0,0 +1,27 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+enum ToolCallEventRegistry {
+ typealias DecodeHandler = AGUIEventDecoder.DecodeHandler
+
+ static func registry() -> [EventType: DecodeHandler] {
+ [
+ .toolCallStart: { data, decoder in
+ try decoder.decode(ToolCallStartEvent.self, from: data).withRawEvent(data)
+ },
+ .toolCallArgs: { data, decoder in
+ try decoder.decode(ToolCallArgsEvent.self, from: data).withRawEvent(data)
+ },
+ .toolCallEnd: { data, decoder in
+ try decoder.decode(ToolCallEndEventDTO.self, from: data).toDomain(rawEvent: data)
+ },
+ .toolCallResult: { data, decoder in
+ try decoder.decode(ToolCallResultEventDTO.self, from: data).toDomain(rawEvent: data)
+ },
+ .toolCallChunk: { data, decoder in
+ try decoder.decode(ToolCallChunkEventDTO.self, from: data).toDomain(rawEvent: data)
+ }
+ ]
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Decoding/RegistryComposer.swift b/sdks/community/swift/Sources/AGUICore/Decoding/RegistryComposer.swift
new file mode 100644
index 0000000000..09b8b85e84
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Decoding/RegistryComposer.swift
@@ -0,0 +1,16 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+enum RegistryComposer {
+ typealias DecodeHandler = AGUIEventDecoder.DecodeHandler
+
+ static func compose(_ registries: [EventType: DecodeHandler]...) -> [EventType: DecodeHandler] {
+ registries.reduce(into: [:]) { result, next in
+ for (key, value) in next {
+ // If you want "last one wins" override behavior:
+ result[key] = value
+ }
+ }
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Decoding/ThinkingRemapState.swift b/sdks/community/swift/Sources/AGUICore/Decoding/ThinkingRemapState.swift
new file mode 100644
index 0000000000..139b26c72b
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Decoding/ThinkingRemapState.swift
@@ -0,0 +1,17 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Mutable state for the THINKING_* → REASONING_* backward-compatibility remap.
+///
+/// The remap needs two stable IDs per sequence:
+/// - `currentReasoningId`: shared by `THINKING_START` and `THINKING_END`
+/// - `currentMessageId`: shared by `THINKING_TEXT_MESSAGE_START`, `_CONTENT`, and `_END`
+///
+/// Stored as a reference type inside the `Sendable` `AGUIEventDecoder` struct.
+/// `AGUIEventDecoder` must be used serially (SSE streams guarantee this), so no
+/// additional synchronization is required.
+final class ThinkingRemapState: @unchecked Sendable {
+ var currentReasoningId: String?
+ var currentMessageId: String?
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Encoding/MessageEncoder.swift b/sdks/community/swift/Sources/AGUICore/Encoding/MessageEncoder.swift
new file mode 100644
index 0000000000..09781ace84
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Encoding/MessageEncoder.swift
@@ -0,0 +1,423 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Encoder for AG-UI protocol messages with polymorphic serialization.
+///
+/// `MessageEncoder` encodes strongly-typed message objects into JSON data based on
+/// the message's role. It uses a registry-based architecture matching the pattern
+/// used by `MessageDecoder`.
+///
+/// ## Basic Usage
+///
+/// ```swift
+/// // Create an encoder with default registry
+/// let encoder = MessageEncoder()
+///
+/// // Encode a message to JSON data
+/// let message = UserMessage(id: "msg-1", content: "Hello!")
+/// let jsonData = try encoder.encode(message)
+///
+/// // Convert to string for viewing
+/// let jsonString = String(data: jsonData, encoding: .utf8)
+/// ```
+///
+/// ## Custom Registries
+///
+/// You can provide a custom registry to control how message types are encoded:
+///
+/// ```swift
+/// let customRegistry: [Role: MessageEncoder.EncodeHandler] = [
+/// .user: { message, encoder in
+/// let userMessage = message as! UserMessage
+/// // Custom encoding logic
+/// return customEncoding(userMessage)
+/// }
+/// ]
+///
+/// let encoder = MessageEncoder(registry: customRegistry)
+/// ```
+///
+/// ## Error Handling
+///
+/// The encoder throws `MessageEncodingError` for various failure scenarios:
+///
+/// - `.unsupportedRole(Role)`: The message role has no registered encoder
+/// - `.invalidMessageType(Role, String)`: Message type doesn't match its role
+/// - `.encodingFailed(String)`: Field-level encoding errors
+///
+/// ## Thread Safety
+///
+/// `MessageEncoder` is thread-safe and can be used concurrently. The encoder itself
+/// is immutable after initialization.
+///
+/// - SeeAlso: `Message`, `Role`, `MessageDecoder`
+public struct MessageEncoder: Sendable {
+
+ /// Handler function type for encoding a specific message type.
+ ///
+ /// Each handler receives a message and a `JSONEncoder`, and returns
+ /// encoded JSON data.
+ ///
+ /// - Parameters:
+ /// - message: The message to encode
+ /// - encoder: A `JSONEncoder` instance for encoding
+ /// - Returns: Encoded JSON data
+ /// - Throws: `MessageEncodingError` or `EncodingError` if encoding fails
+ public typealias EncodeHandler = @Sendable (_ message: any Message, _ encoder: JSONEncoder) throws -> Data
+
+ private let makeEncoder: @Sendable () -> JSONEncoder
+ private let registry: [Role: EncodeHandler]
+
+ // MARK: - Initialization
+
+ /// Creates a new `MessageEncoder`.
+ ///
+ /// - Parameters:
+ /// - makeEncoder: Factory function for creating `JSONEncoder` instances (defaults to standard `JSONEncoder()`)
+ /// - registry: Dictionary mapping roles to their encode handlers (defaults to `defaultRegistry()`)
+ ///
+ /// The encoder uses the provided registry to determine how message types should be encoded.
+ /// If no registry is provided, it uses `defaultRegistry()` which includes all 6 message types.
+ public init(
+ makeEncoder: @escaping @Sendable () -> JSONEncoder = { JSONEncoder() },
+ registry: [Role: EncodeHandler] = MessageEncoder.defaultRegistry()
+ ) {
+ self.makeEncoder = makeEncoder
+ self.registry = registry
+ }
+
+ // MARK: - Encoding
+
+ /// Encodes a `Message` instance into JSON data.
+ ///
+ /// The encoder performs polymorphic serialization by:
+ /// 1. Determining the message's role
+ /// 2. Looking up the appropriate encode handler in the registry
+ /// 3. Invoking the handler to encode the message-specific data
+ ///
+ /// - Parameter message: The message to encode
+ /// - Returns: JSON data representing the message
+ /// - Throws: `MessageEncodingError` if encoding fails or the role is unsupported
+ ///
+ /// Example:
+ /// ```swift
+ /// let encoder = MessageEncoder()
+ /// let message = UserMessage(id: "msg-1", content: "Hello!")
+ /// let jsonData = try encoder.encode(message)
+ /// ```
+ public func encode(_ message: any Message) throws -> Data {
+ let encoder = makeEncoder()
+ let role = message.role
+
+ guard let handler = registry[role] else {
+ throw MessageEncodingError.unsupportedRole(role)
+ }
+
+ return try executeHandler(handler, message: message, encoder: encoder)
+ }
+
+ private func executeHandler(
+ _ handler: EncodeHandler,
+ message: any Message,
+ encoder: JSONEncoder
+ ) throws -> Data {
+ do {
+ return try handler(message, encoder)
+ } catch let error as MessageEncodingError {
+ throw error
+ } catch let error as EncodingError {
+ throw MessageEncodingError.encodingFailed(error.localizedDescription)
+ } catch {
+ throw MessageEncodingError.encodingFailed(error.localizedDescription)
+ }
+ }
+
+ // MARK: - Default Registry
+
+ /// Returns the default registry with handlers for all 7 message types.
+ ///
+ /// The default registry includes:
+ /// - `.developer` → Encodes `DeveloperMessage`
+ /// - `.system` → Encodes `SystemMessage`
+ /// - `.user` → Encodes `UserMessage`
+ /// - `.assistant` → Encodes `AssistantMessage`
+ /// - `.tool` → Encodes `ToolMessage`
+ /// - `.activity` → Encodes `ActivityMessage`
+ /// - `.reasoning` → Encodes `ReasoningMessage`
+ ///
+ /// - Returns: Dictionary mapping each role to its encode handler
+ public static func defaultRegistry() -> [Role: EncodeHandler] {
+ [
+ .developer: { message, encoder in
+ try encodeDeveloperMessage(message, encoder: encoder)
+ },
+ .system: { message, encoder in
+ try encodeSystemMessage(message, encoder: encoder)
+ },
+ .user: { message, encoder in
+ try encodeUserMessage(message, encoder: encoder)
+ },
+ .assistant: { message, encoder in
+ try encodeAssistantMessage(message, encoder: encoder)
+ },
+ .tool: { message, encoder in
+ try encodeToolMessage(message, encoder: encoder)
+ },
+ .activity: { message, encoder in
+ try encodeActivityMessage(message, encoder: encoder)
+ },
+ .reasoning: { message, encoder in
+ try encodeReasoningMessage(message, encoder: encoder)
+ }
+ ]
+ }
+}
+
+// MARK: - Message Encoding Error
+
+/// Errors that can occur during message encoding.
+public enum MessageEncodingError: Error, Sendable {
+ /// The message role has no registered encode handler.
+ ///
+ /// - Parameter role: The unsupported role
+ case unsupportedRole(Role)
+
+ /// The message type doesn't match its declared role.
+ ///
+ /// For example, a message with role `.user` that isn't actually a `UserMessage`.
+ ///
+ /// - Parameters:
+ /// - role: The expected role
+ /// - actualType: The actual type name
+ case invalidMessageType(Role, String)
+
+ /// Encoding failed due to an error in the message data.
+ ///
+ /// - Parameter message: Description of the encoding failure
+ case encodingFailed(String)
+}
+
+extension MessageEncodingError: LocalizedError {
+ public var errorDescription: String? {
+ switch self {
+ case .unsupportedRole(let role):
+ return "Unsupported message role: \(role.rawValue)"
+ case .invalidMessageType(let role, let actualType):
+ return "Message role is \(role.rawValue) but type is \(actualType)"
+ case .encodingFailed(let message):
+ return "Message encoding failed: \(message)"
+ }
+ }
+}
+
+// MARK: - Message Encoding Functions
+
+/// Encodes a DeveloperMessage to JSON data.
+private func encodeDeveloperMessage(_ message: any Message, encoder: JSONEncoder) throws -> Data {
+ guard let devMsg = message as? DeveloperMessage else {
+ throw MessageEncodingError.invalidMessageType(.developer, String(describing: type(of: message)))
+ }
+
+ var dict: [String: Any] = [
+ "id": devMsg.id,
+ "role": devMsg.role.rawValue,
+ "content": devMsg.content ?? ""
+ ]
+ if let name = devMsg.name {
+ dict["name"] = name
+ }
+ if let encryptedValue = devMsg.encryptedValue {
+ dict["encryptedValue"] = encryptedValue
+ }
+ return try JSONSerialization.data(withJSONObject: dict)
+}
+
+/// Encodes a SystemMessage to JSON data.
+private func encodeSystemMessage(_ message: any Message, encoder: JSONEncoder) throws -> Data {
+ guard let sysMsg = message as? SystemMessage else {
+ throw MessageEncodingError.invalidMessageType(.system, String(describing: type(of: message)))
+ }
+
+ var dict: [String: Any] = [
+ "id": sysMsg.id,
+ "role": sysMsg.role.rawValue,
+ "content": sysMsg.content
+ ]
+ if let name = sysMsg.name {
+ dict["name"] = name
+ }
+ if let encryptedValue = sysMsg.encryptedValue {
+ dict["encryptedValue"] = encryptedValue
+ }
+ return try JSONSerialization.data(withJSONObject: dict)
+}
+
+/// Encodes a UserMessage to JSON data.
+private func encodeUserMessage(_ message: any Message, encoder: JSONEncoder) throws -> Data {
+ guard let userMsg = message as? UserMessage else {
+ throw MessageEncodingError.invalidMessageType(.user, String(describing: type(of: message)))
+ }
+
+ var dict: [String: Any] = [
+ "id": userMsg.id,
+ "role": userMsg.role.rawValue
+ ]
+
+ // Handle polymorphic content
+ if let parts = userMsg.contentParts {
+ // Multimodal - encode as array
+ var contentArray: [[String: Any]] = []
+ for part in parts {
+ if let textPart = part as? TextInputContent {
+ contentArray.append([
+ "type": "text",
+ "text": textPart.text
+ ])
+ } else if let binaryPart = part as? BinaryInputContent {
+ var binaryDict: [String: Any] = [
+ "type": "binary",
+ "mimeType": binaryPart.mimeType
+ ]
+ if let id = binaryPart.id {
+ binaryDict["id"] = id
+ }
+ if let url = binaryPart.url {
+ binaryDict["url"] = url
+ }
+ if let data = binaryPart.data {
+ binaryDict["data"] = data
+ }
+ if let filename = binaryPart.filename {
+ binaryDict["filename"] = filename
+ }
+ contentArray.append(binaryDict)
+ } else if let imagePart = part as? ImageInputContent {
+ var d: [String: Any] = ["type": "image"]
+ if let url = imagePart.url { d["url"] = url }
+ if let data = imagePart.data { d["data"] = data }
+ if let detail = imagePart.detail { d["detail"] = detail }
+ if let mimeType = imagePart.mimeType { d["mimeType"] = mimeType }
+ contentArray.append(d)
+ } else if let audioPart = part as? AudioInputContent {
+ var d: [String: Any] = ["type": "audio"]
+ if let url = audioPart.url { d["url"] = url }
+ if let data = audioPart.data { d["data"] = data }
+ if let format = audioPart.format { d["format"] = format }
+ if let mimeType = audioPart.mimeType { d["mimeType"] = mimeType }
+ contentArray.append(d)
+ } else if let videoPart = part as? VideoInputContent {
+ var d: [String: Any] = ["type": "video"]
+ if let url = videoPart.url { d["url"] = url }
+ if let data = videoPart.data { d["data"] = data }
+ if let mimeType = videoPart.mimeType { d["mimeType"] = mimeType }
+ contentArray.append(d)
+ } else if let docPart = part as? DocumentInputContent {
+ var d: [String: Any] = ["type": "document"]
+ if let url = docPart.url { d["url"] = url }
+ if let data = docPart.data { d["data"] = data }
+ if let mimeType = docPart.mimeType { d["mimeType"] = mimeType }
+ if let title = docPart.title { d["title"] = title }
+ contentArray.append(d)
+ }
+ }
+ dict["content"] = contentArray
+ } else {
+ // Text-only
+ dict["content"] = userMsg.content ?? ""
+ }
+
+ if let name = userMsg.name {
+ dict["name"] = name
+ }
+ if let encryptedValue = userMsg.encryptedValue {
+ dict["encryptedValue"] = encryptedValue
+ }
+ return try JSONSerialization.data(withJSONObject: dict)
+}
+
+/// Encodes an AssistantMessage to JSON data.
+private func encodeAssistantMessage(_ message: any Message, encoder: JSONEncoder) throws -> Data {
+ guard let assistantMsg = message as? AssistantMessage else {
+ throw MessageEncodingError.invalidMessageType(.assistant, String(describing: type(of: message)))
+ }
+
+ var dict: [String: Any] = [
+ "id": assistantMsg.id,
+ "role": assistantMsg.role.rawValue
+ ]
+ if let content = assistantMsg.content {
+ dict["content"] = content
+ }
+ if let name = assistantMsg.name {
+ dict["name"] = name
+ }
+ if let toolCalls = assistantMsg.toolCalls {
+ let toolCallsData = try encoder.encode(toolCalls)
+ let toolCallsArray = try JSONSerialization.jsonObject(with: toolCallsData)
+ dict["toolCalls"] = toolCallsArray
+ }
+ if let encryptedValue = assistantMsg.encryptedValue {
+ dict["encryptedValue"] = encryptedValue
+ }
+ return try JSONSerialization.data(withJSONObject: dict)
+}
+
+/// Encodes a ToolMessage to JSON data.
+private func encodeToolMessage(_ message: any Message, encoder: JSONEncoder) throws -> Data {
+ guard let toolMsg = message as? ToolMessage else {
+ throw MessageEncodingError.invalidMessageType(.tool, String(describing: type(of: message)))
+ }
+
+ var dict: [String: Any] = [
+ "id": toolMsg.id,
+ "role": toolMsg.role.rawValue,
+ "toolCallId": toolMsg.toolCallId,
+ "content": toolMsg.content
+ ]
+ if let name = toolMsg.name {
+ dict["name"] = name
+ }
+ if let error = toolMsg.error {
+ dict["error"] = error
+ }
+ if let encryptedValue = toolMsg.encryptedValue {
+ dict["encryptedValue"] = encryptedValue
+ }
+ return try JSONSerialization.data(withJSONObject: dict)
+}
+
+/// Encodes an ActivityMessage to JSON data.
+private func encodeActivityMessage(_ message: any Message, encoder: JSONEncoder) throws -> Data {
+ guard let activityMsg = message as? ActivityMessage else {
+ throw MessageEncodingError.invalidMessageType(.activity, String(describing: type(of: message)))
+ }
+
+ let activityContentObj = try JSONSerialization.jsonObject(with: activityMsg.content)
+ let dict: [String: Any] = [
+ "id": activityMsg.id,
+ "role": activityMsg.role.rawValue,
+ "activityType": activityMsg.activityType,
+ "content": activityContentObj
+ ]
+ return try JSONSerialization.data(withJSONObject: dict)
+}
+
+/// Encodes a ReasoningMessage to JSON data.
+private func encodeReasoningMessage(_ message: any Message, encoder: JSONEncoder) throws -> Data {
+ guard let reasoningMsg = message as? ReasoningMessage else {
+ throw MessageEncodingError.invalidMessageType(.reasoning, String(describing: type(of: message)))
+ }
+
+ var dict: [String: Any] = [
+ "id": reasoningMsg.id,
+ "role": reasoningMsg.role.rawValue
+ ]
+ if let content = reasoningMsg.content {
+ dict["content"] = content
+ }
+ if let encryptedValue = reasoningMsg.encryptedValue {
+ dict["encryptedValue"] = encryptedValue
+ }
+ return try JSONSerialization.data(withJSONObject: dict)
+}
diff --git a/sdks/community/swift/Sources/AGUICore/EventType.swift b/sdks/community/swift/Sources/AGUICore/EventType.swift
new file mode 100644
index 0000000000..c1343d806f
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/EventType.swift
@@ -0,0 +1,128 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Enumeration of all event types in the AG-UI protocol that can be received from or sent to an AG-UI agent.
+///
+/// Each case corresponds to a specific event type in the protocol specification.
+/// The raw value is the exact string used in the JSON "type" field.
+///
+/// ## Event Categories
+/// - **Lifecycle**: `runStarted`, `runFinished`, `runError`, `stepStarted`, `stepFinished`
+/// - **Text Messages**: `textMessageStart`, `textMessageContent`, `textMessageEnd`, `textMessageChunk`
+/// - **Tool Calls**: `toolCallStart`, `toolCallArgs`, `toolCallEnd`, `toolCallResult`, `toolCallChunk`
+/// - **State**: `stateSnapshot`, `stateDelta`, `messagesSnapshot`
+/// - **Reasoning**: `reasoningStart`, `reasoningMessageStart`, `reasoningMessageContent`, `reasoningMessageEnd`, `reasoningMessageChunk`, `reasoningEnd`, `reasoningEncryptedValue`
+/// - **Activity**: `activitySnapshot`, `activityDelta`
+/// - **Special**: `raw`, `custom`
+
+public enum EventType: String, Codable, CaseIterable, Sendable {
+ // MARK: - Lifecycle Events (5)
+
+ /// Agent run has started
+ case runStarted = "RUN_STARTED"
+
+ /// Agent run has completed successfully
+ case runFinished = "RUN_FINISHED"
+
+ /// Agent run encountered an error
+ case runError = "RUN_ERROR"
+
+ /// Agent step has started
+ case stepStarted = "STEP_STARTED"
+
+ /// Agent step has finished
+ case stepFinished = "STEP_FINISHED"
+
+ // MARK: - Text Message Events (4)
+
+ /// Text message generation started
+ case textMessageStart = "TEXT_MESSAGE_START"
+
+ /// Text message content received
+ case textMessageContent = "TEXT_MESSAGE_CONTENT"
+
+ /// Text message generation finished
+ case textMessageEnd = "TEXT_MESSAGE_END"
+
+ /// Chunk of text message received
+ case textMessageChunk = "TEXT_MESSAGE_CHUNK"
+
+ // MARK: - Tool Call Events (5)
+
+ /// Tool call started
+ case toolCallStart = "TOOL_CALL_START"
+
+ /// Tool call arguments received
+ case toolCallArgs = "TOOL_CALL_ARGS"
+
+ /// Tool call finished
+ case toolCallEnd = "TOOL_CALL_END"
+
+ /// Tool call result received
+ case toolCallResult = "TOOL_CALL_RESULT"
+
+ /// Chunk of tool call data received
+ case toolCallChunk = "TOOL_CALL_CHUNK"
+
+ // MARK: - State Management Events (3)
+
+ /// State snapshot received
+ case stateSnapshot = "STATE_SNAPSHOT"
+
+ /// Incremental state update received
+ case stateDelta = "STATE_DELTA"
+
+ /// Messages snapshot received
+ case messagesSnapshot = "MESSAGES_SNAPSHOT"
+
+ // MARK: - Reasoning Events (7)
+
+ /// Reasoning phase started.
+ case reasoningStart = "REASONING_START"
+
+ /// Reasoning message generation started.
+ case reasoningMessageStart = "REASONING_MESSAGE_START"
+
+ /// Reasoning message content received.
+ case reasoningMessageContent = "REASONING_MESSAGE_CONTENT"
+
+ /// Reasoning message generation finished.
+ case reasoningMessageEnd = "REASONING_MESSAGE_END"
+
+ /// Chunk of reasoning message received.
+ case reasoningMessageChunk = "REASONING_MESSAGE_CHUNK"
+
+ /// Reasoning phase ended.
+ case reasoningEnd = "REASONING_END"
+
+ /// Encrypted reasoning value attached to a message or tool call.
+ case reasoningEncryptedValue = "REASONING_ENCRYPTED_VALUE"
+
+ // MARK: - Special Events (2)
+
+ /// Raw untyped event
+ case raw = "RAW"
+
+ /// Custom event type
+ case custom = "CUSTOM"
+
+ // MARK: - Activity Events (2)
+
+ /// Activity snapshot received
+ case activitySnapshot = "ACTIVITY_SNAPSHOT"
+
+ /// Incremental activity update received
+ case activityDelta = "ACTIVITY_DELTA"
+
+ // MARK: - Internal Sentinel (1)
+
+ /// Sentinel type returned by ``UnknownEvent`` for events that could not be decoded.
+ ///
+ /// The raw value `"__UNKNOWN__"` is deliberately not a valid AG-UI wire-format string,
+ /// ensuring it never collides with genuine ``EventType/raw`` events.
+ ///
+ /// - Note: This case is an implementation detail. Consumer code should check `event is UnknownEvent`
+ /// rather than switching on `.unknown` directly.
+ case unknown = "__UNKNOWN__"
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Events/AGUIEvent.swift b/sdks/community/swift/Sources/AGUICore/Events/AGUIEvent.swift
new file mode 100644
index 0000000000..33f5bef039
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Events/AGUIEvent.swift
@@ -0,0 +1,52 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Base protocol for all events in the AG-UI protocol.
+///
+/// Events represent real-time notifications from agents about their execution state,
+/// message generation, tool calls, and state changes. All events follow a common
+/// structure with polymorphic serialization based on the "type" field.
+///
+/// Key Properties:
+/// - `eventType`: The specific type of event (used for pattern matching)
+/// - `timestamp`: Optional timestamp of when the event occurred
+///
+/// Event Categories:
+/// - Lifecycle Events: Run and step start/finish/error events
+/// - Text Message Events: Streaming text message generation
+/// - Tool Call Events: Tool invocation and argument streaming
+/// - State Management Events: State snapshots and incremental updates
+/// - Special Events: Raw and custom event types
+///
+/// Serialization:
+/// Uses polymorphic serialization where the "type" field determines which
+/// specific event type to deserialize to.
+///
+/// - SeeAlso: `EventType`
+public protocol AGUIEvent: Sendable {
+ /// The type of this event.
+ ///
+ /// This property is used for pattern matching and event handling logic.
+ /// The actual "type" field in JSON is used for polymorphic decoding.
+ ///
+ /// - SeeAlso: `EventType`
+ var eventType: EventType { get }
+
+ /// Optional timestamp indicating when this event occurred.
+ ///
+ /// The timestamp is represented as milliseconds since epoch (Unix timestamp).
+ /// This field may be nil if timing information is not available or relevant.
+ ///
+ /// Note: The protocol specification varies between implementations regarding
+ /// timestamp format, but Int64 (milliseconds) is used here for consistency
+ /// with standard timestamp conventions.
+ var timestamp: Int64? { get }
+
+ /// Optional raw event data as received from the agent.
+ ///
+ /// This preserves the original JSON bytes for debugging, logging, and forward
+ /// compatibility with protocol extensions. May be nil if the event was created
+ /// programmatically rather than decoded from JSON.
+ var rawEvent: Data? { get }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Events/ActivityEvents/ActivityDeltaEvent.swift b/sdks/community/swift/Sources/AGUICore/Events/ActivityEvents/ActivityDeltaEvent.swift
new file mode 100644
index 0000000000..9a9396b04b
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Events/ActivityEvents/ActivityDeltaEvent.swift
@@ -0,0 +1,141 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Event containing a JSON Patch delta for an activity message.
+///
+/// This event provides incremental updates to activity content using
+/// RFC 6902 JSON Patch format. It allows efficient updates to structured
+/// activity content without sending the full content each time.
+///
+/// - SeeAlso: `ActivitySnapshotEvent`, `StateDeltaEvent`
+/// - SeeAlso: [RFC 6902 - JSON Patch](https://tools.ietf.org/html/rfc6902)
+public struct ActivityDeltaEvent: AGUIEvent, Equatable, Sendable {
+
+ // MARK: - Properties
+
+ /// The identifier for the activity message to update.
+ ///
+ /// This ID associates this delta with a specific activity instance that
+ /// was previously created via `ActivitySnapshotEvent`.
+ public let messageId: String
+
+ /// The type of activity (e.g., "a2ui-surface").
+ ///
+ /// This must match the activity type of the original snapshot.
+ public let activityType: String
+
+ /// The JSON Patch operations as raw JSON data.
+ ///
+ /// This contains an array of JSON Patch operations serialized as JSON.
+ /// Each operation follows the RFC 6902 format with fields like `op`, `path`,
+ /// `value`, `from`, etc.
+ ///
+ /// To access the parsed operations, use `parsedPatch()` or parse the data
+ /// using `JSONSerialization.jsonObject(with:)`.
+ public let patch: Data
+
+ /// Optional timestamp when the delta was created.
+ ///
+ /// Represented as milliseconds since Unix epoch.
+ public let timestamp: Int64?
+
+ /// Optional raw event data as received from the agent.
+ public let rawEvent: Data?
+
+ /// The type of this event (always `.activityDelta`).
+ public var eventType: EventType { .activityDelta }
+
+ // MARK: - Initialization
+
+ /// Creates a new `ActivityDeltaEvent`.
+ ///
+ /// - Parameters:
+ /// - messageId: The identifier for the activity message to update
+ /// - activityType: The type of activity (e.g., "a2ui-surface")
+ /// - patch: The JSON Patch operations as raw JSON data
+ /// - timestamp: Optional timestamp in milliseconds since epoch
+ /// - rawEvent: Optional raw event data as received from the agent
+ public init(
+ messageId: String,
+ activityType: String,
+ patch: Data,
+ timestamp: Int64? = nil,
+ rawEvent: Data? = nil
+ ) {
+ self.messageId = messageId
+ self.activityType = activityType
+ self.patch = patch
+ self.timestamp = timestamp
+ self.rawEvent = rawEvent
+ }
+
+ // MARK: - Convenience Methods
+
+ /// Parses the patch JSON data into a Swift array of patch operations.
+ ///
+ /// This method uses `JSONSerialization` to parse the patch as an array of
+ /// JSON Patch operations. Each operation is typically a dictionary with fields
+ /// like `op`, `path`, `value`, `from`, etc.
+ ///
+ /// For type-safe parsing when you know the structure, use `parsedPatch(as:)` instead.
+ ///
+ /// - Returns: An array of patch operations (typically `[[String: Any]]`)
+ /// - Throws: An error if the patch data is not valid JSON or not an array
+ public func parsedPatch() throws -> [Any] {
+ let parsed = try JSONSerialization.jsonObject(with: patch, options: [])
+ guard let array = parsed as? [Any] else {
+ throw DecodingError.typeMismatch(
+ [Any].self,
+ DecodingError.Context(codingPath: [], debugDescription: "Patch must be a JSON array")
+ )
+ }
+ return array
+ }
+
+ /// Parses the patch JSON data into a strongly-typed Swift array.
+ ///
+ /// This method uses `Codable` for type-safe decoding when you know the
+ /// structure of the JSON Patch operations. Use this when you have a specific
+ /// type that conforms to `Decodable`.
+ ///
+ /// - Parameter type: The element type to decode the patch array as (must conform to `Decodable`)
+ /// - Returns: An array of decoded instances of the specified type
+ /// - Throws: A `DecodingError` if the patch cannot be decoded as the specified type
+ public func parsedPatch(as type: T.Type, decoder: JSONDecoder = JSONDecoder()) throws -> [T] {
+ try decoder.decode([T].self, from: patch)
+ }
+}
+
+// MARK: - CustomStringConvertible
+extension ActivityDeltaEvent: CustomStringConvertible {
+ public var description: String {
+ let patchSize = patch.count
+ return "ActivityDeltaEvent(messageId: \(messageId), activityType: \(activityType), " +
+ "patch: \(patchSize) bytes, timestamp: \(timestamp?.description ?? "nil"))"
+ }
+}
+
+// MARK: - CustomDebugStringConvertible
+extension ActivityDeltaEvent: CustomDebugStringConvertible {
+ public var debugDescription: String {
+ let patchPreview: String
+ if let jsonString = String(data: patch, encoding: .utf8) {
+ let preview = String(jsonString.prefix(100))
+ patchPreview = jsonString.count > 100 ? "\(preview)..." : preview
+ } else {
+ patchPreview = "\(patch.count) bytes (invalid UTF-8)"
+ }
+
+ return """
+ ActivityDeltaEvent {
+ messageId: "\(messageId)"
+ activityType: "\(activityType)"
+ patch: \(patchPreview)
+ patchSize: \(patch.count) bytes
+ timestamp: \(timestamp.map(String.init) ?? "nil")
+ eventType: \(eventType.rawValue)
+ }
+ """
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Events/ActivityEvents/ActivitySnapshotEvent.swift b/sdks/community/swift/Sources/AGUICore/Events/ActivityEvents/ActivitySnapshotEvent.swift
new file mode 100644
index 0000000000..ae10365cfe
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Events/ActivityEvents/ActivitySnapshotEvent.swift
@@ -0,0 +1,144 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Event containing a snapshot of an activity message.
+///
+/// Activity events are used for streaming structured content that doesn't fit
+/// the standard text/tool paradigm, such as A2UI surfaces. The content field
+/// contains activity-type-specific data.
+///
+/// - SeeAlso: `ActivityDeltaEvent`
+public struct ActivitySnapshotEvent: AGUIEvent, Equatable, Sendable {
+
+ // MARK: - Properties
+
+ /// The identifier for this activity message.
+ ///
+ /// This ID is used to associate delta updates with this specific activity instance.
+ public let messageId: String
+
+ /// The type of activity (e.g., "a2ui-surface").
+ ///
+ /// This identifies the specific activity type and determines how the content
+ /// should be interpreted and rendered.
+ public let activityType: String
+
+ /// The activity-specific content as raw JSON data.
+ ///
+ /// This contains the activity content serialized as JSON. The structure
+ /// of the content is activity-type-specific and can be any valid JSON value.
+ ///
+ /// To access the parsed content, use `parsedContent()` or parse the data
+ /// using `JSONSerialization.jsonObject(with:)`.
+ public let content: Data
+
+ /// Whether this snapshot should replace existing content (default: `true`).
+ ///
+ /// When `true`, this snapshot replaces any previous content for this activity.
+ /// When `false`, this snapshot is merged with or appended to existing content.
+ public let replace: Bool
+
+ /// Optional timestamp when the snapshot was created.
+ ///
+ /// Represented as milliseconds since Unix epoch.
+ public let timestamp: Int64?
+
+ /// Optional raw event data as received from the agent.
+ public let rawEvent: Data?
+
+ /// The type of this event (always `.activitySnapshot`).
+ public var eventType: EventType { .activitySnapshot }
+
+ // MARK: - Initialization
+
+ /// Creates a new `ActivitySnapshotEvent`.
+ ///
+ /// - Parameters:
+ /// - messageId: The identifier for this activity message
+ /// - activityType: The type of activity (e.g., "a2ui-surface")
+ /// - content: The activity-specific content as raw JSON data
+ /// - replace: Whether this snapshot should replace existing content (default: `true`)
+ /// - timestamp: Optional timestamp in milliseconds since epoch
+ /// - rawEvent: Optional raw event data as received from the agent
+ public init(
+ messageId: String,
+ activityType: String,
+ content: Data,
+ replace: Bool = true,
+ timestamp: Int64? = nil,
+ rawEvent: Data? = nil
+ ) {
+ self.messageId = messageId
+ self.activityType = activityType
+ self.content = content
+ self.replace = replace
+ self.timestamp = timestamp
+ self.rawEvent = rawEvent
+ }
+
+ // MARK: - Convenience Methods
+
+ /// Parses the content JSON data into a Swift object.
+ ///
+ /// This method uses `JSONSerialization` because the content structure is
+ /// activity-type-specific and unknown at compile time. It can return any
+ /// valid JSON value (dictionary, array, primitive, or null).
+ ///
+ /// For type-safe parsing when you know the content structure, use
+ /// `parsedContent(as:)` instead.
+ ///
+ /// - Returns: The parsed JSON object (can be a dictionary, array, or primitive value)
+ /// - Throws: An error if the content data is not valid JSON
+ public func parsedContent() throws -> Any {
+ try JSONSerialization.jsonObject(with: content, options: .allowFragments)
+ }
+
+ /// Parses the content JSON data into a strongly-typed Swift object.
+ ///
+ /// This method uses `Codable` for type-safe decoding when you know the
+ /// expected structure of the content. Use this when you have a specific
+ /// type that conforms to `Decodable`.
+ ///
+ /// - Parameter type: The type to decode the content as (must conform to `Decodable`)
+ /// - Returns: A decoded instance of the specified type
+ /// - Throws: A `DecodingError` if the content cannot be decoded as the specified type
+ public func parsedContent(as type: T.Type, decoder: JSONDecoder = JSONDecoder()) throws -> T {
+ try decoder.decode(type, from: content)
+ }
+}
+
+// MARK: - CustomStringConvertible
+extension ActivitySnapshotEvent: CustomStringConvertible {
+ public var description: String {
+ let contentSize = content.count
+ return "ActivitySnapshotEvent(messageId: \(messageId), activityType: \(activityType), " +
+ "content: \(contentSize) bytes, replace: \(replace), " +
+ "timestamp: \(timestamp?.description ?? "nil"))"
+ }
+}
+
+// MARK: - CustomDebugStringConvertible
+extension ActivitySnapshotEvent: CustomDebugStringConvertible {
+ public var debugDescription: String {
+ let contentPreview: String
+ if let jsonString = String(data: content, encoding: .utf8) {
+ let preview = String(jsonString.prefix(100))
+ contentPreview = jsonString.count > 100 ? "\(preview)..." : preview
+ } else {
+ contentPreview = "\(content.count) bytes (invalid UTF-8)"
+ }
+
+ return """
+ ActivitySnapshotEvent {
+ messageId: "\(messageId)"
+ activityType: "\(activityType)"
+ content: \(contentPreview)
+ contentSize: \(content.count) bytes
+ replace: \(replace)
+ timestamp: \(timestamp.map(String.init) ?? "nil")
+ eventType: \(eventType.rawValue)
+ }
+ """
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Events/LifeCycleEvents/RunErrorEvent.swift b/sdks/community/swift/Sources/AGUICore/Events/LifeCycleEvents/RunErrorEvent.swift
new file mode 100644
index 0000000000..4dc029373a
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Events/LifeCycleEvents/RunErrorEvent.swift
@@ -0,0 +1,76 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Event indicating that an agent run has encountered an error.
+///
+/// This event is emitted when an agent run fails due to an unrecoverable error.
+/// It provides error details and optional error codes for debugging and handling.
+///
+/// - SeeAlso: `RunStartedEvent`, `RunFinishedEvent`
+public struct RunErrorEvent: AGUIEvent, Equatable, Hashable, Sendable {
+
+ // MARK: - Properties
+
+ /// Human-readable error message.
+ ///
+ /// Matches the `message` field in the AG-UI protocol.
+ public let message: String
+
+ /// Optional error code (e.g., "TIMEOUT", "TOOL_EXECUTION_FAILED").
+ ///
+ /// Matches the `code` field in the AG-UI protocol.
+ public let code: String?
+
+ /// Optional timestamp when the error occurred.
+ public let timestamp: Int64?
+
+ /// Optional raw event data as received from the agent.
+ public let rawEvent: Data?
+
+ /// The type of this event (always `.runError`).
+ public var eventType: EventType { .runError }
+
+ // MARK: - Initialization
+
+ /// Creates a new `RunErrorEvent`.
+ ///
+ /// - Parameters:
+ /// - message: Human-readable error description
+ /// - code: Optional error code
+ /// - timestamp: Optional timestamp in milliseconds since epoch
+ /// - rawEvent: Optional raw event data as received from the agent
+ public init(
+ message: String,
+ code: String? = nil,
+ timestamp: Int64? = nil,
+ rawEvent: Data? = nil
+ ) {
+ self.message = message
+ self.code = code
+ self.timestamp = timestamp
+ self.rawEvent = rawEvent
+ }
+}
+
+// MARK: - CustomStringConvertible
+
+extension RunErrorEvent: CustomStringConvertible {
+ public var description: String {
+ "RunErrorEvent(message: \"\(message)\", code: \(code ?? "nil"), timestamp: \(timestamp?.description ?? "nil"))"
+ }
+}
+
+// MARK: - CustomDebugStringConvertible
+extension RunErrorEvent: CustomDebugStringConvertible {
+ public var debugDescription: String {
+ """
+ RunErrorEvent {
+ message: "\(message)"
+ code: \(code ?? "nil")
+ timestamp: \(timestamp.map(String.init) ?? "nil")
+ eventType: \(eventType.rawValue)
+ }
+ """
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Events/LifeCycleEvents/RunFinishedEvent.swift b/sdks/community/swift/Sources/AGUICore/Events/LifeCycleEvents/RunFinishedEvent.swift
new file mode 100644
index 0000000000..3d9e3f2c94
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Events/LifeCycleEvents/RunFinishedEvent.swift
@@ -0,0 +1,87 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Event indicating that an agent run has finished.
+///
+/// This event is emitted when an agent has finished processing a run request.
+/// Inspect `outcome` to distinguish between a normal completion and a
+/// human-in-the-loop interrupt.
+///
+/// - SeeAlso: `RunStartedEvent`, `RunErrorEvent`, `RunFinishedOutcome`
+public struct RunFinishedEvent: AGUIEvent, Equatable, Hashable, Sendable {
+
+ // MARK: - Properties
+
+ /// The identifier for the conversation thread.
+ public let threadId: String
+
+ /// The unique identifier for the completed run.
+ public let runId: String
+
+ /// Why the run finished.
+ ///
+ /// `nil` when the `"outcome"` field was absent or `null` on the wire —
+ /// treat this the same as `.success` (legacy producer behaviour).
+ public let outcome: RunFinishedOutcome?
+
+ /// Optional run result as raw JSON.
+ ///
+ /// Corresponds to `result: z.any().optional()` in the AG-UI protocol.
+ /// Stored as opaque `Data` because the result schema is arbitrary JSON.
+ public let result: Data?
+
+ /// Optional timestamp when the run finished.
+ public let timestamp: Int64?
+
+ /// Optional raw event data as received from the agent.
+ public let rawEvent: Data?
+
+ /// The type of this event (always `.runFinished`).
+ public var eventType: EventType { .runFinished }
+
+ // MARK: - Initialization
+
+ /// Creates a new `RunFinishedEvent`.
+ ///
+ /// - Parameters:
+ /// - threadId: The conversation thread identifier
+ /// - runId: The unique run identifier
+ /// - outcome: Why the run finished (`nil` = absent/unknown, treat as success)
+ /// - result: Optional run result as raw JSON data
+ /// - timestamp: Optional timestamp in milliseconds since epoch
+ /// - rawEvent: Optional raw event data as received from the agent
+ public init(
+ threadId: String,
+ runId: String,
+ outcome: RunFinishedOutcome? = nil,
+ result: Data? = nil,
+ timestamp: Int64? = nil,
+ rawEvent: Data? = nil
+ ) {
+ self.threadId = threadId
+ self.runId = runId
+ self.outcome = outcome
+ self.result = result
+ self.timestamp = timestamp
+ self.rawEvent = rawEvent
+ }
+}
+
+// MARK: - CustomStringConvertible
+
+extension RunFinishedEvent: CustomStringConvertible {
+
+ public var description: String {
+ let outcomeDescription: String
+ switch outcome {
+ case .success:
+ outcomeDescription = "success"
+ case .interrupt(let interrupts):
+ outcomeDescription = "interrupt(\(interrupts.count) interrupt(s))"
+ case nil:
+ outcomeDescription = "nil"
+ }
+ return "RunFinishedEvent(threadId: \(threadId), runId: \(runId), outcome: \(outcomeDescription), timestamp: \(timestamp?.description ?? "nil"))"
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Events/LifeCycleEvents/RunFinishedOutcome.swift b/sdks/community/swift/Sources/AGUICore/Events/LifeCycleEvents/RunFinishedOutcome.swift
new file mode 100644
index 0000000000..65fcd37e51
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Events/LifeCycleEvents/RunFinishedOutcome.swift
@@ -0,0 +1,33 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+/// Describes why an agent run finished.
+///
+/// Carried by `RunFinishedEvent` and decoded from the `"outcome"` field in the
+/// AG-UI wire format. The field is a discriminated union object keyed on `"type"`:
+///
+/// ```json
+/// { "type": "success" }
+/// { "type": "interrupt", "interrupts": [ { "id": "...", "reason": "..." } ] }
+/// ```
+///
+/// A `nil` outcome on `RunFinishedEvent` means the field was absent or `null` —
+/// this occurs with legacy producers (e.g. the Python SDK using `model_dump()`
+/// without `exclude_none=True`) and should be treated as a normal completion.
+///
+/// - SeeAlso: `RunFinishedEvent`, `Interrupt`
+public enum RunFinishedOutcome: Equatable, Hashable, Sendable {
+
+ /// The run completed normally.
+ ///
+ /// Wire: `{ "type": "success" }`
+ case success
+
+ /// The run paused and is waiting for human input.
+ ///
+ /// Wire: `{ "type": "interrupt", "interrupts": [...] }`
+ ///
+ /// The associated array contains at least one `Interrupt` describing what
+ /// the agent is waiting for. Resume the run by sending a new `RunAgentInput`
+ /// with a `resume` array referencing each interrupt's `id`.
+ case interrupt([Interrupt])
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Events/LifeCycleEvents/RunStartedEvent.swift b/sdks/community/swift/Sources/AGUICore/Events/LifeCycleEvents/RunStartedEvent.swift
new file mode 100644
index 0000000000..70165ee4c2
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Events/LifeCycleEvents/RunStartedEvent.swift
@@ -0,0 +1,96 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Event indicating that a new agent run has started.
+///
+/// This event is emitted when an agent begins processing a new run request.
+/// It provides the thread and run identifiers that will be used throughout
+/// the execution lifecycle.
+/// - SeeAlso: `RunFinishedEvent`, `RunErrorEvent`
+public struct RunStartedEvent: AGUIEvent, Equatable, Hashable, Sendable {
+
+ // MARK: - Properties
+
+ /// The identifier for the conversation thread.
+ ///
+ /// Matches the AG-UI protocol field exactly.
+ public let threadId: String
+
+ /// The unique identifier for this specific run.
+ ///
+ /// Matches the AG-UI protocol field exactly.
+ public let runId: String
+
+ /// The parent run identifier for nested agent runs, if any.
+ ///
+ /// Corresponds to `parentRunId` in the AG-UI protocol.
+ public let parentRunId: String?
+
+ /// The initial agent input that started this run, as raw JSON.
+ ///
+ /// Corresponds to `input` (RunAgentInputSchema) in the AG-UI protocol.
+ /// Stored as opaque `Data` because the full schema (including messages)
+ /// is too complex for automatic synthesis. Use `JSONDecoder` or
+ /// `JSONSerialization` to parse the contents when needed.
+ public let input: Data?
+
+ /// Optional timestamp when the run started.
+ ///
+ /// Represented as milliseconds since Unix epoch.
+ public let timestamp: Int64?
+
+ /// Optional raw event data as received from the agent.
+ public let rawEvent: Data?
+
+ /// The type of this event (always `.runStarted`).
+ public var eventType: EventType { .runStarted }
+
+ // MARK: - Initialization
+
+ /// Creates a new `RunStartedEvent`.
+ ///
+ /// - Parameters:
+ /// - threadId: The conversation thread identifier
+ /// - runId: The unique run identifier
+ /// - parentRunId: Optional parent run identifier for nested runs
+ /// - input: Optional initial run input as raw JSON data
+ /// - timestamp: Optional timestamp in milliseconds since epoch
+ /// - rawEvent: Optional raw event data as received from the agent
+ public init(
+ threadId: String,
+ runId: String,
+ parentRunId: String? = nil,
+ input: Data? = nil,
+ timestamp: Int64? = nil,
+ rawEvent: Data? = nil
+ ) {
+ self.threadId = threadId
+ self.runId = runId
+ self.parentRunId = parentRunId
+ self.input = input
+ self.timestamp = timestamp
+ self.rawEvent = rawEvent
+ }
+}
+
+// MARK: - CustomStringConvertible
+extension RunStartedEvent: CustomStringConvertible {
+ public var description: String {
+ "RunStartedEvent(threadId: \(threadId), runId: \(runId), timestamp: \(timestamp?.description ?? "nil"))"
+ }
+}
+
+// MARK: - CustomDebugStringConvertible
+extension RunStartedEvent: CustomDebugStringConvertible {
+ public var debugDescription: String {
+ """
+ RunStartedEvent {
+ threadId: "\(threadId)"
+ runId: "\(runId)"
+ timestamp: \(timestamp.map(String.init) ?? "nil")
+ eventType: \(eventType.rawValue)
+ }
+ """
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Events/LifeCycleEvents/StepFinishedEvent.swift b/sdks/community/swift/Sources/AGUICore/Events/LifeCycleEvents/StepFinishedEvent.swift
new file mode 100644
index 0000000000..b66b91d831
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Events/LifeCycleEvents/StepFinishedEvent.swift
@@ -0,0 +1,64 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Event indicating that an execution step has completed.
+///
+/// This event marks the end of a named step in the agent's workflow.
+/// It can be used to track progress and measure step execution times.
+public struct StepFinishedEvent: AGUIEvent, Equatable, Hashable, Sendable {
+
+ // MARK: - Properties
+
+ /// The name of the step that has finished.
+ public let stepName: String
+
+ /// Optional timestamp when the step finished.
+ ///
+ /// Represented as milliseconds since Unix epoch.
+ public let timestamp: Int64?
+
+ /// Optional raw event data as received from the agent.
+ public let rawEvent: Data?
+
+ /// The type of this event (always `.stepFinished`).
+ public var eventType: EventType { .stepFinished }
+
+ // MARK: - Initialization
+
+ /// Creates a new `StepFinishedEvent`.
+ ///
+ /// - Parameters:
+ /// - stepName: The name of the step that has finished
+ /// - timestamp: Optional timestamp in milliseconds since epoch
+ /// - rawEvent: Optional raw event data as received from the agent
+ public init(
+ stepName: String,
+ timestamp: Int64? = nil,
+ rawEvent: Data? = nil
+ ) {
+ self.stepName = stepName
+ self.timestamp = timestamp
+ self.rawEvent = rawEvent
+ }
+}
+
+// MARK: - CustomStringConvertible
+extension StepFinishedEvent: CustomStringConvertible {
+ public var description: String {
+ "StepFinishedEvent(stepName: \(stepName), timestamp: \(timestamp?.description ?? "nil"))"
+ }
+}
+
+// MARK: - CustomDebugStringConvertible
+extension StepFinishedEvent: CustomDebugStringConvertible {
+ public var debugDescription: String {
+ """
+ StepFinishedEvent {
+ stepName: "\(stepName)"
+ timestamp: \(timestamp.map(String.init) ?? "nil")
+ eventType: \(eventType.rawValue)
+ }
+ """
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Events/LifeCycleEvents/StepStartedEvent.swift b/sdks/community/swift/Sources/AGUICore/Events/LifeCycleEvents/StepStartedEvent.swift
new file mode 100644
index 0000000000..63f0822a80
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Events/LifeCycleEvents/StepStartedEvent.swift
@@ -0,0 +1,65 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Event indicating that a new execution step has started.
+///
+/// Steps represent discrete phases of agent execution, such as reasoning,
+/// tool calling, or response generation. This event marks the beginning
+/// of a named step in the agent's workflow.
+public struct StepStartedEvent: AGUIEvent, Equatable, Hashable, Sendable {
+
+ // MARK: - Properties
+
+ /// The name of the step that has started.
+ public let stepName: String
+
+ /// Optional timestamp when the step started.
+ ///
+ /// Represented as milliseconds since Unix epoch.
+ public let timestamp: Int64?
+
+ /// Optional raw event data as received from the agent.
+ public let rawEvent: Data?
+
+ /// The type of this event (always `.stepStarted`).
+ public var eventType: EventType { .stepStarted }
+
+ // MARK: - Initialization
+
+ /// Creates a new `StepStartedEvent`.
+ ///
+ /// - Parameters:
+ /// - stepName: The name of the step that has started
+ /// - timestamp: Optional timestamp in milliseconds since epoch
+ /// - rawEvent: Optional raw event data as received from the agent
+ public init(
+ stepName: String,
+ timestamp: Int64? = nil,
+ rawEvent: Data? = nil
+ ) {
+ self.stepName = stepName
+ self.timestamp = timestamp
+ self.rawEvent = rawEvent
+ }
+}
+
+// MARK: - CustomStringConvertible
+extension StepStartedEvent: CustomStringConvertible {
+ public var description: String {
+ "StepStartedEvent(stepName: \(stepName), timestamp: \(timestamp?.description ?? "nil"))"
+ }
+}
+
+// MARK: - CustomDebugStringConvertible
+extension StepStartedEvent: CustomDebugStringConvertible {
+ public var debugDescription: String {
+ """
+ StepStartedEvent {
+ stepName: "\(stepName)"
+ timestamp: \(timestamp.map(String.init) ?? "nil")
+ eventType: \(eventType.rawValue)
+ }
+ """
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Events/ReasoningEvents/ReasoningEncryptedValueEvent.swift b/sdks/community/swift/Sources/AGUICore/Events/ReasoningEvents/ReasoningEncryptedValueEvent.swift
new file mode 100644
index 0000000000..1d2f0bd5b1
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Events/ReasoningEvents/ReasoningEncryptedValueEvent.swift
@@ -0,0 +1,92 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// The entity type that an encrypted reasoning value is attached to.
+///
+/// - `toolCall`: The encrypted value is attached to a tool call.
+/// - `message`: The encrypted value is attached to a message.
+public enum ReasoningEncryptedValueSubtype: String, Codable, CaseIterable, Sendable {
+ /// The encrypted value is associated with a tool call entity.
+ case toolCall = "tool-call"
+ /// The encrypted value is associated with a message entity.
+ case message
+}
+
+/// Event that attaches an encrypted reasoning value to a message or tool call.
+///
+/// `ReasoningEncryptedValueEvent` carries a cryptographic value produced by the
+/// agent's reasoning process. The `subtype` indicates whether the value is bound
+/// to a message or a tool call, and `entityId` identifies the specific entity.
+///
+/// - SeeAlso: ``ReasoningStartEvent``, ``ReasoningEndEvent``
+public struct ReasoningEncryptedValueEvent: AGUIEvent, Equatable, Hashable, Sendable {
+
+ // MARK: - Properties
+
+ /// Whether this encrypted value is associated with a tool call or a message.
+ public let subtype: ReasoningEncryptedValueSubtype
+
+ /// The identifier of the entity (message or tool call) this value is attached to.
+ public let entityId: String
+
+ /// The encrypted reasoning value.
+ public let encryptedValue: String
+
+ /// Optional timestamp when this event was received.
+ ///
+ /// Represented as milliseconds since Unix epoch.
+ public let timestamp: Int64?
+
+ /// Optional raw event data as received from the agent.
+ public let rawEvent: Data?
+
+ /// The type of this event (always `.reasoningEncryptedValue`).
+ public var eventType: EventType { .reasoningEncryptedValue }
+
+ // MARK: - Initialization
+
+ /// Creates a new `ReasoningEncryptedValueEvent`.
+ ///
+ /// - Parameters:
+ /// - subtype: Whether the encrypted value is for a tool call or a message
+ /// - entityId: The identifier of the target entity
+ /// - encryptedValue: The encrypted reasoning value string
+ /// - timestamp: Optional timestamp in milliseconds since epoch
+ /// - rawEvent: Optional raw event data as received from the agent
+ public init(
+ subtype: ReasoningEncryptedValueSubtype,
+ entityId: String,
+ encryptedValue: String,
+ timestamp: Int64? = nil,
+ rawEvent: Data? = nil
+ ) {
+ self.subtype = subtype
+ self.entityId = entityId
+ self.encryptedValue = encryptedValue
+ self.timestamp = timestamp
+ self.rawEvent = rawEvent
+ }
+}
+
+// MARK: - CustomStringConvertible
+extension ReasoningEncryptedValueEvent: CustomStringConvertible {
+ public var description: String {
+ "ReasoningEncryptedValueEvent(subtype: \(subtype.rawValue), entityId: \"\(entityId)\", timestamp: \(timestamp?.description ?? "nil"))"
+ }
+}
+
+// MARK: - CustomDebugStringConvertible
+extension ReasoningEncryptedValueEvent: CustomDebugStringConvertible {
+ public var debugDescription: String {
+ """
+ ReasoningEncryptedValueEvent {
+ subtype: \(subtype.rawValue)
+ entityId: "\(entityId)"
+ encryptedValue:
+ timestamp: \(timestamp.map(String.init) ?? "nil")
+ eventType: \(eventType.rawValue)
+ }
+ """
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Events/ReasoningEvents/ReasoningEndEvent.swift b/sdks/community/swift/Sources/AGUICore/Events/ReasoningEvents/ReasoningEndEvent.swift
new file mode 100644
index 0000000000..eeeb9f161e
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Events/ReasoningEvents/ReasoningEndEvent.swift
@@ -0,0 +1,67 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Event marking the end of a reasoning phase.
+///
+/// This event signals that the agent has completed its internal reasoning process
+/// for the message identified by `messageId`. It is the replacement for the
+/// deprecated ``ThinkingEndEvent``.
+///
+/// - SeeAlso: ``ReasoningStartEvent``, ``ReasoningMessageEndEvent``
+public struct ReasoningEndEvent: AGUIEvent, Equatable, Hashable, Sendable {
+
+ // MARK: - Properties
+
+ /// The identifier of the message whose reasoning phase has ended.
+ public let messageId: String
+
+ /// Optional timestamp when the reasoning ended.
+ ///
+ /// Represented as milliseconds since Unix epoch.
+ public let timestamp: Int64?
+
+ /// Optional raw event data as received from the agent.
+ public let rawEvent: Data?
+
+ /// The type of this event (always `.reasoningEnd`).
+ public var eventType: EventType { .reasoningEnd }
+
+ // MARK: - Initialization
+
+ /// Creates a new `ReasoningEndEvent`.
+ ///
+ /// - Parameters:
+ /// - messageId: The identifier of the message whose reasoning phase ended
+ /// - timestamp: Optional timestamp in milliseconds since epoch
+ /// - rawEvent: Optional raw event data as received from the agent
+ public init(
+ messageId: String,
+ timestamp: Int64? = nil,
+ rawEvent: Data? = nil
+ ) {
+ self.messageId = messageId
+ self.timestamp = timestamp
+ self.rawEvent = rawEvent
+ }
+}
+
+// MARK: - CustomStringConvertible
+extension ReasoningEndEvent: CustomStringConvertible {
+ public var description: String {
+ "ReasoningEndEvent(messageId: \"\(messageId)\", timestamp: \(timestamp?.description ?? "nil"))"
+ }
+}
+
+// MARK: - CustomDebugStringConvertible
+extension ReasoningEndEvent: CustomDebugStringConvertible {
+ public var debugDescription: String {
+ """
+ ReasoningEndEvent {
+ messageId: "\(messageId)"
+ timestamp: \(timestamp.map(String.init) ?? "nil")
+ eventType: \(eventType.rawValue)
+ }
+ """
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Events/ReasoningEvents/ReasoningMessageChunkEvent.swift b/sdks/community/swift/Sources/AGUICore/Events/ReasoningEvents/ReasoningMessageChunkEvent.swift
new file mode 100644
index 0000000000..d310421835
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Events/ReasoningEvents/ReasoningMessageChunkEvent.swift
@@ -0,0 +1,77 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// A convenience event that streams a chunk of reasoning message data.
+///
+/// `ReasoningMessageChunkEvent` is a combined event that can carry both a `messageId`
+/// and a `delta`, enabling servers to stream reasoning content without emitting separate
+/// start/content/end events. Both fields are optional; at least one should be non-nil
+/// for the event to be meaningful.
+///
+/// - SeeAlso: ``ReasoningMessageStartEvent``, ``ReasoningMessageContentEvent``, ``ReasoningMessageEndEvent``
+public struct ReasoningMessageChunkEvent: AGUIEvent, Equatable, Hashable, Sendable {
+
+ // MARK: - Properties
+
+ /// The optional identifier of the reasoning message this chunk belongs to.
+ public let messageId: String?
+
+ /// The optional incremental reasoning text chunk.
+ public let delta: String?
+
+ /// Optional timestamp when this chunk was received.
+ ///
+ /// Represented as milliseconds since Unix epoch.
+ public let timestamp: Int64?
+
+ /// Optional raw event data as received from the agent.
+ public let rawEvent: Data?
+
+ /// The type of this event (always `.reasoningMessageChunk`).
+ public var eventType: EventType { .reasoningMessageChunk }
+
+ // MARK: - Initialization
+
+ /// Creates a new `ReasoningMessageChunkEvent`.
+ ///
+ /// - Parameters:
+ /// - messageId: Optional identifier of the reasoning message
+ /// - delta: Optional incremental reasoning text chunk
+ /// - timestamp: Optional timestamp in milliseconds since epoch
+ /// - rawEvent: Optional raw event data as received from the agent
+ public init(
+ messageId: String? = nil,
+ delta: String? = nil,
+ timestamp: Int64? = nil,
+ rawEvent: Data? = nil
+ ) {
+ self.messageId = messageId
+ self.delta = delta
+ self.timestamp = timestamp
+ self.rawEvent = rawEvent
+ }
+}
+
+// MARK: - CustomStringConvertible
+extension ReasoningMessageChunkEvent: CustomStringConvertible {
+ public var description: String {
+ let msgId = messageId.map { "\"\($0)\"" } ?? "nil"
+ let d = delta.map { "\"\($0)\"" } ?? "nil"
+ return "ReasoningMessageChunkEvent(messageId: \(msgId), delta: \(d), timestamp: \(timestamp?.description ?? "nil"))"
+ }
+}
+
+// MARK: - CustomDebugStringConvertible
+extension ReasoningMessageChunkEvent: CustomDebugStringConvertible {
+ public var debugDescription: String {
+ """
+ ReasoningMessageChunkEvent {
+ messageId: \(messageId.map { "\"\($0)\"" } ?? "nil")
+ delta: \(delta.map { "\"\($0)\"" } ?? "nil")
+ timestamp: \(timestamp.map(String.init) ?? "nil")
+ eventType: \(eventType.rawValue)
+ }
+ """
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Events/ReasoningEvents/ReasoningMessageContentEvent.swift b/sdks/community/swift/Sources/AGUICore/Events/ReasoningEvents/ReasoningMessageContentEvent.swift
new file mode 100644
index 0000000000..c829881021
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Events/ReasoningEvents/ReasoningMessageContentEvent.swift
@@ -0,0 +1,76 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Event containing a streaming chunk of reasoning message content.
+///
+/// This event delivers an incremental piece of reasoning text during a reasoning
+/// message's lifecycle. It is the replacement for the deprecated
+/// ``ThinkingTextMessageContentEvent``.
+///
+/// - SeeAlso: ``ReasoningMessageStartEvent``, ``ReasoningMessageEndEvent``
+public struct ReasoningMessageContentEvent: AGUIEvent, Equatable, Hashable, Sendable {
+
+ // MARK: - Properties
+
+ /// The unique identifier of the reasoning message this content belongs to.
+ public let messageId: String
+
+ /// The incremental reasoning text chunk.
+ ///
+ /// Must be non-empty.
+ public let delta: String
+
+ /// Optional timestamp when this content chunk was received.
+ ///
+ /// Represented as milliseconds since Unix epoch.
+ public let timestamp: Int64?
+
+ /// Optional raw event data as received from the agent.
+ public let rawEvent: Data?
+
+ /// The type of this event (always `.reasoningMessageContent`).
+ public var eventType: EventType { .reasoningMessageContent }
+
+ // MARK: - Initialization
+
+ /// Creates a new `ReasoningMessageContentEvent`.
+ ///
+ /// - Parameters:
+ /// - messageId: The unique identifier of the reasoning message
+ /// - delta: The incremental reasoning text chunk (non-empty)
+ /// - timestamp: Optional timestamp in milliseconds since epoch
+ /// - rawEvent: Optional raw event data as received from the agent
+ public init(
+ messageId: String,
+ delta: String,
+ timestamp: Int64? = nil,
+ rawEvent: Data? = nil
+ ) {
+ self.messageId = messageId
+ self.delta = delta
+ self.timestamp = timestamp
+ self.rawEvent = rawEvent
+ }
+}
+
+// MARK: - CustomStringConvertible
+extension ReasoningMessageContentEvent: CustomStringConvertible {
+ public var description: String {
+ "ReasoningMessageContentEvent(messageId: \"\(messageId)\", delta: \"\(delta)\", timestamp: \(timestamp?.description ?? "nil"))"
+ }
+}
+
+// MARK: - CustomDebugStringConvertible
+extension ReasoningMessageContentEvent: CustomDebugStringConvertible {
+ public var debugDescription: String {
+ """
+ ReasoningMessageContentEvent {
+ messageId: "\(messageId)"
+ delta: "\(delta)"
+ timestamp: \(timestamp.map(String.init) ?? "nil")
+ eventType: \(eventType.rawValue)
+ }
+ """
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Events/ReasoningEvents/ReasoningMessageEndEvent.swift b/sdks/community/swift/Sources/AGUICore/Events/ReasoningEvents/ReasoningMessageEndEvent.swift
new file mode 100644
index 0000000000..e872ea70fb
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Events/ReasoningEvents/ReasoningMessageEndEvent.swift
@@ -0,0 +1,66 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Event indicating the end of a streaming reasoning message.
+///
+/// This event marks the completion of a reasoning message within a reasoning phase.
+/// It is the replacement for the deprecated ``ThinkingTextMessageEndEvent``.
+///
+/// - SeeAlso: ``ReasoningMessageStartEvent``, ``ReasoningMessageContentEvent``
+public struct ReasoningMessageEndEvent: AGUIEvent, Equatable, Hashable, Sendable {
+
+ // MARK: - Properties
+
+ /// The unique identifier of the reasoning message that has ended.
+ public let messageId: String
+
+ /// Optional timestamp when the reasoning message generation finished.
+ ///
+ /// Represented as milliseconds since Unix epoch.
+ public let timestamp: Int64?
+
+ /// Optional raw event data as received from the agent.
+ public let rawEvent: Data?
+
+ /// The type of this event (always `.reasoningMessageEnd`).
+ public var eventType: EventType { .reasoningMessageEnd }
+
+ // MARK: - Initialization
+
+ /// Creates a new `ReasoningMessageEndEvent`.
+ ///
+ /// - Parameters:
+ /// - messageId: The unique identifier of the reasoning message that ended
+ /// - timestamp: Optional timestamp in milliseconds since epoch
+ /// - rawEvent: Optional raw event data as received from the agent
+ public init(
+ messageId: String,
+ timestamp: Int64? = nil,
+ rawEvent: Data? = nil
+ ) {
+ self.messageId = messageId
+ self.timestamp = timestamp
+ self.rawEvent = rawEvent
+ }
+}
+
+// MARK: - CustomStringConvertible
+extension ReasoningMessageEndEvent: CustomStringConvertible {
+ public var description: String {
+ "ReasoningMessageEndEvent(messageId: \"\(messageId)\", timestamp: \(timestamp?.description ?? "nil"))"
+ }
+}
+
+// MARK: - CustomDebugStringConvertible
+extension ReasoningMessageEndEvent: CustomDebugStringConvertible {
+ public var debugDescription: String {
+ """
+ ReasoningMessageEndEvent {
+ messageId: "\(messageId)"
+ timestamp: \(timestamp.map(String.init) ?? "nil")
+ eventType: \(eventType.rawValue)
+ }
+ """
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Events/ReasoningEvents/ReasoningMessageStartEvent.swift b/sdks/community/swift/Sources/AGUICore/Events/ReasoningEvents/ReasoningMessageStartEvent.swift
new file mode 100644
index 0000000000..191165dd57
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Events/ReasoningEvents/ReasoningMessageStartEvent.swift
@@ -0,0 +1,74 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Event indicating the start of a streaming reasoning message.
+///
+/// This event marks the beginning of a reasoning message within a reasoning phase.
+/// The `role` field will always be `"reasoning"`. It is the replacement for the
+/// deprecated ``ThinkingTextMessageStartEvent``.
+///
+/// - SeeAlso: ``ReasoningMessageContentEvent``, ``ReasoningMessageEndEvent``
+public struct ReasoningMessageStartEvent: AGUIEvent, Equatable, Hashable, Sendable {
+
+ // MARK: - Properties
+
+ /// The unique identifier of the reasoning message.
+ public let messageId: String
+
+ /// The role of this message. Always `"reasoning"`.
+ public let role: String
+
+ /// Optional timestamp when the reasoning message generation started.
+ ///
+ /// Represented as milliseconds since Unix epoch.
+ public let timestamp: Int64?
+
+ /// Optional raw event data as received from the agent.
+ public let rawEvent: Data?
+
+ /// The type of this event (always `.reasoningMessageStart`).
+ public var eventType: EventType { .reasoningMessageStart }
+
+ // MARK: - Initialization
+
+ /// Creates a new `ReasoningMessageStartEvent`.
+ ///
+ /// - Parameters:
+ /// - messageId: The unique identifier of the reasoning message
+ /// - role: The message role (always `"reasoning"`)
+ /// - timestamp: Optional timestamp in milliseconds since epoch
+ /// - rawEvent: Optional raw event data as received from the agent
+ public init(
+ messageId: String,
+ role: String = "reasoning",
+ timestamp: Int64? = nil,
+ rawEvent: Data? = nil
+ ) {
+ self.messageId = messageId
+ self.role = role
+ self.timestamp = timestamp
+ self.rawEvent = rawEvent
+ }
+}
+
+// MARK: - CustomStringConvertible
+extension ReasoningMessageStartEvent: CustomStringConvertible {
+ public var description: String {
+ "ReasoningMessageStartEvent(messageId: \"\(messageId)\", role: \"\(role)\", timestamp: \(timestamp?.description ?? "nil"))"
+ }
+}
+
+// MARK: - CustomDebugStringConvertible
+extension ReasoningMessageStartEvent: CustomDebugStringConvertible {
+ public var debugDescription: String {
+ """
+ ReasoningMessageStartEvent {
+ messageId: "\(messageId)"
+ role: "\(role)"
+ timestamp: \(timestamp.map(String.init) ?? "nil")
+ eventType: \(eventType.rawValue)
+ }
+ """
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Events/ReasoningEvents/ReasoningStartEvent.swift b/sdks/community/swift/Sources/AGUICore/Events/ReasoningEvents/ReasoningStartEvent.swift
new file mode 100644
index 0000000000..eabe87a481
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Events/ReasoningEvents/ReasoningStartEvent.swift
@@ -0,0 +1,67 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Event marking the start of a reasoning phase.
+///
+/// This event signals that the agent has begun its internal reasoning process
+/// for the message identified by `messageId`. It is the replacement for the
+/// deprecated ``ThinkingStartEvent``.
+///
+/// - SeeAlso: ``ReasoningEndEvent``, ``ReasoningMessageStartEvent``
+public struct ReasoningStartEvent: AGUIEvent, Equatable, Hashable, Sendable {
+
+ // MARK: - Properties
+
+ /// The identifier of the message this reasoning phase is associated with.
+ public let messageId: String
+
+ /// Optional timestamp when the reasoning started.
+ ///
+ /// Represented as milliseconds since Unix epoch.
+ public let timestamp: Int64?
+
+ /// Optional raw event data as received from the agent.
+ public let rawEvent: Data?
+
+ /// The type of this event (always `.reasoningStart`).
+ public var eventType: EventType { .reasoningStart }
+
+ // MARK: - Initialization
+
+ /// Creates a new `ReasoningStartEvent`.
+ ///
+ /// - Parameters:
+ /// - messageId: The identifier of the message this reasoning is associated with
+ /// - timestamp: Optional timestamp in milliseconds since epoch
+ /// - rawEvent: Optional raw event data as received from the agent
+ public init(
+ messageId: String,
+ timestamp: Int64? = nil,
+ rawEvent: Data? = nil
+ ) {
+ self.messageId = messageId
+ self.timestamp = timestamp
+ self.rawEvent = rawEvent
+ }
+}
+
+// MARK: - CustomStringConvertible
+extension ReasoningStartEvent: CustomStringConvertible {
+ public var description: String {
+ "ReasoningStartEvent(messageId: \"\(messageId)\", timestamp: \(timestamp?.description ?? "nil"))"
+ }
+}
+
+// MARK: - CustomDebugStringConvertible
+extension ReasoningStartEvent: CustomDebugStringConvertible {
+ public var debugDescription: String {
+ """
+ ReasoningStartEvent {
+ messageId: "\(messageId)"
+ timestamp: \(timestamp.map(String.init) ?? "nil")
+ eventType: \(eventType.rawValue)
+ }
+ """
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Events/SpecialEvents/CustomEvent.swift b/sdks/community/swift/Sources/AGUICore/Events/SpecialEvents/CustomEvent.swift
new file mode 100644
index 0000000000..318b5928cb
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Events/SpecialEvents/CustomEvent.swift
@@ -0,0 +1,150 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Event for custom, application-specific event types.
+///
+/// This event type allows applications to define and use custom event types
+/// beyond the standard AG-UI protocol events. Each custom event has a unique
+/// type identifier and arbitrary JSON data payload.
+///
+/// Use this event type when:
+/// - Implementing application-specific events not covered by the standard protocol
+/// - Extending the protocol with custom behavior
+/// - Handling domain-specific events (e.g., "com.example.analytics.pageView")
+///
+/// Custom event type identifiers typically follow reverse-domain notation
+/// (e.g., "com.myapp.analytics.pageView", "org.example.userAction.buttonClick")
+/// to ensure uniqueness across applications.
+///
+/// - SeeAlso: `RawEvent`, `UnknownEvent`
+public struct CustomEvent: AGUIEvent, Equatable, Sendable {
+
+ // MARK: - Properties
+
+ /// The custom event name, matching the AG-UI protocol `name` wire field.
+ ///
+ /// This string uniquely identifies the type of custom event. It's recommended
+ /// to use reverse-domain notation for globally unique identifiers.
+ ///
+ /// Examples:
+ /// - "com.example.userAction"
+ /// - "org.myapp.analytics.pageView"
+ /// - "simple.message"
+ public let name: String
+
+ /// The custom event payload as raw JSON, matching the AG-UI protocol `value` wire field.
+ ///
+ /// This contains the event-specific payload in JSON format. The structure
+ /// is determined by the custom event type and can be any valid JSON value
+ /// (object, array, primitive, or null).
+ ///
+ /// To access the parsed payload, use `parsedData()` or parse the data
+ /// using `JSONSerialization.jsonObject(with:)`.
+ public let value: Data
+
+ /// Optional timestamp when the event was created.
+ ///
+ /// Represented as milliseconds since Unix epoch.
+ public let timestamp: Int64?
+
+ /// Optional raw event data as received from the agent.
+ public let rawEvent: Data?
+
+ /// The type of this event (always `.custom`).
+ public var eventType: EventType { .custom }
+
+ // MARK: - Initialization
+
+ /// Creates a new `CustomEvent`.
+ ///
+ /// - Parameters:
+ /// - name: The custom event name (e.g., "com.example.userAction"), matching the AG-UI `name` wire field
+ /// - value: The custom event payload as JSON bytes, matching the AG-UI `value` wire field
+ /// - timestamp: Optional timestamp in milliseconds since epoch
+ /// - rawEvent: Optional raw event data as received from the agent
+ public init(
+ name: String,
+ value: Data,
+ timestamp: Int64? = nil,
+ rawEvent: Data? = nil
+ ) {
+ self.name = name
+ self.value = value
+ self.timestamp = timestamp
+ self.rawEvent = rawEvent
+ }
+
+ // MARK: - Convenience Methods
+
+ /// Parses the custom data JSON into a Swift object.
+ ///
+ /// This method uses `JSONSerialization` because the data structure is
+ /// custom and unknown at compile time. It can return any valid JSON value
+ /// (object, array, primitive, or null).
+ ///
+ /// For type-safe parsing when you know the data structure, use
+ /// `parsedData(as:)` instead.
+ ///
+ /// - Returns: The parsed JSON object
+ /// - Throws: An error if the data is not valid JSON
+ public func parsedData() throws -> Any {
+ try JSONSerialization.jsonObject(with: value, options: .allowFragments)
+ }
+
+ /// Parses the custom data JSON into a strongly-typed Swift object.
+ ///
+ /// This method uses `Codable` for type-safe decoding when you know the
+ /// expected structure of the custom data. Use this when you have a specific
+ /// type that conforms to `Decodable` for your custom event.
+ ///
+ /// - Parameter type: The type to decode the data as (must conform to `Decodable`)
+ /// - Returns: A decoded instance of the specified type
+ /// - Throws: A `DecodingError` if the data cannot be decoded as the specified type
+ ///
+ /// Example:
+ /// ```swift
+ /// struct UserActionPayload: Decodable {
+ /// let action: String
+ /// let userId: Int
+ /// }
+ ///
+ /// let payload = try event.parsedData(as: UserActionPayload.self)
+ /// print("User \(payload.userId) performed: \(payload.action)")
+ /// ```
+ public func parsedData(as type: T.Type, decoder: JSONDecoder = JSONDecoder()) throws -> T {
+ try decoder.decode(type, from: value)
+ }
+}
+
+// MARK: - CustomStringConvertible
+extension CustomEvent: CustomStringConvertible {
+ public var description: String {
+ let valueSize = value.count
+ let timestampDesc = timestamp?.description ?? "nil"
+ return "CustomEvent(name: \"\(name)\", value: \(valueSize) bytes, timestamp: \(timestampDesc))"
+ }
+}
+
+// MARK: - CustomDebugStringConvertible
+extension CustomEvent: CustomDebugStringConvertible {
+ public var debugDescription: String {
+ let valuePreview: String
+ if let jsonString = String(data: value, encoding: .utf8) {
+ let preview = String(jsonString.prefix(100))
+ valuePreview = jsonString.count > 100 ? "\(preview)..." : preview
+ } else {
+ valuePreview = "\(value.count) bytes (invalid UTF-8)"
+ }
+
+ return """
+ CustomEvent {
+ name: "\(name)"
+ value: \(valuePreview)
+ valueSize: \(value.count) bytes
+ timestamp: \(timestamp.map(String.init) ?? "nil")
+ eventType: \(eventType.rawValue)
+ }
+ """
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Events/SpecialEvents/RawEvent.swift b/sdks/community/swift/Sources/AGUICore/Events/SpecialEvents/RawEvent.swift
new file mode 100644
index 0000000000..aa64e6bc02
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Events/SpecialEvents/RawEvent.swift
@@ -0,0 +1,138 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Event containing raw, unprocessed event data.
+///
+/// This event type serves as a container for raw event data that doesn't fit into
+/// any of the standard event types. The data is stored as-is without interpretation,
+/// allowing applications to handle custom or unknown event structures.
+///
+/// Use this event type when:
+/// - Receiving events that don't match any standard event types
+/// - Implementing custom event handling logic
+/// - Preserving raw event data for debugging or logging purposes
+///
+/// - SeeAlso: `CustomEvent`, `UnknownEvent`
+public struct RawEvent: AGUIEvent, Equatable, Sendable {
+
+ // MARK: - Properties
+
+ /// The raw event data as received.
+ ///
+ /// This contains the unprocessed event data in its original form as JSON.
+ /// The structure can be any valid JSON value (object, array, primitive, or null).
+ /// Corresponds to the `event` field in the AG-UI protocol wire format.
+ ///
+ /// To access the parsed data, use `parsedData()` or parse the data
+ /// using `JSONSerialization.jsonObject(with:)`.
+ public let data: Data
+
+ /// Optional source identifier for the raw event.
+ ///
+ /// Corresponds to the `source` field in the AG-UI protocol.
+ public let source: String?
+
+ /// Optional timestamp when the event was created.
+ ///
+ /// Represented as milliseconds since Unix epoch.
+ public let timestamp: Int64?
+
+ /// Optional raw event data as received from the agent.
+ public let rawEvent: Data?
+
+ /// The type of this event (always `.raw`).
+ public var eventType: EventType { .raw }
+
+ // MARK: - Initialization
+
+ /// Creates a new `RawEvent`.
+ ///
+ /// - Parameters:
+ /// - data: The raw event data as JSON bytes (wire field: `event`)
+ /// - source: Optional source identifier (wire field: `source`)
+ /// - timestamp: Optional timestamp in milliseconds since epoch
+ /// - rawEvent: Optional raw event data as received from the agent
+ public init(
+ data: Data,
+ source: String? = nil,
+ timestamp: Int64? = nil,
+ rawEvent: Data? = nil
+ ) {
+ self.data = data
+ self.source = source
+ self.timestamp = timestamp
+ self.rawEvent = rawEvent
+ }
+
+ // MARK: - Convenience Methods
+
+ /// Parses the raw data JSON into a Swift object.
+ ///
+ /// This method uses `JSONSerialization` because the data structure is
+ /// unknown at compile time. It can return any valid JSON value (object,
+ /// array, primitive, or null).
+ ///
+ /// For type-safe parsing when you know the data structure, use
+ /// `parsedData(as:)` instead.
+ ///
+ /// - Returns: The parsed JSON object
+ /// - Throws: An error if the data is not valid JSON
+ public func parsedData() throws -> Any {
+ try JSONSerialization.jsonObject(with: data, options: .allowFragments)
+ }
+
+ /// Parses the raw data JSON into a strongly-typed Swift object.
+ ///
+ /// This method uses `Codable` for type-safe decoding when you know the
+ /// expected structure of the data. Use this when you have a specific
+ /// type that conforms to `Decodable`.
+ ///
+ /// - Parameter type: The type to decode the data as (must conform to `Decodable`)
+ /// - Returns: A decoded instance of the specified type
+ /// - Throws: A `DecodingError` if the data cannot be decoded as the specified type
+ ///
+ /// Example:
+ /// ```swift
+ /// struct CustomData: Decodable {
+ /// let field1: String
+ /// let field2: Int
+ /// }
+ ///
+ /// let data = try event.parsedData(as: CustomData.self)
+ /// print("Field1: \(data.field1), Field2: \(data.field2)")
+ /// ```
+ public func parsedData(as type: T.Type, decoder: JSONDecoder = JSONDecoder()) throws -> T {
+ try decoder.decode(type, from: data)
+ }
+}
+
+// MARK: - CustomStringConvertible
+extension RawEvent: CustomStringConvertible {
+ public var description: String {
+ let dataSize = data.count
+ return "RawEvent(data: \(dataSize) bytes, timestamp: \(timestamp?.description ?? "nil"))"
+ }
+}
+
+// MARK: - CustomDebugStringConvertible
+extension RawEvent: CustomDebugStringConvertible {
+ public var debugDescription: String {
+ let dataPreview: String
+ if let jsonString = String(data: data, encoding: .utf8) {
+ let preview = String(jsonString.prefix(100))
+ dataPreview = jsonString.count > 100 ? "\(preview)..." : preview
+ } else {
+ dataPreview = "\(data.count) bytes (invalid UTF-8)"
+ }
+
+ return """
+ RawEvent {
+ data: \(dataPreview)
+ dataSize: \(data.count) bytes
+ timestamp: \(timestamp.map(String.init) ?? "nil")
+ eventType: \(eventType.rawValue)
+ }
+ """
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Events/StateEvents/MessagesSnapshotEvent.swift b/sdks/community/swift/Sources/AGUICore/Events/StateEvents/MessagesSnapshotEvent.swift
new file mode 100644
index 0000000000..921feda4a2
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Events/StateEvents/MessagesSnapshotEvent.swift
@@ -0,0 +1,72 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Event containing a complete snapshot of the conversation messages.
+///
+/// This event provides a full messages snapshot containing the entire conversation
+/// history at a point in time. Messages are decoded at receive time into strongly-typed
+/// `Message` values, so callers can use them directly without further parsing.
+///
+/// - SeeAlso: `StateSnapshotEvent`, `StateDeltaEvent`
+public struct MessagesSnapshotEvent: AGUIEvent, Sendable {
+
+ // MARK: - Properties
+
+ /// The decoded conversation messages.
+ ///
+ /// Messages are decoded during event parsing using `MessageDecoder`, providing
+ /// type-safe access without requiring callers to parse raw JSON.
+ public let messages: [any Message]
+
+ /// Optional timestamp when the messages snapshot was captured.
+ ///
+ /// Represented as milliseconds since Unix epoch.
+ public let timestamp: Int64?
+
+ /// Optional raw event data as received from the agent.
+ public let rawEvent: Data?
+
+ /// The type of this event (always `.messagesSnapshot`).
+ public var eventType: EventType { .messagesSnapshot }
+
+ // MARK: - Initialization
+
+ /// Creates a new `MessagesSnapshotEvent`.
+ ///
+ /// - Parameters:
+ /// - messages: The decoded conversation messages
+ /// - timestamp: Optional timestamp in milliseconds since epoch
+ /// - rawEvent: Optional raw event data as received from the agent
+ public init(
+ messages: [any Message],
+ timestamp: Int64? = nil,
+ rawEvent: Data? = nil
+ ) {
+ self.messages = messages
+ self.timestamp = timestamp
+ self.rawEvent = rawEvent
+ }
+}
+
+// MARK: - CustomStringConvertible
+extension MessagesSnapshotEvent: CustomStringConvertible {
+ public var description: String {
+ "MessagesSnapshotEvent(messages: \(messages.count) messages, timestamp: \(timestamp?.description ?? "nil"))"
+ }
+}
+
+// MARK: - CustomDebugStringConvertible
+extension MessagesSnapshotEvent: CustomDebugStringConvertible {
+ public var debugDescription: String {
+ let preview = messages.prefix(3).map { "\($0.role.rawValue):\($0.id)" }.joined(separator: ", ")
+ let suffix = messages.count > 3 ? ", …" : ""
+ return """
+ MessagesSnapshotEvent {
+ messages: [\(preview)\(suffix)] (\(messages.count) total)
+ timestamp: \(timestamp.map(String.init) ?? "nil")
+ eventType: \(eventType.rawValue)
+ }
+ """
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Events/StateEvents/StateDeltaEvent.swift b/sdks/community/swift/Sources/AGUICore/Events/StateEvents/StateDeltaEvent.swift
new file mode 100644
index 0000000000..5abd100f10
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Events/StateEvents/StateDeltaEvent.swift
@@ -0,0 +1,133 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Event containing incremental state updates using JSON Patch format (RFC 6902).
+///
+/// This event provides bandwidth-efficient state updates by sending only the changes
+/// (delta) rather than the complete state. The delta is an array of JSON Patch operations
+/// that can be applied to the current state to produce the new state.
+///
+/// JSON Patch operations include: `add`, `remove`, `replace`, `move`, `copy`, and `test`.
+///
+/// - SeeAlso: `StateSnapshotEvent`, `MessagesSnapshotEvent`
+/// - SeeAlso: [RFC 6902 - JSON Patch](https://tools.ietf.org/html/rfc6902)
+public struct StateDeltaEvent: AGUIEvent, Equatable, Sendable {
+
+ // MARK: - Properties
+
+ /// The JSON Patch operations as raw JSON data.
+ ///
+ /// This contains an array of JSON Patch operations serialized as JSON. Each operation
+ /// follows the RFC 6902 format with fields like `op`, `path`, `value`, `from`, etc.
+ ///
+ /// To access the parsed operations, use `parsedDelta()` or parse the data
+ /// using `JSONSerialization.jsonObject(with:)`.
+ public let delta: Data
+
+ /// Optional timestamp when the state delta was captured.
+ ///
+ /// Represented as milliseconds since Unix epoch.
+ public let timestamp: Int64?
+
+ /// Optional raw event data as received from the agent.
+ public let rawEvent: Data?
+
+ /// The type of this event (always `.stateDelta`).
+ public var eventType: EventType { .stateDelta }
+
+ // MARK: - Initialization
+
+ /// Creates a new `StateDeltaEvent`.
+ ///
+ /// - Parameters:
+ /// - delta: The JSON Patch operations as raw JSON data
+ /// - timestamp: Optional timestamp in milliseconds since epoch
+ /// - rawEvent: Optional raw event data as received from the agent
+ public init(
+ delta: Data,
+ timestamp: Int64? = nil,
+ rawEvent: Data? = nil
+ ) {
+ self.delta = delta
+ self.timestamp = timestamp
+ self.rawEvent = rawEvent
+ }
+
+ // MARK: - Convenience Methods
+
+ /// Parses the delta JSON data into a Swift array of patch operations.
+ ///
+ /// This method uses `JSONSerialization` to parse the delta as an array of
+ /// JSON Patch operations. Each operation is typically a dictionary with fields
+ /// like `op`, `path`, `value`, `from`, etc.
+ ///
+ /// For type-safe parsing when you know the structure, use `parsedDelta(as:)` instead.
+ ///
+ /// - Returns: An array of patch operations (typically `[[String: Any]]`)
+ /// - Throws: An error if the delta data is not valid JSON or not an array
+ public func parsedDelta() throws -> [Any] {
+ let parsed = try JSONSerialization.jsonObject(with: delta, options: [])
+ guard let array = parsed as? [Any] else {
+ throw DecodingError.typeMismatch(
+ [Any].self,
+ DecodingError.Context(codingPath: [], debugDescription: "Delta must be a JSON array")
+ )
+ }
+ return array
+ }
+
+ /// Parses the delta JSON data into a strongly-typed Swift array.
+ ///
+ /// This method uses `Codable` for type-safe decoding when you know the
+ /// structure of the JSON Patch operations. Use this when you have a specific
+ /// type that conforms to `Decodable`.
+ ///
+ /// - Parameter type: The element type to decode the delta array as (must conform to `Decodable`)
+ /// - Returns: An array of decoded instances of the specified type
+ /// - Throws: A `DecodingError` if the delta cannot be decoded as the specified type
+ ///
+ /// Example:
+ /// ```swift
+ /// struct PatchOperation: Decodable {
+ /// let op: String
+ /// let path: String
+ /// let value: AnyCodable?
+ /// }
+ ///
+ /// let operations = try event.parsedDelta(as: PatchOperation.self)
+ /// ```
+ public func parsedDelta(as type: T.Type, decoder: JSONDecoder = JSONDecoder()) throws -> [T] {
+ try decoder.decode([T].self, from: delta)
+ }
+}
+
+// MARK: - CustomStringConvertible
+extension StateDeltaEvent: CustomStringConvertible {
+ public var description: String {
+ let deltaSize = delta.count
+ return "StateDeltaEvent(delta: \(deltaSize) bytes, timestamp: \(timestamp?.description ?? "nil"))"
+ }
+}
+
+// MARK: - CustomDebugStringConvertible
+extension StateDeltaEvent: CustomDebugStringConvertible {
+ public var debugDescription: String {
+ let deltaPreview: String
+ if let jsonString = String(data: delta, encoding: .utf8) {
+ let preview = String(jsonString.prefix(100))
+ deltaPreview = jsonString.count > 100 ? "\(preview)..." : preview
+ } else {
+ deltaPreview = "\(delta.count) bytes (invalid UTF-8)"
+ }
+
+ return """
+ StateDeltaEvent {
+ delta: \(deltaPreview)
+ deltaSize: \(delta.count) bytes
+ timestamp: \(timestamp.map(String.init) ?? "nil")
+ eventType: \(eventType.rawValue)
+ }
+ """
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Events/StateEvents/StateSnapshotEvent.swift b/sdks/community/swift/Sources/AGUICore/Events/StateEvents/StateSnapshotEvent.swift
new file mode 100644
index 0000000000..0ef7bf282e
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Events/StateEvents/StateSnapshotEvent.swift
@@ -0,0 +1,125 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Event containing a complete snapshot of the application state.
+///
+/// This event provides a full state snapshot that can be used to initialize
+/// or reset the application state. The snapshot is stored as raw JSON data
+/// to preserve its exact structure and allow for flexible state schemas.
+///
+/// - SeeAlso: `StateDeltaEvent`, `MessagesSnapshotEvent`
+public struct StateSnapshotEvent: AGUIEvent, Equatable, Sendable {
+
+ // MARK: - Properties
+
+ /// The complete state snapshot as raw JSON data.
+ ///
+ /// This contains the full state object serialized as JSON. The structure
+ /// of the state is application-specific and can be any valid JSON value
+ /// (object, array, primitive, etc.).
+ ///
+ /// To access the parsed state, use `parsedSnapshot()` or parse the data
+ /// using `JSONSerialization.jsonObject(with:)`.
+ public let snapshot: Data
+
+ /// Optional timestamp when the state snapshot was captured.
+ ///
+ /// Represented as milliseconds since Unix epoch.
+ public let timestamp: Int64?
+
+ /// Optional raw event data as received from the agent.
+ public let rawEvent: Data?
+
+ /// The type of this event (always `.stateSnapshot`).
+ public var eventType: EventType { .stateSnapshot }
+
+ // MARK: - Initialization
+
+ /// Creates a new `StateSnapshotEvent`.
+ ///
+ /// - Parameters:
+ /// - snapshot: The complete state snapshot as raw JSON data
+ /// - timestamp: Optional timestamp in milliseconds since epoch
+ /// - rawEvent: Optional raw event data as received from the agent
+ public init(
+ snapshot: Data,
+ timestamp: Int64? = nil,
+ rawEvent: Data? = nil
+ ) {
+ self.snapshot = snapshot
+ self.timestamp = timestamp
+ self.rawEvent = rawEvent
+ }
+
+ // MARK: - Convenience Methods
+
+ /// Parses the snapshot JSON data into a Swift object.
+ ///
+ /// This method uses `JSONSerialization` because the snapshot structure is
+ /// application-specific and unknown at compile time. It can return any
+ /// valid JSON value (dictionary, array, primitive, or null).
+ ///
+ /// For type-safe parsing when you know the snapshot structure, use
+ /// `parsedSnapshot(as:)` instead.
+ ///
+ /// - Returns: The parsed JSON object (can be a dictionary, array, or primitive value)
+ /// - Throws: An error if the snapshot data is not valid JSON
+ public func parsedSnapshot() throws -> Any {
+ try JSONSerialization.jsonObject(with: snapshot, options: .allowFragments)
+ }
+
+ /// Parses the snapshot JSON data into a strongly-typed Swift object.
+ ///
+ /// This method uses `Codable` for type-safe decoding when you know the
+ /// expected structure of the snapshot. Use this when you have a specific
+ /// type that conforms to `Decodable`.
+ ///
+ /// - Parameter type: The type to decode the snapshot as (must conform to `Decodable`)
+ /// - Returns: A decoded instance of the specified type
+ /// - Throws: A `DecodingError` if the snapshot cannot be decoded as the specified type
+ ///
+ /// Example:
+ /// ```swift
+ /// struct AppState: Decodable {
+ /// let users: [String]
+ /// let count: Int
+ /// }
+ ///
+ /// let state = try event.parsedSnapshot(as: AppState.self)
+ /// print(state.users)
+ /// ```
+ public func parsedSnapshot(as type: T.Type, decoder: JSONDecoder = JSONDecoder()) throws -> T {
+ try decoder.decode(type, from: snapshot)
+ }
+}
+
+// MARK: - CustomStringConvertible
+extension StateSnapshotEvent: CustomStringConvertible {
+ public var description: String {
+ let snapshotSize = snapshot.count
+ return "StateSnapshotEvent(snapshot: \(snapshotSize) bytes, timestamp: \(timestamp?.description ?? "nil"))"
+ }
+}
+
+// MARK: - CustomDebugStringConvertible
+extension StateSnapshotEvent: CustomDebugStringConvertible {
+ public var debugDescription: String {
+ let snapshotPreview: String
+ if let jsonString = String(data: snapshot, encoding: .utf8) {
+ let preview = String(jsonString.prefix(100))
+ snapshotPreview = jsonString.count > 100 ? "\(preview)..." : preview
+ } else {
+ snapshotPreview = "\(snapshot.count) bytes (invalid UTF-8)"
+ }
+
+ return """
+ StateSnapshotEvent {
+ snapshot: \(snapshotPreview)
+ snapshotSize: \(snapshot.count) bytes
+ timestamp: \(timestamp.map(String.init) ?? "nil")
+ eventType: \(eventType.rawValue)
+ }
+ """
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Events/TextMessageEvents/TextMessageChunkEvent.swift b/sdks/community/swift/Sources/AGUICore/Events/TextMessageEvents/TextMessageChunkEvent.swift
new file mode 100644
index 0000000000..fd327eba5e
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Events/TextMessageEvents/TextMessageChunkEvent.swift
@@ -0,0 +1,116 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Chunk event for streaming text message content.
+///
+/// This event represents a single chunk of streaming text message content.
+/// Unlike `TextMessageContentEvent` which requires an active text message sequence,
+/// `TextMessageChunkEvent` can automatically start and end text message sequences
+/// when no text message is currently active.
+///
+/// - SeeAlso: `TextMessageStartEvent`, `TextMessageContentEvent`, `TextMessageEndEvent`
+public struct TextMessageChunkEvent: AGUIEvent, Equatable, Hashable, Sendable {
+
+ // MARK: - Properties
+
+ /// The identifier for the message (required for the first chunk).
+ ///
+ /// This ID is used to associate chunks with a specific message instance.
+ /// It may be `nil` for subsequent chunks if the message ID was already established.
+ public let messageId: String?
+
+ /// The role of the message sender (optional, typically "assistant").
+ ///
+ /// This field is typically only present in the first chunk of a message.
+ public let role: String?
+
+ /// Optional display name for the message sender.
+ ///
+ /// Corresponds to the `name` field in the AG-UI TypeScript spec (events.ts:94).
+ public let name: String?
+
+ /// The text content delta for this chunk.
+ ///
+ /// This contains the new text to append to the message. May be `nil` if
+ /// this chunk only provides metadata (messageId, role) without content.
+ public let delta: String?
+
+ /// Optional timestamp when the chunk was generated.
+ ///
+ /// Represented as milliseconds since Unix epoch.
+ public let timestamp: Int64?
+
+ /// Optional raw event data as received from the agent.
+ public let rawEvent: Data?
+
+ /// The type of this event (always `.textMessageChunk`).
+ public var eventType: EventType { .textMessageChunk }
+
+ // MARK: - Initialization
+
+ /// Creates a new `TextMessageChunkEvent`.
+ ///
+ /// - Parameters:
+ /// - messageId: Optional identifier for the message (required for the first chunk)
+ /// - role: Optional role of the message sender (typically "assistant")
+ /// - name: Optional display name for the message sender
+ /// - delta: Optional text content delta for this chunk
+ /// - timestamp: Optional timestamp in milliseconds since epoch
+ /// - rawEvent: Optional raw event data as received from the agent
+ public init(
+ messageId: String? = nil,
+ role: String? = nil,
+ name: String? = nil,
+ delta: String? = nil,
+ timestamp: Int64? = nil,
+ rawEvent: Data? = nil
+ ) {
+ self.messageId = messageId
+ self.role = role
+ self.name = name
+ self.delta = delta
+ self.timestamp = timestamp
+ self.rawEvent = rawEvent
+ }
+}
+
+// MARK: - CustomStringConvertible
+extension TextMessageChunkEvent: CustomStringConvertible {
+ public var description: String {
+ var parts: [String] = []
+ if let messageId = messageId {
+ parts.append("messageId: \(messageId)")
+ }
+ if let role = role {
+ parts.append("role: \(role)")
+ }
+ if let name = name {
+ parts.append("name: \(name)")
+ }
+ if let delta = delta {
+ let deltaPreview = delta.count > 50 ? String(delta.prefix(50)) + "..." : delta
+ parts.append("delta: \"\(deltaPreview)\"")
+ }
+ if let timestamp = timestamp {
+ parts.append("timestamp: \(timestamp)")
+ }
+ return "TextMessageChunkEvent(\(parts.joined(separator: ", ")))"
+ }
+}
+
+// MARK: - CustomDebugStringConvertible
+extension TextMessageChunkEvent: CustomDebugStringConvertible {
+ public var debugDescription: String {
+ """
+ TextMessageChunkEvent {
+ messageId: \(messageId.map { "\"\($0)\"" } ?? "nil")
+ role: \(role.map { "\"\($0)\"" } ?? "nil")
+ name: \(name.map { "\"\($0)\"" } ?? "nil")
+ delta: \(delta.map { "\"\($0)\"" } ?? "nil")
+ timestamp: \(timestamp.map(String.init) ?? "nil")
+ eventType: \(eventType.rawValue)
+ }
+ """
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Events/TextMessageEvents/TextMessageContentEvent.swift b/sdks/community/swift/Sources/AGUICore/Events/TextMessageEvents/TextMessageContentEvent.swift
new file mode 100644
index 0000000000..b09bf07de0
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Events/TextMessageEvents/TextMessageContentEvent.swift
@@ -0,0 +1,111 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Event containing a chunk of text content for a message.
+///
+/// This event is emitted multiple times during message generation, each time
+/// containing a delta (incremental change) of text content. The delta must
+/// be a non-empty string. These events are associated with a message via the
+/// `messageId` field, which matches the ID from the corresponding
+/// `TextMessageStartEvent`.
+///
+/// - SeeAlso: `TextMessageStartEvent`, `TextMessageEndEvent`, `TextMessageChunkEvent`
+public struct TextMessageContentEvent: AGUIEvent, Equatable, Hashable, Sendable {
+
+ // MARK: - Properties
+
+ /// The unique identifier for this message.
+ ///
+ /// This ID matches the `messageId` from the corresponding `TextMessageStartEvent`
+ /// and is used to associate this content chunk with the correct message.
+ public let messageId: String
+
+ /// The text content delta (incremental change).
+ ///
+ /// This is a non-empty string containing a chunk of the message content.
+ /// Multiple `TextMessageContentEvent` instances with the same `messageId`
+ /// represent the complete message when concatenated.
+ public let delta: String
+
+ /// Optional timestamp when this content chunk was received.
+ ///
+ /// Represented as milliseconds since Unix epoch.
+ public let timestamp: Int64?
+
+ /// Optional raw event data as received from the agent.
+ public let rawEvent: Data?
+
+ /// The type of this event (always `.textMessageContent`).
+ public var eventType: EventType { .textMessageContent }
+
+ // MARK: - Initialization
+
+ /// Creates a new `TextMessageContentEvent`.
+ ///
+ /// - Parameters:
+ /// - messageId: The unique identifier for this message (matches TextMessageStartEvent)
+ /// - delta: The text content delta (must be non-empty)
+ /// - timestamp: Optional timestamp in milliseconds since epoch
+ /// - rawEvent: Optional raw event data as received from the agent
+ public init(
+ messageId: String,
+ delta: String,
+ timestamp: Int64? = nil,
+ rawEvent: Data? = nil
+ ) {
+ self.messageId = messageId
+ self.delta = delta
+ self.timestamp = timestamp
+ self.rawEvent = rawEvent
+ }
+}
+
+// MARK: - Decodable
+
+extension TextMessageContentEvent: Decodable {
+ private enum CodingKeys: String, CodingKey {
+ case messageId
+ case delta
+ case timestamp
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ messageId = try container.decode(String.self, forKey: .messageId)
+ delta = try container.decode(String.self, forKey: .delta)
+ timestamp = try container.decodeIfPresent(Int64.self, forKey: .timestamp)
+ rawEvent = nil
+ }
+
+ func withRawEvent(_ data: Data) -> Self {
+ TextMessageContentEvent(
+ messageId: messageId,
+ delta: delta,
+ timestamp: timestamp,
+ rawEvent: data
+ )
+ }
+}
+
+// MARK: - CustomStringConvertible
+extension TextMessageContentEvent: CustomStringConvertible {
+ public var description: String {
+ "TextMessageContentEvent(messageId: \(messageId), delta: \"\(delta)\", " +
+ "timestamp: \(timestamp?.description ?? "nil"))"
+ }
+}
+
+// MARK: - CustomDebugStringConvertible
+extension TextMessageContentEvent: CustomDebugStringConvertible {
+ public var debugDescription: String {
+ """
+ TextMessageContentEvent {
+ messageId: "\(messageId)"
+ delta: "\(delta)"
+ timestamp: \(timestamp.map(String.init) ?? "nil")
+ eventType: \(eventType.rawValue)
+ }
+ """
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Events/TextMessageEvents/TextMessageEndEvent.swift b/sdks/community/swift/Sources/AGUICore/Events/TextMessageEvents/TextMessageEndEvent.swift
new file mode 100644
index 0000000000..c77e20a2d7
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Events/TextMessageEvents/TextMessageEndEvent.swift
@@ -0,0 +1,71 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Event indicating that text message generation has completed.
+///
+/// This event is emitted when an agent finishes generating a text message response.
+/// It signals the end of the message generation process and is associated with
+/// the message via the `messageId` field, which matches the ID from the
+/// corresponding `TextMessageStartEvent`.
+///
+/// - SeeAlso: `TextMessageStartEvent`, `TextMessageContentEvent`, `TextMessageChunkEvent`
+public struct TextMessageEndEvent: AGUIEvent, Equatable, Hashable, Sendable {
+
+ // MARK: - Properties
+
+ /// The unique identifier for this message.
+ ///
+ /// This ID matches the `messageId` from the corresponding `TextMessageStartEvent`
+ /// and is used to associate this end event with the correct message.
+ public let messageId: String
+
+ /// Optional timestamp when the message generation finished.
+ ///
+ /// Represented as milliseconds since Unix epoch.
+ public let timestamp: Int64?
+
+ /// Optional raw event data as received from the agent.
+ public let rawEvent: Data?
+
+ /// The type of this event (always `.textMessageEnd`).
+ public var eventType: EventType { .textMessageEnd }
+
+ // MARK: - Initialization
+
+ /// Creates a new `TextMessageEndEvent`.
+ ///
+ /// - Parameters:
+ /// - messageId: The unique identifier for this message (matches TextMessageStartEvent)
+ /// - timestamp: Optional timestamp in milliseconds since epoch
+ /// - rawEvent: Optional raw event data as received from the agent
+ public init(
+ messageId: String,
+ timestamp: Int64? = nil,
+ rawEvent: Data? = nil
+ ) {
+ self.messageId = messageId
+ self.timestamp = timestamp
+ self.rawEvent = rawEvent
+ }
+}
+
+// MARK: - CustomStringConvertible
+extension TextMessageEndEvent: CustomStringConvertible {
+ public var description: String {
+ "TextMessageEndEvent(messageId: \(messageId), timestamp: \(timestamp?.description ?? "nil"))"
+ }
+}
+
+// MARK: - CustomDebugStringConvertible
+extension TextMessageEndEvent: CustomDebugStringConvertible {
+ public var debugDescription: String {
+ """
+ TextMessageEndEvent {
+ messageId: "\(messageId)"
+ timestamp: \(timestamp.map(String.init) ?? "nil")
+ eventType: \(eventType.rawValue)
+ }
+ """
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Events/TextMessageEvents/TextMessageStartEvent.swift b/sdks/community/swift/Sources/AGUICore/Events/TextMessageEvents/TextMessageStartEvent.swift
new file mode 100644
index 0000000000..ee8555fbe2
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Events/TextMessageEvents/TextMessageStartEvent.swift
@@ -0,0 +1,119 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Event indicating that text message generation has started.
+///
+/// This event is emitted when an agent begins generating a text message response.
+/// It provides the message identifier that will be used to associate subsequent
+/// content chunks and the end event with this message.
+///
+/// - SeeAlso: `TextMessageContentEvent`, `TextMessageEndEvent`, `TextMessageChunkEvent`
+public struct TextMessageStartEvent: AGUIEvent, Equatable, Hashable, Sendable {
+
+ // MARK: - Properties
+
+ /// The unique identifier for this message.
+ ///
+ /// This ID is used to associate content chunks and the end event
+ /// with this specific message instance.
+ public let messageId: String
+
+ /// The role of the message sender (always "assistant" for agent messages).
+ public let role: String
+
+ /// Optional display name for the message sender.
+ ///
+ /// Corresponds to the `name` field in the AG-UI TypeScript spec (events.ts:75).
+ public let name: String?
+
+ /// Optional timestamp when the message generation started.
+ ///
+ /// Represented as milliseconds since Unix epoch.
+ public let timestamp: Int64?
+
+ /// Optional raw event data as received from the agent.
+ public let rawEvent: Data?
+
+ /// The type of this event (always `.textMessageStart`).
+ public var eventType: EventType { .textMessageStart }
+
+ // MARK: - Initialization
+
+ /// Creates a new `TextMessageStartEvent`.
+ ///
+ /// - Parameters:
+ /// - messageId: The unique identifier for this message
+ /// - role: The role of the message sender (typically "assistant")
+ /// - name: Optional display name for the message sender
+ /// - timestamp: Optional timestamp in milliseconds since epoch
+ /// - rawEvent: Optional raw event data as received from the agent
+ public init(
+ messageId: String,
+ role: String = "assistant",
+ name: String? = nil,
+ timestamp: Int64? = nil,
+ rawEvent: Data? = nil
+ ) {
+ self.messageId = messageId
+ self.role = role
+ self.name = name
+ self.timestamp = timestamp
+ self.rawEvent = rawEvent
+ }
+}
+
+// MARK: - Decodable
+
+extension TextMessageStartEvent: Decodable {
+ private enum CodingKeys: String, CodingKey {
+ case messageId
+ case role
+ case name
+ case timestamp
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ messageId = try container.decode(String.self, forKey: .messageId)
+ role = try container.decode(String.self, forKey: .role)
+ name = try container.decodeIfPresent(String.self, forKey: .name)
+ timestamp = try container.decodeIfPresent(Int64.self, forKey: .timestamp)
+ rawEvent = nil
+ }
+
+ func withRawEvent(_ data: Data) -> Self {
+ TextMessageStartEvent(
+ messageId: messageId,
+ role: role,
+ name: name,
+ timestamp: timestamp,
+ rawEvent: data
+ )
+ }
+}
+
+// MARK: - CustomStringConvertible
+extension TextMessageStartEvent: CustomStringConvertible {
+ public var description: String {
+ var parts = "TextMessageStartEvent(messageId: \(messageId), role: \(role)"
+ if let name = name { parts += ", name: \(name)" }
+ parts += ", timestamp: \(timestamp?.description ?? "nil"))"
+ return parts
+ }
+}
+
+// MARK: - CustomDebugStringConvertible
+extension TextMessageStartEvent: CustomDebugStringConvertible {
+ public var debugDescription: String {
+ """
+ TextMessageStartEvent {
+ messageId: "\(messageId)"
+ role: "\(role)"
+ name: \(name.map { "\"\($0)\"" } ?? "nil")
+ timestamp: \(timestamp.map(String.init) ?? "nil")
+ eventType: \(eventType.rawValue)
+ }
+ """
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Events/ToolCallEvents/ToolCallArgsEvent.swift b/sdks/community/swift/Sources/AGUICore/Events/ToolCallEvents/ToolCallArgsEvent.swift
new file mode 100644
index 0000000000..682d6687d0
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Events/ToolCallEvents/ToolCallArgsEvent.swift
@@ -0,0 +1,110 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Event containing a chunk of argument data for a tool call.
+///
+/// This event is emitted multiple times during tool call argument streaming, each time
+/// containing a delta (incremental change) of argument data. The delta must be a
+/// non-empty string. These events are associated with a tool call via the `toolCallId`
+/// field, which matches the ID from the corresponding `ToolCallStartEvent`.
+///
+/// - SeeAlso: `ToolCallStartEvent`, `ToolCallEndEvent`, `ToolCallResultEvent`, `ToolCallChunkEvent`
+public struct ToolCallArgsEvent: AGUIEvent, Equatable, Hashable, Sendable {
+
+ // MARK: - Properties
+
+ /// The unique identifier for this tool call.
+ ///
+ /// This ID matches the `toolCallId` from the corresponding `ToolCallStartEvent`
+ /// and is used to associate this argument chunk with the correct tool call.
+ public let toolCallId: String
+
+ /// The argument data delta (incremental change).
+ ///
+ /// This is a non-empty string containing a chunk of the tool call arguments.
+ /// Multiple `ToolCallArgsEvent` instances with the same `toolCallId`
+ /// represent the complete arguments when concatenated.
+ public let delta: String
+
+ /// Optional timestamp when this argument chunk was received.
+ ///
+ /// Represented as milliseconds since Unix epoch.
+ public let timestamp: Int64?
+
+ /// Optional raw event data as received from the agent.
+ public let rawEvent: Data?
+
+ /// The type of this event (always `.toolCallArgs`).
+ public var eventType: EventType { .toolCallArgs }
+
+ // MARK: - Initialization
+
+ /// Creates a new `ToolCallArgsEvent`.
+ ///
+ /// - Parameters:
+ /// - toolCallId: The unique identifier for this tool call (matches ToolCallStartEvent)
+ /// - delta: The argument data delta (must be non-empty)
+ /// - timestamp: Optional timestamp in milliseconds since epoch
+ /// - rawEvent: Optional raw event data as received from the agent
+ public init(
+ toolCallId: String,
+ delta: String,
+ timestamp: Int64? = nil,
+ rawEvent: Data? = nil
+ ) {
+ self.toolCallId = toolCallId
+ self.delta = delta
+ self.timestamp = timestamp
+ self.rawEvent = rawEvent
+ }
+}
+
+// MARK: - Decodable
+
+extension ToolCallArgsEvent: Decodable {
+ private enum CodingKeys: String, CodingKey {
+ case toolCallId
+ case delta
+ case timestamp
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ toolCallId = try container.decode(String.self, forKey: .toolCallId)
+ delta = try container.decode(String.self, forKey: .delta)
+ timestamp = try container.decodeIfPresent(Int64.self, forKey: .timestamp)
+ rawEvent = nil
+ }
+
+ func withRawEvent(_ data: Data) -> Self {
+ ToolCallArgsEvent(
+ toolCallId: toolCallId,
+ delta: delta,
+ timestamp: timestamp,
+ rawEvent: data
+ )
+ }
+}
+
+// MARK: - CustomStringConvertible
+extension ToolCallArgsEvent: CustomStringConvertible {
+ public var description: String {
+ "ToolCallArgsEvent(toolCallId: \(toolCallId), delta: \"\(delta)\", " +
+ "timestamp: \(timestamp?.description ?? "nil"))"
+ }
+}
+
+// MARK: - CustomDebugStringConvertible
+extension ToolCallArgsEvent: CustomDebugStringConvertible {
+ public var debugDescription: String {
+ """
+ ToolCallArgsEvent {
+ toolCallId: "\(toolCallId)"
+ delta: "\(delta)"
+ timestamp: \(timestamp.map(String.init) ?? "nil")
+ eventType: \(eventType.rawValue)
+ }
+ """
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Events/ToolCallEvents/ToolCallChunkEvent.swift b/sdks/community/swift/Sources/AGUICore/Events/ToolCallEvents/ToolCallChunkEvent.swift
new file mode 100644
index 0000000000..c6948366ef
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Events/ToolCallEvents/ToolCallChunkEvent.swift
@@ -0,0 +1,118 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Chunk event for streaming tool call arguments.
+///
+/// This event represents a single chunk of streaming tool call arguments.
+/// Unlike `ToolCallArgsEvent` which requires an active tool call sequence,
+/// `ToolCallChunkEvent` can automatically start and end tool call sequences
+/// when no tool call is currently active.
+///
+/// - SeeAlso: `ToolCallStartEvent`, `ToolCallArgsEvent`, `ToolCallEndEvent`, `ToolCallResultEvent`
+public struct ToolCallChunkEvent: AGUIEvent, Equatable, Hashable, Sendable {
+
+ // MARK: - Properties
+
+ /// The identifier for the tool call (required for the first chunk).
+ ///
+ /// This ID is used to associate chunks with a specific tool call instance.
+ /// It may be `nil` for subsequent chunks if the tool call ID was already established.
+ public let toolCallId: String?
+
+ /// The name of the tool being called (required for the first chunk).
+ ///
+ /// This identifies which tool function is being invoked. It is typically
+ /// only present in the first chunk of a tool call.
+ public let toolCallName: String?
+
+ /// The arguments content delta for this chunk.
+ ///
+ /// This contains the new arguments content to append. May be `nil` if
+ /// this chunk only provides metadata (toolCallId, toolCallName) without content.
+ public let delta: String?
+
+ /// Optional identifier for the parent message containing this tool call.
+ ///
+ /// If provided, this associates the tool call with a specific message
+ /// in the conversation.
+ public let parentMessageId: String?
+
+ /// Optional timestamp when the chunk was generated.
+ ///
+ /// Represented as milliseconds since Unix epoch.
+ public let timestamp: Int64?
+
+ /// Optional raw event data as received from the agent.
+ public let rawEvent: Data?
+
+ /// The type of this event (always `.toolCallChunk`).
+ public var eventType: EventType { .toolCallChunk }
+
+ // MARK: - Initialization
+
+ /// Creates a new `ToolCallChunkEvent`.
+ ///
+ /// - Parameters:
+ /// - toolCallId: Optional identifier for the tool call (required for the first chunk)
+ /// - toolCallName: Optional name of the tool being called (required for the first chunk)
+ /// - delta: Optional arguments content delta for this chunk
+ /// - parentMessageId: Optional identifier of the parent message
+ /// - timestamp: Optional timestamp in milliseconds since epoch
+ /// - rawEvent: Optional raw event data as received from the agent
+ public init(
+ toolCallId: String? = nil,
+ toolCallName: String? = nil,
+ delta: String? = nil,
+ parentMessageId: String? = nil,
+ timestamp: Int64? = nil,
+ rawEvent: Data? = nil
+ ) {
+ self.toolCallId = toolCallId
+ self.toolCallName = toolCallName
+ self.delta = delta
+ self.parentMessageId = parentMessageId
+ self.timestamp = timestamp
+ self.rawEvent = rawEvent
+ }
+}
+
+// MARK: - CustomStringConvertible
+extension ToolCallChunkEvent: CustomStringConvertible {
+ public var description: String {
+ var parts: [String] = []
+ if let toolCallId = toolCallId {
+ parts.append("toolCallId: \(toolCallId)")
+ }
+ if let toolCallName = toolCallName {
+ parts.append("toolCallName: \(toolCallName)")
+ }
+ if let delta = delta {
+ let deltaPreview = delta.count > 50 ? String(delta.prefix(50)) + "..." : delta
+ parts.append("delta: \"\(deltaPreview)\"")
+ }
+ if let parentMessageId = parentMessageId {
+ parts.append("parentMessageId: \(parentMessageId)")
+ }
+ if let timestamp = timestamp {
+ parts.append("timestamp: \(timestamp)")
+ }
+ return "ToolCallChunkEvent(\(parts.joined(separator: ", ")))"
+ }
+}
+
+// MARK: - CustomDebugStringConvertible
+extension ToolCallChunkEvent: CustomDebugStringConvertible {
+ public var debugDescription: String {
+ """
+ ToolCallChunkEvent {
+ toolCallId: \(toolCallId.map { "\"\($0)\"" } ?? "nil")
+ toolCallName: \(toolCallName.map { "\"\($0)\"" } ?? "nil")
+ delta: \(delta.map { "\"\($0)\"" } ?? "nil")
+ parentMessageId: \(parentMessageId.map { "\"\($0)\"" } ?? "nil")
+ timestamp: \(timestamp.map(String.init) ?? "nil")
+ eventType: \(eventType.rawValue)
+ }
+ """
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Events/ToolCallEvents/ToolCallEndEvent.swift b/sdks/community/swift/Sources/AGUICore/Events/ToolCallEvents/ToolCallEndEvent.swift
new file mode 100644
index 0000000000..2c677f32b6
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Events/ToolCallEvents/ToolCallEndEvent.swift
@@ -0,0 +1,70 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Event indicating that a tool call has completed.
+///
+/// This event is emitted when an agent finishes invoking a tool. It signals the end
+/// of the tool call process and is associated with the tool call via the `toolCallId`
+/// field, which matches the ID from the corresponding `ToolCallStartEvent`.
+///
+/// - SeeAlso: `ToolCallStartEvent`, `ToolCallArgsEvent`, `ToolCallResultEvent`, `ToolCallChunkEvent`
+public struct ToolCallEndEvent: AGUIEvent, Equatable, Hashable, Sendable {
+
+ // MARK: - Properties
+
+ /// The unique identifier for this tool call.
+ ///
+ /// This ID matches the `toolCallId` from the corresponding `ToolCallStartEvent`
+ /// and is used to associate this end event with the correct tool call.
+ public let toolCallId: String
+
+ /// Optional timestamp when the tool call finished.
+ ///
+ /// Represented as milliseconds since Unix epoch.
+ public let timestamp: Int64?
+
+ /// Optional raw event data as received from the agent.
+ public let rawEvent: Data?
+
+ /// The type of this event (always `.toolCallEnd`).
+ public var eventType: EventType { .toolCallEnd }
+
+ // MARK: - Initialization
+
+ /// Creates a new `ToolCallEndEvent`.
+ ///
+ /// - Parameters:
+ /// - toolCallId: The unique identifier for this tool call (matches ToolCallStartEvent)
+ /// - timestamp: Optional timestamp in milliseconds since epoch
+ /// - rawEvent: Optional raw event data as received from the agent
+ public init(
+ toolCallId: String,
+ timestamp: Int64? = nil,
+ rawEvent: Data? = nil
+ ) {
+ self.toolCallId = toolCallId
+ self.timestamp = timestamp
+ self.rawEvent = rawEvent
+ }
+}
+
+// MARK: - CustomStringConvertible
+extension ToolCallEndEvent: CustomStringConvertible {
+ public var description: String {
+ "ToolCallEndEvent(toolCallId: \(toolCallId), timestamp: \(timestamp?.description ?? "nil"))"
+ }
+}
+
+// MARK: - CustomDebugStringConvertible
+extension ToolCallEndEvent: CustomDebugStringConvertible {
+ public var debugDescription: String {
+ """
+ ToolCallEndEvent {
+ toolCallId: "\(toolCallId)"
+ timestamp: \(timestamp.map(String.init) ?? "nil")
+ eventType: \(eventType.rawValue)
+ }
+ """
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Events/ToolCallEvents/ToolCallResultEvent.swift b/sdks/community/swift/Sources/AGUICore/Events/ToolCallEvents/ToolCallResultEvent.swift
new file mode 100644
index 0000000000..cb12d9db68
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Events/ToolCallEvents/ToolCallResultEvent.swift
@@ -0,0 +1,98 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Event containing the result/output from a tool call execution.
+///
+/// This event is emitted when a tool call completes and produces a result.
+/// It provides the tool call result content and associates it with both the
+/// tool call (via `toolCallId`) and the conversation message (via `messageId`).
+///
+/// - SeeAlso: `ToolCallStartEvent`, `ToolCallArgsEvent`, `ToolCallEndEvent`, `ToolCallChunkEvent`
+public struct ToolCallResultEvent: AGUIEvent, Equatable, Hashable, Sendable {
+
+ // MARK: - Properties
+
+ /// The unique identifier for the conversation message this result belongs to.
+ ///
+ /// This associates the tool call result with a specific message in the conversation.
+ public let messageId: String
+
+ /// The unique identifier for this tool call.
+ ///
+ /// This ID matches the `toolCallId` from the corresponding `ToolCallStartEvent`
+ /// and is used to associate this result with the correct tool call.
+ public let toolCallId: String
+
+ /// The actual result/output content from the tool execution.
+ ///
+ /// This contains the output produced by the tool, which may be text, JSON,
+ /// or any other format depending on the tool's implementation.
+ public let content: String
+
+ /// Optional role identifier (typically "tool" for tool results).
+ public let role: String?
+
+ /// Optional timestamp when the tool call result was received.
+ ///
+ /// Represented as milliseconds since Unix epoch.
+ public let timestamp: Int64?
+
+ /// Optional raw event data as received from the agent.
+ public let rawEvent: Data?
+
+ /// The type of this event (always `.toolCallResult`).
+ public var eventType: EventType { .toolCallResult }
+
+ // MARK: - Initialization
+
+ /// Creates a new `ToolCallResultEvent`.
+ ///
+ /// - Parameters:
+ /// - messageId: The unique identifier for the conversation message
+ /// - toolCallId: The unique identifier for this tool call (matches ToolCallStartEvent)
+ /// - content: The actual result/output content from the tool execution
+ /// - role: Optional role identifier (typically "tool")
+ /// - timestamp: Optional timestamp in milliseconds since epoch
+ /// - rawEvent: Optional raw event data as received from the agent
+ public init(
+ messageId: String,
+ toolCallId: String,
+ content: String,
+ role: String? = nil,
+ timestamp: Int64? = nil,
+ rawEvent: Data? = nil
+ ) {
+ self.messageId = messageId
+ self.toolCallId = toolCallId
+ self.content = content
+ self.role = role
+ self.timestamp = timestamp
+ self.rawEvent = rawEvent
+ }
+}
+
+// MARK: - CustomStringConvertible
+extension ToolCallResultEvent: CustomStringConvertible {
+ public var description: String {
+ "ToolCallResultEvent(messageId: \(messageId), toolCallId: \(toolCallId), " +
+ "content: \"\(content)\", role: \(role ?? "nil"), " +
+ "timestamp: \(timestamp?.description ?? "nil"))"
+ }
+}
+
+// MARK: - CustomDebugStringConvertible
+extension ToolCallResultEvent: CustomDebugStringConvertible {
+ public var debugDescription: String {
+ """
+ ToolCallResultEvent {
+ messageId: "\(messageId)"
+ toolCallId: "\(toolCallId)"
+ content: "\(content)"
+ role: \(role.map { "\"\($0)\"" } ?? "nil")
+ timestamp: \(timestamp.map(String.init) ?? "nil")
+ eventType: \(eventType.rawValue)
+ }
+ """
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Events/ToolCallEvents/ToolCallStartEvent.swift b/sdks/community/swift/Sources/AGUICore/Events/ToolCallEvents/ToolCallStartEvent.swift
new file mode 100644
index 0000000000..0c20b370f3
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Events/ToolCallEvents/ToolCallStartEvent.swift
@@ -0,0 +1,121 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Event indicating that a tool call has started.
+///
+/// This event is emitted when an agent begins invoking a tool. It provides the
+/// tool call identifier and name that will be used to associate subsequent
+/// argument chunks, the result, and the end event with this tool call.
+///
+/// - SeeAlso: `ToolCallArgsEvent`, `ToolCallEndEvent`, `ToolCallResultEvent`, `ToolCallChunkEvent`
+public struct ToolCallStartEvent: AGUIEvent, Equatable, Hashable, Sendable {
+
+ // MARK: - Properties
+
+ /// The unique identifier for this tool call.
+ ///
+ /// This ID is used to associate argument chunks, the result, and the end event
+ /// with this specific tool call instance.
+ public let toolCallId: String
+
+ /// The name of the tool being called.
+ ///
+ /// This identifies which tool function is being invoked.
+ public let toolCallName: String
+
+ /// Optional identifier of the parent message.
+ ///
+ /// If provided, this associates the tool call with a specific message
+ /// in the conversation.
+ public let parentMessageId: String?
+
+ /// Optional timestamp when the tool call started.
+ ///
+ /// Represented as milliseconds since Unix epoch.
+ public let timestamp: Int64?
+
+ /// Optional raw event data as received from the agent.
+ public let rawEvent: Data?
+
+ /// The type of this event (always `.toolCallStart`).
+ public var eventType: EventType { .toolCallStart }
+
+ // MARK: - Initialization
+
+ /// Creates a new `ToolCallStartEvent`.
+ ///
+ /// - Parameters:
+ /// - toolCallId: The unique identifier for this tool call
+ /// - toolCallName: The name of the tool being called
+ /// - parentMessageId: Optional identifier of the parent message
+ /// - timestamp: Optional timestamp in milliseconds since epoch
+ /// - rawEvent: Optional raw event data as received from the agent
+ public init(
+ toolCallId: String,
+ toolCallName: String,
+ parentMessageId: String? = nil,
+ timestamp: Int64? = nil,
+ rawEvent: Data? = nil
+ ) {
+ self.toolCallId = toolCallId
+ self.toolCallName = toolCallName
+ self.parentMessageId = parentMessageId
+ self.timestamp = timestamp
+ self.rawEvent = rawEvent
+ }
+}
+
+// MARK: - Decodable
+
+extension ToolCallStartEvent: Decodable {
+ private enum CodingKeys: String, CodingKey {
+ case toolCallId
+ case toolCallName
+ case parentMessageId
+ case timestamp
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ toolCallId = try container.decode(String.self, forKey: .toolCallId)
+ toolCallName = try container.decode(String.self, forKey: .toolCallName)
+ parentMessageId = try container.decodeIfPresent(String.self, forKey: .parentMessageId)
+ timestamp = try container.decodeIfPresent(Int64.self, forKey: .timestamp)
+ rawEvent = nil
+ }
+
+ func withRawEvent(_ data: Data) -> Self {
+ ToolCallStartEvent(
+ toolCallId: toolCallId,
+ toolCallName: toolCallName,
+ parentMessageId: parentMessageId,
+ timestamp: timestamp,
+ rawEvent: data
+ )
+ }
+}
+
+// MARK: - CustomStringConvertible
+extension ToolCallStartEvent: CustomStringConvertible {
+ public var description: String {
+ "ToolCallStartEvent(toolCallId: \(toolCallId), toolCallName: \(toolCallName), " +
+ "parentMessageId: \(parentMessageId ?? "nil"), " +
+ "timestamp: \(timestamp?.description ?? "nil"))"
+ }
+}
+
+// MARK: - CustomDebugStringConvertible
+extension ToolCallStartEvent: CustomDebugStringConvertible {
+ public var debugDescription: String {
+ """
+ ToolCallStartEvent {
+ toolCallId: "\(toolCallId)"
+ toolCallName: "\(toolCallName)"
+ parentMessageId: \(parentMessageId.map { "\"\($0)\"" } ?? "nil")
+ timestamp: \(timestamp.map(String.init) ?? "nil")
+ eventType: \(eventType.rawValue)
+ }
+ """
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Events/UnknownEvent.swift b/sdks/community/swift/Sources/AGUICore/Events/UnknownEvent.swift
new file mode 100644
index 0000000000..2524761fe4
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Events/UnknownEvent.swift
@@ -0,0 +1,102 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Event representing an unknown or unsupported event type.
+///
+/// `UnknownEvent` is returned by `AGUIEventDecoder` when operating in tolerant mode
+/// (`unknownEventStrategy = .returnUnknown`) and encounters an event that cannot be
+/// decoded into a known event type.
+///
+/// ## When UnknownEvent is Created
+///
+/// An `UnknownEvent` is created in two scenarios:
+///
+/// 1. **Unknown Event Type**: The event's "type" field contains a value that is not
+/// recognized by the `EventType` enum (e.g., a future protocol extension or custom type).
+///
+/// 2. **Unsupported Event Type**: The event type is recognized but no decoder handler
+/// is registered for it in the decoder's registry (e.g., a known type that hasn't
+/// been implemented yet).
+///
+/// ## Usage
+///
+/// ```swift
+/// var config = AGUIEventDecoder.Configuration()
+/// config.unknownEventStrategy = .returnUnknown
+/// let decoder = AGUIEventDecoder(config: config)
+///
+/// let event = try decoder.decode(data)
+/// if let unknown = event as? UnknownEvent {
+/// print("Received unknown event type: \(unknown.typeRaw)")
+/// // Access raw JSON for inspection or forwarding
+/// if let rawData = unknown.rawEvent {
+/// let json = try JSONSerialization.jsonObject(with: rawData)
+/// // Handle or forward the unknown event
+/// }
+/// }
+/// ```
+///
+/// ## Properties
+///
+/// - `typeRaw`: The raw string value of the "type" field from the JSON
+/// - `rawEvent`: The complete original JSON data, preserved for inspection or forwarding
+/// - `eventType`: Always returns `.unknown` — distinct from the genuine `.raw` wire event
+/// - `timestamp`: Always returns `nil` since unknown events cannot be parsed for timestamps
+///
+/// ## Forward Compatibility
+///
+/// Using `UnknownEvent` enables forward compatibility with protocol extensions. When
+/// new event types are added to the AG-UI protocol, older SDK versions can still
+/// receive and forward these events without crashing, even if they can't decode them.
+///
+/// - SeeAlso: `AGUIEventDecoder`, `EventDecodingError`, `EventType`
+public struct UnknownEvent: AGUIEvent, Sendable {
+
+ // MARK: - Properties
+
+ /// The raw string value of the "type" field from the JSON event.
+ ///
+ /// This contains the exact type string as received from the agent, which may be:
+ /// - A future event type not yet in the `EventType` enum
+ /// - A custom event type defined by the agent implementation
+ /// - A known event type that has no registered decoder handler
+ public let typeRaw: String
+
+ /// The complete original JSON data for this event.
+ ///
+ /// This preserves the full event payload, allowing you to:
+ /// - Inspect the event structure for debugging
+ /// - Forward the event to other systems that might understand it
+ /// - Log the event for later analysis
+ ///
+ /// The data is guaranteed to be valid JSON (otherwise decoding would have failed earlier).
+ public let rawEvent: Data?
+
+ /// The type of this event (always `.unknown`).
+ ///
+ /// Unknown events return the sentinel `.unknown` case, which is distinct from
+ /// the genuine `.raw` wire-format event type. This allows consumers to
+ /// differentiate between a real `RAW` event sent by an agent and an event
+ /// whose type string was not recognised by the decoder.
+ public var eventType: EventType { .unknown }
+
+ /// Optional timestamp when the event occurred (always `nil`).
+ ///
+ /// Since unknown events cannot be fully decoded, timestamp information
+ /// is not available. If you need the timestamp, you can parse the `rawEvent`
+ /// JSON data directly.
+ public var timestamp: Int64? { nil }
+
+ // MARK: - Initialization
+
+ /// Creates a new `UnknownEvent`.
+ ///
+ /// - Parameters:
+ /// - typeRaw: The raw string value of the "type" field from the JSON
+ /// - rawEvent: The complete original JSON data for this event
+ public init(typeRaw: String, rawEvent: Data) {
+ self.typeRaw = typeRaw
+ self.rawEvent = rawEvent
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Types/AgentExecution/Context.swift b/sdks/community/swift/Sources/AGUICore/Types/AgentExecution/Context.swift
new file mode 100644
index 0000000000..eb72f5ff01
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Types/AgentExecution/Context.swift
@@ -0,0 +1,84 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Represents a piece of contextual information provided to an agent.
+///
+/// `Context` enables passing additional metadata, configuration, or environmental
+/// information to agents beyond the conversation messages. This helps agents
+/// understand the broader context of a conversation.
+///
+/// ## Use Cases
+///
+/// Context can be used for:
+/// - **User Preferences**: Theme settings, language preferences, accessibility options
+/// - **Session Information**: User ID, session tokens, authentication state
+/// - **Environmental Data**: Timezone, location, device information
+/// - **API Credentials**: Keys, tokens, or configuration for external services
+/// - **Application State**: Current view, navigation context, feature flags
+///
+/// ## Structure
+///
+/// Each context item consists of:
+/// - `description`: Human-readable description of what the context represents
+/// - `value`: The actual context value as a string
+///
+/// ## Usage Examples
+///
+/// ```swift
+/// // User preference context
+/// let themeContext = Context(
+/// description: "User theme preference",
+/// value: "dark"
+/// )
+///
+/// // Location context
+/// let locationContext = Context(
+/// description: "User location",
+/// value: "San Francisco, CA"
+/// )
+///
+/// // Multiple contexts in RunAgentInput
+/// let input = RunAgentInput(
+/// threadId: "thread-123",
+/// runId: "run-456",
+/// context: [themeContext, locationContext]
+/// )
+/// ```
+///
+/// ## Value Encoding
+///
+/// The `value` field is a string, but it can contain:
+/// - Simple values: `"dark"`, `"en-US"`, `"12345"`
+/// - Structured data as JSON strings: `"{\"name\": \"Alice\", \"role\": \"admin\"}"`
+/// - Timestamps: `"2024-01-01T12:00:00Z"`
+/// - Any other string-encoded data
+///
+/// - SeeAlso: ``RunAgentInput``
+public struct Context: Sendable, Codable, Hashable {
+ /// A human-readable description of the context.
+ ///
+ /// This field describes what the context value represents,
+ /// helping agents understand how to interpret the value.
+ ///
+ /// Examples:
+ /// - `"User theme preference"`
+ /// - `"API authentication token"`
+ /// - `"Current timezone"`
+ public let description: String
+
+ /// The context value.
+ ///
+ /// This can be any string value, including JSON-encoded structured data.
+ public let value: String
+
+ /// Creates a new context item.
+ ///
+ /// - Parameters:
+ /// - description: Human-readable description of the context
+ /// - value: The context value as a string
+ public init(description: String, value: String) {
+ self.description = description
+ self.value = value
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Types/AgentExecution/RunAgentInput.swift b/sdks/community/swift/Sources/AGUICore/Types/AgentExecution/RunAgentInput.swift
new file mode 100644
index 0000000000..87923eb612
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Types/AgentExecution/RunAgentInput.swift
@@ -0,0 +1,297 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Input parameters for connecting to and executing an agent.
+///
+/// `RunAgentInput` represents the complete request body sent to an agent's
+/// HTTP endpoint when initiating or continuing a conversation. It contains
+/// all necessary information for the agent to process the request.
+///
+/// ## Core Identifiers
+///
+/// - **threadId**: Identifies the conversation thread
+/// - **runId**: Unique identifier for this specific run/execution
+/// - **parentRunId**: Optional parent run for nested agent calls
+///
+/// ## Execution Context
+///
+/// - **state**: Current state data passed to the agent
+/// - **messages**: Conversation history
+/// - **tools**: Available tools the agent can invoke
+/// - **context**: Additional contextual information
+/// - **forwardedProps**: Custom properties to forward to the agent
+///
+/// ## Usage Examples
+///
+/// ```swift
+/// // Simple agent execution
+/// let input = RunAgentInput(
+/// threadId: "thread-123",
+/// runId: "run-456"
+/// )
+///
+/// // With conversation history
+/// let messages: [any Message] = [
+/// DeveloperMessage(id: "dev-1", content: "You are helpful"),
+/// UserMessage(id: "user-1", content: "Hello!")
+/// ]
+///
+/// let input = RunAgentInput(
+/// threadId: "thread-123",
+/// runId: "run-456",
+/// messages: messages
+/// )
+///
+/// // With tools and context
+/// let tools = [
+/// Tool(name: "get_weather", description: "Get weather", parameters: ...)
+/// ]
+///
+/// let contexts = [
+/// Context(description: "user_location", value: "San Francisco")
+/// ]
+///
+/// let input = RunAgentInput(
+/// threadId: "thread-123",
+/// runId: "run-456",
+/// messages: messages,
+/// tools: tools,
+/// context: contexts
+/// )
+/// ```
+///
+/// ## HTTP POST Request
+///
+/// This type is typically serialized to JSON and sent as the body of
+/// a POST request to an agent's endpoint.
+///
+/// - SeeAlso: ``Message``, ``Tool``, ``Context``
+public struct RunAgentInput: Sendable, Codable, Hashable {
+ /// The conversation thread identifier.
+ public let threadId: String
+
+ /// The unique identifier for this run.
+ public let runId: String
+
+ /// Optional parent run identifier for nested agent calls.
+ public let parentRunId: String?
+
+ /// Current state data as JSON.
+ ///
+ /// Defaults to an empty JSON object.
+ public let state: Data
+
+ /// Conversation message history.
+ ///
+ /// Defaults to an empty array.
+ public let messages: [any Message]
+
+ /// Available tools the agent can invoke.
+ ///
+ /// Defaults to an empty array.
+ public let tools: [Tool]
+
+ /// Additional contextual information.
+ ///
+ /// Defaults to an empty array.
+ public let context: [Context]
+
+ /// Custom properties forwarded to the agent as JSON.
+ ///
+ /// Defaults to an empty JSON object.
+ public let forwardedProps: Data
+
+ /// Responses to human-in-the-loop interrupts from the previous run.
+ ///
+ /// Populate this when resuming a run that finished with
+ /// `RunFinishedOutcome.interrupt`. Each entry references one `Interrupt` by
+ /// its `id` and carries the caller's resolution status and optional payload.
+ ///
+ /// Defaults to `nil` (omitted from the wire payload).
+ public let resume: [ResumeEntry]?
+
+ /// Creates a new agent input.
+ ///
+ /// - Parameters:
+ /// - threadId: Conversation thread identifier
+ /// - runId: Unique run identifier
+ /// - parentRunId: Optional parent run identifier
+ /// - state: State data as JSON (defaults to empty object)
+ /// - messages: Message history (defaults to empty)
+ /// - tools: Available tools (defaults to empty)
+ /// - context: Context items (defaults to empty)
+ /// - forwardedProps: Custom properties as JSON (defaults to empty object)
+ /// - resume: Interrupt resume entries (defaults to nil)
+ public init(
+ threadId: String,
+ runId: String,
+ parentRunId: String? = nil,
+ state: Data = Data("{}".utf8),
+ messages: [any Message] = [],
+ tools: [Tool] = [],
+ context: [Context] = [],
+ forwardedProps: Data = Data("{}".utf8),
+ resume: [ResumeEntry]? = nil
+ ) {
+ self.threadId = threadId
+ self.runId = runId
+ self.parentRunId = parentRunId
+ self.state = state
+ self.messages = messages
+ self.tools = tools
+ self.context = context
+ self.forwardedProps = forwardedProps
+ self.resume = resume
+ }
+
+ // MARK: - Codable
+
+ private enum CodingKeys: String, CodingKey {
+ case threadId
+ case runId
+ case parentRunId
+ case state
+ case messages
+ case tools
+ case context
+ case forwardedProps
+ case resume
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+
+ // Decode simple string fields
+ threadId = try container.decode(String.self, forKey: .threadId)
+ runId = try container.decode(String.self, forKey: .runId)
+ parentRunId = try container.decodeIfPresent(String.self, forKey: .parentRunId)
+
+ // Decode state as arbitrary JSON object and convert to Data.
+ // The encoder writes `null` when state is an empty `{}` object, so treat
+ // an explicit null here as equivalent to "no state" (empty object).
+ if container.contains(.state), !(try container.decodeNil(forKey: .state)) {
+ let stateContainer = try container.nestedContainer(keyedBy: JSONCodingKeys.self, forKey: .state)
+ let stateObject = try stateContainer.decodeJSONObject()
+ state = try JSONSerialization.data(withJSONObject: stateObject)
+ } else {
+ state = Data("{}".utf8)
+ }
+
+ // Decode messages array using MessageDecoder for polymorphic deserialization
+ if container.contains(.messages) {
+ var messagesContainer = try container.nestedUnkeyedContainer(forKey: .messages)
+ let messagesArray = try messagesContainer.decodeJSONArray()
+
+ let messageDecoder = MessageDecoder()
+ messages = try messagesArray.map { messageObj in
+ let messageData = try JSONSerialization.data(withJSONObject: messageObj)
+ return try messageDecoder.decode(messageData)
+ }
+ } else {
+ messages = []
+ }
+
+ // Decode tools and context arrays (these already conform to Codable)
+ tools = try container.decodeIfPresent([Tool].self, forKey: .tools) ?? []
+ context = try container.decodeIfPresent([Context].self, forKey: .context) ?? []
+
+ // Decode resume entries (optional — absent when not resuming an interrupt)
+ resume = try container.decodeIfPresent([ResumeEntry].self, forKey: .resume)
+
+ // Decode forwardedProps as arbitrary JSON object and convert to Data
+ if container.contains(.forwardedProps) {
+ let propsContainer = try container.nestedContainer(keyedBy: JSONCodingKeys.self, forKey: .forwardedProps)
+ let propsObject = try propsContainer.decodeJSONObject()
+ forwardedProps = try JSONSerialization.data(withJSONObject: propsObject)
+ } else {
+ forwardedProps = Data("{}".utf8)
+ }
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+
+ // Encode simple string fields
+ try container.encode(threadId, forKey: .threadId)
+ try container.encode(runId, forKey: .runId)
+ try container.encodeIfPresent(parentRunId, forKey: .parentRunId)
+
+ // Encode state: send null when empty so backends treat it as "no state".
+ // An empty {} object causes some adapters (e.g. claude-agent-sdk) to
+ // spin up unnecessary state-management infrastructure. null is the
+ // correct sentinel for "caller has no state to manage."
+ let stateObject = try JSONSerialization.jsonObject(with: state)
+ if let stateDict = stateObject as? [String: Any], stateDict.isEmpty {
+ try container.encodeNil(forKey: .state)
+ } else {
+ var stateContainer = container.nestedContainer(keyedBy: JSONCodingKeys.self, forKey: .state)
+ try stateContainer.encodeJSONObject(stateObject)
+ }
+
+ // Encode messages as polymorphic array using MessageEncoder
+ let messageEncoder = MessageEncoder()
+ var messagesArray: [Any] = []
+ for message in messages {
+ let messageData = try messageEncoder.encode(message)
+ let messageDict = try JSONSerialization.jsonObject(with: messageData)
+ messagesArray.append(messageDict)
+ }
+ var messagesContainer = container.nestedUnkeyedContainer(forKey: .messages)
+ try messagesContainer.encodeJSONArray(messagesArray)
+
+ // Encode tools and context arrays (these already conform to Codable)
+ try container.encode(tools, forKey: .tools)
+ try container.encode(context, forKey: .context)
+
+ // Encode resume entries — omit the key entirely when nil (not null)
+ try container.encodeIfPresent(resume, forKey: .resume)
+
+ // Encode forwardedProps as arbitrary JSON object
+ let propsObject = try JSONSerialization.jsonObject(with: forwardedProps)
+ var propsContainer = container.nestedContainer(keyedBy: JSONCodingKeys.self, forKey: .forwardedProps)
+ try propsContainer.encodeJSONObject(propsObject)
+ }
+
+ // MARK: - Hashable
+
+ public func hash(into hasher: inout Hasher) {
+ hasher.combine(threadId)
+ hasher.combine(runId)
+ hasher.combine(parentRunId)
+ hasher.combine(state)
+ hasher.combine(tools)
+ hasher.combine(context)
+ hasher.combine(forwardedProps)
+ hasher.combine(resume)
+
+ // Hash each message's identifying properties
+ for message in messages {
+ hasher.combine(message.id)
+ hasher.combine(message.role)
+ hasher.combine(message.name)
+ }
+ }
+
+ public static func == (lhs: RunAgentInput, rhs: RunAgentInput) -> Bool {
+ // Fast path: check simple properties first
+ guard lhs.threadId == rhs.threadId &&
+ lhs.runId == rhs.runId &&
+ lhs.parentRunId == rhs.parentRunId &&
+ lhs.state == rhs.state &&
+ lhs.tools == rhs.tools &&
+ lhs.context == rhs.context &&
+ lhs.forwardedProps == rhs.forwardedProps &&
+ lhs.resume == rhs.resume &&
+ lhs.messages.count == rhs.messages.count else {
+ return false
+ }
+
+ // Compare each message's identifying properties
+ return zip(lhs.messages, rhs.messages).allSatisfy { lhsMsg, rhsMsg in
+ lhsMsg.id == rhsMsg.id &&
+ lhsMsg.role == rhsMsg.role &&
+ lhsMsg.name == rhsMsg.name
+ }
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Types/AgentExecution/RunAgentInputBuilder.swift b/sdks/community/swift/Sources/AGUICore/Types/AgentExecution/RunAgentInputBuilder.swift
new file mode 100644
index 0000000000..b3273281f3
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Types/AgentExecution/RunAgentInputBuilder.swift
@@ -0,0 +1,300 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Builder for constructing `RunAgentInput` instances with a fluent interface.
+///
+/// `RunAgentInputBuilder` provides an ergonomic way to construct complex `RunAgentInput`
+/// instances through method chaining, making the code more readable and reducing
+/// errors when working with multiple optional parameters.
+///
+/// ## Basic Usage
+///
+/// ```swift
+/// let input = RunAgentInput.builder()
+/// .threadId("thread-123")
+/// .runId("run-456")
+/// .build()
+/// ```
+///
+/// ## Building Complex Inputs
+///
+/// ```swift
+/// let input = RunAgentInput.builder()
+/// .threadId("chat-session-1")
+/// .runId("run-1")
+/// .message(DeveloperMessage(id: "dev-1", content: "You are helpful"))
+/// .message(UserMessage(id: "user-1", content: "Hello!"))
+/// .tool(weatherTool)
+/// .contextItem(Context(description: "location", value: "SF"))
+/// .build()
+/// ```
+///
+/// ## Incremental Building
+///
+/// ```swift
+/// var builder = RunAgentInput.builder()
+/// .threadId("thread-1")
+/// .runId("run-1")
+///
+/// // Add messages conditionally
+/// if includeSystemPrompt {
+/// builder = builder.message(SystemMessage(id: "sys-1", content: "Be concise"))
+/// }
+///
+/// let input = builder.build()
+/// ```
+///
+/// ## Thread Safety
+///
+/// The builder uses value semantics (struct) and is naturally thread-safe. Each
+/// builder method returns a new builder instance, making it safe to use across
+/// isolation boundaries and in concurrent contexts.
+///
+/// ## Concurrency
+///
+/// The builder uses value semantics and is intended for use on a single task or actor.
+/// It does not conform to `Sendable` in Swift 6 because it contains mutable stored properties.
+/// Prefer building inputs on one actor/task and then pass the resulting `RunAgentInput` across boundaries.
+///
+/// - SeeAlso: `RunAgentInput`
+public struct RunAgentInputBuilder {
+
+ private var _threadId: String?
+ private var _runId: String?
+ private var _parentRunId: String?
+ private var _state = Data("{}".utf8)
+ private var _messages: [any Message] = []
+ private var _tools: [Tool] = []
+ private var _context: [Context] = []
+ private var _forwardedProps = Data("{}".utf8)
+ private var _resume: [ResumeEntry]?
+
+ /// Creates a new builder instance.
+ public init() {}
+
+ // MARK: - Required Fields
+
+ /// Sets the conversation thread identifier.
+ ///
+ /// - Parameter threadId: The thread identifier
+ /// - Returns: A new builder instance with the thread ID set
+ public func threadId(_ threadId: String) -> Self {
+ var builder = self
+ builder._threadId = threadId
+ return builder
+ }
+
+ /// Sets the unique identifier for this run.
+ ///
+ /// - Parameter runId: The run identifier
+ /// - Returns: A new builder instance with the run ID set
+ public func runId(_ runId: String) -> Self {
+ var builder = self
+ builder._runId = runId
+ return builder
+ }
+
+ // MARK: - Optional Fields
+
+ /// Sets the parent run identifier for nested agent calls.
+ ///
+ /// - Parameter parentRunId: The parent run identifier
+ /// - Returns: A new builder instance with the parent run ID set
+ public func parentRunId(_ parentRunId: String) -> Self {
+ var builder = self
+ builder._parentRunId = parentRunId
+ return builder
+ }
+
+ /// Sets the state data as JSON.
+ ///
+ /// - Parameter state: The state data
+ /// - Returns: A new builder instance with the state set
+ public func state(_ state: Data) -> Self {
+ var builder = self
+ builder._state = state
+ return builder
+ }
+
+ /// Sets the forwarded properties as JSON.
+ ///
+ /// - Parameter forwardedProps: The forwarded properties data
+ /// - Returns: A new builder instance with the forwarded properties set
+ public func forwardedProps(_ forwardedProps: Data) -> Self {
+ var builder = self
+ builder._forwardedProps = forwardedProps
+ return builder
+ }
+
+ // MARK: - Messages
+
+ /// Adds a single message to the conversation history.
+ ///
+ /// Can be called multiple times to add messages incrementally.
+ ///
+ /// - Parameter message: The message to add
+ /// - Returns: A new builder instance with the message added
+ public func message(_ message: any Message) -> Self {
+ var builder = self
+ builder._messages.append(message)
+ return builder
+ }
+
+ /// Sets the complete message array, replacing any previously added messages.
+ ///
+ /// - Parameter messages: The array of messages
+ /// - Returns: A new builder instance with the messages set
+ public func messages(_ messages: [any Message]) -> Self {
+ var builder = self
+ builder._messages = messages
+ return builder
+ }
+
+ // MARK: - Tools
+
+ /// Adds a single tool to the available tools.
+ ///
+ /// Can be called multiple times to add tools incrementally.
+ ///
+ /// - Parameter tool: The tool to add
+ /// - Returns: A new builder instance with the tool added
+ public func tool(_ tool: Tool) -> Self {
+ var builder = self
+ builder._tools.append(tool)
+ return builder
+ }
+
+ /// Sets the complete tools array, replacing any previously added tools.
+ ///
+ /// - Parameter tools: The array of tools
+ /// - Returns: A new builder instance with the tools set
+ public func tools(_ tools: [Tool]) -> Self {
+ var builder = self
+ builder._tools = tools
+ return builder
+ }
+
+ // MARK: - Context
+
+ /// Adds a single context item to the contextual information.
+ ///
+ /// Can be called multiple times to add context items incrementally.
+ ///
+ /// - Parameter item: The context item to add
+ /// - Returns: A new builder instance with the context item added
+ public func contextItem(_ item: Context) -> Self {
+ var builder = self
+ builder._context.append(item)
+ return builder
+ }
+
+ /// Sets the complete context array, replacing any previously added context items.
+ ///
+ /// - Parameter context: The array of context items
+ /// - Returns: A new builder instance with the context set
+ public func context(_ context: [Context]) -> Self {
+ var builder = self
+ builder._context = context
+ return builder
+ }
+
+ // MARK: - Resume
+
+ /// Sets the interrupt resume entries for continuing a human-in-the-loop flow.
+ ///
+ /// Call this when the previous run finished with `RunFinishedOutcome.interrupt`.
+ /// Each `ResumeEntry` references one `Interrupt` by its `id` and carries the
+ /// caller's resolution status and optional response payload.
+ ///
+ /// - Parameter entries: The resume entries to include
+ /// - Returns: A new builder instance with resume entries set
+ public func resume(_ entries: [ResumeEntry]) -> Self {
+ var builder = self
+ builder._resume = entries
+ return builder
+ }
+
+ // MARK: - Build
+
+ /// Builds and returns a `RunAgentInput` instance.
+ ///
+ /// - Returns: A configured `RunAgentInput` instance
+ /// - Throws: `BuilderError` if required fields are missing
+ ///
+ /// Example:
+ /// ```swift
+ /// do {
+ /// let input = try RunAgentInput.builder()
+ /// .threadId("thread-1")
+ /// .runId("run-1")
+ /// .build()
+ /// } catch {
+ /// print("Failed to build input: \(error)")
+ /// }
+ /// ```
+ public func build() throws -> RunAgentInput {
+ guard let threadId = _threadId else {
+ throw BuilderError.missingThreadId
+ }
+ guard let runId = _runId else {
+ throw BuilderError.missingRunId
+ }
+
+ return RunAgentInput(
+ threadId: threadId,
+ runId: runId,
+ parentRunId: _parentRunId,
+ state: _state,
+ messages: _messages,
+ tools: _tools,
+ context: _context,
+ forwardedProps: _forwardedProps,
+ resume: _resume
+ )
+ }
+}
+
+// MARK: - Builder Error
+
+/// Errors that can occur when building a `RunAgentInput`.
+public enum BuilderError: Error {
+ /// The thread ID is required but was not set.
+ case missingThreadId
+
+ /// The run ID is required but was not set.
+ case missingRunId
+}
+
+extension BuilderError: LocalizedError {
+ public var errorDescription: String? {
+ switch self {
+ case .missingThreadId:
+ return "Thread ID is required. Call .threadId(_:) before building."
+ case .missingRunId:
+ return "Run ID is required. Call .runId(_:) before building."
+ }
+ }
+}
+
+// MARK: - RunAgentInput Extension
+
+extension RunAgentInput {
+ /// Creates a new builder for constructing `RunAgentInput` instances.
+ ///
+ /// Provides a fluent interface for building complex inputs with method chaining.
+ ///
+ /// - Returns: A new `RunAgentInputBuilder` instance
+ ///
+ /// Example:
+ /// ```swift
+ /// let input = RunAgentInput.builder()
+ /// .threadId("thread-123")
+ /// .runId("run-456")
+ /// .message(UserMessage(id: "msg-1", content: "Hello"))
+ /// .build()
+ /// ```
+ public static func builder() -> RunAgentInputBuilder {
+ RunAgentInputBuilder()
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Types/AgentExecution/State.swift b/sdks/community/swift/Sources/AGUICore/Types/AgentExecution/State.swift
new file mode 100644
index 0000000000..608d9510b9
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Types/AgentExecution/State.swift
@@ -0,0 +1,63 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Represents agent state as JSON data.
+///
+/// State is a type alias for `Data` that represents arbitrary JSON-formatted state
+/// information passed to and from agents during execution. The state can contain
+/// any JSON-serializable data structure including objects, arrays, primitives, and null.
+///
+/// ## Usage
+///
+/// State is primarily used in ``RunAgentInput`` to provide contextual information
+/// to agents. Applications can encode their state models as JSON and pass them
+/// to agent endpoints.
+///
+/// ```swift
+/// // Create state from a Codable model
+/// struct AppState: Codable {
+/// let sessionId: String
+/// let preferences: [String: String]
+/// }
+///
+/// let appState = AppState(
+/// sessionId: "session-123",
+/// preferences: ["theme": "dark"]
+/// )
+/// let state: State = try JSONEncoder().encode(appState)
+///
+/// // Or create state from raw JSON
+/// let jsonState: State = Data("""
+/// {
+/// "counter": 42,
+/// "items": ["a", "b", "c"]
+/// }
+/// """.utf8)
+/// ```
+///
+/// ## Default State
+///
+/// An empty JSON object `{}` is typically used as the default state:
+///
+/// ```swift
+/// let emptyState: State = Data("{}".utf8)
+/// ```
+///
+/// ## Type Design
+///
+/// This is intentionally a simple type alias rather than a custom type to maintain
+/// flexibility. Applications can:
+/// - Encode any Codable type into State
+/// - Decode State into any expected Codable type
+/// - Manipulate State as raw Data when needed
+/// - Pass State across actor boundaries (Data is Sendable)
+///
+/// Future versions may introduce a more structured `JSONValue` enum if type safety
+/// benefits outweigh flexibility concerns.
+///
+/// - Note: State must contain valid JSON data. Invalid JSON will cause decoding
+/// errors when processed by agents or client code.
+///
+/// - SeeAlso: ``RunAgentInput``
+public typealias State = Data
diff --git a/sdks/community/swift/Sources/AGUICore/Types/HumanInTheLoop/Interrupt.swift b/sdks/community/swift/Sources/AGUICore/Types/HumanInTheLoop/Interrupt.swift
new file mode 100644
index 0000000000..a629973546
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Types/HumanInTheLoop/Interrupt.swift
@@ -0,0 +1,190 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Describes a single human-in-the-loop interrupt emitted by an agent.
+///
+/// When an agent run finishes with `RunFinishedOutcome.interrupt`, it carries one or
+/// more `Interrupt` values that describe what the agent is waiting for and how the
+/// caller should respond.
+///
+/// On the wire this appears inside the `RunFinishedEvent.outcome` object:
+/// ```json
+/// {
+/// "type": "interrupt",
+/// "interrupts": [
+/// {
+/// "id": "interrupt-uuid",
+/// "reason": "Approval required",
+/// "toolCallId": "tool-call-abc",
+/// "responseSchema": { "approved": { "type": "boolean" } }
+/// }
+/// ]
+/// }
+/// ```
+///
+/// The caller resumes the run by sending a new `RunAgentInput` with a `resume` array
+/// containing a `ResumeEntry` that references this interrupt's `id`.
+///
+/// - SeeAlso: `RunFinishedOutcome`, `ResumeEntry`, `RunAgentInput`
+public struct Interrupt: Sendable, Equatable, Hashable {
+
+ /// Unique identifier for this interrupt.
+ public let id: String
+
+ /// Human-readable description of why the agent interrupted.
+ public let reason: String
+
+ /// Optional message for the user.
+ public let message: String?
+
+ /// ID of the tool call that triggered this interrupt, if applicable.
+ public let toolCallId: String?
+
+ /// JSON Schema describing the expected response payload.
+ ///
+ /// Corresponds to `responseSchema: z.record(z.any()).optional()` in the TypeScript spec.
+ /// Stored as raw JSON `Data` because the schema structure is arbitrary.
+ public let responseSchema: Data?
+
+ /// Optional expiry timestamp (ISO-8601 string) after which this interrupt is no longer valid.
+ public let expiresAt: String?
+
+ /// Arbitrary tool metadata as raw JSON.
+ ///
+ /// Corresponds to `metadata: z.record(z.any()).optional()` in the TypeScript spec.
+ public let metadata: Data?
+
+ /// Creates a new `Interrupt`.
+ public init(
+ id: String,
+ reason: String,
+ message: String? = nil,
+ toolCallId: String? = nil,
+ responseSchema: Data? = nil,
+ expiresAt: String? = nil,
+ metadata: Data? = nil
+ ) {
+ self.id = id
+ self.reason = reason
+ self.message = message
+ self.toolCallId = toolCallId
+ self.responseSchema = responseSchema
+ self.expiresAt = expiresAt
+ self.metadata = metadata
+ }
+}
+
+// MARK: - Decoding
+
+extension Interrupt {
+
+ /// Decodes an `Interrupt` from a raw JSON dictionary extracted via `JSONSerialization`.
+ ///
+ /// - Parameter dict: A `[String: Any]` dictionary for a single interrupt object.
+ /// - Throws: `DecodingError` when required fields are missing or have the wrong type.
+ static func decode(from dict: [String: Any]) throws -> Interrupt {
+ guard let id = dict["id"] as? String else {
+ throw DecodingError.keyNotFound(
+ CodingKeys.id,
+ DecodingError.Context(codingPath: [], debugDescription: "Missing required field: id")
+ )
+ }
+ guard let reason = dict["reason"] as? String else {
+ throw DecodingError.keyNotFound(
+ CodingKeys.reason,
+ DecodingError.Context(codingPath: [], debugDescription: "Missing required field: reason")
+ )
+ }
+
+ let message = dict["message"] as? String
+ let toolCallId = dict["toolCallId"] as? String
+ let expiresAt = dict["expiresAt"] as? String
+
+ var responseSchema: Data?
+ if let raw = dict["responseSchema"], !(raw is NSNull) {
+ responseSchema = try? JSONSerialization.data(withJSONObject: raw)
+ }
+
+ var metadata: Data?
+ if let raw = dict["metadata"], !(raw is NSNull) {
+ metadata = try? JSONSerialization.data(withJSONObject: raw)
+ }
+
+ return Interrupt(
+ id: id,
+ reason: reason,
+ message: message,
+ toolCallId: toolCallId,
+ responseSchema: responseSchema,
+ expiresAt: expiresAt,
+ metadata: metadata
+ )
+ }
+}
+
+// MARK: - Codable
+
+extension Interrupt: Codable {
+
+ private enum CodingKeys: String, CodingKey {
+ case id, reason, message, toolCallId, responseSchema, expiresAt, metadata
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ id = try container.decode(String.self, forKey: .id)
+ reason = try container.decode(String.self, forKey: .reason)
+ message = try container.decodeIfPresent(String.self, forKey: .message)
+ toolCallId = try container.decodeIfPresent(String.self, forKey: .toolCallId)
+ expiresAt = try container.decodeIfPresent(String.self, forKey: .expiresAt)
+
+ // Decode arbitrary JSON objects → Data
+ if container.contains(.responseSchema),
+ !(try container.decodeNil(forKey: .responseSchema)) {
+ let schemaContainer = try container.nestedContainer(
+ keyedBy: JSONCodingKeys.self, forKey: .responseSchema
+ )
+ let schemaObject = try schemaContainer.decodeJSONObject()
+ responseSchema = try JSONSerialization.data(withJSONObject: schemaObject)
+ } else {
+ responseSchema = nil
+ }
+
+ if container.contains(.metadata),
+ !(try container.decodeNil(forKey: .metadata)) {
+ let metaContainer = try container.nestedContainer(
+ keyedBy: JSONCodingKeys.self, forKey: .metadata
+ )
+ let metaObject = try metaContainer.decodeJSONObject()
+ metadata = try JSONSerialization.data(withJSONObject: metaObject)
+ } else {
+ metadata = nil
+ }
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+ try container.encode(id, forKey: .id)
+ try container.encode(reason, forKey: .reason)
+ try container.encodeIfPresent(message, forKey: .message)
+ try container.encodeIfPresent(toolCallId, forKey: .toolCallId)
+ try container.encodeIfPresent(expiresAt, forKey: .expiresAt)
+
+ if let responseSchema {
+ let schemaObject = try JSONSerialization.jsonObject(with: responseSchema)
+ var schemaContainer = container.nestedContainer(
+ keyedBy: JSONCodingKeys.self, forKey: .responseSchema
+ )
+ try schemaContainer.encodeJSONObject(schemaObject)
+ }
+
+ if let metadata {
+ let metaObject = try JSONSerialization.jsonObject(with: metadata)
+ var metaContainer = container.nestedContainer(
+ keyedBy: JSONCodingKeys.self, forKey: .metadata
+ )
+ try metaContainer.encodeJSONObject(metaObject)
+ }
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Types/HumanInTheLoop/ResumeEntry.swift b/sdks/community/swift/Sources/AGUICore/Types/HumanInTheLoop/ResumeEntry.swift
new file mode 100644
index 0000000000..683331d21d
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Types/HumanInTheLoop/ResumeEntry.swift
@@ -0,0 +1,159 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Records the caller's response to a single human-in-the-loop interrupt.
+///
+/// When an agent run finishes with `RunFinishedOutcome.interrupt`, the caller
+/// resolves or cancels each interrupt and then restarts the run by including a
+/// `resume` array in the next `RunAgentInput`.
+///
+/// ```swift
+/// let resumeEntry = ResumeEntry(
+/// interruptId: "int-1",
+/// status: .resolved,
+/// payload: Data(#"{"approved": true}"#.utf8)
+/// )
+///
+/// let input = RunAgentInput.builder()
+/// .threadId(threadId)
+/// .runId(UUID().uuidString)
+/// .resume([resumeEntry])
+/// .build()
+/// ```
+///
+/// - SeeAlso: `Interrupt`, `RunFinishedOutcome`, `RunAgentInput`
+public struct ResumeEntry: Sendable, Equatable, Hashable {
+
+ /// The `id` of the `Interrupt` this entry responds to.
+ public let interruptId: String
+
+ /// Whether the interrupt was resolved or cancelled.
+ public let status: ResumeStatus
+
+ /// Arbitrary JSON payload supplied by the caller in response to the interrupt.
+ ///
+ /// Corresponds to `payload: z.any().optional()` in the TypeScript spec.
+ /// Stored as raw JSON `Data` because the payload structure is defined by the
+ /// agent's `responseSchema`.
+ public let payload: Data?
+
+ /// Creates a new `ResumeEntry`.
+ public init(interruptId: String, status: ResumeStatus, payload: Data? = nil) {
+ self.interruptId = interruptId
+ self.status = status
+ self.payload = payload
+ }
+
+ /// The resolution status of an interrupt.
+ public enum ResumeStatus: String, Sendable, Equatable, Hashable, Codable {
+ case resolved
+ case cancelled
+ }
+}
+
+// MARK: - Codable
+
+extension ResumeEntry: Codable {
+
+ private enum CodingKeys: String, CodingKey {
+ case interruptId, status, payload
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ interruptId = try container.decode(String.self, forKey: .interruptId)
+ status = try container.decode(ResumeStatus.self, forKey: .status)
+
+ // payload is z.any() — it can be any JSON value: object, array, string,
+ // number, or boolean. We preserve it as raw JSON Data.
+ if container.contains(.payload), !(try container.decodeNil(forKey: .payload)) {
+ payload = try Self.decodeAnyPayload(from: container, forKey: .payload)
+ } else {
+ payload = nil
+ }
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+ try container.encode(interruptId, forKey: .interruptId)
+ try container.encode(status, forKey: .status)
+
+ if let payload {
+ try Self.encodeAnyPayload(payload, into: &container, forKey: .payload)
+ }
+ // When payload is nil, omit the key entirely (encodeIfPresent semantics)
+ }
+
+ // MARK: - Any-JSON payload helpers
+
+ /// Decodes a JSON value of any type into raw `Data`.
+ ///
+ /// Scalar values (bool, number, string) are serialized directly to their JSON byte
+ /// representations (`true`, `false`, `42`, `"hello"`) rather than through
+ /// `JSONSerialization.data(withJSONObject:)`, which only accepts top-level
+ /// containers (dict or array) on all supported platform versions.
+ private static func decodeAnyPayload(
+ from container: KeyedDecodingContainer,
+ forKey key: CodingKeys
+ ) throws -> Data? {
+ // Try object
+ if let objectContainer = try? container.nestedContainer(keyedBy: JSONCodingKeys.self, forKey: key) {
+ let object = try objectContainer.decodeJSONObject()
+ return try JSONSerialization.data(withJSONObject: object)
+ }
+ // Try array
+ if var arrayContainer = try? container.nestedUnkeyedContainer(forKey: key) {
+ let array = try arrayContainer.decodeJSONArray()
+ return try JSONSerialization.data(withJSONObject: array)
+ }
+ // Decode scalar types — Bool before Int/Double to avoid NSNumber ambiguity.
+ // Use raw JSON bytes; JSONSerialization.data() does not accept scalars as top-level values.
+ if let value = try? container.decode(Bool.self, forKey: key) {
+ return Data(value ? "true".utf8 : "false".utf8)
+ }
+ if let value = try? container.decode(Int.self, forKey: key) {
+ return Data("\(value)".utf8)
+ }
+ if let value = try? container.decode(Double.self, forKey: key) {
+ return Data("\(value)".utf8)
+ }
+ if let value = try? container.decode(String.self, forKey: key) {
+ // Produce a properly JSON-escaped quoted string via array wrapper.
+ let wrapped = try JSONSerialization.data(withJSONObject: [value])
+ if let json = String(data: wrapped, encoding: .utf8),
+ json.hasPrefix("[") && json.hasSuffix("]") {
+ return Data(json.dropFirst().dropLast().utf8)
+ }
+ return Data("\"\(value)\"".utf8)
+ }
+ return nil
+ }
+
+ /// Encodes the `Data` payload back as the correct JSON value type.
+ private static func encodeAnyPayload(
+ _ payload: Data,
+ into container: inout KeyedEncodingContainer,
+ forKey key: CodingKeys
+ ) throws {
+ let value = try JSONSerialization.jsonObject(with: payload, options: .fragmentsAllowed)
+
+ if let dict = value as? [String: Any] {
+ var nested = container.nestedContainer(keyedBy: JSONCodingKeys.self, forKey: key)
+ try nested.encodeJSONObject(dict)
+ } else if let array = value as? [Any] {
+ var nested = container.nestedUnkeyedContainer(forKey: key)
+ try nested.encodeJSONArray(array)
+ } else if let bool = value as? Bool {
+ try container.encode(bool, forKey: key)
+ } else if let int = value as? Int {
+ try container.encode(int, forKey: key)
+ } else if let double = value as? Double {
+ try container.encode(double, forKey: key)
+ } else if let string = value as? String {
+ try container.encode(string, forKey: key)
+ } else {
+ try container.encodeNil(forKey: key)
+ }
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Types/InputContent/AudioInputContent.swift b/sdks/community/swift/Sources/AGUICore/Types/InputContent/AudioInputContent.swift
new file mode 100644
index 0000000000..d764c2cdb8
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Types/InputContent/AudioInputContent.swift
@@ -0,0 +1,58 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Represents audio content in multimodal user input.
+///
+/// `AudioInputContent` carries audio data either as a URL reference or as
+/// base64-encoded bytes. The optional `format` field identifies the audio codec
+/// (e.g., `"mp3"`, `"wav"`, `"ogg"`).
+///
+/// - SeeAlso: ``InputContent``, ``UserMessage``
+public struct AudioInputContent: InputContent, Hashable, Sendable {
+
+ /// The content type discriminator (always `"audio"`).
+ public let type: String
+
+ /// Optional URL pointing to the audio file.
+ public let url: String?
+
+ /// Optional base64-encoded audio data.
+ public let data: String?
+
+ /// Optional audio format identifier.
+ ///
+ /// Common values: `"mp3"`, `"wav"`, `"ogg"`, `"flac"`.
+ public let format: String?
+
+ /// Optional MIME type of the audio (e.g., `"audio/mpeg"`, `"audio/wav"`).
+ public let mimeType: String?
+
+ /// Creates audio content from a URL.
+ ///
+ /// - Parameters:
+ /// - url: URL pointing to the audio file
+ /// - format: Optional format identifier (e.g., `"mp3"`)
+ /// - mimeType: Optional MIME type (e.g., `"audio/mpeg"`)
+ public init(url: String, format: String? = nil, mimeType: String? = nil) {
+ self.type = "audio"
+ self.url = url
+ self.data = nil
+ self.format = format
+ self.mimeType = mimeType
+ }
+
+ /// Creates audio content from base64-encoded data.
+ ///
+ /// - Parameters:
+ /// - data: Base64-encoded audio bytes
+ /// - format: Optional format identifier (e.g., `"wav"`)
+ /// - mimeType: Optional MIME type (e.g., `"audio/wav"`)
+ public init(data: String, format: String? = nil, mimeType: String? = nil) {
+ self.type = "audio"
+ self.url = nil
+ self.data = data
+ self.format = format
+ self.mimeType = mimeType
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Types/InputContent/BinaryInputContent.swift b/sdks/community/swift/Sources/AGUICore/Types/InputContent/BinaryInputContent.swift
new file mode 100644
index 0000000000..8694a7f26e
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Types/InputContent/BinaryInputContent.swift
@@ -0,0 +1,203 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Represents binary data (images, audio, documents) in multimodal user input.
+///
+/// `BinaryInputContent` enables users to include rich media in their messages,
+/// supporting images, audio, video, documents, and other binary formats. Binary
+/// content can be provided via URL, identifier, or embedded base64-encoded data.
+///
+/// ## Source Options
+///
+/// Binary content must specify at least one source:
+/// - **URL**: Link to externally hosted content
+/// - **ID**: Identifier for content stored in a content management system
+/// - **Data**: Base64-encoded binary data embedded directly in the message
+///
+/// ## MIME Types
+///
+/// The `mimeType` field identifies the content format:
+/// - Images: `"image/jpeg"`, `"image/png"`, `"image/gif"`, `"image/webp"`
+/// - Documents: `"application/pdf"`, `"application/msword"`
+/// - Audio: `"audio/mpeg"`, `"audio/wav"`, `"audio/ogg"`
+/// - Video: `"video/mp4"`, `"video/webm"`
+///
+/// ## Usage Examples
+///
+/// ```swift
+/// // Image from URL
+/// let imageContent = BinaryInputContent(
+/// mimeType: "image/jpeg",
+/// url: "https://example.com/photo.jpg",
+/// filename: "vacation.jpg"
+/// )
+///
+/// // Embedded base64 image
+/// let embeddedImage = BinaryInputContent(
+/// mimeType: "image/png",
+/// data: "iVBORw0KGgoAAAANSUhEUg...",
+/// filename: "screenshot.png"
+/// )
+///
+/// // Document by ID
+/// let document = BinaryInputContent(
+/// mimeType: "application/pdf",
+/// id: "doc-annual-report-2024"
+/// )
+///
+/// // Multimodal message
+/// let contents: [any InputContent] = [
+/// TextInputContent(text: "Analyze this image and document:"),
+/// imageContent,
+/// document
+/// ]
+/// ```
+///
+/// ## Content Source Selection
+///
+/// When multiple sources are provided, the consuming system typically prioritizes:
+/// 1. **Data** (embedded): Fastest, no external fetch required
+/// 2. **URL**: Enables streaming large files
+/// 3. **ID**: Requires content management system integration
+///
+/// - SeeAlso: ``InputContent``, ``TextInputContent``, ``UserMessage``
+public struct BinaryInputContent: InputContent {
+ /// Validation errors for BinaryInputContent
+ public enum ValidationError: Error, LocalizedError {
+ case noSourceProvided
+
+ public var errorDescription: String? {
+ switch self {
+ case .noSourceProvided:
+ return "BinaryInputContent requires id, url, or data to be provided"
+ }
+ }
+ }
+
+ /// The content type discriminator (always "binary").
+ public let type: String
+
+ /// The MIME type identifying the binary content format.
+ ///
+ /// Common MIME types:
+ /// - Images: `image/jpeg`, `image/png`, `image/gif`
+ /// - Documents: `application/pdf`, `text/plain`
+ /// - Audio: `audio/mpeg`, `audio/wav`
+ /// - Video: `video/mp4`, `video/quicktime`
+ public let mimeType: String
+
+ /// Optional identifier for retrieving the binary content.
+ ///
+ /// Used when content is stored in a content management system or
+ /// blob storage and can be retrieved by ID.
+ public let id: String?
+
+ /// Optional URL pointing to the binary content.
+ ///
+ /// The URL should be accessible to the agent system and return the
+ /// binary content with the specified MIME type.
+ public let url: String?
+
+ /// Optional base64-encoded binary data.
+ ///
+ /// For smaller files (images under ~1MB, short audio clips), data can be
+ /// embedded directly in the message as base64. Larger files should use
+ /// URL or ID references.
+ public let data: String?
+
+ /// Optional original filename of the binary content.
+ ///
+ /// Provides context about the content and can be used when saving
+ /// or displaying the file.
+ public let filename: String?
+
+ /// Creates a new binary content instance.
+ ///
+ /// - Parameters:
+ /// - mimeType: The MIME type of the binary content
+ /// - id: Optional identifier for content retrieval
+ /// - url: Optional URL pointing to the content
+ /// - data: Optional base64-encoded binary data
+ /// - filename: Optional original filename
+ ///
+ /// - Throws: `ValidationError.noSourceProvided` if none of id, url, or data are provided
+ public init(
+ mimeType: String,
+ id: String? = nil,
+ url: String? = nil,
+ data: String? = nil,
+ filename: String? = nil
+ ) throws {
+ // Validate that at least one source is provided
+ try Self.validate(mimeType: mimeType, id: id, url: url, data: data)
+
+ self.type = "binary"
+ self.mimeType = mimeType
+ self.id = id
+ self.url = url
+ self.data = data
+ self.filename = filename
+ }
+
+ /// Validates that at least one source field is provided.
+ internal static func validate(
+ mimeType: String,
+ id: String?,
+ url: String?,
+ data: String?
+ ) throws {
+ guard id != nil || url != nil || data != nil else {
+ throw ValidationError.noSourceProvided
+ }
+ }
+}
+
+// MARK: - Convenience Initializers
+
+extension BinaryInputContent {
+ /// Creates binary content from a URL.
+ ///
+ /// - Parameters:
+ /// - mimeType: The MIME type of the content
+ /// - url: URL pointing to the binary content
+ /// - filename: Optional original filename
+ public init(mimeType: String, url: String, filename: String? = nil) {
+ self.type = "binary"
+ self.mimeType = mimeType
+ self.id = nil
+ self.url = url
+ self.data = nil
+ self.filename = filename
+ }
+
+ /// Creates binary content from an ID.
+ ///
+ /// - Parameters:
+ /// - mimeType: The MIME type of the content
+ /// - id: Identifier for content retrieval
+ /// - filename: Optional original filename
+ public init(mimeType: String, id: String, filename: String? = nil) {
+ self.type = "binary"
+ self.mimeType = mimeType
+ self.id = id
+ self.url = nil
+ self.data = nil
+ self.filename = filename
+ }
+
+ /// Creates binary content from base64-encoded data.
+ ///
+ /// - Parameters:
+ /// - mimeType: The MIME type of the content
+ /// - data: Base64-encoded binary data
+ /// - filename: Optional original filename
+ public init(mimeType: String, data: String, filename: String? = nil) {
+ self.type = "binary"
+ self.mimeType = mimeType
+ self.id = nil
+ self.url = nil
+ self.data = data
+ self.filename = filename
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Types/InputContent/DocumentInputContent.swift b/sdks/community/swift/Sources/AGUICore/Types/InputContent/DocumentInputContent.swift
new file mode 100644
index 0000000000..77919e74af
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Types/InputContent/DocumentInputContent.swift
@@ -0,0 +1,56 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Represents a document in multimodal user input.
+///
+/// `DocumentInputContent` carries document data either as a URL reference or as
+/// base64-encoded bytes. The optional `mimeType` and `title` fields provide
+/// metadata about the document.
+///
+/// - SeeAlso: ``InputContent``, ``UserMessage``
+public struct DocumentInputContent: InputContent, Hashable, Sendable {
+
+ /// The content type discriminator (always `"document"`).
+ public let type: String
+
+ /// Optional URL pointing to the document.
+ public let url: String?
+
+ /// Optional base64-encoded document data.
+ public let data: String?
+
+ /// Optional MIME type of the document (e.g., `"application/pdf"`, `"text/plain"`).
+ public let mimeType: String?
+
+ /// Optional human-readable title for the document.
+ public let title: String?
+
+ /// Creates document content from a URL.
+ ///
+ /// - Parameters:
+ /// - url: URL pointing to the document
+ /// - mimeType: Optional MIME type (e.g., `"application/pdf"`)
+ /// - title: Optional document title
+ public init(url: String, mimeType: String? = nil, title: String? = nil) {
+ self.type = "document"
+ self.url = url
+ self.data = nil
+ self.mimeType = mimeType
+ self.title = title
+ }
+
+ /// Creates document content from base64-encoded data.
+ ///
+ /// - Parameters:
+ /// - data: Base64-encoded document bytes
+ /// - mimeType: Optional MIME type (e.g., `"application/pdf"`)
+ /// - title: Optional document title
+ public init(data: String, mimeType: String? = nil, title: String? = nil) {
+ self.type = "document"
+ self.url = nil
+ self.data = data
+ self.mimeType = mimeType
+ self.title = title
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Types/InputContent/ImageInputContent.swift b/sdks/community/swift/Sources/AGUICore/Types/InputContent/ImageInputContent.swift
new file mode 100644
index 0000000000..0de7395491
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Types/InputContent/ImageInputContent.swift
@@ -0,0 +1,58 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Represents an image in multimodal user input.
+///
+/// `ImageInputContent` carries image data either as a URL reference or as
+/// base64-encoded bytes. The optional `detail` field controls how agents
+/// with vision capabilities process the image (e.g., `"high"`, `"low"`, `"auto"`).
+///
+/// - SeeAlso: ``InputContent``, ``UserMessage``
+public struct ImageInputContent: InputContent, Hashable, Sendable {
+
+ /// The content type discriminator (always `"image"`).
+ public let type: String
+
+ /// Optional URL pointing to the image.
+ public let url: String?
+
+ /// Optional base64-encoded image data.
+ public let data: String?
+
+ /// Optional detail level hint for vision-capable agents.
+ ///
+ /// Common values: `"high"`, `"low"`, `"auto"`.
+ public let detail: String?
+
+ /// Optional MIME type of the image (e.g., `"image/png"`, `"image/jpeg"`).
+ public let mimeType: String?
+
+ /// Creates an image content from a URL.
+ ///
+ /// - Parameters:
+ /// - url: URL pointing to the image
+ /// - detail: Optional detail level (`"high"`, `"low"`, `"auto"`)
+ /// - mimeType: Optional MIME type (e.g., `"image/png"`)
+ public init(url: String, detail: String? = nil, mimeType: String? = nil) {
+ self.type = "image"
+ self.url = url
+ self.data = nil
+ self.detail = detail
+ self.mimeType = mimeType
+ }
+
+ /// Creates an image content from base64-encoded data.
+ ///
+ /// - Parameters:
+ /// - data: Base64-encoded image bytes
+ /// - detail: Optional detail level (`"high"`, `"low"`, `"auto"`)
+ /// - mimeType: Optional MIME type (e.g., `"image/jpeg"`)
+ public init(data: String, detail: String? = nil, mimeType: String? = nil) {
+ self.type = "image"
+ self.url = nil
+ self.data = data
+ self.detail = detail
+ self.mimeType = mimeType
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Types/InputContent/InputContent.swift b/sdks/community/swift/Sources/AGUICore/Types/InputContent/InputContent.swift
new file mode 100644
index 0000000000..b837ce3858
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Types/InputContent/InputContent.swift
@@ -0,0 +1,91 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Base protocol for multimodal content in user messages.
+///
+/// `InputContent` enables users to send rich, multimodal input to agents beyond
+/// simple text. The protocol supports polymorphic content types distinguished by
+/// a `type` discriminator field.
+///
+/// ## Content Types
+///
+/// The AG-UI protocol defines six concrete content types:
+/// - ``TextInputContent``: Plain text fragments (`"text"`)
+/// - ``BinaryInputContent``: Legacy catch-all binary data with MIME types (`"binary"`)
+/// - ``ImageInputContent``: Images with optional detail level (`"image"`)
+/// - ``AudioInputContent``: Audio with optional format (`"audio"`)
+/// - ``VideoInputContent``: Video data (`"video"`)
+/// - ``DocumentInputContent``: Documents with optional MIME type and title (`"document"`)
+///
+/// ## Polymorphic Serialization
+///
+/// Content types use the `type` field as a discriminator for JSON serialization:
+/// - `"text"`: Deserializes to ``TextInputContent``
+/// - `"binary"`: Deserializes to ``BinaryInputContent``
+/// - `"image"`: Deserializes to ``ImageInputContent``
+/// - `"audio"`: Deserializes to ``AudioInputContent``
+/// - `"video"`: Deserializes to ``VideoInputContent``
+/// - `"document"`: Deserializes to ``DocumentInputContent``
+///
+/// ## Usage in UserMessage
+///
+/// User messages can contain:
+/// 1. **Simple text**: Single string content field
+/// 2. **Multimodal**: Array of InputContent mixing text and binary data
+///
+/// ```swift
+/// // Multimodal message with text and image
+/// let contents: [any InputContent] = [
+/// TextInputContent(text: "What's in this image?"),
+/// BinaryInputContent(
+/// mimeType: "image/png",
+/// url: "https://example.com/photo.png"
+/// )
+/// ]
+/// ```
+///
+/// ## Type Discrimination
+///
+/// The `type` property identifies the concrete content type during deserialization,
+/// enabling the protocol to route JSON to the appropriate Swift type via DTOs.
+///
+/// ## Serialization
+///
+/// InputContent types use the DTO pattern for serialization:
+/// - Decoding is handled by ``TextInputContentDTO`` and ``BinaryInputContentDTO``
+/// - Encoding is handled by ``UserMessageDTO`` when encoding UserMessage
+///
+/// - SeeAlso: ``TextInputContent``, ``BinaryInputContent``, ``UserMessage``
+public protocol InputContent: Sendable, Hashable {
+ /// The content type discriminator.
+ ///
+ /// This field identifies the concrete content type:
+ /// - `"text"` for ``TextInputContent``
+ /// - `"binary"` for ``BinaryInputContent``
+ ///
+ /// The type field enables polymorphic deserialization, allowing mixed
+ /// content arrays to contain different concrete types.
+ var type: String { get }
+}
+
+// MARK: - Type-erased equality and hashing for existential arrays
+
+extension InputContent {
+ /// Returns `true` if `other` is the same concrete type and compares equal.
+ ///
+ /// Because `[any InputContent]` cannot use `==` directly on the existential,
+ /// this helper downcasts to `Self` before delegating to the type's `Equatable`
+ /// conformance. Used by ``UserMessage`` to compare `contentParts`.
+ func isEqual(to other: any InputContent) -> Bool {
+ guard let other = other as? Self else { return false }
+ return self == other
+ }
+
+ /// A type-erased wrapper suitable for use with `Hasher`.
+ ///
+ /// In a protocol extension `self` is the concrete conforming type, so
+ /// `AnyHashable(self)` wraps the underlying value without losing type
+ /// information. Used by ``UserMessage`` to hash `contentParts`.
+ var anyHashable: AnyHashable { AnyHashable(self) }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Types/InputContent/TextInputContent.swift b/sdks/community/swift/Sources/AGUICore/Types/InputContent/TextInputContent.swift
new file mode 100644
index 0000000000..20bb6f99bd
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Types/InputContent/TextInputContent.swift
@@ -0,0 +1,68 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Represents a text fragment in multimodal user input.
+///
+/// `TextInputContent` is the simplest form of user input content, containing
+/// plain text. It is used alongside ``BinaryInputContent`` to build multimodal
+/// messages that combine text and binary data.
+///
+/// ## Usage
+///
+/// Text content can be used in two ways:
+///
+/// 1. **Simple text messages** (via UserMessage string content)
+/// 2. **Multimodal messages** (via UserMessage content array)
+///
+/// ```swift
+/// // Standalone text content
+/// let textContent = TextInputContent(
+/// text: "What is the capital of France?"
+/// )
+///
+/// // Mixed with binary content in multimodal message
+/// let contents: [any InputContent] = [
+/// TextInputContent(text: "Analyze this image:"),
+/// BinaryInputContent(
+/// mimeType: "image/jpeg",
+/// url: "https://example.com/photo.jpg"
+/// ),
+/// TextInputContent(text: "What objects do you see?")
+/// ]
+/// ```
+///
+/// ## Text Content Characteristics
+///
+/// - **Simple**: Contains only text, no formatting or metadata
+/// - **Flexible**: Supports any string content including Unicode, code, markdown
+/// - **Composable**: Can be interleaved with binary content in multimodal messages
+///
+/// ## Type Discrimination
+///
+/// The `type` field is always `"text"`, enabling polymorphic deserialization
+/// when InputContent arrays are decoded from JSON.
+///
+/// - SeeAlso: ``InputContent``, ``BinaryInputContent``, ``UserMessage``
+public struct TextInputContent: InputContent {
+ /// The content type discriminator (always "text").
+ public let type: String
+
+ /// The text content.
+ ///
+ /// This can contain:
+ /// - Simple questions or statements
+ /// - Multi-line text with formatting
+ /// - Code snippets
+ /// - Markdown
+ /// - Unicode characters and emoji
+ public let text: String
+
+ /// Creates a new text content instance.
+ ///
+ /// - Parameter text: The text content
+ public init(text: String) {
+ self.type = "text"
+ self.text = text
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Types/InputContent/VideoInputContent.swift b/sdks/community/swift/Sources/AGUICore/Types/InputContent/VideoInputContent.swift
new file mode 100644
index 0000000000..11cbfa2d54
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Types/InputContent/VideoInputContent.swift
@@ -0,0 +1,48 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Represents video content in multimodal user input.
+///
+/// `VideoInputContent` carries video data either as a URL reference or as
+/// base64-encoded bytes.
+///
+/// - SeeAlso: ``InputContent``, ``UserMessage``
+public struct VideoInputContent: InputContent, Hashable, Sendable {
+
+ /// The content type discriminator (always `"video"`).
+ public let type: String
+
+ /// Optional URL pointing to the video file.
+ public let url: String?
+
+ /// Optional base64-encoded video data.
+ public let data: String?
+
+ /// Optional MIME type of the video (e.g., `"video/mp4"`, `"video/webm"`).
+ public let mimeType: String?
+
+ /// Creates video content from a URL.
+ ///
+ /// - Parameters:
+ /// - url: URL pointing to the video file
+ /// - mimeType: Optional MIME type (e.g., `"video/mp4"`)
+ public init(url: String, mimeType: String? = nil) {
+ self.type = "video"
+ self.url = url
+ self.data = nil
+ self.mimeType = mimeType
+ }
+
+ /// Creates video content from base64-encoded data.
+ ///
+ /// - Parameters:
+ /// - data: Base64-encoded video bytes
+ /// - mimeType: Optional MIME type (e.g., `"video/webm"`)
+ public init(data: String, mimeType: String? = nil) {
+ self.type = "video"
+ self.url = nil
+ self.data = data
+ self.mimeType = mimeType
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Types/Messages/ActivityMessage.swift b/sdks/community/swift/Sources/AGUICore/Types/Messages/ActivityMessage.swift
new file mode 100644
index 0000000000..73c5fddc8e
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Types/Messages/ActivityMessage.swift
@@ -0,0 +1,133 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Represents a structured activity update in the conversation.
+///
+/// `ActivityMessage` enables agents to stream dynamic UI elements, progress indicators,
+/// A2UI surfaces, and other structured content beyond simple text messages.
+///
+/// ## Activity Types
+///
+/// The `activityType` field identifies the kind of activity:
+/// - **Progress**: Progress indicators and status updates
+/// - **Visualization**: Charts, graphs, and data visualizations
+/// - **A2UI Surface**: Interactive UI components (forms, buttons, etc.)
+/// - **Status**: System status and state updates
+/// - **Custom**: Application-specific activity types
+///
+/// ## Content
+///
+/// The `content` field stores flexible JSON data as a `Data` object,
+/// allowing each activity type to define its own content structure.
+///
+/// ## Usage Examples
+///
+/// ```swift
+/// // Progress indicator
+/// let progressContent = Data("""
+/// {
+/// "percent": 75,
+/// "message": "Processing files...",
+/// "current": 15,
+/// "total": 20
+/// }
+/// """.utf8)
+///
+/// let progress = ActivityMessage(
+/// id: "progress-1",
+/// activityType: "progress",
+/// content: progressContent
+/// )
+///
+/// // Chart visualization
+/// let chartContent = Data("""
+/// {
+/// "chartType": "bar",
+/// "data": {
+/// "labels": ["Q1", "Q2", "Q3", "Q4"],
+/// "datasets": [
+/// {"label": "Sales", "values": [100, 150, 120, 180]}
+/// ]
+/// }
+/// }
+/// """.utf8)
+///
+/// let chart = ActivityMessage(
+/// id: "viz-1",
+/// activityType: "chart",
+/// content: chartContent
+/// )
+///
+/// // A2UI form surface
+/// let formContent = Data("""
+/// {
+/// "surfaceType": "form",
+/// "fields": [
+/// {"name": "email", "type": "text"},
+/// {"name": "submit", "type": "button"}
+/// ]
+/// }
+/// """.utf8)
+///
+/// let form = ActivityMessage(
+/// id: "surface-1",
+/// activityType: "a2ui-form",
+/// content: formContent
+/// )
+/// ```
+///
+/// ## Message Protocol
+///
+/// ActivityMessage conforms to the Message protocol. `name` and `encryptedValue`
+/// are always `nil` since activities use structured JSON `content` instead.
+///
+/// - SeeAlso: ``Message``, ``Role``
+public struct ActivityMessage: Message, Sendable, Hashable {
+ /// The unique identifier for this message.
+ public let id: String
+
+ /// The message role (always `.activity`).
+ public let role: Role
+
+ /// The type of activity being reported.
+ ///
+ /// Common activity types:
+ /// - `"progress"`: Progress indicators and completion status
+ /// - `"chart"`: Data visualizations and charts
+ /// - `"a2ui-form"`: Interactive form surfaces
+ /// - `"status"`: System status updates
+ /// - Custom application-specific types
+ public let activityType: String
+
+ /// The structured activity content as JSON data.
+ ///
+ /// This field contains a JSON object with activity-specific data.
+ /// The structure varies based on the `activityType`.
+ public let content: Data
+
+ /// Optional sender name (always `nil` for activity messages).
+ public let name: String? = nil
+
+ /// Encrypted value (always `nil` for activity messages).
+ ///
+ /// The AG-UI protocol does not define `encryptedValue` for activity messages.
+ public let encryptedValue: String? = nil
+
+ /// Creates a new activity message.
+ ///
+ /// - Parameters:
+ /// - id: Unique identifier for the message
+ /// - activityType: The type of activity
+ /// - content: JSON data representing the activity content
+ public init(
+ id: String,
+ activityType: String,
+ content: Data
+ ) {
+ self.id = id
+ self.role = .activity
+ self.activityType = activityType
+ self.content = content
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Types/Messages/AssistantMessage.swift b/sdks/community/swift/Sources/AGUICore/Types/Messages/AssistantMessage.swift
new file mode 100644
index 0000000000..f3873f06b0
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Types/Messages/AssistantMessage.swift
@@ -0,0 +1,155 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// A message containing the AI agent's responses.
+///
+/// Assistant messages represent the agent's output, which can include:
+/// - **Text content**: Responses, explanations, questions to the user
+/// - **Tool calls**: Requests to execute external functions
+/// - **Mixed content**: Text combined with tool calls for explained actions
+///
+/// ## Content Types
+///
+/// Assistant messages can contain:
+/// 1. **Text only**: Simple responses without tool invocations
+/// 2. **Tool calls only**: Silent function invocations without explanation
+/// 3. **Text + tool calls**: Explained actions combining both
+///
+/// ## Streaming Construction
+///
+/// Assistant messages may be built incrementally through streaming events:
+/// 1. Start with basic structure (id, role)
+/// 2. Add text content through TextMessageContent events
+/// 3. Add tool calls through ToolCallStart/Args/End events
+/// 4. Complete with TextMessageEnd or ToolCallEnd events
+///
+/// ## Example
+///
+/// ```swift
+/// // Simple text response
+/// let textResponse = AssistantMessage(
+/// id: "asst-1",
+/// content: "I understand your question. Let me explain..."
+/// )
+///
+/// // Tool call with explanation
+/// let weatherCall = ToolCall(
+/// id: "call_weather",
+/// function: FunctionCall(
+/// name: "get_current_weather",
+/// arguments: "{\"location\":\"San Francisco\"}"
+/// )
+/// )
+///
+/// let toolResponse = AssistantMessage(
+/// id: "asst-2",
+/// content: "Let me check the weather for you.",
+/// toolCalls: [weatherCall]
+/// )
+///
+/// // Multiple simultaneous tool calls
+/// let multiToolResponse = AssistantMessage(
+/// id: "asst-3",
+/// content: "Gathering information from multiple sources...",
+/// toolCalls: [
+/// ToolCall(...),
+/// ToolCall(...),
+/// ToolCall(...)
+/// ]
+/// )
+/// ```
+///
+/// ## Tool Call Flow
+///
+/// When an assistant message includes tool calls:
+/// 1. Assistant creates message with toolCalls array
+/// 2. Tool system executes each tool call
+/// 3. Results returned in ``ToolMessage`` instances with matching toolCallId
+/// 4. Assistant processes results and continues conversation
+///
+/// - SeeAlso: ``Message``, ``ToolCall``, ``ToolMessage``
+public struct AssistantMessage: Message, Sendable, Hashable {
+ /// Unique identifier for this message.
+ public let id: String
+
+ /// The role of this message (always `.assistant`).
+ public let role: Role
+
+ /// The agent's text response.
+ ///
+ /// This content is optional because:
+ /// - The message may contain only tool calls without explanatory text
+ /// - Streaming messages start with structure before content arrives
+ /// - Tool-only operations may not require text explanation
+ public let content: String?
+
+ /// Optional identifier for the assistant.
+ ///
+ /// This can distinguish between different assistant personas, modes,
+ /// or variants in multi-agent systems.
+ public let name: String?
+
+ /// Optional array of tool calls the assistant is requesting.
+ ///
+ /// When present, indicates the assistant wants to execute one or more
+ /// external functions. Each tool call has a unique ID that will be
+ /// referenced in the corresponding ``ToolMessage`` response.
+ ///
+ /// Tool calls may be executed:
+ /// - Sequentially (one after another)
+ /// - In parallel (multiple simultaneous calls)
+ /// - Conditionally (based on previous results)
+ public let toolCalls: [ToolCall]?
+
+ /// Optional encrypted value associated with this message.
+ ///
+ /// When present, carries a cryptographic value produced by the agent's
+ /// reasoning process.
+ public let encryptedValue: String?
+
+ /// Creates a new assistant message.
+ ///
+ /// - Parameters:
+ /// - id: Unique identifier for this message
+ /// - content: The agent's text response (optional)
+ /// - name: Optional identifier for the assistant
+ /// - toolCalls: Optional array of tool calls to execute
+ /// - encryptedValue: Optional encrypted reasoning value
+ public init(
+ id: String,
+ content: String? = nil,
+ name: String? = nil,
+ toolCalls: [ToolCall]? = nil,
+ encryptedValue: String? = nil
+ ) {
+ self.id = id
+ self.role = .assistant
+ self.content = content
+ self.name = name
+ self.toolCalls = toolCalls
+ self.encryptedValue = encryptedValue
+ }
+}
+
+// MARK: - Decodable
+
+extension AssistantMessage: Decodable {
+ private enum CodingKeys: String, CodingKey {
+ case id
+ case content
+ case name
+ case toolCalls
+ case encryptedValue
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ id = try container.decode(String.self, forKey: .id)
+ role = .assistant
+ content = try container.decodeIfPresent(String.self, forKey: .content)
+ name = try container.decodeIfPresent(String.self, forKey: .name)
+ toolCalls = try container.decodeIfPresent([ToolCall].self, forKey: .toolCalls)
+ encryptedValue = try container.decodeIfPresent(String.self, forKey: .encryptedValue)
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Types/Messages/DeveloperMessage.swift b/sdks/community/swift/Sources/AGUICore/Types/Messages/DeveloperMessage.swift
new file mode 100644
index 0000000000..2973761189
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Types/Messages/DeveloperMessage.swift
@@ -0,0 +1,113 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// A message containing system-level instructions and configuration.
+///
+/// Developer messages provide system-level instructions, configuration, and
+/// administrative communication that differs from regular system prompts. They
+/// typically contain meta-instructions about how the agent should behave or
+/// technical configuration details.
+///
+/// ## Use Cases
+///
+/// Developer messages are used for:
+/// - System configuration and initialization
+/// - Meta-instructions about agent behavior
+/// - Technical constraints and requirements
+/// - Administrative control messages
+/// - Debug and logging configuration
+///
+/// ## Example
+///
+/// ```swift
+/// let configMessage = DeveloperMessage(
+/// id: "dev-config-1",
+/// content: """
+/// System configuration:
+/// - Enable debug logging
+/// - Set max response length to 2000 tokens
+/// - Use conservative safety settings
+/// """,
+/// name: "SystemConfigurator"
+/// )
+/// ```
+///
+/// ## Differences from SystemMessage
+///
+/// While both developer and system messages guide agent behavior:
+/// - **DeveloperMessage**: System-level technical configuration and meta-instructions
+/// - **SystemMessage**: High-level behavioral guidelines and personality instructions
+///
+/// Developer messages typically contain more technical, configuration-oriented
+/// content, while system messages focus on behavioral patterns and response style.
+///
+/// - SeeAlso: ``Message``, ``SystemMessage``
+public struct DeveloperMessage: Message, Sendable, Hashable {
+ /// Unique identifier for this message.
+ public let id: String
+
+ /// The role of this message (always `.developer`).
+ public let role: Role
+
+ /// The developer's message content.
+ ///
+ /// This typically contains system-level instructions, configuration details,
+ /// or meta-instructions about agent behavior.
+ ///
+ /// While the protocol allows optional content, developer messages in practice
+ /// always contain content, so the initializer requires a non-nil value.
+ public let content: String?
+
+ /// Optional identifier for the developer or system.
+ ///
+ /// This can be used to identify which developer, system component, or
+ /// configuration module generated the message.
+ public let name: String?
+
+ /// Optional encrypted value associated with this message.
+ ///
+ /// When present, carries a cryptographic value produced by the agent's
+ /// reasoning process.
+ public let encryptedValue: String?
+
+ /// Creates a new developer message.
+ ///
+ /// - Parameters:
+ /// - id: Unique identifier for this message
+ /// - content: The developer's message content
+ /// - name: Optional identifier for the developer or system
+ /// - encryptedValue: Optional encrypted reasoning value
+ public init(
+ id: String,
+ content: String,
+ name: String? = nil,
+ encryptedValue: String? = nil
+ ) {
+ self.id = id
+ self.role = .developer
+ self.content = content
+ self.name = name
+ self.encryptedValue = encryptedValue
+ }
+}
+
+// MARK: - Decodable
+
+extension DeveloperMessage: Decodable {
+ private enum CodingKeys: String, CodingKey {
+ case id
+ case content
+ case name
+ case encryptedValue
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ id = try container.decode(String.self, forKey: .id)
+ role = .developer
+ content = try container.decode(String.self, forKey: .content)
+ name = try container.decodeIfPresent(String.self, forKey: .name)
+ encryptedValue = try container.decodeIfPresent(String.self, forKey: .encryptedValue)
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Types/Messages/Message.swift b/sdks/community/swift/Sources/AGUICore/Types/Messages/Message.swift
new file mode 100644
index 0000000000..4fabf7d665
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Types/Messages/Message.swift
@@ -0,0 +1,88 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Base protocol for all message types in the AG-UI protocol.
+///
+/// Messages represent communication between different entities in an agent conversation:
+/// developers, systems, AI assistants, users, tools, and activities. Each message type
+/// is identified by its ``Role``, which serves as the discriminator for polymorphic
+/// message deserialization.
+///
+/// ## Common Properties
+///
+/// All messages share these properties:
+/// - ``id``: Unique identifier for the message instance
+/// - ``role``: The sender's role (developer, system, assistant, user, tool, activity)
+/// - ``name``: Optional identifier for the sender
+///
+/// ## Message Types
+///
+/// The protocol is implemented by six concrete message types:
+/// - ``DeveloperMessage``: System-level configuration and instructions
+/// - ``SystemMessage``: Agent behavioral guidelines and instructions
+/// - ``AssistantMessage``: AI agent responses, possibly with tool calls
+/// - ``UserMessage``: Human input supporting text and multimodal content
+/// - ``ToolMessage``: Results from tool/function executions
+/// - ``ActivityMessage``: Streaming structured JSON for custom UI surfaces
+///
+/// ## Polymorphic Deserialization
+///
+/// In the AG-UI protocol, messages are serialized with a "role" field that indicates
+/// their concrete type. The role field enables protocol implementations to deserialize
+/// JSON into the appropriate concrete message type.
+///
+/// ## Concurrency
+///
+/// All message types must conform to `Sendable` to safely cross actor isolation
+/// boundaries in Swift's structured concurrency model.
+///
+/// ## Example
+///
+/// ```swift
+/// // Creating different message types
+/// let systemMsg = SystemMessage(
+/// id: "msg-1",
+/// content: "You are a helpful assistant."
+/// )
+///
+/// let userMsg = UserMessage(
+/// id: "msg-2",
+/// content: "Hello, how can you help me?"
+/// )
+///
+/// // All messages conform to the same protocol
+/// let messages: [any Message] = [systemMsg, userMsg]
+/// ```
+///
+/// - SeeAlso: ``Role``
+public protocol Message: Sendable {
+ /// Unique identifier for this message.
+ ///
+ /// Message IDs should be unique within a conversation thread to enable
+ /// referencing and tracking individual messages.
+ var id: String { get }
+
+ /// The role of the message sender.
+ ///
+ /// This property serves as the discriminator for polymorphic message types,
+ /// identifying whether the message comes from a developer, system, assistant,
+ /// user, tool, or activity.
+ ///
+ /// - SeeAlso: ``Role``
+ var role: Role { get }
+
+ /// Optional identifier for the message sender.
+ ///
+ /// This can be used to:
+ /// - Identify specific users in multi-user conversations
+ /// - Label different AI agents in multi-agent systems
+ /// - Tag tool invocations with descriptive names
+ var name: String? { get }
+
+ /// Optional encrypted value associated with this message.
+ ///
+ /// When present, carries a cryptographic value produced by the agent's
+ /// reasoning process (e.g., from a ``ReasoningEncryptedValueEvent``).
+ var encryptedValue: String? { get }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Types/Messages/ReasoningMessage.swift b/sdks/community/swift/Sources/AGUICore/Types/Messages/ReasoningMessage.swift
new file mode 100644
index 0000000000..8d158f0d62
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Types/Messages/ReasoningMessage.swift
@@ -0,0 +1,80 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// A message containing the agent's internal chain-of-thought reasoning.
+///
+/// `ReasoningMessage` represents the reasoning steps an AI agent produces during
+/// a reasoning phase. It is built up incrementally by streaming reasoning events
+/// (`ReasoningMessageStart`, `ReasoningMessageContent`, `ReasoningMessageEnd`).
+///
+/// ## Content
+///
+/// The `content` field contains the agent's reasoning text, which may be empty
+/// at the start of streaming and populated as content events arrive.
+///
+/// ## Encrypted Value
+///
+/// The optional `encryptedValue` field carries a cryptographic value produced
+/// by the agent's reasoning process, delivered via ``ReasoningEncryptedValueEvent``.
+/// It can be used in verified reasoning workflows to authenticate the reasoning output.
+///
+/// ## Example
+///
+/// ```swift
+/// let reasoning = ReasoningMessage(
+/// id: "reasoning-1",
+/// content: "Let me think step by step about this problem..."
+/// )
+///
+/// // With an encrypted value for verified reasoning
+/// let verifiedReasoning = ReasoningMessage(
+/// id: "reasoning-2",
+/// content: "First, I need to analyse the inputs...",
+/// encryptedValue: ""
+/// )
+/// ```
+///
+/// - SeeAlso: ``Message``, ``Role``, ``ReasoningStartEvent``, ``ReasoningEncryptedValueEvent``
+public struct ReasoningMessage: Message, Sendable, Hashable {
+ /// The unique identifier for this message.
+ public let id: String
+
+ /// The message role (always `.reasoning`).
+ public let role: Role
+
+ /// The agent's reasoning text content.
+ ///
+ /// This is the chain-of-thought or internal reasoning the agent produced.
+ /// Returned as `String?` to satisfy the `Message` protocol; in practice
+ /// this value is always present.
+ public let content: String?
+
+ /// Sender name (always `nil` for reasoning messages).
+ ///
+ /// The AG-UI protocol does not define a `name` field for reasoning messages.
+ public let name: String? = nil
+
+ /// Optional encrypted value for this reasoning message.
+ ///
+ /// When present, carries a cryptographic value delivered via
+ /// ``ReasoningEncryptedValueEvent`` for verified reasoning workflows.
+ public let encryptedValue: String?
+
+ /// Creates a new reasoning message.
+ ///
+ /// - Parameters:
+ /// - id: Unique identifier for the message
+ /// - content: The agent's reasoning text (non-nil)
+ /// - encryptedValue: Optional encrypted reasoning value
+ public init(
+ id: String,
+ content: String,
+ encryptedValue: String? = nil
+ ) {
+ self.id = id
+ self.role = .reasoning
+ self.content = content
+ self.encryptedValue = encryptedValue
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Types/Messages/Role.swift b/sdks/community/swift/Sources/AGUICore/Types/Messages/Role.swift
new file mode 100644
index 0000000000..4c67cec3de
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Types/Messages/Role.swift
@@ -0,0 +1,57 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Represents the role of a message sender in the AG-UI protocol.
+///
+/// Roles are used to identify the source of messages in conversations between
+/// developers, systems, AI assistants, users, tools, and activities.
+///
+/// The role field serves as the discriminator for polymorphic message types,
+/// allowing the protocol to deserialize messages into their specific concrete types.
+///
+/// - SeeAlso: ``Message``
+public enum Role: String, Sendable, Codable, CaseIterable, Hashable {
+ /// Developer-level instructions and configuration messages.
+ ///
+ /// Developer messages typically contain system-level instructions that configure
+ /// or control the agent's behavior at a foundational level.
+ case developer
+
+ /// System instructions and behavioral guidelines for the agent.
+ ///
+ /// System messages provide high-level instructions that guide the agent's
+ /// behavior, personality, and response patterns.
+ case system
+
+ /// Messages generated by the AI assistant.
+ ///
+ /// Assistant messages represent responses from the AI agent, which may include
+ /// text content and/or tool calls requesting to execute functions.
+ case assistant
+
+ /// Messages from the human user.
+ ///
+ /// User messages contain human input, supporting both simple text content
+ /// and multimodal content (text, images, binary data).
+ case user
+
+ /// Results from tool execution.
+ ///
+ /// Tool messages contain the results of function executions that were
+ /// requested by the assistant through tool calls.
+ case tool
+
+ /// Streaming structured content for UI surfaces.
+ ///
+ /// Activity messages enable agents to stream structured JSON content
+ /// that can be rendered in custom UI surfaces.
+ case activity
+
+ /// Internal reasoning content produced by the agent.
+ ///
+ /// Reasoning messages contain the agent's chain-of-thought or internal
+ /// reasoning steps. They may optionally carry an encrypted value for
+ /// verified reasoning workflows.
+ case reasoning
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Types/Messages/SystemMessage.swift b/sdks/community/swift/Sources/AGUICore/Types/Messages/SystemMessage.swift
new file mode 100644
index 0000000000..07679f9049
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Types/Messages/SystemMessage.swift
@@ -0,0 +1,113 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// A message containing system instructions and behavioral guidelines for the agent.
+///
+/// System messages provide high-level instructions, personality traits, behavioral
+/// guidelines, and context that shape how the agent responds. These messages are
+/// typically established at the conversation's start and remain active throughout
+/// the interaction.
+///
+/// ## Use Cases
+///
+/// System messages are used for:
+/// - Defining agent personality and tone
+/// - Setting behavioral guidelines and constraints
+/// - Providing domain-specific context
+/// - Establishing response format preferences
+/// - Configuring safety and ethical boundaries
+///
+/// ## Example
+///
+/// ```swift
+/// let systemPrompt = SystemMessage(
+/// id: "sys-1",
+/// content: """
+/// You are a professional coding assistant with expertise in Swift.
+/// Always:
+/// - Explain your reasoning
+/// - Write clean, well-documented code
+/// - Follow Swift best practices
+/// - Be concise but thorough
+/// """,
+/// name: "SwiftExpert"
+/// )
+/// ```
+///
+/// ## Differences from DeveloperMessage
+///
+/// While both guide agent behavior:
+/// - **SystemMessage**: High-level behavioral guidelines, personality, and response patterns
+/// - **DeveloperMessage**: System-level technical configuration and meta-instructions
+///
+/// System messages focus on how the agent should communicate and behave, while
+/// developer messages focus on technical constraints and system configuration.
+///
+/// - SeeAlso: ``Message``, ``DeveloperMessage``
+public struct SystemMessage: Message, Sendable, Hashable {
+ /// Unique identifier for this message.
+ public let id: String
+
+ /// The role of this message (always `.system`).
+ public let role: Role
+
+ /// The system's instruction content.
+ ///
+ /// This typically contains behavioral guidelines, personality traits,
+ /// response format preferences, or contextual information.
+ /// Matches the TypeScript schema `content: z.string()` — required, non-nullable.
+ public let content: String
+
+ /// Optional identifier for the system or instruction set.
+ ///
+ /// This can be used to identify different system personas, instruction
+ /// sets, or behavioral modes (e.g., "ProfessionalMode", "CasualAssistant").
+ public let name: String?
+
+ /// Optional encrypted value associated with this message.
+ ///
+ /// When present, carries a cryptographic value produced by the agent's
+ /// reasoning process.
+ public let encryptedValue: String?
+
+ /// Creates a new system message.
+ ///
+ /// - Parameters:
+ /// - id: Unique identifier for this message
+ /// - content: The system's instruction content (optional)
+ /// - name: Optional identifier for the system or instruction set
+ /// - encryptedValue: Optional encrypted reasoning value
+ public init(
+ id: String,
+ content: String = "",
+ name: String? = nil,
+ encryptedValue: String? = nil
+ ) {
+ self.id = id
+ self.role = .system
+ self.content = content
+ self.name = name
+ self.encryptedValue = encryptedValue
+ }
+}
+
+// MARK: - Decodable
+
+extension SystemMessage: Decodable {
+ private enum CodingKeys: String, CodingKey {
+ case id
+ case content
+ case name
+ case encryptedValue
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ id = try container.decode(String.self, forKey: .id)
+ role = .system
+ content = try container.decodeIfPresent(String.self, forKey: .content) ?? ""
+ name = try container.decodeIfPresent(String.self, forKey: .name)
+ encryptedValue = try container.decodeIfPresent(String.self, forKey: .encryptedValue)
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Types/Messages/ToolMessage.swift b/sdks/community/swift/Sources/AGUICore/Types/Messages/ToolMessage.swift
new file mode 100644
index 0000000000..698a47c3da
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Types/Messages/ToolMessage.swift
@@ -0,0 +1,143 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// A message containing the results of a tool or function execution.
+///
+/// Tool messages represent results returned after an assistant requests tool execution
+/// through a tool call. They link back to the original tool call request via the
+/// ``toolCallId`` property, allowing the agent to correlate results with specific
+/// invocations.
+///
+/// ## Use Cases
+///
+/// Tool messages are used for:
+/// - Returning successful tool execution results
+/// - Reporting tool execution errors and failures
+/// - Providing structured output from function calls
+/// - Delivering API responses to the agent
+/// - Communicating database query results
+///
+/// ## Example
+///
+/// ```swift
+/// // Successful tool execution
+/// let successMessage = ToolMessage(
+/// id: "tool-msg-1",
+/// content: "Successfully saved 3 files to /documents",
+/// toolCallId: "call-save-123",
+/// name: "save_files"
+/// )
+///
+/// // Failed tool execution
+/// let errorMessage = ToolMessage(
+/// id: "tool-msg-2",
+/// content: "Operation failed",
+/// toolCallId: "call-delete-456",
+/// name: "delete_file",
+/// error: "Permission denied: Cannot delete system file"
+/// )
+/// ```
+///
+/// ## Tool Call Linkage
+///
+/// The ``toolCallId`` property is critical for maintaining the request-response flow:
+/// 1. Assistant sends a tool call with ID "call-123"
+/// 2. Tool executes and returns a ToolMessage with toolCallId = "call-123"
+/// 3. Agent correlates the result with the original request
+///
+/// ## Error Handling
+///
+/// When tool execution fails, use the ``error`` property to communicate the failure:
+/// - Set ``content`` to a user-friendly error description
+/// - Set ``error`` to a technical error message for debugging
+/// - The agent can then decide how to handle or report the error
+///
+/// - SeeAlso: ``Message``, ``AssistantMessage``
+public struct ToolMessage: Message, Sendable, Hashable, Decodable {
+ /// Unique identifier for this message.
+ public let id: String
+
+ /// The role of this message (always `.tool`).
+ public let role: Role
+
+ /// The tool's output or result text.
+ ///
+ /// This contains the result of the tool execution, whether successful or failed.
+ /// For failed executions, this typically contains a user-friendly error description.
+ /// Matches the TypeScript schema `content: z.string()` — required, non-nullable.
+ public let content: String
+
+ /// The ID of the tool call this message responds to.
+ ///
+ /// This field links the tool message to its corresponding request, allowing the
+ /// agent to correlate results with specific tool invocations. The toolCallId
+ /// must match the ID from the original tool call in the assistant's message.
+ public let toolCallId: String
+
+ /// Optional name of the tool that generated this message.
+ ///
+ /// This can be used to identify which tool produced the result, especially
+ /// useful when multiple tools are used in a conversation.
+ public let name: String?
+
+ /// Optional error message if tool execution failed.
+ ///
+ /// When present, indicates that the tool execution encountered an error.
+ /// This should contain technical error details for debugging, while ``content``
+ /// should contain a user-friendly error description.
+ public let error: String?
+
+ /// Optional encrypted value associated with this tool result.
+ ///
+ /// When present, carries a cryptographic value for verified tool execution
+ /// workflows (e.g., from a ``ReasoningEncryptedValueEvent`` with subtype `.toolCall`).
+ public let encryptedValue: String?
+
+ /// Creates a new tool message.
+ ///
+ /// - Parameters:
+ /// - id: Unique identifier for this message
+ /// - content: The tool's output or result text
+ /// - toolCallId: The ID of the tool call this message responds to
+ /// - name: Optional name of the tool that generated this message
+ /// - error: Optional error message if tool execution failed
+ /// - encryptedValue: Optional encrypted value for verified tool execution
+ public init(
+ id: String,
+ content: String,
+ toolCallId: String,
+ name: String? = nil,
+ error: String? = nil,
+ encryptedValue: String? = nil
+ ) {
+ self.id = id
+ self.role = .tool
+ self.content = content
+ self.toolCallId = toolCallId
+ self.name = name
+ self.error = error
+ self.encryptedValue = encryptedValue
+ }
+}
+
+// MARK: - Decodable
+
+extension ToolMessage {
+ private enum CodingKeys: String, CodingKey {
+ case id, content, toolCallId, name, error, encryptedValue
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ id = try container.decode(String.self, forKey: .id)
+ role = .tool
+ // Default to "" when absent — matches TypeScript z.string() (required but may be
+ // missing in legacy payloads).
+ content = try container.decodeIfPresent(String.self, forKey: .content) ?? ""
+ toolCallId = try container.decode(String.self, forKey: .toolCallId)
+ name = try container.decodeIfPresent(String.self, forKey: .name)
+ error = try container.decodeIfPresent(String.self, forKey: .error)
+ encryptedValue = try container.decodeIfPresent(String.self, forKey: .encryptedValue)
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Types/Messages/UserMessage.swift b/sdks/community/swift/Sources/AGUICore/Types/Messages/UserMessage.swift
new file mode 100644
index 0000000000..dfc85ee5c3
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Types/Messages/UserMessage.swift
@@ -0,0 +1,170 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Represents a message from a user in the conversation.
+///
+/// `UserMessage` supports both simple text messages and multimodal messages
+/// containing text, images, audio, documents, and other binary data.
+///
+/// ## Text-only Messages
+///
+/// For simple text input, use the standard initializer:
+///
+/// ```swift
+/// let message = UserMessage(
+/// id: "user-1",
+/// content: "What is the weather like today?"
+/// )
+/// ```
+///
+/// ## Multimodal Messages
+///
+/// For rich input combining text and binary data, use the multimodal factory:
+///
+/// ```swift
+/// let parts: [any InputContent] = [
+/// TextInputContent(text: "What's in this image?"),
+/// BinaryInputContent(
+/// mimeType: "image/jpeg",
+/// url: "https://example.com/photo.jpg"
+/// )
+/// ]
+///
+/// let message = UserMessage.multimodal(
+/// id: "user-2",
+/// parts: parts
+/// )
+/// ```
+///
+/// ## Serialization
+///
+/// UserMessage uses custom Codable serialization:
+/// - **Text-only**: `content` field is a JSON string
+/// - **Multimodal**: `content` field is a JSON array of InputContent objects
+///
+/// The serialization is transparent and handled automatically during encoding/decoding.
+///
+/// - SeeAlso: ``Message``, ``InputContent``, ``TextInputContent``, ``BinaryInputContent``
+public struct UserMessage: Message, Sendable, Hashable {
+ /// The unique identifier for this message.
+ public let id: String
+
+ /// The message role (always `.user`).
+ public let role: Role
+
+ /// The text content of the message.
+ ///
+ /// For text-only messages, this contains the user's input.
+ /// For multimodal messages, this is empty and content is in `contentParts`.
+ public let content: String?
+
+ /// Optional name of the user sending the message.
+ public let name: String?
+
+ /// The multimodal content parts.
+ ///
+ /// This is `nil` for text-only messages and contains the array of
+ /// InputContent for multimodal messages.
+ public let contentParts: [any InputContent]?
+
+ /// Optional encrypted value associated with this message.
+ ///
+ /// When present, carries a cryptographic value produced by the agent's
+ /// reasoning process.
+ public let encryptedValue: String?
+
+ /// Whether this message contains multimodal content.
+ ///
+ /// Returns `true` if the message has `contentParts`, `false` for text-only.
+ public var isMultimodal: Bool {
+ contentParts != nil
+ }
+
+ /// Creates a text-only user message.
+ ///
+ /// - Parameters:
+ /// - id: Unique identifier for the message
+ /// - content: The text content
+ /// - name: Optional name of the user
+ /// - encryptedValue: Optional encrypted reasoning value
+ public init(
+ id: String,
+ content: String,
+ name: String? = nil,
+ encryptedValue: String? = nil
+ ) {
+ self.id = id
+ self.role = .user
+ self.content = content
+ self.name = name
+ self.contentParts = nil
+ self.encryptedValue = encryptedValue
+ }
+
+ /// Creates a multimodal user message with mixed text and binary content.
+ ///
+ /// - Parameters:
+ /// - id: Unique identifier for the message
+ /// - parts: Array of InputContent (text and/or binary)
+ /// - name: Optional name of the user
+ /// - encryptedValue: Optional encrypted reasoning value
+ /// - Returns: A multimodal UserMessage
+ public static func multimodal(
+ id: String,
+ parts: [any InputContent],
+ name: String? = nil,
+ encryptedValue: String? = nil
+ ) -> UserMessage {
+ UserMessage(
+ id: id,
+ role: .user,
+ content: "",
+ name: name,
+ contentParts: parts,
+ encryptedValue: encryptedValue
+ )
+ }
+
+ /// Internal initializer for creating multimodal messages.
+ private init(
+ id: String,
+ role: Role,
+ content: String,
+ name: String?,
+ contentParts: [any InputContent]?,
+ encryptedValue: String? = nil
+ ) {
+ self.id = id
+ self.role = role
+ self.content = content
+ self.name = name
+ self.contentParts = contentParts
+ self.encryptedValue = encryptedValue
+ }
+
+ // MARK: - Hashable
+
+ public func hash(into hasher: inout Hasher) {
+ hasher.combine(id)
+ hasher.combine(role)
+ hasher.combine(content)
+ hasher.combine(name)
+ contentParts?.forEach { hasher.combine($0.anyHashable) }
+ }
+
+ public static func == (lhs: UserMessage, rhs: UserMessage) -> Bool {
+ guard lhs.id == rhs.id,
+ lhs.role == rhs.role,
+ lhs.content == rhs.content,
+ lhs.name == rhs.name else { return false }
+ switch (lhs.contentParts, rhs.contentParts) {
+ case (nil, nil):
+ return true
+ case (let lParts?, let rParts?) where lParts.count == rParts.count:
+ return zip(lParts, rParts).allSatisfy { $0.isEqual(to: $1) }
+ default:
+ return false
+ }
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Types/Tools/FunctionCall.swift b/sdks/community/swift/Sources/AGUICore/Types/Tools/FunctionCall.swift
new file mode 100644
index 0000000000..ccc237bea5
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Types/Tools/FunctionCall.swift
@@ -0,0 +1,96 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Represents a function name and its arguments in a tool call.
+///
+/// `FunctionCall` encapsulates the essential information needed to invoke a function:
+/// the function's name and its arguments encoded as a JSON string. This structure
+/// provides flexibility in how arguments are structured and validated, deferring
+/// parsing and validation to the actual tool execution layer.
+///
+/// ## Arguments Encoding
+///
+/// Arguments are stored as a JSON-encoded string rather than a parsed object. This design:
+/// - Allows flexibility in argument structures
+/// - Defers validation to tool execution time
+/// - Supports dynamic function signatures
+/// - Maintains compatibility with various serialization formats
+///
+/// ## Example
+///
+/// ```swift
+/// // Simple function call with basic arguments
+/// let weatherCall = FunctionCall(
+/// name: "get_weather",
+/// arguments: """
+/// {
+/// "location": "San Francisco",
+/// "units": "celsius"
+/// }
+/// """
+/// )
+///
+/// // Function with no arguments
+/// let pingCall = FunctionCall(
+/// name: "ping",
+/// arguments: "{}"
+/// )
+///
+/// // Parsing arguments at execution time
+/// struct WeatherArgs: Codable {
+/// let location: String
+/// let units: String
+/// }
+///
+/// let argsData = Data(weatherCall.arguments.utf8)
+/// let parsedArgs = try JSONDecoder().decode(WeatherArgs.self, from: argsData)
+/// ```
+///
+/// ## Relationship with ToolCall
+///
+/// `FunctionCall` is typically embedded within a ``ToolCall``, which adds:
+/// - A unique identifier for tracking the call
+/// - The function type discriminator
+///
+/// - SeeAlso: ``ToolCall``, ``Tool``
+public struct FunctionCall: Sendable, Codable, Hashable {
+ /// The name of the function to invoke.
+ ///
+ /// This should match the name defined in the corresponding ``Tool`` definition.
+ /// Function names are typically lowercase with underscores (e.g., "get_weather",
+ /// "send_email", "execute_query").
+ public let name: String
+
+ /// The function arguments encoded as a JSON string.
+ ///
+ /// Arguments must be valid JSON. Common patterns:
+ /// - Empty arguments: `"{}"`
+ /// - Simple arguments: `"{\"location\":\"Paris\"}"`
+ /// - Complex arguments: `"{\"filters\":{\"date\":\"2024-01-01\"},\"limit\":100}"`
+ ///
+ /// The arguments string can be parsed at execution time into a strongly-typed
+ /// struct using `JSONDecoder`:
+ ///
+ /// ```swift
+ /// let argsData = Data(functionCall.arguments.utf8)
+ /// let parsed = try JSONDecoder().decode(MyArgsType.self, from: argsData)
+ /// ```
+ public let arguments: String
+
+ /// Creates a new function call.
+ ///
+ /// - Parameters:
+ /// - name: The name of the function to invoke
+ /// - arguments: The function arguments encoded as a JSON string
+ ///
+ /// - Note: The arguments parameter should contain valid JSON. Invalid JSON
+ /// will cause errors when the arguments are parsed during execution.
+ public init(
+ name: String,
+ arguments: String
+ ) {
+ self.name = name
+ self.arguments = arguments
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Types/Tools/Tool.swift b/sdks/community/swift/Sources/AGUICore/Types/Tools/Tool.swift
new file mode 100644
index 0000000000..e79672ef5e
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Types/Tools/Tool.swift
@@ -0,0 +1,262 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Defines a tool or function that agents can invoke.
+///
+/// Tools represent capabilities that agents can use to:
+/// - Request specific information from external systems
+/// - Perform actions in external systems
+/// - Ask for human input or confirmation
+/// - Access specialized capabilities beyond the agent's core knowledge
+///
+/// ## Tool Definition
+///
+/// Each tool is defined by:
+/// - **Name**: A unique identifier for the tool (e.g., "get_weather", "send_email")
+/// - **Description**: Human-readable explanation of what the tool does
+/// - **Parameters**: JSON Schema defining the expected input structure
+///
+/// ## JSON Schema Parameters
+///
+/// The parameters field contains a JSON Schema that defines:
+/// - Required and optional parameters
+/// - Parameter types (string, integer, boolean, object, array)
+/// - Validation rules (enums, min/max values, patterns)
+/// - Default values
+/// - Parameter descriptions for agent understanding
+///
+/// ## Example
+///
+/// ```swift
+/// // Define a weather tool
+/// let weatherSchema = Data("""
+/// {
+/// "type": "object",
+/// "properties": {
+/// "location": {
+/// "type": "string",
+/// "description": "City and state, e.g., San Francisco, CA"
+/// },
+/// "unit": {
+/// "type": "string",
+/// "enum": ["celsius", "fahrenheit"],
+/// "default": "fahrenheit"
+/// }
+/// },
+/// "required": ["location"]
+/// }
+/// """.utf8)
+///
+/// let weatherTool = Tool(
+/// name: "get_current_weather",
+/// description: "Get the current weather in a given location",
+/// parameters: weatherSchema
+/// )
+///
+/// // Register tools with the agent
+/// let tools = [weatherTool]
+/// ```
+///
+/// ## Tool Usage Flow
+///
+/// 1. **Registration**: Tools are registered with the agent system
+/// 2. **Selection**: Agent analyzes user request and selects appropriate tool
+/// 3. **Invocation**: Agent creates a ``ToolCall`` with function arguments
+/// 4. **Execution**: Tool system validates arguments against schema and executes
+/// 5. **Response**: Results returned in a ``ToolMessage``
+///
+/// ## Schema Validation
+///
+/// The JSON Schema in the parameters field enables:
+/// - Automatic argument validation before execution
+/// - Type safety for tool implementations
+/// - Clear documentation for agents about expected inputs
+/// - IDE support and autocomplete for tool arguments
+///
+/// ## Design Considerations
+///
+/// Parameters are stored as `Data` (raw JSON Schema) rather than a parsed structure to:
+/// - Maintain flexibility in schema complexity
+/// - Defer validation to execution time
+/// - Support evolving JSON Schema standards
+/// - Enable custom schema extensions
+///
+/// - SeeAlso: ``ToolCall``, ``FunctionCall``, ``ToolMessage``
+public struct Tool: Sendable, Codable, Hashable {
+ /// The unique identifier for this tool.
+ ///
+ /// Tool names should be descriptive and follow snake_case convention
+ /// (e.g., "get_weather", "send_email", "execute_query"). The name is
+ /// used by agents to identify and invoke the tool.
+ public let name: String
+
+ /// Human-readable description of what this tool does.
+ ///
+ /// The description helps agents understand:
+ /// - What the tool can do
+ /// - When to use the tool
+ /// - What results to expect
+ ///
+ /// Good descriptions are clear, concise, and action-oriented:
+ /// - ✓ "Get the current weather in a given location"
+ /// - ✓ "Send an email to a specified recipient"
+ /// - ✗ "Weather" (too vague)
+ /// - ✗ "This tool can be used to retrieve weather data..." (too verbose)
+ public let description: String
+
+ /// JSON Schema defining the tool's parameters.
+ ///
+ /// This schema describes the structure and constraints of the arguments
+ /// the tool expects. It should be a valid JSON Schema (Draft 7 or later)
+ /// encoded as Data.
+ ///
+ /// Common schema patterns:
+ /// - Empty parameters: `Data("{}".utf8)`
+ /// - Simple parameters: Object type with properties and required fields
+ /// - Complex parameters: Nested objects, arrays, enums, validation rules
+ ///
+ /// The schema is validated at tool execution time, allowing the agent to
+ /// understand what arguments are needed without strict compile-time coupling.
+ public let parameters: Data
+
+ /// Optional metadata as raw JSON bytes.
+ ///
+ /// Used by A2UI schema extensions and tool registry annotations. Stored as `Data`
+ /// for `Sendable` compliance; encoded and decoded via the same `AnyCodable` pattern
+ /// as `parameters`. Corresponds to the `metadata` field in the AG-UI protocol spec.
+ public let metadata: Data?
+
+ /// Creates a new tool definition.
+ ///
+ /// - Parameters:
+ /// - name: Unique identifier for the tool
+ /// - description: Human-readable explanation of the tool's purpose
+ /// - parameters: JSON Schema defining the tool's parameters as Data
+ /// - metadata: Optional metadata as raw JSON bytes
+ ///
+ /// - Note: The parameters should contain valid JSON Schema. Invalid schema
+ /// may cause validation errors during tool execution.
+ public init(
+ name: String,
+ description: String,
+ parameters: Data,
+ metadata: Data? = nil
+ ) {
+ self.name = name
+ self.description = description
+ self.parameters = parameters
+ self.metadata = metadata
+ }
+
+ // MARK: - Codable
+
+ private enum CodingKeys: String, CodingKey {
+ case name
+ case description
+ case parameters
+ case metadata
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ name = try container.decode(String.self, forKey: .name)
+ description = try container.decode(String.self, forKey: .description)
+
+ // Decode parameters as nested JSON and convert to Data
+ // This allows parameters to be a JSON object in the encoded form
+ let parametersValue = try container.decode(AnyCodable.self, forKey: .parameters)
+ let jsonData = try JSONSerialization.data(withJSONObject: parametersValue.value)
+ parameters = jsonData
+
+ // Decode optional metadata using same AnyCodable pattern as parameters
+ if let metadataValue = try? container.decode(AnyCodable.self, forKey: .metadata),
+ !(metadataValue.value is NSNull) {
+ metadata = try JSONSerialization.data(withJSONObject: metadataValue.value)
+ } else {
+ metadata = nil
+ }
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+ try container.encode(name, forKey: .name)
+ try container.encode(description, forKey: .description)
+
+ // Encode parameters as nested JSON object instead of base64 string
+ // This maintains JSON compatibility with the protocol
+ let jsonObject = try JSONSerialization.jsonObject(with: parameters)
+ try container.encode(AnyCodable(jsonObject), forKey: .parameters)
+
+ // Encode optional metadata as nested JSON object (same pattern as parameters)
+ if let metadataData = metadata {
+ let metadataObject = try JSONSerialization.jsonObject(with: metadataData)
+ try container.encode(AnyCodable(metadataObject), forKey: .metadata)
+ }
+ }
+}
+
+// MARK: - AnyCodable Helper
+
+/// Helper type to encode/decode arbitrary JSON values
+private struct AnyCodable: Codable {
+ let value: Any
+
+ init(_ value: Any) {
+ self.value = value
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.singleValueContainer()
+
+ if let boolValue = try? container.decode(Bool.self) {
+ value = boolValue
+ } else if let intValue = try? container.decode(Int.self) {
+ value = intValue
+ } else if let doubleValue = try? container.decode(Double.self) {
+ value = doubleValue
+ } else if let stringValue = try? container.decode(String.self) {
+ value = stringValue
+ } else if let arrayValue = try? container.decode([AnyCodable].self) {
+ value = arrayValue.map { $0.value }
+ } else if let dictionaryValue = try? container.decode([String: AnyCodable].self) {
+ value = dictionaryValue.mapValues { $0.value }
+ } else if container.decodeNil() {
+ value = NSNull()
+ } else {
+ throw DecodingError.dataCorruptedError(
+ in: container,
+ debugDescription: "Unable to decode value"
+ )
+ }
+ }
+
+ func encode(to encoder: Encoder) throws {
+ var container = encoder.singleValueContainer()
+
+ switch value {
+ case let intValue as Int:
+ try container.encode(intValue)
+ case let doubleValue as Double:
+ try container.encode(doubleValue)
+ case let boolValue as Bool:
+ try container.encode(boolValue)
+ case let stringValue as String:
+ try container.encode(stringValue)
+ case let arrayValue as [Any]:
+ try container.encode(arrayValue.map { AnyCodable($0) })
+ case let dictionaryValue as [String: Any]:
+ try container.encode(dictionaryValue.mapValues { AnyCodable($0) })
+ case is NSNull:
+ try container.encodeNil()
+ default:
+ throw EncodingError.invalidValue(
+ value,
+ EncodingError.Context(
+ codingPath: container.codingPath,
+ debugDescription: "Unable to encode value of type \(type(of: value))"
+ )
+ )
+ }
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Types/Tools/ToolCall.swift b/sdks/community/swift/Sources/AGUICore/Types/Tools/ToolCall.swift
new file mode 100644
index 0000000000..97809f53b8
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Types/Tools/ToolCall.swift
@@ -0,0 +1,136 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Represents a request to execute a tool or function.
+///
+/// `ToolCall` pairs a unique identifier with a function call, enabling the agent
+/// to request tool execution and later correlate the results through the shared ID.
+/// This is a critical component of the agent-tool interaction flow in the AG-UI protocol.
+///
+/// ## Tool Call Flow
+///
+/// 1. **Request**: Agent creates a ToolCall with a unique ID and function details
+/// 2. **Execution**: Tool system executes the requested function
+/// 3. **Response**: Results are returned in a ``ToolMessage`` with matching toolCallId
+/// 4. **Correlation**: Agent matches response to request using the ID
+///
+/// ## Type Field
+///
+/// The `type` field is always `"function"` and is included in JSON serialization
+/// to maintain protocol compatibility and avoid conflicts with event discriminators.
+///
+/// ## Example
+///
+/// ```swift
+/// // Create a tool call
+/// let weatherCall = ToolCall(
+/// id: "call_weather_123",
+/// function: FunctionCall(
+/// name: "get_current_weather",
+/// arguments: """
+/// {
+/// "location": "San Francisco",
+/// "unit": "celsius"
+/// }
+/// """
+/// )
+/// )
+///
+/// // Later, receive the response
+/// let response = ToolMessage(
+/// id: "msg_1",
+/// content: "Temperature: 18°C, Conditions: Partly cloudy",
+/// toolCallId: weatherCall.id // Links back to the call
+/// )
+/// ```
+///
+/// ## Multiple Tool Calls
+///
+/// Agents can request multiple tool executions simultaneously by creating
+/// an array of ToolCalls, each with a unique ID:
+///
+/// ```swift
+/// let toolCalls: [ToolCall] = [
+/// ToolCall(id: "call_1", function: FunctionCall(...)),
+/// ToolCall(id: "call_2", function: FunctionCall(...)),
+/// ToolCall(id: "call_3", function: FunctionCall(...))
+/// ]
+/// ```
+///
+/// - SeeAlso: ``FunctionCall``, ``ToolMessage``, ``Tool``
+public struct ToolCall: Sendable, Codable, Hashable {
+ /// Unique identifier for this tool call.
+ ///
+ /// This ID is used to correlate the tool call request with the subsequent
+ /// ``ToolMessage`` response. IDs must be unique within a conversation context
+ /// to ensure proper request-response matching.
+ public let id: String
+
+ /// The type of tool call (always "function").
+ ///
+ /// This field is included in JSON serialization to maintain protocol
+ /// compatibility and prevent conflicts with the 'type' discriminator
+ /// used in AG-UI events.
+ public let type: String
+
+ /// The function call details including name and arguments.
+ ///
+ /// Contains the function name to invoke and its JSON-encoded arguments.
+ /// The actual tool execution system uses this information to dispatch
+ /// the call to the appropriate handler.
+ public let function: FunctionCall
+
+ /// Optional encrypted value associated with this tool call.
+ ///
+ /// When present, carries a cryptographic value for verified tool-call
+ /// workflows (e.g., from a ``ReasoningEncryptedValueEvent`` with subtype `.toolCall`).
+ public let encryptedValue: String?
+
+ /// Creates a new tool call.
+ ///
+ /// - Parameters:
+ /// - id: Unique identifier for this tool call
+ /// - function: The function call details including name and arguments
+ /// - encryptedValue: Optional encrypted value for verified tool execution
+ ///
+ /// - Note: The `type` field is automatically set to "function" and does not
+ /// need to be specified.
+ public init(
+ id: String,
+ function: FunctionCall,
+ encryptedValue: String? = nil
+ ) {
+ self.id = id
+ self.type = "function"
+ self.function = function
+ self.encryptedValue = encryptedValue
+ }
+
+ // MARK: - Codable
+
+ private enum CodingKeys: String, CodingKey {
+ case id
+ case type
+ case function
+ case encryptedValue
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ id = try container.decode(String.self, forKey: .id)
+ function = try container.decode(FunctionCall.self, forKey: .function)
+ encryptedValue = try container.decodeIfPresent(String.self, forKey: .encryptedValue)
+
+ // Type should always be "function", decode if present but default to "function"
+ type = try container.decodeIfPresent(String.self, forKey: .type) ?? "function"
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+ try container.encode(id, forKey: .id)
+ try container.encode(type, forKey: .type)
+ try container.encode(function, forKey: .function)
+ try container.encodeIfPresent(encryptedValue, forKey: .encryptedValue)
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Utilities/JSONCodingHelpers.swift b/sdks/community/swift/Sources/AGUICore/Utilities/JSONCodingHelpers.swift
new file mode 100644
index 0000000000..121ec9c500
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Utilities/JSONCodingHelpers.swift
@@ -0,0 +1,169 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Dynamic coding keys for encoding/decoding arbitrary JSON objects.
+///
+/// This type enables working with JSON objects that have dynamic or unknown keys
+/// at compile time, commonly needed when bridging between strongly-typed Swift
+/// and loosely-typed JSON.
+///
+/// ## Usage
+///
+/// Use with `KeyedEncodingContainer` and `KeyedDecodingContainer` to handle
+/// arbitrary JSON structures:
+///
+/// ```swift
+/// let container = try decoder.container(keyedBy: JSONCodingKeys.self)
+/// let jsonObject = try container.decodeJSONObject()
+/// ```
+public struct JSONCodingKeys: CodingKey {
+ public var stringValue: String
+ public var intValue: Int?
+
+ public init(stringValue: String) {
+ self.stringValue = stringValue
+ self.intValue = nil
+ }
+
+ public init?(intValue: Int) {
+ self.stringValue = String(intValue)
+ self.intValue = intValue
+ }
+}
+
+// MARK: - Decoding Extensions
+
+extension KeyedDecodingContainer where K == JSONCodingKeys {
+ /// Decodes an arbitrary JSON object (dictionary) to a Swift dictionary.
+ ///
+ /// Recursively decodes nested objects and arrays, preserving the JSON structure.
+ /// Supported types: String, Int, Double, Bool, nested objects, nested arrays, and null.
+ ///
+ /// - Returns: A dictionary with string keys and `Any` values representing the JSON object
+ /// - Throws: `DecodingError` if the structure cannot be decoded
+ public func decodeJSONObject() throws -> Any {
+ var result: [String: Any] = [:]
+
+ for key in allKeys {
+ if let value = try? decode(String.self, forKey: key) {
+ result[key.stringValue] = value
+ } else if let value = try? decode(Int.self, forKey: key) {
+ result[key.stringValue] = value
+ } else if let value = try? decode(Double.self, forKey: key) {
+ result[key.stringValue] = value
+ } else if let value = try? decode(Bool.self, forKey: key) {
+ result[key.stringValue] = value
+ } else if let nestedContainer = try? nestedContainer(keyedBy: JSONCodingKeys.self, forKey: key) {
+ result[key.stringValue] = try nestedContainer.decodeJSONObject()
+ } else if var nestedContainer = try? nestedUnkeyedContainer(forKey: key) {
+ result[key.stringValue] = try nestedContainer.decodeJSONArray()
+ } else {
+ result[key.stringValue] = NSNull()
+ }
+ }
+
+ return result
+ }
+}
+
+extension UnkeyedDecodingContainer {
+ /// Decodes an arbitrary JSON array to a Swift array.
+ ///
+ /// Recursively decodes nested objects and arrays, preserving the JSON structure.
+ /// Supported types: String, Int, Double, Bool, nested objects, nested arrays, and null.
+ ///
+ /// - Returns: An array of `Any` representing the JSON array
+ /// - Throws: `DecodingError` if the structure cannot be decoded
+ public mutating func decodeJSONArray() throws -> [Any] {
+ var result: [Any] = []
+
+ while !isAtEnd {
+ if let value = try? decode(String.self) {
+ result.append(value)
+ } else if let value = try? decode(Int.self) {
+ result.append(value)
+ } else if let value = try? decode(Double.self) {
+ result.append(value)
+ } else if let value = try? decode(Bool.self) {
+ result.append(value)
+ } else if let nestedContainer = try? nestedContainer(keyedBy: JSONCodingKeys.self) {
+ result.append(try nestedContainer.decodeJSONObject())
+ } else if var nestedContainer = try? nestedUnkeyedContainer() {
+ result.append(try nestedContainer.decodeJSONArray())
+ } else {
+ result.append(NSNull())
+ }
+ }
+
+ return result
+ }
+}
+
+// MARK: - Encoding Extensions
+
+extension KeyedEncodingContainer where K == JSONCodingKeys {
+ /// Encodes an arbitrary JSON object (dictionary) from a Swift dictionary.
+ ///
+ /// Recursively encodes nested objects and arrays, preserving the JSON structure.
+ /// Supported types: String, Int, Double, Bool, nested dictionaries, nested arrays, and null.
+ ///
+ /// - Parameter object: The object to encode (typically a `[String: Any]` dictionary)
+ /// - Throws: `EncodingError` if the object cannot be encoded
+ public mutating func encodeJSONObject(_ object: Any) throws {
+ if let dict = object as? [String: Any] {
+ for (key, value) in dict {
+ let codingKey = JSONCodingKeys(stringValue: key)
+
+ if let stringValue = value as? String {
+ try encode(stringValue, forKey: codingKey)
+ } else if let boolValue = value as? Bool {
+ try encode(boolValue, forKey: codingKey)
+ } else if let intValue = value as? Int {
+ try encode(intValue, forKey: codingKey)
+ } else if let doubleValue = value as? Double {
+ try encode(doubleValue, forKey: codingKey)
+ } else if value is NSNull {
+ try encodeNil(forKey: codingKey)
+ } else if let nestedDict = value as? [String: Any] {
+ var nestedContainer = nestedContainer(keyedBy: JSONCodingKeys.self, forKey: codingKey)
+ try nestedContainer.encodeJSONObject(nestedDict)
+ } else if let nestedArray = value as? [Any] {
+ var nestedContainer = nestedUnkeyedContainer(forKey: codingKey)
+ try nestedContainer.encodeJSONArray(nestedArray)
+ }
+ }
+ }
+ }
+}
+
+extension UnkeyedEncodingContainer {
+ /// Encodes an arbitrary JSON array from a Swift array.
+ ///
+ /// Recursively encodes nested objects and arrays, preserving the JSON structure.
+ /// Supported types: String, Int, Double, Bool, nested dictionaries, nested arrays, and null.
+ ///
+ /// - Parameter array: The array to encode (typically an `[Any]` array)
+ /// - Throws: `EncodingError` if the array cannot be encoded
+ public mutating func encodeJSONArray(_ array: [Any]) throws {
+ for value in array {
+ if let stringValue = value as? String {
+ try encode(stringValue)
+ } else if let boolValue = value as? Bool {
+ try encode(boolValue)
+ } else if let intValue = value as? Int {
+ try encode(intValue)
+ } else if let doubleValue = value as? Double {
+ try encode(doubleValue)
+ } else if value is NSNull {
+ try encodeNil()
+ } else if let nestedDict = value as? [String: Any] {
+ var nestedContainer = nestedContainer(keyedBy: JSONCodingKeys.self)
+ try nestedContainer.encodeJSONObject(nestedDict)
+ } else if let nestedArray = value as? [Any] {
+ var nestedContainer = nestedUnkeyedContainer()
+ try nestedContainer.encodeJSONArray(nestedArray)
+ }
+ }
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUICore/Utilities/JSONPrimitiveWrapper.swift b/sdks/community/swift/Sources/AGUICore/Utilities/JSONPrimitiveWrapper.swift
new file mode 100644
index 0000000000..80891e9f4a
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUICore/Utilities/JSONPrimitiveWrapper.swift
@@ -0,0 +1,40 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Wraps an untyped `Any` primitive so it can be encoded into JSON via `Codable`.
+///
+/// This is an internal helper used by DTO types that receive raw `Any` values
+/// from `JSONSerialization` and need to round-trip them through `JSONEncoder`.
+///
+/// Supported value types: `Bool`, `Int`, `Int64`, `Double`, `String`, `NSNull`.
+struct JSONPrimitiveWrapper: Encodable {
+ let value: Any
+
+ func encode(to encoder: Encoder) throws {
+ var container = encoder.singleValueContainer()
+
+ switch value {
+ case let bool as Bool:
+ try container.encode(bool)
+ case let int as Int:
+ try container.encode(int)
+ case let int64 as Int64:
+ try container.encode(int64)
+ case let double as Double:
+ try container.encode(double)
+ case let string as String:
+ try container.encode(string)
+ case is NSNull:
+ try container.encodeNil()
+ default:
+ throw EncodingError.invalidValue(
+ value,
+ EncodingError.Context(
+ codingPath: [],
+ debugDescription: "Unsupported primitive type"
+ )
+ )
+ }
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUITools/AGUITools.swift b/sdks/community/swift/Sources/AGUITools/AGUITools.swift
new file mode 100644
index 0000000000..97ddaf119f
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUITools/AGUITools.swift
@@ -0,0 +1,10 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+// AGUITools provides the tool execution framework for AG-UI agents.
+//
+// Core types:
+// - ToolExecutor: Protocol for implementing tool executors
+// - ToolExecutionResult: Result type for tool executions
+// - ToolExecutionContext: Context provided to tool executors
+// - ToolRegistry: Registry for managing and executing tools
+// - ToolExecutionManager: Manages tool execution lifecycle
diff --git a/sdks/community/swift/Sources/AGUITools/Core/ToolErrorHandler.swift b/sdks/community/swift/Sources/AGUITools/Core/ToolErrorHandler.swift
new file mode 100644
index 0000000000..289c1b3d0f
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUITools/Core/ToolErrorHandler.swift
@@ -0,0 +1,384 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUICore
+import Foundation
+
+// MARK: - RetryStrategy
+
+/// Strategy for calculating retry delays.
+public enum RetryStrategy: Sendable {
+ /// Constant delay between retries.
+ case fixed
+ /// Delay grows linearly with attempt count.
+ case linear
+ /// Delay grows exponentially with attempt count.
+ case exponential
+ /// Exponential delay with random jitter to prevent thundering herd.
+ case exponentialJitter
+}
+
+// MARK: - CircuitBreakerState
+
+/// State of the circuit breaker.
+public enum CircuitBreakerState: Sendable {
+ /// Circuit is closed — requests flow normally.
+ case closed
+ /// Circuit is open — requests are rejected immediately.
+ case open
+ /// Circuit is probing — one request allowed to test recovery.
+ case halfOpen
+}
+
+// MARK: - CircuitBreakerConfig
+
+/// Configuration for a ``CircuitBreaker``.
+public struct CircuitBreakerConfig: Sendable {
+ /// Number of failures before opening the circuit. Default: 5.
+ public var failureThreshold: Int
+ /// Seconds to wait in OPEN state before trying HALF_OPEN. Default: 60.
+ public var recoveryTimeoutSeconds: TimeInterval
+ /// Number of successes in HALF_OPEN state to close the circuit. Default: 2.
+ public var successThreshold: Int
+
+ public init(
+ failureThreshold: Int = 5,
+ recoveryTimeoutSeconds: TimeInterval = 60,
+ successThreshold: Int = 2
+ ) {
+ self.failureThreshold = failureThreshold
+ self.recoveryTimeoutSeconds = recoveryTimeoutSeconds
+ self.successThreshold = successThreshold
+ }
+}
+
+// MARK: - CircuitBreaker
+
+/// Thread-safe circuit breaker actor.
+///
+/// Prevents cascading failures by rejecting calls when a tool is consistently failing.
+/// Transitions between three states:
+/// - **closed**: Normal operation; requests are allowed through.
+/// - **open**: Failure threshold exceeded; requests are rejected immediately.
+/// - **halfOpen**: Recovery probe; one request is allowed through to test recovery.
+///
+/// ## State Machine
+///
+/// ```
+/// closed ──(failures >= threshold)──► open
+/// ▲ │
+/// └──(successes >= threshold)── ◄─── │ (recovery timeout elapsed)
+/// halfOpen ◄┘
+/// ```
+public actor CircuitBreaker {
+ private let config: CircuitBreakerConfig
+ private var state: CircuitBreakerState = .closed
+ private var failureCount: Int = 0
+ private var successCount: Int = 0
+ private var lastFailureTime: Date?
+
+ /// Creates a new circuit breaker with the given configuration.
+ ///
+ /// - Parameter config: Circuit breaker configuration.
+ public init(config: CircuitBreakerConfig = CircuitBreakerConfig()) {
+ self.config = config
+ }
+
+ /// Returns the current circuit breaker state.
+ public func currentState() -> CircuitBreakerState { state }
+
+ /// Returns `true` if the next call should be allowed through, `false` if rejected.
+ ///
+ /// When the circuit is `open`, this method transitions to `halfOpen` once the
+ /// recovery timeout has elapsed.
+ public func allowRequest() -> Bool {
+ switch state {
+ case .closed:
+ return true
+ case .open:
+ guard let lastFailure = lastFailureTime,
+ Date().timeIntervalSince(lastFailure) >= config.recoveryTimeoutSeconds
+ else {
+ return false
+ }
+ state = .halfOpen
+ successCount = 0
+ return true
+ case .halfOpen:
+ return true
+ }
+ }
+
+ /// Records a successful call and updates state accordingly.
+ ///
+ /// In `halfOpen`, accumulates successes until the threshold is met and
+ /// the circuit transitions back to `closed`.
+ public func recordSuccess() {
+ switch state {
+ case .halfOpen:
+ successCount += 1
+ if successCount >= config.successThreshold {
+ state = .closed
+ failureCount = 0
+ successCount = 0
+ }
+ case .closed:
+ failureCount = 0
+ case .open:
+ break
+ }
+ }
+
+ /// Records a failed call and updates state accordingly.
+ ///
+ /// In `closed`, accumulates failures until the threshold is met and
+ /// the circuit transitions to `open`. In `halfOpen`, any failure immediately
+ /// re-opens the circuit.
+ public func recordFailure() {
+ lastFailureTime = Date()
+ switch state {
+ case .closed:
+ failureCount += 1
+ if failureCount >= config.failureThreshold {
+ state = .open
+ }
+ case .halfOpen:
+ state = .open
+ successCount = 0
+ case .open:
+ break
+ }
+ }
+
+ /// Resets the circuit breaker to the `closed` state.
+ public func reset() {
+ state = .closed
+ failureCount = 0
+ successCount = 0
+ lastFailureTime = nil
+ }
+}
+
+// MARK: - ToolErrorConfig
+
+/// Configuration for ``ToolErrorHandler``.
+public struct ToolErrorConfig: Sendable {
+ /// Maximum number of retry attempts. Default: 3.
+ public var maxRetryAttempts: Int
+ /// Base delay in milliseconds for retry calculations. Default: 1000.
+ public var baseRetryDelayMs: Int64
+ /// Maximum delay cap in milliseconds. Default: 30000.
+ public var maxRetryDelayMs: Int64
+ /// Retry delay strategy. Default: `.exponentialJitter`.
+ public var retryStrategy: RetryStrategy
+ /// Whether to retry on tool-not-found errors. Default: `false`.
+ public var retryOnNotFound: Bool
+ /// Whether to retry on validation errors. Default: `false`.
+ public var retryOnValidation: Bool
+ /// Circuit breaker configuration.
+ public var circuitBreaker: CircuitBreakerConfig
+
+ public init(
+ maxRetryAttempts: Int = 3,
+ baseRetryDelayMs: Int64 = 1000,
+ maxRetryDelayMs: Int64 = 30_000,
+ retryStrategy: RetryStrategy = .exponentialJitter,
+ retryOnNotFound: Bool = false,
+ retryOnValidation: Bool = false,
+ circuitBreaker: CircuitBreakerConfig = CircuitBreakerConfig()
+ ) {
+ self.maxRetryAttempts = maxRetryAttempts
+ self.baseRetryDelayMs = baseRetryDelayMs
+ self.maxRetryDelayMs = maxRetryDelayMs
+ self.retryStrategy = retryStrategy
+ self.retryOnNotFound = retryOnNotFound
+ self.retryOnValidation = retryOnValidation
+ self.circuitBreaker = circuitBreaker
+ }
+}
+
+// MARK: - ToolErrorDecision
+
+/// Decision returned by ``ToolErrorHandler/handleError(error:context:attempt:)``.
+public enum ToolErrorDecision: Sendable {
+ /// Retry the tool call after the specified delay (nanoseconds).
+ case retry(delayNanoseconds: UInt64)
+ /// Fail immediately with the given error message.
+ case fail(message: String)
+ /// Circuit breaker is open; reject without retrying.
+ case circuitOpen
+}
+
+// MARK: - ToolErrorHandler
+
+/// Handles tool execution errors with retry logic and circuit-breaker protection.
+///
+/// `ToolErrorHandler` combines a configurable retry strategy with a ``CircuitBreaker``
+/// to prevent cascading failures. On each error, it decides whether to retry (and after
+/// what delay), fail immediately, or reject because the circuit is open.
+///
+/// ## Example
+///
+/// ```swift
+/// let handler = ToolErrorHandler()
+///
+/// func executeWithRetry(
+/// context: ToolExecutionContext,
+/// executor: any ToolExecutor
+/// ) async throws -> ToolExecutionResult {
+/// var attempt = 0
+/// while true {
+/// do {
+/// let result = try await executor.execute(context: context)
+/// await handler.recordSuccess()
+/// return result
+/// } catch {
+/// let decision = await handler.handleError(
+/// error: error,
+/// context: context,
+/// attempt: attempt
+/// )
+/// switch decision {
+/// case .retry(let delayNs):
+/// try await Task.sleep(nanoseconds: delayNs)
+/// attempt += 1
+/// case .fail(let message):
+/// throw ToolExecutionError.executionFailed(
+/// toolName: context.toolCall.function.name,
+/// underlyingError: ToolExecutionError.validationFailed(message: message)
+/// )
+/// case .circuitOpen:
+/// throw ToolExecutionError.executionFailed(
+/// toolName: context.toolCall.function.name,
+/// underlyingError: ToolExecutionError.validationFailed(message: "Circuit breaker open")
+/// )
+/// }
+/// }
+/// }
+/// }
+/// ```
+public actor ToolErrorHandler {
+ private let config: ToolErrorConfig
+ private let circuitBreaker: CircuitBreaker
+
+ /// Creates a new error handler with the given configuration.
+ ///
+ /// - Parameter config: Configuration controlling retry behaviour and the circuit breaker.
+ public init(config: ToolErrorConfig = ToolErrorConfig()) {
+ self.config = config
+ self.circuitBreaker = CircuitBreaker(config: config.circuitBreaker)
+ }
+
+ // MARK: - Public Interface
+
+ /// Evaluates an error and returns what to do next.
+ ///
+ /// Call this method after every failed tool execution. Pass the zero-based
+ /// `attempt` index so the handler can compute an appropriate back-off delay
+ /// and enforce the maximum retry limit.
+ ///
+ /// - Parameters:
+ /// - error: The error from the tool execution.
+ /// - context: The execution context for the failed call.
+ /// - attempt: Zero-based retry attempt index (0 = first failure).
+ /// - Returns: ``ToolErrorDecision`` indicating whether to retry, fail, or reject.
+ public func handleError(
+ error: Error,
+ context: ToolExecutionContext,
+ attempt: Int
+ ) async -> ToolErrorDecision {
+ // Reject immediately when the circuit is open.
+ guard await circuitBreaker.allowRequest() else {
+ return .circuitOpen
+ }
+
+ // Record the failure so the circuit breaker can track state.
+ await circuitBreaker.recordFailure()
+
+ let isRetryable = shouldRetry(error: error)
+
+ guard isRetryable, attempt < config.maxRetryAttempts else {
+ return .fail(message: error.localizedDescription)
+ }
+
+ let delayMs = calculateDelay(attempt: attempt)
+ let delayNs = UInt64(delayMs) * 1_000_000
+ return .retry(delayNanoseconds: delayNs)
+ }
+
+ /// Records a successful execution for circuit breaker state management.
+ ///
+ /// Call this after every successful tool execution so the circuit breaker
+ /// can track recovery in ``CircuitBreakerState/halfOpen`` state.
+ public func recordSuccess() async {
+ await circuitBreaker.recordSuccess()
+ }
+
+ /// Returns the current circuit breaker state.
+ public func circuitBreakerState() async -> CircuitBreakerState {
+ await circuitBreaker.currentState()
+ }
+
+ /// Resets the circuit breaker to the `closed` state.
+ ///
+ /// Useful in tests or after a known-bad period has ended.
+ public func resetCircuitBreaker() async {
+ await circuitBreaker.reset()
+ }
+
+ // MARK: - Private Helpers
+
+ /// Determines whether the given error is eligible for a retry attempt.
+ private func shouldRetry(error: Error) -> Bool {
+ // ToolRegistryError.toolNotFound — not retryable unless explicitly configured.
+ if let registryError = error as? ToolRegistryError {
+ switch registryError {
+ case .toolNotFound:
+ return config.retryOnNotFound
+ case .alreadyRegistered, .emptyToolName:
+ return false
+ }
+ }
+
+ // ToolExecutionError — varies by case.
+ if let execError = error as? ToolExecutionError {
+ switch execError {
+ case .validationFailed:
+ return config.retryOnValidation
+ case .toolNotFound:
+ return config.retryOnNotFound
+ case .timeout, .executionFailed:
+ return true
+ }
+ }
+
+ // Retry all other errors by default.
+ return true
+ }
+
+ /// Calculates the retry delay in milliseconds for the given attempt index.
+ private func calculateDelay(attempt: Int) -> Int64 {
+ let base = config.baseRetryDelayMs
+ let maxDelay = config.maxRetryDelayMs
+
+ let delay: Int64
+ switch config.retryStrategy {
+ case .fixed:
+ delay = base
+
+ case .linear:
+ delay = base * Int64(attempt + 1)
+
+ case .exponential:
+ let multiplier = Int64(pow(2.0, Double(attempt)))
+ delay = base * multiplier
+
+ case .exponentialJitter:
+ let multiplier = Int64(pow(2.0, Double(attempt)))
+ let exponential = base * multiplier
+ let jitter = Int64.random(in: 0 ... (exponential / 2))
+ delay = exponential + jitter
+ }
+
+ return min(delay, maxDelay)
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUITools/Core/ToolExecutionContext.swift b/sdks/community/swift/Sources/AGUITools/Core/ToolExecutionContext.swift
new file mode 100644
index 0000000000..6c5fd4e2ba
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUITools/Core/ToolExecutionContext.swift
@@ -0,0 +1,100 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUICore
+import Foundation
+
+/// Context provided to tool executors during execution.
+///
+/// `ToolExecutionContext` provides all the necessary information for a tool to execute,
+/// including the tool call being executed, optional thread and run identifiers for tracking,
+/// and metadata for additional execution context.
+///
+/// ## Usage
+///
+/// ```swift
+/// // Minimal context
+/// let context = ToolExecutionContext(toolCall: toolCall)
+///
+/// // Full context with tracking and metadata
+/// let context = ToolExecutionContext(
+/// toolCall: toolCall,
+/// threadId: "thread_abc123",
+/// runId: "run_xyz789",
+/// metadata: [
+/// "userId": "user_123",
+/// "sessionId": "session_456",
+/// "timestamp": "2025-01-01T12:00:00Z"
+/// ]
+/// )
+/// ```
+///
+/// ## Thread and Run IDs
+///
+/// - **threadId**: Identifies the conversation thread this tool execution belongs to
+/// - **runId**: Identifies the specific agent run within the thread
+///
+/// These identifiers enable:
+/// - Tracking tool executions across conversations
+/// - Correlating tool calls with agent runs
+/// - Debugging and observability
+///
+/// ## Metadata
+///
+/// The metadata dictionary allows passing additional context-specific information
+/// such as user IDs, session data, timestamps, or configuration values.
+///
+/// ## Design Notes
+///
+/// - Conforms to `Sendable` for safe concurrent access
+/// - All fields are immutable for thread safety
+/// - Optional thread/run IDs support both tracked and standalone tool executions
+///
+/// - SeeAlso: ``ToolExecutor``, ``ToolExecutionResult``
+public struct ToolExecutionContext: Sendable {
+ /// The tool call being executed.
+ ///
+ /// Contains the tool name, function details, and JSON-encoded arguments
+ /// that the tool executor will process.
+ public let toolCall: ToolCall
+
+ /// The thread ID (if available).
+ ///
+ /// Identifies the conversation thread this tool execution belongs to.
+ /// May be nil for standalone tool executions outside of a thread context.
+ public let threadId: String?
+
+ /// The run ID (if available).
+ ///
+ /// Identifies the specific agent run within the thread.
+ /// May be nil for tool executions not associated with an agent run.
+ public let runId: String?
+
+ /// Additional execution metadata.
+ ///
+ /// Can contain any context-specific information such as:
+ /// - User identification
+ /// - Session data
+ /// - Timestamps
+ /// - Configuration values
+ /// - Custom execution parameters
+ public let metadata: [String: String]
+
+ /// Creates a new tool execution context.
+ ///
+ /// - Parameters:
+ /// - toolCall: The tool call being executed
+ /// - threadId: Optional thread identifier
+ /// - runId: Optional run identifier
+ /// - metadata: Additional execution metadata (defaults to empty)
+ public init(
+ toolCall: ToolCall,
+ threadId: String? = nil,
+ runId: String? = nil,
+ metadata: [String: String] = [:]
+ ) {
+ self.toolCall = toolCall
+ self.threadId = threadId
+ self.runId = runId
+ self.metadata = metadata
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUITools/Core/ToolExecutionEvent.swift b/sdks/community/swift/Sources/AGUITools/Core/ToolExecutionEvent.swift
new file mode 100644
index 0000000000..b73ccb27f1
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUITools/Core/ToolExecutionEvent.swift
@@ -0,0 +1,32 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Events emitted during the tool execution lifecycle.
+///
+/// Observe these to monitor tool execution progress for logging, metrics,
+/// or UI feedback.
+public enum ToolExecutionEvent: Sendable {
+ /// Tool call received and queued for execution.
+ case started(toolCallId: String, toolName: String)
+ /// Tool is actively running.
+ case executing(toolCallId: String, toolName: String)
+ /// Tool completed successfully.
+ case succeeded(toolCallId: String, toolName: String, result: ToolExecutionResult)
+ /// Tool failed (either not found or threw an error).
+ case failed(toolCallId: String, toolName: String, error: String)
+
+ public var toolCallId: String {
+ switch self {
+ case .started(let id, _), .executing(let id, _), .succeeded(let id, _, _), .failed(let id, _, _):
+ return id
+ }
+ }
+
+ public var toolName: String {
+ switch self {
+ case .started(_, let n), .executing(_, let n), .succeeded(_, let n, _), .failed(_, let n, _):
+ return n
+ }
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUITools/Core/ToolExecutionManager.swift b/sdks/community/swift/Sources/AGUITools/Core/ToolExecutionManager.swift
new file mode 100644
index 0000000000..a226b40016
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUITools/Core/ToolExecutionManager.swift
@@ -0,0 +1,292 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUICore
+import Foundation
+
+/// Intercepts tool call events from an event stream, executes the corresponding tools,
+/// and sends results back to the agent via a response handler.
+///
+/// `ToolExecutionManager` acts as middleware in the event pipeline:
+/// - All events from the upstream source are forwarded unchanged.
+/// - Tool call events (`ToolCallStartEvent`, `ToolCallArgsEvent`, `ToolCallEndEvent`) are
+/// intercepted and used to drive tool execution.
+/// - Tool execution happens concurrently with event forwarding.
+/// - Execution lifecycle notifications are published via `executionEvents`.
+///
+/// ## Usage
+///
+/// ```swift
+/// let manager = ToolExecutionManager(
+/// toolRegistry: registry,
+/// responseHandler: myHandler
+/// )
+///
+/// let processedStream = manager.processEventStream(
+/// rawStream,
+/// threadId: "thread_123",
+/// runId: "run_456"
+/// )
+///
+/// for try await event in processedStream {
+/// // Handle events as usual — tool calls are executed automatically
+/// }
+/// ```
+///
+/// - SeeAlso: ``ToolRegistry``, ``ToolResponseHandler``, ``ToolExecutionEvent``
+public actor ToolExecutionManager {
+
+ private let toolRegistry: any ToolRegistry
+ private let responseHandler: any ToolResponseHandler
+ /// Configuration template used to mint one `ToolErrorHandler` per tool name.
+ private let errorHandlerConfig: ToolErrorConfig
+ /// Per-tool error handlers, keyed by tool name.
+ /// Each tool gets its own circuit breaker so one tool's failures don't block others.
+ private var errorHandlers: [String: ToolErrorHandler] = [:]
+ private var activeExecutions: [String: Task] = [:]
+ private var toolCallBuffer: [String: ToolCallBuilder] = [:]
+
+ // Continuation for the execution events stream
+ private var eventsContinuation: AsyncStream.Continuation?
+
+ /// Stream of tool execution lifecycle events.
+ ///
+ /// Subscribe to this stream to receive notifications when tool calls are
+ /// started, executing, completed, or failed.
+ public let executionEvents: AsyncStream
+
+ /// Creates a new `ToolExecutionManager`.
+ ///
+ /// - Parameters:
+ /// - toolRegistry: The registry used to look up and execute tools.
+ /// - responseHandler: The handler used to send tool results back to the agent.
+ /// - errorHandlerConfig: Configuration template used to create one `ToolErrorHandler`
+ /// per tool name, giving each tool an independent retry counter and circuit breaker.
+ /// Defaults to sensible values (3 retries, exponential jitter, circuit opens after
+ /// 5 consecutive failures).
+ public init(
+ toolRegistry: any ToolRegistry,
+ responseHandler: any ToolResponseHandler,
+ errorHandlerConfig: ToolErrorConfig = ToolErrorConfig()
+ ) {
+ self.toolRegistry = toolRegistry
+ self.responseHandler = responseHandler
+ self.errorHandlerConfig = errorHandlerConfig
+ var cont: AsyncStream.Continuation!
+ self.executionEvents = AsyncStream { cont = $0 }
+ self.eventsContinuation = cont
+ }
+
+ /// Returns the per-tool error handler for `toolName`, creating one on first access.
+ private func errorHandler(for toolName: String) -> ToolErrorHandler {
+ if let existing = errorHandlers[toolName] { return existing }
+ let handler = ToolErrorHandler(config: errorHandlerConfig)
+ errorHandlers[toolName] = handler
+ return handler
+ }
+
+ deinit {
+ eventsContinuation?.finish()
+ }
+
+ /// Wraps an event stream, executing any tool calls found within it.
+ ///
+ /// All events are passed through unchanged. Tool execution happens as a side
+ /// effect of consuming the returned stream. The manager awaits any in-flight
+ /// tool executions before the stream terminates.
+ ///
+ /// - Parameters:
+ /// - events: The upstream event sequence to process.
+ /// - threadId: The conversation thread ID to pass to tool executors and the response handler.
+ /// - runId: The run ID to pass to tool executors and the response handler.
+ /// - Returns: An `AsyncThrowingStream` that forwards all upstream events unchanged.
+ public func processEventStream(
+ _ events: S,
+ threadId: String?,
+ runId: String?
+ ) -> AsyncThrowingStream where S.Element == any AGUIEvent {
+ AsyncThrowingStream { continuation in
+ let task = Task {
+ do {
+ for try await event in events {
+ continuation.yield(event)
+ await self.handleEvent(event, threadId: threadId, runId: runId)
+ }
+ await self.awaitAllExecutions()
+ continuation.finish()
+ } catch {
+ continuation.finish(throwing: error)
+ }
+ }
+ continuation.onTermination = { _ in task.cancel() }
+ }
+ }
+
+ /// Cancels all active tool execution tasks.
+ public func cancelAllExecutions() {
+ activeExecutions.values.forEach { $0.cancel() }
+ activeExecutions.removeAll()
+ }
+
+ /// Returns the count of currently executing tool calls.
+ public func activeExecutionCount() -> Int {
+ activeExecutions.count
+ }
+
+ /// Returns true if a specific tool call is still executing.
+ public func isExecuting(toolCallId: String) -> Bool {
+ activeExecutions[toolCallId] != nil
+ }
+
+ // MARK: - Private helpers
+
+ private func handleEvent(_ event: any AGUIEvent, threadId: String?, runId: String?) async {
+ switch event {
+ case let e as ToolCallStartEvent:
+ toolCallBuffer[e.toolCallId] = ToolCallBuilder(id: e.toolCallId, name: e.toolCallName)
+ eventsContinuation?.yield(.started(toolCallId: e.toolCallId, toolName: e.toolCallName))
+
+ case let e as ToolCallArgsEvent:
+ toolCallBuffer[e.toolCallId]?.appendArguments(e.delta)
+
+ case let e as ToolCallEndEvent:
+ guard let builder = toolCallBuffer.removeValue(forKey: e.toolCallId) else { return }
+ let toolCall = builder.build()
+ let execTask = Task {
+ await self.executeToolCall(toolCall, threadId: threadId, runId: runId)
+ }
+ activeExecutions[e.toolCallId] = execTask
+
+ case is RunFinishedEvent, is RunErrorEvent:
+ // Tool execution tasks will be awaited in processEventStream after the loop
+ break
+
+ default:
+ break
+ }
+ }
+
+ private func executeToolCall(_ toolCall: ToolCall, threadId: String?, runId: String?) async {
+ let toolCallId = toolCall.id
+ let toolName = toolCall.function.name
+
+ defer { activeExecutions.removeValue(forKey: toolCallId) }
+
+ eventsContinuation?.yield(.executing(toolCallId: toolCallId, toolName: toolName))
+
+ let handler = errorHandler(for: toolName)
+ let context = ToolExecutionContext(toolCall: toolCall, threadId: threadId, runId: runId)
+ var attempt = 0
+
+ while true {
+ do {
+ let result = try await toolRegistry.execute(context: context)
+ await handler.recordSuccess()
+
+ let content: String
+ if let data = result.result, let decoded = String(data: data, encoding: .utf8), !decoded.isEmpty {
+ content = decoded
+ } else if let msg = result.message, !msg.isEmpty {
+ content = msg
+ } else {
+ content = result.success ? "true" : "false"
+ }
+
+ let toolMessage = ToolMessage(
+ id: "msg_\(UUID().uuidString)",
+ content: content,
+ toolCallId: toolCallId
+ )
+ // Issue 26: surface delivery failures instead of silently discarding them.
+ do {
+ try await responseHandler.sendToolResponse(toolMessage, threadId: threadId, runId: runId)
+ eventsContinuation?.yield(.succeeded(toolCallId: toolCallId, toolName: toolName, result: result))
+ } catch {
+ eventsContinuation?.yield(.failed(
+ toolCallId: toolCallId,
+ toolName: toolName,
+ error: "Response delivery failed: \(error.localizedDescription)"
+ ))
+ }
+ return
+
+ } catch {
+ let decision = await handler.handleError(error: error, context: context, attempt: attempt)
+ switch decision {
+ case .retry(let delayNs):
+ if delayNs > 0 {
+ try? await Task.sleep(nanoseconds: delayNs)
+ }
+ attempt += 1
+
+ case .fail(let message):
+ let errorMessage = ToolMessage(
+ id: "msg_\(UUID().uuidString)",
+ content: "Error: \(message)",
+ toolCallId: toolCallId
+ )
+ do {
+ try await responseHandler.sendToolResponse(errorMessage, threadId: threadId, runId: runId)
+ eventsContinuation?.yield(.failed(toolCallId: toolCallId, toolName: toolName, error: message))
+ } catch {
+ eventsContinuation?.yield(.failed(
+ toolCallId: toolCallId,
+ toolName: toolName,
+ error: "\(message); response delivery failed: \(error.localizedDescription)"
+ ))
+ }
+ return
+
+ case .circuitOpen:
+ let circuitError = "Circuit breaker open"
+ let errorMessage = ToolMessage(
+ id: "msg_\(UUID().uuidString)",
+ content: "Error: Tool '\(toolName)' is temporarily unavailable",
+ toolCallId: toolCallId
+ )
+ do {
+ try await responseHandler.sendToolResponse(errorMessage, threadId: threadId, runId: runId)
+ eventsContinuation?.yield(.failed(toolCallId: toolCallId, toolName: toolName, error: circuitError))
+ } catch {
+ eventsContinuation?.yield(.failed(
+ toolCallId: toolCallId,
+ toolName: toolName,
+ error: "\(circuitError); response delivery failed: \(error.localizedDescription)"
+ ))
+ }
+ return
+ }
+ }
+ }
+ }
+
+ private func awaitAllExecutions() async {
+ let tasks = Array(activeExecutions.values)
+ for task in tasks {
+ await task.value
+ }
+ }
+}
+
+// MARK: - ToolCallBuilder
+
+/// Builds a `ToolCall` from streaming events by accumulating argument deltas.
+///
+/// Value type: all mutation happens inside the `ToolExecutionManager` actor,
+private struct ToolCallBuilder {
+ let id: String
+ let name: String
+ private(set) var arguments: String = ""
+
+ init(id: String, name: String) {
+ self.id = id
+ self.name = name
+ }
+
+ mutating func appendArguments(_ delta: String) {
+ arguments += delta
+ }
+
+ func build() -> ToolCall {
+ ToolCall(id: id, function: FunctionCall(name: name, arguments: arguments))
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUITools/Core/ToolExecutionResult.swift b/sdks/community/swift/Sources/AGUITools/Core/ToolExecutionResult.swift
new file mode 100644
index 0000000000..4693f07bcb
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUITools/Core/ToolExecutionResult.swift
@@ -0,0 +1,112 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Result of a tool execution.
+///
+/// `ToolExecutionResult` encapsulates the outcome of executing a tool, including
+/// success/failure status, optional result data, and optional human-readable messages.
+/// This type supports both successful outcomes with data and failed outcomes with
+/// error information.
+///
+/// ## Usage
+///
+/// ```swift
+/// // Successful execution with data
+/// let jsonData = Data(#"{"temperature": 72, "conditions": "sunny"}"#.utf8)
+/// let result = ToolExecutionResult.success(
+/// result: jsonData,
+/// message: "Weather retrieved successfully"
+/// )
+///
+/// // Failed execution
+/// let error = ToolExecutionResult.failure(
+/// message: "Failed to connect to weather service"
+/// )
+/// ```
+///
+/// ## Design Notes
+///
+/// - Result data is stored as `Data` (JSON) to maintain flexibility
+/// - Both success and failure cases can include optional messages
+/// - Conforms to `Sendable` for safe concurrent access
+/// - Conforms to `Equatable` for testing and comparison
+///
+/// - SeeAlso: ``ToolExecutor``, ``ToolExecutionContext``
+public struct ToolExecutionResult: Sendable, Equatable {
+ /// Whether the tool execution was successful.
+ public let success: Bool
+
+ /// The result data (if successful) or error information (if failed).
+ ///
+ /// This is typically JSON-encoded data. For successful executions, it contains
+ /// the tool's output. For failed executions, it may contain structured error details.
+ public let result: Data?
+
+ /// Optional human-readable message about the result.
+ ///
+ /// For successful executions, this might describe what was accomplished.
+ /// For failed executions, this typically contains an error message.
+ public let message: String?
+
+ /// Creates a new tool execution result.
+ ///
+ /// - Parameters:
+ /// - success: Whether the execution was successful
+ /// - result: Optional result data (typically JSON)
+ /// - message: Optional human-readable message
+ public init(
+ success: Bool,
+ result: Data? = nil,
+ message: String? = nil
+ ) {
+ self.success = success
+ self.result = result
+ self.message = message
+ }
+
+ /// Creates a successful execution result.
+ ///
+ /// - Parameters:
+ /// - result: Optional result data (typically JSON)
+ /// - message: Optional success message
+ /// - Returns: A successful execution result
+ public static func success(
+ result: Data? = nil,
+ message: String? = nil
+ ) -> ToolExecutionResult {
+ ToolExecutionResult(success: true, result: result, message: message)
+ }
+
+ /// Creates a failed execution result.
+ ///
+ /// - Parameters:
+ /// - message: Error message describing the failure
+ /// - result: Optional error data (typically JSON with error details)
+ /// - Returns: A failed execution result
+ public static func failure(
+ message: String,
+ result: Data? = nil
+ ) -> ToolExecutionResult {
+ ToolExecutionResult(success: false, result: result, message: message)
+ }
+
+ /// Decodes the result data as the given `Decodable` type.
+ ///
+ /// ```swift
+ /// let weather = try result.decode(as: WeatherResponse.self)
+ /// ```
+ ///
+ /// - Parameters:
+ /// - type: The target `Decodable` type.
+ /// - decoder: The JSON decoder to use (defaults to `JSONDecoder()`).
+ /// - Returns: The decoded value, or `nil` when `result` is `nil`.
+ /// - Throws: `DecodingError` if the data cannot be decoded as `T`.
+ public func decode(
+ as type: T.Type,
+ using decoder: JSONDecoder = JSONDecoder()
+ ) throws -> T? {
+ guard let data = result else { return nil }
+ return try decoder.decode(T.self, from: data)
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUITools/Core/ToolExecutor.swift b/sdks/community/swift/Sources/AGUITools/Core/ToolExecutor.swift
new file mode 100644
index 0000000000..c2324f71f0
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUITools/Core/ToolExecutor.swift
@@ -0,0 +1,249 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUICore
+import Foundation
+
+/// Protocol for executing tools.
+///
+/// Tool executors are responsible for:
+/// - Validating tool call arguments against the tool's schema
+/// - Performing the actual tool execution
+/// - Handling errors and timeouts
+/// - Returning structured results
+///
+/// Implementations should be:
+/// - Thread-safe (multiple concurrent executions via actor isolation)
+/// - Idempotent where possible
+/// - Defensive (validate all inputs)
+/// - Fast (avoid blocking operations when possible)
+///
+/// ## Usage
+///
+/// ```swift
+/// actor WeatherToolExecutor: ToolExecutor {
+/// let tool: Tool
+///
+/// init() {
+/// self.tool = Tool(
+/// name: "get_weather",
+/// description: "Get current weather for a location",
+/// parameters: Data(#"{"type": "object", ...}"#.utf8)
+/// )
+/// }
+///
+/// func execute(context: ToolExecutionContext) async throws -> ToolExecutionResult {
+/// // Decode arguments
+/// let args = try JSONDecoder().decode(
+/// WeatherArgs.self,
+/// from: Data(context.toolCall.function.arguments.utf8)
+/// )
+///
+/// // Execute the tool
+/// let weather = try await fetchWeather(for: args.location)
+///
+/// // Return result
+/// let resultData = try JSONEncoder().encode(weather)
+/// return .success(result: resultData, message: "Weather retrieved")
+/// }
+///
+/// func maximumExecutionTime() -> Duration? {
+/// .seconds(30)
+/// }
+/// }
+/// ```
+///
+/// ## Thread Safety
+///
+/// Tool executors should be implemented as actors to ensure thread-safe
+/// concurrent execution. The protocol requires `Sendable` conformance.
+///
+/// - SeeAlso: ``ToolExecutionContext``, ``ToolExecutionResult``, ``ToolValidationResult``
+public protocol ToolExecutor: Sendable {
+ /// The tool definition this executor handles.
+ ///
+ /// This defines the tool's name, description, and parameter schema that
+ /// the executor validates against and executes.
+ var tool: Tool { get }
+
+ /// Executes a tool call.
+ ///
+ /// This is the main execution method that performs the tool's functionality.
+ /// Implementations should:
+ /// - Parse and validate the tool call arguments
+ /// - Perform the requested operation
+ /// - Return a structured result or throw an error
+ ///
+ /// - Parameter context: The execution context including the tool call and metadata
+ /// - Returns: The execution result
+ /// - Throws: ``ToolExecutionError`` if execution fails in an unrecoverable way
+ func execute(context: ToolExecutionContext) async throws -> ToolExecutionResult
+
+ /// Validates a tool call before execution.
+ ///
+ /// This method checks if the tool call arguments are valid according to
+ /// the tool's parameter schema. The default implementation returns valid
+ /// for all calls. Override to implement custom validation logic.
+ ///
+ /// - Parameter toolCall: The tool call to validate
+ /// - Returns: Validation result with success/failure and error messages
+ nonisolated func validate(toolCall: ToolCall) -> ToolValidationResult
+
+ /// Gets the maximum execution time for this tool.
+ ///
+ /// Used by tool registries to implement timeouts. Return nil for no timeout.
+ /// Default implementation returns nil.
+ ///
+ /// - Returns: Maximum execution time, or nil for no timeout
+ nonisolated func maximumExecutionTime() -> Duration?
+}
+
+// MARK: - Default Implementations
+
+public extension ToolExecutor {
+ /// Default validation implementation that accepts all tool calls.
+ ///
+ /// Override this method to implement custom validation logic based on
+ /// your tool's parameter schema.
+ nonisolated func validate(toolCall: ToolCall) -> ToolValidationResult {
+ .valid
+ }
+
+ /// Default timeout implementation (no timeout).
+ ///
+ /// Override this method to specify a maximum execution time for your tool.
+ nonisolated func maximumExecutionTime() -> Duration? {
+ nil
+ }
+}
+
+// MARK: - ToolValidationResult
+
+/// Result of tool call validation.
+///
+/// Indicates whether a tool call's arguments are valid according to the
+/// tool's parameter schema, and provides error messages for invalid calls.
+///
+/// ## Usage
+///
+/// ```swift
+/// // Valid tool call
+/// let result = ToolValidationResult.valid
+///
+/// // Invalid tool call with errors
+/// let result = ToolValidationResult.invalid(errors: [
+/// "Missing required parameter: location",
+/// "Invalid type for parameter 'temperature': expected number"
+/// ])
+/// ```
+public struct ToolValidationResult: Sendable {
+ /// Whether the validation passed.
+ public let isValid: Bool
+
+ /// Validation error messages (empty if valid).
+ ///
+ /// Contains human-readable descriptions of validation failures:
+ /// - Missing required parameters
+ /// - Invalid parameter types
+ /// - Schema constraint violations
+ /// - Unknown parameters
+ public let errors: [String]
+
+ /// Creates a new validation result.
+ ///
+ /// - Parameters:
+ /// - isValid: Whether the validation passed
+ /// - errors: Validation error messages (empty if valid)
+ public init(isValid: Bool, errors: [String] = []) {
+ self.isValid = isValid
+ self.errors = errors
+ }
+
+ /// Creates a successful validation result.
+ public static var valid: ToolValidationResult {
+ ToolValidationResult(isValid: true, errors: [])
+ }
+
+ /// Creates a failed validation result with error messages.
+ ///
+ /// - Parameter errors: Validation error messages
+ /// - Returns: An invalid validation result
+ public static func invalid(errors: [String]) -> ToolValidationResult {
+ ToolValidationResult(isValid: false, errors: errors)
+ }
+}
+
+// MARK: - ToolExecutionError
+
+/// Errors that can occur during tool execution.
+///
+/// This enum categorizes different types of tool execution failures to enable
+/// appropriate error handling, retry logic, and user feedback.
+///
+/// ## Error Categories
+///
+/// - **validationFailed**: Tool call arguments are invalid (user error, not retryable)
+/// - **timeout**: Execution exceeded maximum time (may be retryable)
+/// - **toolNotFound**: Requested tool doesn't exist (configuration error, not retryable)
+/// - **executionFailed**: General execution failure (may be retryable depending on cause)
+///
+/// ## Usage
+///
+/// ```swift
+/// // Validation failure
+/// throw ToolExecutionError.validationFailed(
+/// message: "Missing required parameter: location"
+/// )
+///
+/// // Timeout
+/// throw ToolExecutionError.timeout(
+/// toolName: "slow_api_call",
+/// duration: .seconds(30)
+/// )
+///
+/// // Execution failure with underlying error
+/// do {
+/// try await apiCall()
+/// } catch {
+/// throw ToolExecutionError.executionFailed(
+/// toolName: "api_tool",
+/// underlyingError: error
+/// )
+/// }
+/// ```
+public enum ToolExecutionError: Error, Sendable {
+ /// Tool call validation failed.
+ ///
+ /// Indicates that the tool call arguments are invalid according to the
+ /// tool's parameter schema. This is typically a user error and not retryable.
+ ///
+ /// - Parameter message: Description of the validation failure
+ case validationFailed(message: String)
+
+ /// Tool execution exceeded the maximum allowed time.
+ ///
+ /// Indicates that the tool took longer than its configured timeout.
+ /// This may be a transient error and could be worth retrying.
+ ///
+ /// - Parameters:
+ /// - toolName: Name of the tool that timed out
+ /// - duration: How long the tool was allowed to run
+ case timeout(toolName: String, duration: Duration)
+
+ /// Requested tool was not found in the registry.
+ ///
+ /// Indicates a configuration error where the agent requested a tool
+ /// that doesn't exist or wasn't registered. This is not retryable.
+ ///
+ /// - Parameter toolName: Name of the tool that wasn't found
+ case toolNotFound(toolName: String)
+
+ /// Tool execution failed with an error.
+ ///
+ /// General execution failure that wraps an underlying error. Whether
+ /// this is retryable depends on the nature of the underlying error.
+ ///
+ /// - Parameters:
+ /// - toolName: Name of the tool that failed
+ /// - underlyingError: The underlying error that caused the failure
+ case executionFailed(toolName: String, underlyingError: any Error & Sendable)
+}
diff --git a/sdks/community/swift/Sources/AGUITools/Core/ToolResponseHandler.swift b/sdks/community/swift/Sources/AGUITools/Core/ToolResponseHandler.swift
new file mode 100644
index 0000000000..fca9695da4
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUITools/Core/ToolResponseHandler.swift
@@ -0,0 +1,30 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUICore
+import Foundation
+
+/// Contract for sending tool execution results back to the agent.
+///
+/// Conforming types deliver `ToolMessage` responses to whatever transport
+/// the agent uses (HTTP, WebSocket, in-process, etc.).
+public protocol ToolResponseHandler: Sendable {
+ /// Sends a tool response message back to the agent.
+ ///
+ /// - Parameters:
+ /// - message: The completed tool message to send
+ /// - threadId: The conversation thread ID (may be nil)
+ /// - runId: The run ID this response belongs to (may be nil)
+ func sendToolResponse(
+ _ message: ToolMessage,
+ threadId: String?,
+ runId: String?
+ ) async throws
+}
+
+/// No-op implementation that discards all tool responses.
+///
+/// Useful for testing or when tool responses are not needed.
+public struct NullToolResponseHandler: ToolResponseHandler {
+ public init() {}
+ public func sendToolResponse(_ message: ToolMessage, threadId: String?, runId: String?) async throws {}
+}
diff --git a/sdks/community/swift/Sources/AGUITools/Registry/ToolExecutionStats.swift b/sdks/community/swift/Sources/AGUITools/Registry/ToolExecutionStats.swift
new file mode 100644
index 0000000000..b946217970
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUITools/Registry/ToolExecutionStats.swift
@@ -0,0 +1,86 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+
+/// Statistics about tool execution.
+///
+/// `ToolExecutionStats` tracks execution metrics for a tool, including counts
+/// of executions, successes, failures, and timing information. These statistics
+/// are useful for monitoring tool performance, identifying problematic tools,
+/// and debugging execution issues.
+///
+/// ## Usage
+///
+/// ```swift
+/// // Get stats from a registry
+/// let stats = await registry.stats(for: "my_tool")
+/// print("Success rate: \(stats.successRate * 100)%")
+/// print("Average time: \(stats.averageExecutionTime)")
+/// ```
+///
+/// ## Metrics Tracked
+///
+/// - **Execution counts**: Total, successful, and failed executions
+/// - **Success rate**: Percentage of successful executions
+/// - **Timing**: Total and average execution time
+///
+/// ## Design Notes
+///
+/// - Immutable value type for thread safety
+/// - Sendable for safe concurrent access
+/// - Success rate computed property for convenience
+///
+/// - SeeAlso: ``ToolRegistry``, ``DefaultToolRegistry``
+public struct ToolExecutionStats: Sendable, Equatable, Hashable {
+ /// Total number of executions (successes + failures).
+ public let executionCount: Int
+
+ /// Number of successful executions.
+ public let successCount: Int
+
+ /// Number of failed executions.
+ public let failureCount: Int
+
+ /// Total time spent executing the tool across all executions.
+ public let totalExecutionTime: Duration
+
+ /// Average execution time per invocation.
+ public let averageExecutionTime: Duration
+
+ /// Creates new execution statistics.
+ ///
+ /// - Parameters:
+ /// - executionCount: Total number of executions (defaults to 0)
+ /// - successCount: Number of successful executions (defaults to 0)
+ /// - failureCount: Number of failed executions (defaults to 0)
+ /// - totalExecutionTime: Total time spent executing (defaults to zero)
+ /// - averageExecutionTime: Average execution time (defaults to zero)
+ public init(
+ executionCount: Int = 0,
+ successCount: Int = 0,
+ failureCount: Int = 0,
+ totalExecutionTime: Duration = .zero,
+ averageExecutionTime: Duration = .zero
+ ) {
+ self.executionCount = executionCount
+ self.successCount = successCount
+ self.failureCount = failureCount
+ self.totalExecutionTime = totalExecutionTime
+ self.averageExecutionTime = averageExecutionTime
+ }
+
+ /// Success rate as a decimal between 0.0 and 1.0.
+ ///
+ /// Calculated as `successCount / executionCount`. Returns 0.0 if there
+ /// have been no executions.
+ ///
+ /// ## Examples
+ ///
+ /// - 10 successes out of 10 executions = 1.0 (100%)
+ /// - 7 successes out of 10 executions = 0.7 (70%)
+ /// - 0 executions = 0.0
+ public var successRate: Double {
+ guard executionCount > 0 else { return 0.0 }
+ return Double(successCount) / Double(executionCount)
+ }
+}
diff --git a/sdks/community/swift/Sources/AGUITools/Registry/ToolRegistry.swift b/sdks/community/swift/Sources/AGUITools/Registry/ToolRegistry.swift
new file mode 100644
index 0000000000..2b22f30622
--- /dev/null
+++ b/sdks/community/swift/Sources/AGUITools/Registry/ToolRegistry.swift
@@ -0,0 +1,328 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUICore
+import Foundation
+
+/// Protocol for managing tool executors.
+///
+/// A tool registry provides a centralized location for:
+/// - Registering and discovering tool executors
+/// - Executing tool calls with automatic executor lookup
+/// - Tracking execution statistics
+/// - Managing tool lifecycle
+///
+/// ## Usage
+///
+/// ```swift
+/// // Create and configure a registry
+/// let registry = DefaultToolRegistry()
+///
+/// // Register tools
+/// try await registry.register(executor: MyToolExecutor())
+///
+/// // Execute a tool call
+/// let result = try await registry.execute(context: context)
+///
+/// // Query statistics
+/// if let stats = await registry.stats(for: "my_tool") {
+/// print("Success rate: \(stats.successRate)")
+/// }
+/// ```
+///
+/// ## Thread Safety
+///
+/// All ToolRegistry implementations must be thread-safe and support
+/// concurrent access from multiple tasks/actors.
+///
+/// - SeeAlso: ``DefaultToolRegistry``, ``ToolExecutor``, ``ToolExecutionStats``
+public protocol ToolRegistry: Sendable {
+ /// Registers a tool executor.
+ ///
+ /// - Parameter executor: The tool executor to register
+ /// - Throws: ``ToolRegistryError/alreadyRegistered(_:)`` if a tool with the same name exists
+ /// - Throws: ``ToolRegistryError/emptyToolName`` if the tool name is empty
+ func register(executor: any ToolExecutor) async throws
+
+ /// Unregisters a tool executor by name.
+ ///
+ /// - Parameter toolName: The name of the tool to unregister
+ /// - Returns: `true` if the tool was unregistered, `false` if not found
+ func unregister(toolName: String) async -> Bool
+
+ /// Gets a tool executor by name.
+ ///
+ /// - Parameter toolName: The name of the tool
+ /// - Returns: The tool executor, or `nil` if not found
+ func executor(for toolName: String) async -> (any ToolExecutor)?
+
+ /// Gets all registered tool definitions.
+ ///
+ /// Used by clients to populate the tools array in RunAgentInput.
+ ///
+ /// - Returns: List of all registered tools
+ func allTools() async -> [Tool]
+
+ /// Checks if a tool is registered.
+ ///
+ /// - Parameter toolName: The name of the tool
+ /// - Returns: `true` if the tool is registered
+ func isToolRegistered(toolName: String) async -> Bool
+
+ /// Executes a tool call.
+ ///
+ /// This method:
+ /// 1. Looks up the executor for the tool
+ /// 2. Executes the tool with timeout handling
+ /// 3. Updates execution statistics
+ ///
+ /// - Parameter context: The execution context
+ /// - Returns: The execution result
+ /// - Throws: ``ToolRegistryError/toolNotFound(_:)`` if tool not registered
+ /// - Throws: ``ToolExecutionError`` if execution fails
+ func execute(context: ToolExecutionContext) async throws -> ToolExecutionResult
+
+ /// Gets execution statistics for a specific tool.
+ ///
+ /// - Parameter toolName: The name of the tool
+ /// - Returns: Execution statistics, or `nil` if tool not found
+ func stats(for toolName: String) async -> ToolExecutionStats?
+
+ /// Gets execution statistics for all tools.
+ ///
+ /// - Returns: Map of tool name to execution statistics
+ func getAllStats() async -> [String: ToolExecutionStats]
+
+ /// Clears execution statistics for all tools.
+ func clearStats() async
+
+ /// Gets all registered tool executors.
+ ///
+ /// Returns a snapshot of the current executor map, keyed by tool name.
+ ///
+ /// - Returns: Map of tool name to executor
+ func getAllExecutors() async -> [String: any ToolExecutor]
+}
+
+// MARK: - ToolRegistryError
+
+/// Errors that can occur during tool registry operations.
+public enum ToolRegistryError: Error, Sendable {
+ /// A tool with the given name is already registered.
+ case alreadyRegistered(String)
+
+ /// Cannot register a tool with an empty name.
+ case emptyToolName
+
+ /// The requested tool was not found in the registry.
+ case toolNotFound(String)
+}
+
+// MARK: - DefaultToolRegistry
+
+/// Default implementation of ToolRegistry using actor isolation.
+///
+/// `DefaultToolRegistry` provides a thread-safe tool registry that:
+/// - Uses actor isolation for automatic synchronization
+/// - Tracks execution statistics for monitoring
+/// - Supports timeout handling based on tool configuration
+/// - Handles errors gracefully with statistics updates
+///
+/// ## Usage
+///
+/// ```swift
+/// // Create a registry
+/// let registry = DefaultToolRegistry()
+///
+/// // Register tools
+/// try await registry.register(executor: WeatherToolExecutor())
+/// try await registry.register(executor: CalculatorToolExecutor())
+///
+/// // Execute tool calls from agent
+/// for toolCall in agentToolCalls {
+/// let context = ToolExecutionContext(toolCall: toolCall)
+/// let result = try await registry.execute(context: context)
+/// // Send result back to agent
+/// }
+/// ```
+///
+/// ## Thread Safety
+///
+/// This actor-based implementation provides automatic thread safety through
+/// Swift's actor isolation. All mutable state is protected by the actor.
+///
+/// - SeeAlso: ``ToolRegistry``, ``ToolExecutor``
+public actor DefaultToolRegistry: ToolRegistry {
+ private var executors: [String: any ToolExecutor] = [:]
+ private var stats: [String: MutableToolExecutionStats] = [:]
+
+ /// Creates a new empty tool registry.
+ public init() {}
+
+ public func register(executor: any ToolExecutor) async throws {
+ let toolName = executor.tool.name
+
+ guard !toolName.isEmpty else {
+ throw ToolRegistryError.emptyToolName
+ }
+
+ guard executors[toolName] == nil else {
+ throw ToolRegistryError.alreadyRegistered(toolName)
+ }
+
+ executors[toolName] = executor
+ stats[toolName] = MutableToolExecutionStats()
+ }
+
+ public func unregister(toolName: String) async -> Bool {
+ let wasPresent = executors.removeValue(forKey: toolName) != nil
+ stats.removeValue(forKey: toolName)
+ return wasPresent
+ }
+
+ public func executor(for toolName: String) async -> (any ToolExecutor)? {
+ executors[toolName]
+ }
+
+ public func allTools() async -> [Tool] {
+ executors.values.map { $0.tool }
+ }
+
+ public func isToolRegistered(toolName: String) async -> Bool {
+ executors[toolName] != nil
+ }
+
+ public func execute(context: ToolExecutionContext) async throws -> ToolExecutionResult {
+ let toolName = context.toolCall.function.name
+
+ guard let executor = executors[toolName] else {
+ throw ToolRegistryError.toolNotFound(toolName)
+ }
+
+ let startTime = ContinuousClock.now
+ let result: ToolExecutionResult
+
+ do {
+ // Validate before executing
+ let validation = executor.validate(toolCall: context.toolCall)
+ if !validation.isValid {
+ throw ToolExecutionError.validationFailed(
+ message: validation.errors.joined(separator: "; ")
+ )
+ }
+
+ // Execute with timeout if specified
+ if let maxTime = executor.maximumExecutionTime() {
+ result = try await withTimeout(maxTime, toolName: toolName) {
+ try await executor.execute(context: context)
+ }
+ } else {
+ result = try await executor.execute(context: context)
+ }
+
+ // Update success statistics
+ let duration = startTime.duration(to: .now)
+ stats[toolName]?.recordSuccess(duration: duration)
+
+ return result
+ } catch {
+ // Update failure statistics
+ let duration = startTime.duration(to: .now)
+ stats[toolName]?.recordFailure(duration: duration)
+ throw error
+ }
+ }
+
+ public func stats(for toolName: String) async -> ToolExecutionStats? {
+ stats[toolName]?.toImmutable()
+ }
+
+ public func getAllStats() async -> [String: ToolExecutionStats] {
+ stats.mapValues { $0.toImmutable() }
+ }
+
+ public func clearStats() async {
+ for key in stats.keys {
+ stats[key]?.clear()
+ }
+ }
+
+ public func getAllExecutors() async -> [String: any ToolExecutor] {
+ executors
+ }
+}
+
+// MARK: - MutableToolExecutionStats
+
+/// Mutable version of ToolExecutionStats for internal tracking.
+///
+/// Value type: all mutation happens through the `DefaultToolRegistry` actor's
+/// dictionary subscript (inout), which serialises access — no `@unchecked Sendable` needed.
+private struct MutableToolExecutionStats {
+ private(set) var executionCount: Int = 0
+ private(set) var successCount: Int = 0
+ private(set) var failureCount: Int = 0
+ private(set) var totalExecutionTime: Duration = .zero
+ private(set) var averageExecutionTime: Duration = .zero
+
+ mutating func recordSuccess(duration: Duration) {
+ executionCount += 1
+ successCount += 1
+ totalExecutionTime += duration
+ averageExecutionTime = totalExecutionTime / executionCount
+ }
+
+ mutating func recordFailure(duration: Duration) {
+ executionCount += 1
+ failureCount += 1
+ totalExecutionTime += duration
+ averageExecutionTime = totalExecutionTime / executionCount
+ }
+
+ mutating func clear() {
+ executionCount = 0
+ successCount = 0
+ failureCount = 0
+ totalExecutionTime = .zero
+ averageExecutionTime = .zero
+ }
+
+ func toImmutable() -> ToolExecutionStats {
+ ToolExecutionStats(
+ executionCount: executionCount,
+ successCount: successCount,
+ failureCount: failureCount,
+ totalExecutionTime: totalExecutionTime,
+ averageExecutionTime: averageExecutionTime
+ )
+ }
+}
+
+// MARK: - Timeout Helper
+
+/// Executes an operation with a timeout.
+///
+/// - Parameters:
+/// - duration: Maximum time allowed for the operation
+/// - operation: The async operation to execute
+/// - Returns: The result of the operation
+/// - Throws: ``ToolExecutionError/timeout(toolName:duration:)`` if timeout exceeded
+private func withTimeout(
+ _ duration: Duration,
+ toolName: String,
+ operation: @escaping @Sendable () async throws -> T
+) async throws -> T {
+ try await withThrowingTaskGroup(of: T.self) { group in
+ group.addTask {
+ try await operation()
+ }
+
+ group.addTask {
+ try await Task.sleep(for: duration)
+ throw ToolExecutionError.timeout(toolName: toolName, duration: duration)
+ }
+
+ let result = try await group.next()!
+ group.cancelAll()
+ return result
+ }
+}
diff --git a/sdks/community/swift/Tests/.swiftlint.yml b/sdks/community/swift/Tests/.swiftlint.yml
new file mode 100644
index 0000000000..42500d44b6
--- /dev/null
+++ b/sdks/community/swift/Tests/.swiftlint.yml
@@ -0,0 +1,9 @@
+# SwiftLint Configuration for Test Files
+#
+# Disable sorted_imports for test files to allow @testable imports after regular imports.
+# SwiftLint sorts alphabetically, but Swift convention prefers @testable after regular imports.
+
+disabled_rules:
+ - sorted_imports
+
+
diff --git a/sdks/community/swift/Tests/AGUIAgentSDKTests/AGUIAgentSDKTests.swift b/sdks/community/swift/Tests/AGUIAgentSDKTests/AGUIAgentSDKTests.swift
new file mode 100644
index 0000000000..0af7425892
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUIAgentSDKTests/AGUIAgentSDKTests.swift
@@ -0,0 +1,15 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUIAgentSDK
+
+final class AGUIAgentSDKTests: XCTestCase {
+ func testVersion() {
+ XCTAssertEqual(AGUIAgentSDK.version, "1.0.0")
+ }
+
+ func testInitialization() {
+ let sdk = AGUIAgentSDK()
+ XCTAssertNotNil(sdk)
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUIAgentSDKTests/AgUiAgentTests.swift b/sdks/community/swift/Tests/AGUIAgentSDKTests/AgUiAgentTests.swift
new file mode 100644
index 0000000000..ebaaccbd08
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUIAgentSDKTests/AgUiAgentTests.swift
@@ -0,0 +1,258 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUIClient
+import AGUICore
+import AGUITools
+import XCTest
+@testable import AGUIAgentSDK
+
+// MARK: - Test helpers
+
+actor CapturingTransport: AgentTransport {
+ private(set) var capturedInputs: [RunAgentInput] = []
+ var mockEvents: [any AGUIEvent] = []
+
+ func setMockEvents(_ events: [any AGUIEvent]) {
+ mockEvents = events
+ }
+
+ nonisolated func run(input: RunAgentInput) -> AsyncThrowingStream {
+ AsyncThrowingStream { continuation in
+ let task = Task {
+ await self.record(input)
+ let events = await self.mockEvents
+ for event in events { continuation.yield(event) }
+ continuation.finish()
+ }
+ continuation.onTermination = { _ in task.cancel() }
+ }
+ }
+
+ private func record(_ input: RunAgentInput) {
+ capturedInputs.append(input)
+ }
+}
+
+// MARK: - AgUiAgentTests
+
+final class AgUiAgentTests: XCTestCase {
+
+ private let agentURL = URL(string: "https://agent.example.com")!
+
+ private func makeCapturingAgent(
+ configure: (inout AgUiAgentConfig) -> Void = { _ in }
+ ) -> (AgUiAgent, CapturingTransport) {
+ var cfg = AgUiAgentConfig()
+ configure(&cfg)
+ let transport = CapturingTransport()
+ let agent = AgUiAgent(transport: transport, config: cfg)
+ return (agent, transport)
+ }
+
+ // MARK: - sendMessage constructs correct RunAgentInput
+
+ func testSendMessageProducesUserMessage() async throws {
+ let (agent, transport) = makeCapturingAgent()
+ let stream = agent.sendMessage("Hello!")
+ for try await _ in stream {}
+
+ let inputs = await transport.capturedInputs
+ XCTAssertEqual(inputs.count, 1)
+
+ let input = try XCTUnwrap(inputs.first)
+ XCTAssertEqual(input.messages.count, 1)
+ XCTAssertEqual(input.messages[0].role, .user)
+ XCTAssertEqual((input.messages[0] as? UserMessage)?.content, "Hello!")
+ }
+
+ func testSendMessagePrependsSystemPromptWhenConfigured() async throws {
+ let (agent, transport) = makeCapturingAgent { config in
+ config.systemPrompt = "Be concise."
+ }
+ let stream = agent.sendMessage("Hi", includeSystemPrompt: true)
+ for try await _ in stream {}
+
+ let captured1 = await transport.capturedInputs
+ let input = try XCTUnwrap(captured1.first)
+ XCTAssertEqual(input.messages.count, 2)
+ XCTAssertEqual(input.messages[0].role, .system)
+ XCTAssertEqual((input.messages[0] as? SystemMessage)?.content, "Be concise.")
+ XCTAssertEqual(input.messages[1].role, .user)
+ }
+
+ func testSendMessageOmitsSystemPromptWhenDisabled() async throws {
+ let (agent, transport) = makeCapturingAgent { config in
+ config.systemPrompt = "Be concise."
+ }
+ let stream = agent.sendMessage("Hi", includeSystemPrompt: false)
+ for try await _ in stream {}
+
+ let captured2 = await transport.capturedInputs
+ let input = try XCTUnwrap(captured2.first)
+ XCTAssertEqual(input.messages.count, 1)
+ XCTAssertEqual(input.messages[0].role, .user)
+ }
+
+ func testSendMessageUsesProvidedThreadId() async throws {
+ let (agent, transport) = makeCapturingAgent()
+ let stream = agent.sendMessage("Hello", threadId: "my-thread")
+ for try await _ in stream {}
+
+ let captured3 = await transport.capturedInputs
+ let input = try XCTUnwrap(captured3.first)
+ XCTAssertEqual(input.threadId, "my-thread")
+ }
+
+ func testSendMessageUsesProvidedState() async throws {
+ let customState = Data("{\"mode\":\"test\"}".utf8)
+ let (agent, transport) = makeCapturingAgent()
+ let stream = agent.sendMessage("Hello", state: customState)
+ for try await _ in stream {}
+
+ let captured4 = await transport.capturedInputs
+ let input = try XCTUnwrap(captured4.first)
+ XCTAssertEqual(input.state, customState)
+ }
+
+ func testSendMessageEachCallIsFreshNoHistory() async throws {
+ let (agent, transport) = makeCapturingAgent()
+
+ for try await _ in agent.sendMessage("Message 1") {}
+ for try await _ in agent.sendMessage("Message 2") {}
+
+ let capturedAll = await transport.capturedInputs
+ XCTAssertEqual(capturedAll.count, 2)
+
+ let first = capturedAll[0]
+ let second = capturedAll[1]
+
+ XCTAssertEqual(first.messages.count, 1)
+ XCTAssertEqual((first.messages[0] as? UserMessage)?.content, "Message 1")
+
+ XCTAssertEqual(second.messages.count, 1)
+ XCTAssertEqual((second.messages[0] as? UserMessage)?.content, "Message 2")
+ }
+
+ // MARK: - Tool registry integration
+
+ func testSendMessageIncludesToolsFromRegistry() async throws {
+ let tool1 = Tool(name: "get_weather", description: "Get weather", parameters: Data("{}".utf8))
+ let tool2 = Tool(name: "search_web", description: "Search the web", parameters: Data("{}".utf8))
+ let registry = MockToolRegistry(tools: [tool1, tool2])
+
+ let (agent, transport) = makeCapturingAgent { config in
+ config.toolRegistry = registry
+ }
+
+ let stream = agent.sendMessage("What's the weather?")
+ for try await _ in stream {}
+
+ let captured5 = await transport.capturedInputs
+ let input = try XCTUnwrap(captured5.first)
+ XCTAssertEqual(input.tools.count, 2)
+ XCTAssertEqual(input.tools[0].name, "get_weather")
+ XCTAssertEqual(input.tools[1].name, "search_web")
+ }
+
+ func testSendMessageHasEmptyToolsWhenNoRegistry() async throws {
+ let (agent, transport) = makeCapturingAgent()
+ let stream = agent.sendMessage("Hello")
+ for try await _ in stream {}
+
+ let captured6 = await transport.capturedInputs
+ let input = try XCTUnwrap(captured6.first)
+ XCTAssertTrue(input.tools.isEmpty)
+ }
+
+ // MARK: - Context passthrough
+
+ func testSendMessageIncludesContext() async throws {
+ let ctx = Context(description: "timezone", value: "America/New_York")
+ let (agent, transport) = makeCapturingAgent { config in
+ config.context = [ctx]
+ }
+ let stream = agent.sendMessage("What time is it?")
+ for try await _ in stream {}
+
+ let captured7 = await transport.capturedInputs
+ let input = try XCTUnwrap(captured7.first)
+ XCTAssertEqual(input.context.count, 1)
+ XCTAssertEqual(input.context[0].description, "timezone")
+ }
+
+ // MARK: - Event passthrough
+
+ func testSendMessageYieldsEventsFromRun() async throws {
+ let (agent, transport) = makeCapturingAgent()
+ await transport.setMockEvents([
+ RunStartedEvent(threadId: "t1", runId: "r1"),
+ RunFinishedEvent(threadId: "t1", runId: "r1"),
+ ])
+
+ var received: [any AGUIEvent] = []
+ for try await event in agent.sendMessage("Hi") {
+ received.append(event)
+ }
+
+ XCTAssertEqual(received.count, 2)
+ XCTAssertTrue(received[0] is RunStartedEvent)
+ XCTAssertTrue(received[1] is RunFinishedEvent)
+ }
+
+ // MARK: - threadId stability (Issue 25)
+
+ func test_sendMessage_defaultThreadId_isStableAcrossCalls() async throws {
+ // Two consecutive calls with no explicit threadId must share the same ID.
+ // This fails before the fix because UUID().uuidString is evaluated fresh each call.
+ let (agent, transport) = makeCapturingAgent()
+
+ for try await _ in agent.sendMessage("First") {}
+ for try await _ in agent.sendMessage("Second") {}
+
+ let inputs = await transport.capturedInputs
+ XCTAssertEqual(inputs.count, 2)
+ XCTAssertEqual(inputs[0].threadId, inputs[1].threadId,
+ "Expected stable threadId across calls, got '\(inputs[0].threadId)' and '\(inputs[1].threadId)'")
+ }
+
+ func test_sendMessage_explicitThreadId_overridesAgentDefault() async throws {
+ // Explicit threadId must be honoured; the following un-keyed call uses the stable default.
+ let (agent, transport) = makeCapturingAgent()
+
+ for try await _ in agent.sendMessage("Hi", threadId: "explicit-thread") {}
+ for try await _ in agent.sendMessage("Again") {}
+
+ let inputs = await transport.capturedInputs
+ XCTAssertEqual(inputs[0].threadId, "explicit-thread")
+ XCTAssertNotEqual(inputs[1].threadId, "explicit-thread")
+ }
+
+ func test_sendMessage_twoAgentInstances_haveIndependentDefaultThreadIds() async throws {
+ // Two independent agent instances must not share thread IDs.
+ let (agent1, transport1) = makeCapturingAgent()
+ let (agent2, transport2) = makeCapturingAgent()
+
+ for try await _ in agent1.sendMessage("Hi") {}
+ for try await _ in agent2.sendMessage("Hi") {}
+
+ let id1 = await transport1.capturedInputs.first?.threadId
+ let id2 = await transport2.capturedInputs.first?.threadId
+
+ XCTAssertNotNil(id1)
+ XCTAssertNotNil(id2)
+ XCTAssertNotEqual(id1, id2)
+ }
+
+ // MARK: - close()
+
+ func testCloseDoesNotCrash() async {
+ let agent = AgUiAgent(url: agentURL)
+ await agent.close()
+ }
+
+ func testCloseCanBeCalledMultipleTimes() async {
+ let agent = AgUiAgent(url: agentURL)
+ await agent.close()
+ await agent.close()
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUIAgentSDKTests/AgentBuildersTests.swift b/sdks/community/swift/Tests/AGUIAgentSDKTests/AgentBuildersTests.swift
new file mode 100644
index 0000000000..f6e322d662
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUIAgentSDKTests/AgentBuildersTests.swift
@@ -0,0 +1,174 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUICore
+import AGUITools
+import XCTest
+@testable import AGUIAgentSDK
+
+// MARK: - AgentBuildersTests
+
+final class AgentBuildersTests: XCTestCase {
+
+ private let agentURL = URL(string: "https://agent.example.com")!
+
+ // MARK: - agentWithBearer
+
+ func testAgentWithBearerCreatesAgUiAgent() {
+ let agent = AgentBuilders.agentWithBearer(url: agentURL, token: "tok_test")
+ XCTAssertNotNil(agent)
+ }
+
+ func testAgentWithBearerSetsAuthorizationHeader() {
+ let agent = AgentBuilders.agentWithBearer(url: agentURL, token: "sk-secret")
+ let headers = agent.config.buildHeaders()
+ XCTAssertEqual(headers["Authorization"], "Bearer sk-secret")
+ }
+
+ func testAgentWithBearerStoresToken() {
+ let agent = AgentBuilders.agentWithBearer(url: agentURL, token: "my-token")
+ XCTAssertEqual(agent.config.bearerToken, "my-token")
+ }
+
+ // MARK: - agentWithApiKey
+
+ func testAgentWithApiKeyCreatesAgUiAgent() {
+ let agent = AgentBuilders.agentWithApiKey(url: agentURL, apiKey: "key123")
+ XCTAssertNotNil(agent)
+ }
+
+ func testAgentWithApiKeyUsesDefaultHeader() {
+ let agent = AgentBuilders.agentWithApiKey(url: agentURL, apiKey: "key123")
+ let headers = agent.config.buildHeaders()
+ XCTAssertEqual(headers["X-API-Key"], "key123")
+ }
+
+ func testAgentWithApiKeyUsesCustomHeader() {
+ let agent = AgentBuilders.agentWithApiKey(url: agentURL, apiKey: "key123", header: "X-Custom-Key")
+ let headers = agent.config.buildHeaders()
+ XCTAssertEqual(headers["X-Custom-Key"], "key123")
+ XCTAssertNil(headers["X-API-Key"])
+ }
+
+ func testAgentWithApiKeyStoresKey() {
+ let agent = AgentBuilders.agentWithApiKey(url: agentURL, apiKey: "key_abc")
+ XCTAssertEqual(agent.config.apiKey, "key_abc")
+ }
+
+ // MARK: - agentWithTools
+
+ func testAgentWithToolsCreatesAgUiAgent() {
+ let registry = MockToolRegistry()
+ let agent = AgentBuilders.agentWithTools(url: agentURL, registry: registry)
+ XCTAssertNotNil(agent)
+ }
+
+ func testAgentWithToolsSetsRegistry() {
+ let registry = MockToolRegistry()
+ let agent = AgentBuilders.agentWithTools(url: agentURL, registry: registry)
+ XCTAssertNotNil(agent.config.toolRegistry)
+ }
+
+ // MARK: - debugAgent
+
+ func testDebugAgentCreatesAgUiAgent() {
+ let agent = AgentBuilders.debugAgent(url: agentURL)
+ XCTAssertNotNil(agent)
+ }
+
+ func testDebugAgentSetsDebugFlag() {
+ let agent = AgentBuilders.debugAgent(url: agentURL)
+ XCTAssertTrue(agent.config.debug)
+ }
+
+ func testNonDebugAgentHasDebugFalse() {
+ let agent = AgUiAgent(url: agentURL)
+ XCTAssertFalse(agent.config.debug)
+ }
+
+ // MARK: - chatAgent
+
+ func testChatAgentCreatesStatefulAgUiAgent() {
+ let agent = AgentBuilders.chatAgent(url: agentURL, systemPrompt: "Be helpful.")
+ XCTAssertNotNil(agent)
+ }
+
+ func testChatAgentSetsSystemPrompt() {
+ let agent = AgentBuilders.chatAgent(url: agentURL, systemPrompt: "You are a pirate.")
+ XCTAssertEqual(agent.config.systemPrompt, "You are a pirate.")
+ }
+
+ func testChatAgentReturnsStatefulType() {
+ let agent = AgentBuilders.chatAgent(url: agentURL, systemPrompt: "Hi")
+ XCTAssert(agent is StatefulAgUiAgent)
+ }
+
+ // MARK: - statefulAgent
+
+ func testStatefulAgentCreatesStatefulAgUiAgent() {
+ let agent = AgentBuilders.statefulAgent(url: agentURL, initialState: Data("{}".utf8))
+ XCTAssertNotNil(agent)
+ }
+
+ func testStatefulAgentSetsInitialState() {
+ let state = Data("{\"key\":\"value\"}".utf8)
+ let agent = AgentBuilders.statefulAgent(url: agentURL, initialState: state)
+ XCTAssertEqual(agent.config.initialState, state)
+ }
+
+ func testStatefulAgentReturnsStatefulType() {
+ let agent = AgentBuilders.statefulAgent(url: agentURL, initialState: Data("{}".utf8))
+ XCTAssert(agent is StatefulAgUiAgent)
+ }
+
+ // MARK: - AgUiAgentConfig.buildHeaders
+
+ func testBuildHeadersBearerTakesEffectOverApiKey() {
+ var config = AgUiAgentConfig()
+ config.bearerToken = "tok"
+ config.apiKey = "key"
+ config.apiKeyHeader = "X-API-Key"
+ let headers = config.buildHeaders()
+ XCTAssertEqual(headers["Authorization"], "Bearer tok")
+ XCTAssertEqual(headers["X-API-Key"], "key")
+ }
+
+ func testBuildHeadersExplicitHeadersOverrideAuth() {
+ var config = AgUiAgentConfig()
+ config.bearerToken = "tok"
+ config.headers = ["Authorization": "Basic override"]
+ let headers = config.buildHeaders()
+ // Explicit headers override auth helpers
+ XCTAssertEqual(headers["Authorization"], "Basic override")
+ }
+
+ func testBuildHeadersEmptyWhenNothingConfigured() {
+ let config = AgUiAgentConfig()
+ XCTAssertTrue(config.buildHeaders().isEmpty)
+ }
+
+ // MARK: - StatefulAgUiAgentConfig.buildHeaders
+
+ func test_statefulConfig_buildHeaders_bearerToken() {
+ var config = StatefulAgUiAgentConfig(baseURL: agentURL)
+ config.bearerToken = "sk-secret"
+ XCTAssertEqual(config.buildHeaders()["Authorization"], "Bearer sk-secret")
+ }
+
+ func test_statefulConfig_buildHeaders_apiKey() {
+ var config = StatefulAgUiAgentConfig(baseURL: agentURL)
+ config.apiKey = "my-key"
+ XCTAssertEqual(config.buildHeaders()["X-API-Key"], "my-key")
+ }
+
+ func test_statefulConfig_buildHeaders_explicitHeadersOverrideAuth() {
+ var config = StatefulAgUiAgentConfig(baseURL: agentURL)
+ config.bearerToken = "tok"
+ config.headers = ["Authorization": "Basic override"]
+ XCTAssertEqual(config.buildHeaders()["Authorization"], "Basic override")
+ }
+
+ func test_statefulConfig_buildHeaders_emptyWhenNothingConfigured() {
+ let config = StatefulAgUiAgentConfig(baseURL: agentURL)
+ XCTAssertTrue(config.buildHeaders().isEmpty)
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUIAgentSDKTests/AgentMessageTests.swift b/sdks/community/swift/Tests/AGUIAgentSDKTests/AgentMessageTests.swift
new file mode 100644
index 0000000000..6e3241eb2c
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUIAgentSDKTests/AgentMessageTests.swift
@@ -0,0 +1,77 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUIAgentSDK
+
+final class AgentMessageTests: XCTestCase {
+
+ // MARK: - Initialization
+
+ func testInit_setsAllProvidedFields() {
+ let msg = AgentMessage(id: "id-123", role: .user, content: "Hello!")
+ XCTAssertEqual(msg.id, "id-123")
+ XCTAssertEqual(msg.role, .user)
+ XCTAssertEqual(msg.content, "Hello!")
+ }
+
+ func testInit_defaultId_isNonEmpty() {
+ let msg = AgentMessage(role: .assistant, content: "Hi")
+ XCTAssertFalse(msg.id.isEmpty)
+ }
+
+ func testInit_twoMessagesWithDefaultId_haveUniqueIds() {
+ let a = AgentMessage(role: .user, content: "A")
+ let b = AgentMessage(role: .user, content: "B")
+ XCTAssertNotEqual(a.id, b.id)
+ }
+
+ // MARK: - Role
+
+ func testRole_allCasesExist() {
+ let expected: [AgentMessage.Role] = [.user, .assistant, .system, .tool]
+ XCTAssertEqual(AgentMessage.Role.allCases.count, expected.count)
+ for role in expected {
+ XCTAssertTrue(AgentMessage.Role.allCases.contains(role))
+ }
+ }
+
+ func testRole_rawValues_matchProtocolNames() {
+ XCTAssertEqual(AgentMessage.Role.user.rawValue, "user")
+ XCTAssertEqual(AgentMessage.Role.assistant.rawValue, "assistant")
+ XCTAssertEqual(AgentMessage.Role.system.rawValue, "system")
+ XCTAssertEqual(AgentMessage.Role.tool.rawValue, "tool")
+ }
+
+ // MARK: - Equatable
+
+ func testEquatable_identicalFields_areEqual() {
+ let a = AgentMessage(id: "x", role: .user, content: "hello")
+ let b = AgentMessage(id: "x", role: .user, content: "hello")
+ XCTAssertEqual(a, b)
+ }
+
+ func testEquatable_differentId_notEqual() {
+ let a = AgentMessage(id: "a", role: .user, content: "hello")
+ let b = AgentMessage(id: "b", role: .user, content: "hello")
+ XCTAssertNotEqual(a, b)
+ }
+
+ func testEquatable_differentRole_notEqual() {
+ let a = AgentMessage(id: "x", role: .user, content: "hello")
+ let b = AgentMessage(id: "x", role: .assistant, content: "hello")
+ XCTAssertNotEqual(a, b)
+ }
+
+ func testEquatable_differentContent_notEqual() {
+ let a = AgentMessage(id: "x", role: .user, content: "hello")
+ let b = AgentMessage(id: "x", role: .user, content: "world")
+ XCTAssertNotEqual(a, b)
+ }
+
+ // MARK: - Identifiable
+
+ func testIdentifiable_idMatchesInitParameter() {
+ let msg = AgentMessage(id: "unique-id", role: .assistant, content: "Hi")
+ XCTAssertEqual(msg.id, "unique-id")
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUIAgentSDKTests/AgentViewModelCompatTests.swift b/sdks/community/swift/Tests/AGUIAgentSDKTests/AgentViewModelCompatTests.swift
new file mode 100644
index 0000000000..5c6465897e
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUIAgentSDKTests/AgentViewModelCompatTests.swift
@@ -0,0 +1,166 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUICore
+import XCTest
+@testable import AGUIAgentSDK
+
+@MainActor
+final class AgentViewModelCompatTests: XCTestCase {
+
+ private var mock: MockChatAgent!
+ private var sut: AgentViewModelCompat!
+
+ override func setUp() {
+ super.setUp()
+ mock = MockChatAgent()
+ sut = AgentViewModelCompat(agent: mock, threadId: "test-thread")
+ }
+
+ override func tearDown() {
+ sut = nil
+ mock = nil
+ super.tearDown()
+ }
+
+ // MARK: - Initial state
+
+ func testInitialState_isClean() {
+ XCTAssertTrue(sut.messages.isEmpty)
+ XCTAssertFalse(sut.isRunning)
+ XCTAssertNil(sut.lastError)
+ XCTAssertEqual(sut.threadId, "test-thread")
+ }
+
+ // MARK: - send() — user message
+
+ func testSend_appendsUserMessageImmediately() async {
+ await sut.send("Hello!")
+ let userMessages = sut.messages.filter { $0.role == .user }
+ XCTAssertEqual(userMessages.count, 1)
+ XCTAssertEqual(userMessages[0].content, "Hello!")
+ }
+
+ func testSend_passesCorrectParametersToAgent() async {
+ await sut.send("What is Swift?")
+ XCTAssertEqual(mock.chatCalls.count, 1)
+ XCTAssertEqual(mock.chatCalls[0].message, "What is Swift?")
+ XCTAssertEqual(mock.chatCalls[0].threadId, "test-thread")
+ }
+
+ func testSend_isRunningFalseAfterStreamFinishes() async {
+ await sut.send("Hi")
+ XCTAssertFalse(sut.isRunning)
+ }
+
+ func testSend_clearsLastErrorAtStart() async {
+ // Prime a prior error
+ mock.streamThrows = URLError(.timedOut)
+ await sut.send("First")
+ XCTAssertNotNil(sut.lastError)
+
+ // Second send should clear it
+ mock.streamThrows = nil
+ await sut.send("Second")
+ XCTAssertNil(sut.lastError)
+ }
+
+ // MARK: - send() — assistant message
+
+ func testSend_appendsAssistantMessageOnTextMessageStart() async {
+ mock.eventsToYield = [
+ TextMessageStartEvent(messageId: "msg1"),
+ TextMessageEndEvent(messageId: "msg1"),
+ ]
+ await sut.send("Hi")
+ let assistants = sut.messages.filter { $0.role == .assistant }
+ XCTAssertEqual(assistants.count, 1)
+ }
+
+ func testSend_accumulatesContentDeltas() async {
+ mock.eventsToYield = [
+ TextMessageStartEvent(messageId: "msg1"),
+ TextMessageContentEvent(messageId: "msg1", delta: "Hello"),
+ TextMessageContentEvent(messageId: "msg1", delta: ", "),
+ TextMessageContentEvent(messageId: "msg1", delta: "world!"),
+ TextMessageEndEvent(messageId: "msg1"),
+ ]
+ await sut.send("Hi")
+ let assistant = sut.messages.first(where: { $0.role == .assistant })
+ XCTAssertEqual(assistant?.content, "Hello, world!")
+ }
+
+ func testSend_preservesAssistantMessageIdAcrossDeltas() async {
+ mock.eventsToYield = [
+ TextMessageStartEvent(messageId: "msg1"),
+ TextMessageContentEvent(messageId: "msg1", delta: "A"),
+ TextMessageContentEvent(messageId: "msg1", delta: "B"),
+ ]
+ await sut.send("Hi")
+ let assistants = sut.messages.filter { $0.role == .assistant }
+ XCTAssertEqual(assistants.count, 1, "Deltas must update the same message, not append new ones")
+ XCTAssertFalse(assistants[0].id.isEmpty)
+ }
+
+ func testSend_handlesMultipleAssistantMessages() async {
+ mock.eventsToYield = [
+ TextMessageStartEvent(messageId: "msg1"),
+ TextMessageContentEvent(messageId: "msg1", delta: "First"),
+ TextMessageEndEvent(messageId: "msg1"),
+ TextMessageStartEvent(messageId: "msg2"),
+ TextMessageContentEvent(messageId: "msg2", delta: "Second"),
+ TextMessageEndEvent(messageId: "msg2"),
+ ]
+ await sut.send("Hi")
+ let assistants = sut.messages.filter { $0.role == .assistant }
+ XCTAssertEqual(assistants.count, 2)
+ XCTAssertEqual(assistants[0].content, "First")
+ XCTAssertEqual(assistants[1].content, "Second")
+ }
+
+ // MARK: - send() — error paths
+
+ func testSend_setsLastErrorOnRunErrorEvent() async {
+ mock.eventsToYield = [
+ RunErrorEvent(message: "Agent unavailable", code: "ERR_503"),
+ ]
+ await sut.send("Hi")
+ let agentError = sut.lastError as? AgentError
+ XCTAssertNotNil(agentError)
+ if case .runError(let msg, let code) = agentError {
+ XCTAssertEqual(msg, "Agent unavailable")
+ XCTAssertEqual(code, "ERR_503")
+ } else {
+ XCTFail("Expected AgentError.runError")
+ }
+ }
+
+ func testSend_setsLastErrorWhenStreamThrows() async {
+ mock.streamThrows = URLError(.notConnectedToInternet)
+ await sut.send("Hi")
+ XCTAssertNotNil(sut.lastError)
+ XCTAssertFalse(sut.isRunning)
+ }
+
+ func testSend_setsLastErrorWhenChatThrows() async {
+ mock.chatThrows = URLError(.timedOut)
+ await sut.send("Hi")
+ XCTAssertNotNil(sut.lastError)
+ XCTAssertFalse(sut.isRunning)
+ }
+
+ // MARK: - clear()
+
+ func testClear_emptiesMessages() async {
+ await sut.send("Hello")
+ XCTAssertFalse(sut.messages.isEmpty)
+
+ await sut.clear()
+ XCTAssertTrue(sut.messages.isEmpty)
+ }
+
+ func testClear_callsAgentClearHistoryWithThreadId() async {
+ await sut.clear()
+ XCTAssertEqual(mock.clearCalls.count, 1)
+ XCTAssertEqual(mock.clearCalls[0], "test-thread")
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUIAgentSDKTests/AgentViewModelTests.swift b/sdks/community/swift/Tests/AGUIAgentSDKTests/AgentViewModelTests.swift
new file mode 100644
index 0000000000..60bd028e43
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUIAgentSDKTests/AgentViewModelTests.swift
@@ -0,0 +1,171 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+// AgentViewModel requires iOS 17 / macOS 14 (Observation framework).
+// Tests are gated by the same availability so the suite stays green on older OS.
+#if canImport(Observation)
+
+import AGUICore
+import XCTest
+@testable import AGUIAgentSDK
+
+@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+@MainActor
+final class AgentViewModelTests: XCTestCase {
+
+ private var mock: MockChatAgent!
+ private var sut: AgentViewModel!
+
+ override func setUp() {
+ super.setUp()
+ mock = MockChatAgent()
+ sut = AgentViewModel(agent: mock, threadId: "test-thread")
+ }
+
+ override func tearDown() {
+ sut = nil
+ mock = nil
+ super.tearDown()
+ }
+
+ // MARK: - Initial state
+
+ func testInitialState_isClean() {
+ XCTAssertTrue(sut.messages.isEmpty)
+ XCTAssertFalse(sut.isRunning)
+ XCTAssertNil(sut.lastError)
+ XCTAssertEqual(sut.threadId, "test-thread")
+ }
+
+ // MARK: - send() — user message
+
+ func testSend_appendsUserMessageImmediately() async {
+ await sut.send("Hello!")
+ let userMessages = sut.messages.filter { $0.role == .user }
+ XCTAssertEqual(userMessages.count, 1)
+ XCTAssertEqual(userMessages[0].content, "Hello!")
+ }
+
+ func testSend_passesCorrectParametersToAgent() async {
+ await sut.send("What is Swift?")
+ XCTAssertEqual(mock.chatCalls.count, 1)
+ XCTAssertEqual(mock.chatCalls[0].message, "What is Swift?")
+ XCTAssertEqual(mock.chatCalls[0].threadId, "test-thread")
+ }
+
+ func testSend_isRunningFalseAfterStreamFinishes() async {
+ await sut.send("Hi")
+ XCTAssertFalse(sut.isRunning)
+ }
+
+ func testSend_clearsLastErrorAtStart() async {
+ mock.streamThrows = URLError(.timedOut)
+ await sut.send("First")
+ XCTAssertNotNil(sut.lastError)
+
+ mock.streamThrows = nil
+ await sut.send("Second")
+ XCTAssertNil(sut.lastError)
+ }
+
+ // MARK: - send() — assistant message
+
+ func testSend_appendsAssistantMessageOnTextMessageStart() async {
+ mock.eventsToYield = [
+ TextMessageStartEvent(messageId: "msg1"),
+ TextMessageEndEvent(messageId: "msg1"),
+ ]
+ await sut.send("Hi")
+ let assistants = sut.messages.filter { $0.role == .assistant }
+ XCTAssertEqual(assistants.count, 1)
+ }
+
+ func testSend_accumulatesContentDeltas() async {
+ mock.eventsToYield = [
+ TextMessageStartEvent(messageId: "msg1"),
+ TextMessageContentEvent(messageId: "msg1", delta: "Hello"),
+ TextMessageContentEvent(messageId: "msg1", delta: ", "),
+ TextMessageContentEvent(messageId: "msg1", delta: "world!"),
+ TextMessageEndEvent(messageId: "msg1"),
+ ]
+ await sut.send("Hi")
+ let assistant = sut.messages.first(where: { $0.role == .assistant })
+ XCTAssertEqual(assistant?.content, "Hello, world!")
+ }
+
+ func testSend_preservesAssistantMessageIdAcrossDeltas() async {
+ mock.eventsToYield = [
+ TextMessageStartEvent(messageId: "msg1"),
+ TextMessageContentEvent(messageId: "msg1", delta: "A"),
+ TextMessageContentEvent(messageId: "msg1", delta: "B"),
+ ]
+ await sut.send("Hi")
+ let assistants = sut.messages.filter { $0.role == .assistant }
+ XCTAssertEqual(assistants.count, 1, "Deltas must update the same message, not append new ones")
+ XCTAssertFalse(assistants[0].id.isEmpty)
+ }
+
+ func testSend_handlesMultipleAssistantMessages() async {
+ mock.eventsToYield = [
+ TextMessageStartEvent(messageId: "msg1"),
+ TextMessageContentEvent(messageId: "msg1", delta: "First"),
+ TextMessageEndEvent(messageId: "msg1"),
+ TextMessageStartEvent(messageId: "msg2"),
+ TextMessageContentEvent(messageId: "msg2", delta: "Second"),
+ TextMessageEndEvent(messageId: "msg2"),
+ ]
+ await sut.send("Hi")
+ let assistants = sut.messages.filter { $0.role == .assistant }
+ XCTAssertEqual(assistants.count, 2)
+ XCTAssertEqual(assistants[0].content, "First")
+ XCTAssertEqual(assistants[1].content, "Second")
+ }
+
+ // MARK: - send() — error paths
+
+ func testSend_setsLastErrorOnRunErrorEvent() async {
+ mock.eventsToYield = [
+ RunErrorEvent(message: "Agent unavailable", code: "ERR_503"),
+ ]
+ await sut.send("Hi")
+ let agentError = sut.lastError as? AgentError
+ XCTAssertNotNil(agentError)
+ if case .runError(let msg, let code) = agentError {
+ XCTAssertEqual(msg, "Agent unavailable")
+ XCTAssertEqual(code, "ERR_503")
+ } else {
+ XCTFail("Expected AgentError.runError")
+ }
+ }
+
+ func testSend_setsLastErrorWhenStreamThrows() async {
+ mock.streamThrows = URLError(.notConnectedToInternet)
+ await sut.send("Hi")
+ XCTAssertNotNil(sut.lastError)
+ XCTAssertFalse(sut.isRunning)
+ }
+
+ func testSend_setsLastErrorWhenChatThrows() async {
+ mock.chatThrows = URLError(.timedOut)
+ await sut.send("Hi")
+ XCTAssertNotNil(sut.lastError)
+ XCTAssertFalse(sut.isRunning)
+ }
+
+ // MARK: - clear()
+
+ func testClear_emptiesMessages() async {
+ await sut.send("Hello")
+ XCTAssertFalse(sut.messages.isEmpty)
+
+ await sut.clear()
+ XCTAssertTrue(sut.messages.isEmpty)
+ }
+
+ func testClear_callsAgentClearHistoryWithThreadId() async {
+ await sut.clear()
+ XCTAssertEqual(mock.clearCalls.count, 1)
+ XCTAssertEqual(mock.clearCalls[0], "test-thread")
+ }
+}
+
+#endif
diff --git a/sdks/community/swift/Tests/AGUIAgentSDKTests/ConversationHistoryTests.swift b/sdks/community/swift/Tests/AGUIAgentSDKTests/ConversationHistoryTests.swift
new file mode 100644
index 0000000000..3fada65484
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUIAgentSDKTests/ConversationHistoryTests.swift
@@ -0,0 +1,173 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUICore
+@testable import AGUIAgentSDK
+import XCTest
+
+final class ConversationHistoryTests: XCTestCase {
+
+ // MARK: - Basic Operations
+
+ func testEmptyHistoryInitially() async throws {
+ // Given: A new history manager
+ let manager = ConversationHistoryManager()
+
+ // When: Getting history for a thread
+ let history = await manager.history(for: "thread-1")
+
+ // Then: Should be empty
+ XCTAssertTrue(history.isEmpty)
+ XCTAssertEqual(history.count, 0)
+ }
+
+ func testAppendSingleMessage() async throws {
+ // Given: A history manager
+ let manager = ConversationHistoryManager()
+ let message = UserMessage(id: "msg1", content: "Hello")
+
+ // When: Appending a message
+ await manager.append(message: message, to: "thread-1")
+
+ // Then: History should contain the message
+ let history = await manager.history(for: "thread-1")
+ XCTAssertEqual(history.count, 1)
+ XCTAssertEqual(history.first?.id, "msg1")
+ }
+
+ func testAppendMultipleMessages() async throws {
+ // Given: A history manager
+ let manager = ConversationHistoryManager()
+
+ // When: Appending multiple messages
+ await manager.append(message: UserMessage(id: "msg1", content: "Hello"), to: "thread-1")
+ await manager.append(message: AssistantMessage(id: "msg2", content: "Hi there"), to: "thread-1")
+ await manager.append(message: UserMessage(id: "msg3", content: "How are you?"), to: "thread-1")
+
+ // Then: History should contain all messages in order
+ let history = await manager.history(for: "thread-1")
+ XCTAssertEqual(history.count, 3)
+ XCTAssertEqual(history[0].id, "msg1")
+ XCTAssertEqual(history[1].id, "msg2")
+ XCTAssertEqual(history[2].id, "msg3")
+ }
+
+ // MARK: - Multi-Thread Tests
+
+ func testSeparateThreadHistories() async throws {
+ // Given: A history manager with messages in different threads
+ let manager = ConversationHistoryManager()
+ await manager.append(message: UserMessage(id: "t1-msg1", content: "Thread 1"), to: "thread-1")
+ await manager.append(message: UserMessage(id: "t2-msg1", content: "Thread 2"), to: "thread-2")
+ await manager.append(message: UserMessage(id: "t1-msg2", content: "Thread 1 again"), to: "thread-1")
+
+ // When: Getting histories
+ let history1 = await manager.history(for: "thread-1")
+ let history2 = await manager.history(for: "thread-2")
+
+ // Then: Each thread should have its own independent history
+ XCTAssertEqual(history1.count, 2)
+ XCTAssertEqual(history1[0].id, "t1-msg1")
+ XCTAssertEqual(history1[1].id, "t1-msg2")
+
+ XCTAssertEqual(history2.count, 1)
+ XCTAssertEqual(history2[0].id, "t2-msg1")
+ }
+
+ func testCountForThread() async throws {
+ // Given: A history manager with different thread sizes
+ let manager = ConversationHistoryManager()
+ await manager.append(message: UserMessage(id: "msg1", content: "Hello"), to: "thread-1")
+ await manager.append(message: UserMessage(id: "msg2", content: "Hi"), to: "thread-1")
+ await manager.append(message: UserMessage(id: "msg3", content: "Hey"), to: "thread-2")
+
+ // When: Getting counts
+ let count1 = await manager.count(for: "thread-1")
+ let count2 = await manager.count(for: "thread-2")
+ let count3 = await manager.count(for: "nonexistent")
+
+ // Then: Counts should be accurate
+ XCTAssertEqual(count1, 2)
+ XCTAssertEqual(count2, 1)
+ XCTAssertEqual(count3, 0)
+ }
+
+ func testAllThreadIds() async throws {
+ // Given: A history manager with multiple threads
+ let manager = ConversationHistoryManager()
+ await manager.append(message: UserMessage(id: "msg1", content: "Hello"), to: "thread-1")
+ await manager.append(message: UserMessage(id: "msg2", content: "Hi"), to: "thread-2")
+ await manager.append(message: UserMessage(id: "msg3", content: "Hey"), to: "thread-3")
+
+ // When: Getting all thread IDs
+ let threadIds = await manager.allThreadIds()
+
+ // Then: Should contain all thread IDs
+ XCTAssertEqual(Set(threadIds), Set(["thread-1", "thread-2", "thread-3"]))
+ }
+
+ // MARK: - Clear Tests
+
+ func testClearSpecificThread() async throws {
+ // Given: A history manager with multiple threads
+ let manager = ConversationHistoryManager()
+ await manager.append(message: UserMessage(id: "msg1", content: "Thread 1"), to: "thread-1")
+ await manager.append(message: UserMessage(id: "msg2", content: "Thread 2"), to: "thread-2")
+
+ // When: Clearing one thread
+ await manager.clear(threadId: "thread-1")
+
+ // Then: Only that thread should be cleared
+ let history1 = await manager.history(for: "thread-1")
+ let history2 = await manager.history(for: "thread-2")
+
+ XCTAssertTrue(history1.isEmpty)
+ XCTAssertEqual(history2.count, 1)
+ }
+
+ func testClearAllThreads() async throws {
+ // Given: A history manager with multiple threads
+ let manager = ConversationHistoryManager()
+ await manager.append(message: UserMessage(id: "msg1", content: "Thread 1"), to: "thread-1")
+ await manager.append(message: UserMessage(id: "msg2", content: "Thread 2"), to: "thread-2")
+ await manager.append(message: UserMessage(id: "msg3", content: "Thread 3"), to: "thread-3")
+
+ // When: Clearing all threads
+ await manager.clear(threadId: nil)
+
+ // Then: All threads should be cleared
+ let history1 = await manager.history(for: "thread-1")
+ let history2 = await manager.history(for: "thread-2")
+ let history3 = await manager.history(for: "thread-3")
+
+ XCTAssertTrue(history1.isEmpty)
+ XCTAssertTrue(history2.isEmpty)
+ XCTAssertTrue(history3.isEmpty)
+
+ let threadIds = await manager.allThreadIds()
+ XCTAssertTrue(threadIds.isEmpty)
+ }
+
+ // MARK: - Message Type Tests
+
+ func testMixedMessageTypes() async throws {
+ // Given: A history manager
+ let manager = ConversationHistoryManager()
+
+ // When: Adding different message types
+ await manager.append(message: SystemMessage(id: "sys1", content: "You are helpful"), to: "thread-1")
+ await manager.append(message: UserMessage(id: "usr1", content: "Hello"), to: "thread-1")
+ await manager.append(message: AssistantMessage(id: "ast1", content: "Hi"), to: "thread-1")
+ await manager.append(
+ message: ToolMessage(id: "tool1", content: "Result", toolCallId: "tc1"),
+ to: "thread-1"
+ )
+
+ // Then: All message types should be preserved
+ let history = await manager.history(for: "thread-1")
+ XCTAssertEqual(history.count, 4)
+ XCTAssertTrue(history[0] is SystemMessage)
+ XCTAssertTrue(history[1] is UserMessage)
+ XCTAssertTrue(history[2] is AssistantMessage)
+ XCTAssertTrue(history[3] is ToolMessage)
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUIAgentSDKTests/EndToEndPipelineTests.swift b/sdks/community/swift/Tests/AGUIAgentSDKTests/EndToEndPipelineTests.swift
new file mode 100644
index 0000000000..58b19dfdf2
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUIAgentSDKTests/EndToEndPipelineTests.swift
@@ -0,0 +1,305 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUIClient
+import AGUICore
+import AGUITools
+import XCTest
+@testable import AGUIAgentSDK
+
+// MARK: - MockAgentTransport
+
+actor MockAgentTransport: AgentTransport {
+ private var _eventSequences: [[any AGUIEvent]] = []
+
+ func enqueue(_ events: [any AGUIEvent]) {
+ _eventSequences.append(events)
+ }
+
+ nonisolated func run(input: RunAgentInput) -> AsyncThrowingStream {
+ AsyncThrowingStream { continuation in
+ let task = Task {
+ let events = await self.dequeue()
+ for event in events { continuation.yield(event) }
+ continuation.finish()
+ }
+ continuation.onTermination = { _ in task.cancel() }
+ }
+ }
+
+ private func dequeue() -> [any AGUIEvent] {
+ _eventSequences.isEmpty ? [] : _eventSequences.removeFirst()
+ }
+}
+
+// MARK: - EndToEndPipelineTests
+
+final class EndToEndPipelineTests: XCTestCase {
+
+ // MARK: - Text-only conversation
+
+ func testTextOnlyConversationBuildsMessages() async throws {
+ let mockTransport = MockAgentTransport()
+ await mockTransport.enqueue([
+ RunStartedEvent(threadId: "t1", runId: "r1"),
+ TextMessageStartEvent(messageId: "msg1"),
+ TextMessageContentEvent(messageId: "msg1", delta: "Hello"),
+ TextMessageContentEvent(messageId: "msg1", delta: ", world!"),
+ TextMessageEndEvent(messageId: "msg1"),
+ RunFinishedEvent(threadId: "t1", runId: "r1"),
+ ])
+
+ let agent = AbstractAgent(transport: mockTransport)
+ try await agent.runAgent()
+
+ let messages = await agent.messages
+ XCTAssertEqual(messages.count, 1)
+ let assistantMsg = try XCTUnwrap(messages.first as? AssistantMessage)
+ XCTAssertEqual(assistantMsg.id, "msg1")
+ XCTAssertEqual(assistantMsg.content, "Hello, world!")
+ }
+
+ // MARK: - State snapshot & delta
+
+ func testStateDeltaIsAppliedCorrectly() async throws {
+ let mockTransport = MockAgentTransport()
+
+ let initialJSON = Data("{\"count\":0}".utf8)
+ let patchJSON = Data("[{\"op\":\"replace\",\"path\":\"/count\",\"value\":5}]".utf8)
+
+ await mockTransport.enqueue([
+ RunStartedEvent(threadId: "t1", runId: "r1"),
+ StateSnapshotEvent(snapshot: initialJSON),
+ StateDeltaEvent(delta: patchJSON),
+ RunFinishedEvent(threadId: "t1", runId: "r1"),
+ ])
+
+ let agent = AbstractAgent(transport: mockTransport)
+ try await agent.runAgent()
+
+ let finalState = await agent.state
+ guard let json = try? JSONSerialization.jsonObject(with: finalState) as? [String: Any],
+ let count = json["count"] as? Int else {
+ XCTFail("Could not parse final state")
+ return
+ }
+ XCTAssertEqual(count, 5)
+ }
+
+ func testStateSnapshotReplacesState() async throws {
+ let mockTransport = MockAgentTransport()
+ let snapshotJSON = Data("{\"mode\":\"creative\"}".utf8)
+
+ await mockTransport.enqueue([
+ RunStartedEvent(threadId: "t1", runId: "r1"),
+ StateSnapshotEvent(snapshot: snapshotJSON),
+ RunFinishedEvent(threadId: "t1", runId: "r1"),
+ ])
+
+ let agent = AbstractAgent(transport: mockTransport)
+ try await agent.runAgent()
+
+ let state = await agent.state
+ guard let json = try? JSONSerialization.jsonObject(with: state) as? [String: Any],
+ let mode = json["mode"] as? String else {
+ XCTFail("Could not parse state")
+ return
+ }
+ XCTAssertEqual(mode, "creative")
+ }
+
+ // MARK: - Sequential multi-run (state persists)
+
+ func testSequentialRunsMaintainState() async throws {
+ let mockTransport = MockAgentTransport()
+
+ let state1 = Data("{\"turn\":1}".utf8)
+ await mockTransport.enqueue([
+ RunStartedEvent(threadId: "t1", runId: "r1"),
+ StateSnapshotEvent(snapshot: state1),
+ RunFinishedEvent(threadId: "t1", runId: "r1"),
+ ])
+
+ let agent = AbstractAgent(transport: mockTransport)
+ try await agent.runAgent()
+
+ let stateAfterRun1 = await agent.state
+ guard let json1 = try? JSONSerialization.jsonObject(with: stateAfterRun1) as? [String: Any],
+ let turn1 = json1["turn"] as? Int else {
+ XCTFail("Could not parse state after run 1")
+ return
+ }
+ XCTAssertEqual(turn1, 1)
+
+ let state2 = Data("{\"turn\":2}".utf8)
+ await mockTransport.enqueue([
+ RunStartedEvent(threadId: "t1", runId: "r2"),
+ StateSnapshotEvent(snapshot: state2),
+ RunFinishedEvent(threadId: "t1", runId: "r2"),
+ ])
+
+ try await agent.runAgent()
+
+ let stateAfterRun2 = await agent.state
+ guard let json2 = try? JSONSerialization.jsonObject(with: stateAfterRun2) as? [String: Any],
+ let turn2 = json2["turn"] as? Int else {
+ XCTFail("Could not parse state after run 2")
+ return
+ }
+ XCTAssertEqual(turn2, 2)
+ }
+
+ // MARK: - Invalid event stream — EventVerifier
+
+ func testInvalidEventStreamThrowsProtocolError() async throws {
+ let mockTransport = MockAgentTransport()
+
+ await mockTransport.enqueue([
+ TextMessageStartEvent(messageId: "msg1"),
+ ])
+
+ let agent = AbstractAgent(transport: mockTransport)
+ do {
+ try await agent.runAgent()
+ XCTFail("Expected AGUIProtocolError to be thrown")
+ } catch let error as AGUIProtocolError {
+ XCTAssertFalse(error.message.isEmpty)
+ } catch {
+ XCTFail("Expected AGUIProtocolError, got \(type(of: error)): \(error)")
+ }
+ }
+
+ func testRunAfterRunErrorThrows() async throws {
+ let mockTransport = MockAgentTransport()
+
+ await mockTransport.enqueue([
+ RunStartedEvent(threadId: "t1", runId: "r1"),
+ RunErrorEvent(message: "fatal error", code: "FATAL"),
+ TextMessageStartEvent(messageId: "msg1"),
+ ])
+
+ let agent = AbstractAgent(transport: mockTransport)
+ do {
+ try await agent.runAgent()
+ XCTFail("Expected AGUIProtocolError to be thrown")
+ } catch let error as AGUIProtocolError {
+ XCTAssertTrue(error.message.contains("errored"))
+ } catch {
+ XCTFail("Expected AGUIProtocolError, got \(type(of: error)): \(error)")
+ }
+ }
+
+ // MARK: - Tool call accumulation
+
+ func testToolCallSequenceBuildsAssistantMessageWithToolCalls() async throws {
+ let mockTransport = MockAgentTransport()
+ await mockTransport.enqueue([
+ RunStartedEvent(threadId: "t1", runId: "r1"),
+ ToolCallStartEvent(toolCallId: "tc1", toolCallName: "get_weather"),
+ ToolCallArgsEvent(toolCallId: "tc1", delta: "{\"city\":"),
+ ToolCallArgsEvent(toolCallId: "tc1", delta: "\"London\"}"),
+ ToolCallEndEvent(toolCallId: "tc1"),
+ RunFinishedEvent(threadId: "t1", runId: "r1"),
+ ])
+
+ let agent = AbstractAgent(transport: mockTransport)
+ try await agent.runAgent()
+
+ let messages = await agent.messages
+ XCTAssertFalse(messages.isEmpty)
+
+ let assistantMsg = messages.compactMap { $0 as? AssistantMessage }.first
+ let msg = try XCTUnwrap(assistantMsg)
+ let calls = try XCTUnwrap(msg.toolCalls)
+ XCTAssertFalse(calls.isEmpty)
+
+ let call = try XCTUnwrap(calls.first)
+ XCTAssertEqual(call.id, "tc1")
+ XCTAssertEqual(call.function.name, "get_weather")
+ XCTAssertEqual(call.function.arguments, "{\"city\":\"London\"}")
+ }
+
+ // MARK: - Custom & raw events
+
+ func testRawEventsAccumulate() async throws {
+ let mockTransport = MockAgentTransport()
+ await mockTransport.enqueue([
+ RunStartedEvent(threadId: "t1", runId: "r1"),
+ RawEvent(data: Data("{\"event\":\"raw_1\"}".utf8)),
+ RawEvent(data: Data("{\"event\":\"raw_2\"}".utf8)),
+ RunFinishedEvent(threadId: "t1", runId: "r1"),
+ ])
+
+ let agent = AbstractAgent(transport: mockTransport)
+ try await agent.runAgent()
+
+ let rawEvents = await agent.rawEvents
+ XCTAssertEqual(rawEvents.count, 2)
+ }
+
+ func testCustomEventsAccumulate() async throws {
+ let mockTransport = MockAgentTransport()
+ await mockTransport.enqueue([
+ RunStartedEvent(threadId: "t1", runId: "r1"),
+ CustomEvent(name: "ping", value: Data("{\"ts\":1}".utf8)),
+ CustomEvent(name: "pong", value: Data("{\"ts\":2}".utf8)),
+ RunFinishedEvent(threadId: "t1", runId: "r1"),
+ ])
+
+ let agent = AbstractAgent(transport: mockTransport)
+ try await agent.runAgent()
+
+ let customEvents = await agent.customEvents
+ XCTAssertEqual(customEvents.count, 2)
+ XCTAssertEqual(customEvents[0].name, "ping")
+ XCTAssertEqual(customEvents[1].name, "pong")
+ }
+
+ // MARK: - getAllExecutors()
+
+ func testDefaultToolRegistryGetAllExecutors() async throws {
+ let registry = DefaultToolRegistry()
+
+ let executor = SimpleMockExecutor(toolName: "search", description: "Search")
+ try await registry.register(executor: executor)
+
+ let executors = await registry.getAllExecutors()
+ XCTAssertEqual(executors.count, 1)
+ XCTAssertNotNil(executors["search"])
+ }
+
+ func testGetAllExecutorsReturnsEmptyWhenNoTools() async {
+ let registry = DefaultToolRegistry()
+ let executors = await registry.getAllExecutors()
+ XCTAssertTrue(executors.isEmpty)
+ }
+
+ func testGetAllExecutorsAfterUnregister() async throws {
+ let registry = DefaultToolRegistry()
+ let executor = SimpleMockExecutor(toolName: "tool_a", description: "A")
+ try await registry.register(executor: executor)
+ _ = await registry.unregister(toolName: "tool_a")
+
+ let executors = await registry.getAllExecutors()
+ XCTAssertTrue(executors.isEmpty)
+ }
+}
+
+// MARK: - SimpleMockExecutor
+
+private final class SimpleMockExecutor: ToolExecutor, Sendable {
+ let tool: Tool
+
+ init(toolName: String, description: String) {
+ self.tool = Tool(
+ name: toolName,
+ description: description,
+ parameters: Data("{}".utf8)
+ )
+ }
+
+ func execute(context: ToolExecutionContext) async throws -> ToolExecutionResult {
+ ToolExecutionResult(success: true, result: Data("ok".utf8))
+ }
+
+ func getMaxExecutionTimeMs() -> Int64? { nil }
+}
diff --git a/sdks/community/swift/Tests/AGUIAgentSDKTests/HistoryTrimmingTests.swift b/sdks/community/swift/Tests/AGUIAgentSDKTests/HistoryTrimmingTests.swift
new file mode 100644
index 0000000000..7b673946cc
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUIAgentSDKTests/HistoryTrimmingTests.swift
@@ -0,0 +1,226 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUICore
+@testable import AGUIAgentSDK
+import XCTest
+
+final class HistoryTrimmingTests: XCTestCase {
+
+ // MARK: - Basic Trimming
+
+ func testTrimNoEffectWhenUnderLimit() async throws {
+ // Given: History with 3 messages and limit of 5
+ let manager = ConversationHistoryManager()
+ await manager.append(message: UserMessage(id: "msg1", content: "Hello"), to: "thread-1")
+ await manager.append(message: AssistantMessage(id: "msg2", content: "Hi"), to: "thread-1")
+ await manager.append(message: UserMessage(id: "msg3", content: "How are you?"), to: "thread-1")
+
+ // When: Trimming to 5
+ await manager.trim(threadId: "thread-1", maxLength: 5)
+
+ // Then: All messages should remain
+ let history = await manager.history(for: "thread-1")
+ XCTAssertEqual(history.count, 3)
+ XCTAssertEqual(history[0].id, "msg1")
+ XCTAssertEqual(history[1].id, "msg2")
+ XCTAssertEqual(history[2].id, "msg3")
+ }
+
+ func testTrimRemovesOldestMessages() async throws {
+ // Given: History with 5 messages
+ let manager = ConversationHistoryManager()
+ await manager.append(message: UserMessage(id: "msg1", content: "1"), to: "thread-1")
+ await manager.append(message: AssistantMessage(id: "msg2", content: "2"), to: "thread-1")
+ await manager.append(message: UserMessage(id: "msg3", content: "3"), to: "thread-1")
+ await manager.append(message: AssistantMessage(id: "msg4", content: "4"), to: "thread-1")
+ await manager.append(message: UserMessage(id: "msg5", content: "5"), to: "thread-1")
+
+ // When: Trimming to 3
+ await manager.trim(threadId: "thread-1", maxLength: 3)
+
+ // Then: Should keep last 3 messages
+ let history = await manager.history(for: "thread-1")
+ XCTAssertEqual(history.count, 3)
+ XCTAssertEqual(history[0].id, "msg3")
+ XCTAssertEqual(history[1].id, "msg4")
+ XCTAssertEqual(history[2].id, "msg5")
+ }
+
+ // MARK: - System Message Preservation
+
+ func testTrimPreservesSystemMessage() async throws {
+ // Given: History with system message and 4 user/assistant messages
+ let manager = ConversationHistoryManager()
+ await manager.append(message: SystemMessage(id: "sys1", content: "You are helpful"), to: "thread-1")
+ await manager.append(message: UserMessage(id: "msg1", content: "1"), to: "thread-1")
+ await manager.append(message: AssistantMessage(id: "msg2", content: "2"), to: "thread-1")
+ await manager.append(message: UserMessage(id: "msg3", content: "3"), to: "thread-1")
+ await manager.append(message: AssistantMessage(id: "msg4", content: "4"), to: "thread-1")
+
+ // When: Trimming to 3 (system excluded from count — keep last 3 non-system)
+ await manager.trim(threadId: "thread-1", maxLength: 3)
+
+ // Then: System + last 3 non-system messages = 4 total
+ let history = await manager.history(for: "thread-1")
+ XCTAssertEqual(history.count, 4)
+ XCTAssertTrue(history[0] is SystemMessage)
+ XCTAssertEqual(history[0].id, "sys1")
+ XCTAssertEqual(history[1].id, "msg2")
+ XCTAssertEqual(history[2].id, "msg3")
+ XCTAssertEqual(history[3].id, "msg4")
+ }
+
+ func testTrimWithOnlySystemMessage() async throws {
+ // Given: History with only system message
+ let manager = ConversationHistoryManager()
+ await manager.append(message: SystemMessage(id: "sys1", content: "You are helpful"), to: "thread-1")
+
+ // When: Trimming to 3
+ await manager.trim(threadId: "thread-1", maxLength: 3)
+
+ // Then: System message should remain
+ let history = await manager.history(for: "thread-1")
+ XCTAssertEqual(history.count, 1)
+ XCTAssertEqual(history[0].id, "sys1")
+ }
+
+ func testTrimWithSystemMessageAndOneOther() async throws {
+ // Given: System message + one user message, trim to 1
+ // maxLength=1 means keep 1 non-system message (system excluded from count).
+ // With exactly 1 non-system message, no messages are dropped.
+ let manager = ConversationHistoryManager()
+ await manager.append(message: SystemMessage(id: "sys1", content: "System"), to: "thread-1")
+ await manager.append(message: UserMessage(id: "msg1", content: "User"), to: "thread-1")
+
+ // When: Trimming to 1
+ await manager.trim(threadId: "thread-1", maxLength: 1)
+
+ // Then: System + 1 non-system = 2 total (nothing is dropped since count matches limit)
+ let history = await manager.history(for: "thread-1")
+ XCTAssertEqual(history.count, 2)
+ XCTAssertEqual(history[0].id, "sys1")
+ XCTAssertEqual(history[1].id, "msg1")
+ }
+
+ // MARK: - Edge Cases
+
+ func testTrimToZeroWithoutSystemMessage() async throws {
+ // Given: History with 3 messages, no system message
+ let manager = ConversationHistoryManager()
+ await manager.append(message: UserMessage(id: "msg1", content: "1"), to: "thread-1")
+ await manager.append(message: AssistantMessage(id: "msg2", content: "2"), to: "thread-1")
+ await manager.append(message: UserMessage(id: "msg3", content: "3"), to: "thread-1")
+
+ // When: Trimming to 0
+ await manager.trim(threadId: "thread-1", maxLength: 0)
+
+ // Then: All messages removed
+ let history = await manager.history(for: "thread-1")
+ XCTAssertTrue(history.isEmpty)
+ }
+
+ func testTrimToOneWithSystemMessage() async throws {
+ // Given: System message + 3 others
+ // maxLength=1 means keep 1 non-system (system excluded from count).
+ let manager = ConversationHistoryManager()
+ await manager.append(message: SystemMessage(id: "sys1", content: "System"), to: "thread-1")
+ await manager.append(message: UserMessage(id: "msg1", content: "1"), to: "thread-1")
+ await manager.append(message: AssistantMessage(id: "msg2", content: "2"), to: "thread-1")
+ await manager.append(message: UserMessage(id: "msg3", content: "3"), to: "thread-1")
+
+ // When: Trimming to 1
+ await manager.trim(threadId: "thread-1", maxLength: 1)
+
+ // Then: System + last 1 non-system = 2 total
+ let history = await manager.history(for: "thread-1")
+ XCTAssertEqual(history.count, 2)
+ XCTAssertEqual(history[0].id, "sys1")
+ XCTAssertEqual(history[1].id, "msg3")
+ }
+
+ // MARK: - Documented contract: system message excluded from maxLength (Issue 41)
+
+ func test_trim_withSystemMessage_systemExcludedFromCount_docContractExample() async throws {
+ // This is the exact example from the method's doc comment.
+ // The doc states: "The system message does not count toward the limit."
+ // Given: [SystemMessage, User1, Assistant1, User2, Assistant2, User3]
+ let manager = ConversationHistoryManager()
+ await manager.append(message: SystemMessage(id: "sys", content: "System"), to: "t")
+ await manager.append(message: UserMessage(id: "u1", content: "1"), to: "t")
+ await manager.append(message: AssistantMessage(id: "a1", content: "2"), to: "t")
+ await manager.append(message: UserMessage(id: "u2", content: "3"), to: "t")
+ await manager.append(message: AssistantMessage(id: "a2", content: "4"), to: "t")
+ await manager.append(message: UserMessage(id: "u3", content: "5"), to: "t")
+
+ // When: trim(maxLength: 3) — keep last 3 non-system messages
+ await manager.trim(threadId: "t", maxLength: 3)
+
+ // Then: [SystemMessage, User2, Assistant2, User3] — 4 total
+ // System does NOT count; we keep exactly 3 non-system messages.
+ let history = await manager.history(for: "t")
+ XCTAssertEqual(history.count, 4, "Expected system + 3 non-system messages (system excluded from count)")
+ XCTAssertTrue(history[0] is SystemMessage)
+ XCTAssertEqual(history[1].id, "u2")
+ XCTAssertEqual(history[2].id, "a2")
+ XCTAssertEqual(history[3].id, "u3")
+ }
+
+ func testTrimNonexistentThread() async throws {
+ // Given: Empty manager
+ let manager = ConversationHistoryManager()
+
+ // When: Trimming nonexistent thread
+ await manager.trim(threadId: "nonexistent", maxLength: 5)
+
+ // Then: Should not crash, history still empty
+ let history = await manager.history(for: "nonexistent")
+ XCTAssertTrue(history.isEmpty)
+ }
+
+ // MARK: - Realistic Scenarios
+
+ func testRealisticConversationTrim() async throws {
+ // Given: A realistic conversation (system + 6 non-system messages)
+ let manager = ConversationHistoryManager()
+ await manager.append(message: SystemMessage(id: "sys", content: "You are helpful"), to: "chat")
+ await manager.append(message: UserMessage(id: "u1", content: "Hello"), to: "chat")
+ await manager.append(message: AssistantMessage(id: "a1", content: "Hi!"), to: "chat")
+ await manager.append(message: UserMessage(id: "u2", content: "Weather?"), to: "chat")
+ await manager.append(message: AssistantMessage(id: "a2", content: "Sunny"), to: "chat")
+ await manager.append(message: UserMessage(id: "u3", content: "Thanks"), to: "chat")
+ await manager.append(message: AssistantMessage(id: "a3", content: "Welcome"), to: "chat")
+
+ // When: Trimming with maxLength=5 (keep last 5 non-system; system excluded from count)
+ await manager.trim(threadId: "chat", maxLength: 5)
+
+ // Then: System + last 5 non-system = 6 total
+ let history = await manager.history(for: "chat")
+ XCTAssertEqual(history.count, 6)
+ XCTAssertEqual(history[0].id, "sys")
+ XCTAssertEqual(history[1].id, "a1")
+ XCTAssertEqual(history[2].id, "u2")
+ XCTAssertEqual(history[3].id, "a2")
+ XCTAssertEqual(history[4].id, "u3")
+ XCTAssertEqual(history[5].id, "a3")
+ }
+
+ func testAggressiveTrimToSystemOnly() async throws {
+ // Given: Long conversation (system + 20 non-system messages)
+ let manager = ConversationHistoryManager()
+ await manager.append(message: SystemMessage(id: "sys", content: "System"), to: "chat")
+ for i in 1...10 {
+ await manager.append(message: UserMessage(id: "u\(i)", content: "\(i)"), to: "chat")
+ await manager.append(message: AssistantMessage(id: "a\(i)", content: "\(i)"), to: "chat")
+ }
+
+ // When: Trimming with maxLength=2 (keep last 2 non-system; system excluded from count)
+ await manager.trim(threadId: "chat", maxLength: 2)
+
+ // Then: System + last 2 non-system = 3 total
+ let history = await manager.history(for: "chat")
+ XCTAssertEqual(history.count, 3)
+ XCTAssertEqual(history[0].id, "sys")
+ XCTAssertEqual(history[1].id, "u10")
+ XCTAssertEqual(history[2].id, "a10")
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUIAgentSDKTests/MockChatAgent.swift b/sdks/community/swift/Tests/AGUIAgentSDKTests/MockChatAgent.swift
new file mode 100644
index 0000000000..caf9a8d5b2
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUIAgentSDKTests/MockChatAgent.swift
@@ -0,0 +1,55 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUICore
+import Foundation
+@testable import AGUIAgentSDK
+
+/// Test double for `ChatAgent`.
+///
+/// Configured and read exclusively from `@MainActor` context (both test classes
+/// are `@MainActor`), so `@MainActor` isolation satisfies the `Sendable` requirement
+/// without locks or `@unchecked`.
+@MainActor
+final class MockChatAgent: ChatAgent, Sendable {
+
+ // MARK: - Configuration (set before each test)
+
+ /// Events that `chat()` will yield through the returned stream.
+ var eventsToYield: [any AGUIEvent] = []
+
+ /// When set, `chat()` itself throws this error before returning a stream.
+ var chatThrows: Error? = nil
+
+ /// When set, the returned stream finishes by throwing this error.
+ var streamThrows: Error? = nil
+
+ // MARK: - Captured invocations (assert after the test)
+
+ private(set) var chatCalls: [(message: String, threadId: String)] = []
+ private(set) var clearCalls: [String?] = []
+
+ // MARK: - ChatAgent conformance
+
+ func chat(
+ message: String,
+ threadId: String
+ ) async throws -> AsyncThrowingStream {
+ chatCalls.append((message: message, threadId: threadId))
+
+ if let err = chatThrows { throw err }
+
+ let events = eventsToYield
+ let streamError = streamThrows
+
+ return AsyncThrowingStream { continuation in
+ for event in events {
+ continuation.yield(event)
+ }
+ continuation.finish(throwing: streamError)
+ }
+ }
+
+ func clearHistory(threadId: String?) async {
+ clearCalls.append(threadId)
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUIAgentSDKTests/Mocks/MockToolRegistry.swift b/sdks/community/swift/Tests/AGUIAgentSDKTests/Mocks/MockToolRegistry.swift
new file mode 100644
index 0000000000..c8ef87ea58
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUIAgentSDKTests/Mocks/MockToolRegistry.swift
@@ -0,0 +1,27 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUICore
+import AGUITools
+
+// MARK: - Shared mock ToolRegistry for AGUIAgentSDK tests
+
+actor MockToolRegistry: ToolRegistry {
+ private let tools: [Tool]
+
+ init(tools: [Tool] = []) {
+ self.tools = tools
+ }
+
+ func allTools() async -> [Tool] { tools }
+ func register(executor: any ToolExecutor) async throws {}
+ func unregister(toolName: String) async -> Bool { false }
+ func executor(for toolName: String) async -> (any ToolExecutor)? { nil }
+ func execute(context: ToolExecutionContext) async throws -> ToolExecutionResult {
+ ToolExecutionResult(success: false, message: "mock")
+ }
+ func isToolRegistered(toolName: String) async -> Bool { false }
+ func stats(for toolName: String) async -> ToolExecutionStats? { nil }
+ func getAllStats() async -> [String: ToolExecutionStats] { [:] }
+ func clearStats() async {}
+ func getAllExecutors() async -> [String: any ToolExecutor] { [:] }
+}
diff --git a/sdks/community/swift/Tests/AGUIAgentSDKTests/StatefulAgUiAgentTests.swift b/sdks/community/swift/Tests/AGUIAgentSDKTests/StatefulAgUiAgentTests.swift
new file mode 100644
index 0000000000..5c1468c4b3
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUIAgentSDKTests/StatefulAgUiAgentTests.swift
@@ -0,0 +1,527 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUIClient
+import AGUICore
+import XCTest
+@testable import AGUIAgentSDK
+
+// MARK: - StatefulAgUiAgentTests
+
+final class StatefulAgUiAgentTests: XCTestCase {
+
+ // MARK: - Helpers
+
+ private func makeAgent(
+ configure: (inout StatefulAgUiAgentConfig) -> Void = { _ in }
+ ) -> (StatefulAgUiAgent, CapturingTransport) {
+ let url = URL(string: "https://placeholder.local")!
+ var cfg = StatefulAgUiAgentConfig(baseURL: url)
+ configure(&cfg)
+ let transport = CapturingTransport()
+ let agent = StatefulAgUiAgent(transport: transport, config: cfg)
+ return (agent, transport)
+ }
+
+ private func drain(
+ _ stream: AsyncThrowingStream
+ ) async throws -> [any AGUIEvent] {
+ var received: [any AGUIEvent] = []
+ for try await event in stream { received.append(event) }
+ return received
+ }
+
+ // MARK: - RunAgentInput construction
+
+ func testUserMessageIncludedInInput() async throws {
+ let (agent, transport) = makeAgent()
+ _ = try await drain(agent.sendMessage(
+ message: "Hello",
+ threadId: "t1",
+ state: nil,
+ includeSystemPrompt: false
+ ))
+
+ let inputs = await transport.capturedInputs
+ let input = try XCTUnwrap(inputs.first)
+ let userMessages = input.messages.filter { $0.role == .user }
+ XCTAssertEqual(userMessages.count, 1)
+ XCTAssertEqual((userMessages[0] as? UserMessage)?.content, "Hello")
+ }
+
+ func testSystemPromptAddedOnFirstMessageWhenEnabled() async throws {
+ let (agent, transport) = makeAgent { cfg in
+ cfg.systemPrompt = "Be helpful."
+ }
+ _ = try await drain(agent.sendMessage(
+ message: "Hi",
+ threadId: "t1",
+ state: nil,
+ includeSystemPrompt: true
+ ))
+
+ let inputs = await transport.capturedInputs
+ let input = try XCTUnwrap(inputs.first)
+ XCTAssertEqual(input.messages.count, 2)
+ XCTAssertEqual(input.messages[0].role, .system)
+ XCTAssertEqual((input.messages[0] as? SystemMessage)?.content, "Be helpful.")
+ XCTAssertEqual(input.messages[1].role, .user)
+ }
+
+ func testSystemPromptOmittedWhenFlagIsFalse() async throws {
+ let (agent, transport) = makeAgent { cfg in
+ cfg.systemPrompt = "Be helpful."
+ }
+ _ = try await drain(agent.sendMessage(
+ message: "Hi",
+ threadId: "t1",
+ state: nil,
+ includeSystemPrompt: false
+ ))
+
+ let inputs = await transport.capturedInputs
+ let input = try XCTUnwrap(inputs.first)
+ XCTAssertEqual(input.messages.count, 1)
+ XCTAssertEqual(input.messages[0].role, .user)
+ }
+
+ func testSystemPromptAddedOnlyOnFirstMessage() async throws {
+ let (agent, _) = makeAgent { cfg in
+ cfg.systemPrompt = "Be helpful."
+ }
+
+ _ = try await drain(agent.sendMessage(
+ message: "First",
+ threadId: "t1",
+ state: nil,
+ includeSystemPrompt: true
+ ))
+ _ = try await drain(agent.sendMessage(
+ message: "Second",
+ threadId: "t1",
+ state: nil,
+ includeSystemPrompt: true
+ ))
+
+ let history = await agent.history(for: "t1")
+ let systemMessages = history.filter { $0.role == .system }
+ XCTAssertEqual(systemMessages.count, 1, "System prompt must appear exactly once")
+ }
+
+ func testThreadIdPassedToTransport() async throws {
+ let (agent, transport) = makeAgent()
+ _ = try await drain(agent.sendMessage(
+ message: "Hi",
+ threadId: "my-thread",
+ state: nil,
+ includeSystemPrompt: false
+ ))
+
+ let inputs = await transport.capturedInputs
+ XCTAssertEqual(inputs.first?.threadId, "my-thread")
+ }
+
+ func testCustomStatePassedToTransport() async throws {
+ let customState = Data("{\"mode\":\"creative\"}".utf8)
+ let (agent, transport) = makeAgent()
+ _ = try await drain(agent.sendMessage(
+ message: "Hi",
+ threadId: "t1",
+ state: customState,
+ includeSystemPrompt: false
+ ))
+
+ let inputs = await transport.capturedInputs
+ XCTAssertEqual(inputs.first?.state, customState)
+ }
+
+ // MARK: - chat() convenience
+
+ func testChatDelegatesToSendMessageWithGivenThread() async throws {
+ let (agent, transport) = makeAgent()
+ _ = try await drain(agent.chat(message: "Hello!", threadId: "t1"))
+
+ let inputs = await transport.capturedInputs
+ XCTAssertEqual(inputs.count, 1)
+ XCTAssertEqual(inputs[0].threadId, "t1")
+ }
+
+ func testChatIncludesSystemPromptByDefault() async throws {
+ let (agent, transport) = makeAgent { cfg in
+ cfg.systemPrompt = "You are helpful."
+ }
+ _ = try await drain(agent.chat(message: "Hi", threadId: "t1"))
+
+ let inputs = await transport.capturedInputs
+ let input = try XCTUnwrap(inputs.first)
+ XCTAssertEqual(input.messages[0].role, .system)
+ }
+
+ // MARK: - History accumulation
+
+ func testUserMessageAppendedToHistory() async throws {
+ let (agent, _) = makeAgent()
+ _ = try await drain(agent.sendMessage(
+ message: "Hello",
+ threadId: "t1",
+ state: nil,
+ includeSystemPrompt: false
+ ))
+
+ let history = await agent.history(for: "t1")
+ let userMessages = history.filter { $0.role == .user }
+ XCTAssertEqual(userMessages.count, 1)
+ XCTAssertEqual((userMessages[0] as? UserMessage)?.content, "Hello")
+ }
+
+ func testHistoryAccumulatesAcrossRounds() async throws {
+ let (agent, _) = makeAgent()
+
+ _ = try await drain(agent.sendMessage(
+ message: "First",
+ threadId: "t1",
+ state: nil,
+ includeSystemPrompt: false
+ ))
+ _ = try await drain(agent.sendMessage(
+ message: "Second",
+ threadId: "t1",
+ state: nil,
+ includeSystemPrompt: false
+ ))
+
+ let history = await agent.history(for: "t1")
+ let userMessages = history.filter { $0.role == .user }
+ XCTAssertEqual(userMessages.count, 2)
+ XCTAssertEqual((userMessages[0] as? UserMessage)?.content, "First")
+ XCTAssertEqual((userMessages[1] as? UserMessage)?.content, "Second")
+ }
+
+ func testHistoryPassedToTransportOnSecondCall() async throws {
+ let (agent, transport) = makeAgent()
+
+ _ = try await drain(agent.sendMessage(
+ message: "First",
+ threadId: "t1",
+ state: nil,
+ includeSystemPrompt: false
+ ))
+ _ = try await drain(agent.sendMessage(
+ message: "Second",
+ threadId: "t1",
+ state: nil,
+ includeSystemPrompt: false
+ ))
+
+ let inputs = await transport.capturedInputs
+ XCTAssertEqual(inputs.count, 2)
+ // Second call's input must contain "First" already in history
+ XCTAssertEqual(inputs[1].messages.count, 2)
+ XCTAssertEqual((inputs[1].messages[0] as? UserMessage)?.content, "First")
+ XCTAssertEqual((inputs[1].messages[1] as? UserMessage)?.content, "Second")
+ }
+
+ func testHistoryIsolatedPerThread() async throws {
+ let (agent, _) = makeAgent()
+
+ _ = try await drain(agent.sendMessage(
+ message: "Thread A",
+ threadId: "thread-a",
+ state: nil,
+ includeSystemPrompt: false
+ ))
+ _ = try await drain(agent.sendMessage(
+ message: "Thread B",
+ threadId: "thread-b",
+ state: nil,
+ includeSystemPrompt: false
+ ))
+
+ let historyA = await agent.history(for: "thread-a")
+ let historyB = await agent.history(for: "thread-b")
+ XCTAssertEqual(historyA.count, 1)
+ XCTAssertEqual(historyB.count, 1)
+ XCTAssertEqual((historyA[0] as? UserMessage)?.content, "Thread A")
+ XCTAssertEqual((historyB[0] as? UserMessage)?.content, "Thread B")
+ }
+
+ func testHistoryTrimmingRespected() async throws {
+ let (agent, _) = makeAgent { cfg in
+ cfg.maxHistoryLength = 3
+ }
+
+ for i in 1...5 {
+ _ = try await drain(agent.sendMessage(
+ message: "Message \(i)",
+ threadId: "t1",
+ state: nil,
+ includeSystemPrompt: false
+ ))
+ }
+
+ let history = await agent.history(for: "t1")
+ XCTAssertLessThanOrEqual(history.count, 3)
+ }
+
+ func testHistoryForNewThreadIsEmpty() async {
+ let (agent, _) = makeAgent()
+ let history = await agent.history(for: "nonexistent-thread")
+ XCTAssertTrue(history.isEmpty)
+ }
+
+ // MARK: - clearHistory
+
+ func testClearHistoryClearsSpecificThread() async throws {
+ let (agent, _) = makeAgent()
+ _ = try await drain(agent.sendMessage(
+ message: "Hi",
+ threadId: "t1",
+ state: nil,
+ includeSystemPrompt: false
+ ))
+
+ await agent.clearHistory(threadId: "t1")
+
+ let history = await agent.history(for: "t1")
+ XCTAssertTrue(history.isEmpty)
+ }
+
+ func testClearHistoryNilClearsAllThreads() async throws {
+ let (agent, _) = makeAgent()
+
+ for threadId in ["t1", "t2", "t3"] {
+ _ = try await drain(agent.sendMessage(
+ message: "Hi",
+ threadId: threadId,
+ state: nil,
+ includeSystemPrompt: false
+ ))
+ }
+
+ await agent.clearHistory()
+
+ for threadId in ["t1", "t2", "t3"] {
+ let history = await agent.history(for: threadId)
+ XCTAssertTrue(history.isEmpty, "Thread \(threadId) should be empty after clearHistory()")
+ }
+ }
+
+ // MARK: - Event passthrough
+
+ func testAllEventsYieldedDownstream() async throws {
+ let (agent, transport) = makeAgent()
+ await transport.setMockEvents([
+ RunStartedEvent(threadId: "t1", runId: "r1"),
+ RunFinishedEvent(threadId: "t1", runId: "r1"),
+ ])
+
+ let received = try await drain(agent.sendMessage(
+ message: "Hi",
+ threadId: "t1",
+ state: nil,
+ includeSystemPrompt: false
+ ))
+
+ XCTAssertEqual(received.count, 2)
+ XCTAssertTrue(received[0] is RunStartedEvent)
+ XCTAssertTrue(received[1] is RunFinishedEvent)
+ }
+
+ // MARK: - Text message assembly (trackHistoryAndState)
+
+ func testTextMessageAssemblyRecordedInHistory() async throws {
+ let (agent, transport) = makeAgent()
+ await transport.setMockEvents([
+ TextMessageStartEvent(messageId: "msg1"),
+ TextMessageContentEvent(messageId: "msg1", delta: "Hello"),
+ TextMessageContentEvent(messageId: "msg1", delta: ", world!"),
+ TextMessageEndEvent(messageId: "msg1"),
+ ])
+
+ _ = try await drain(agent.sendMessage(
+ message: "Hi",
+ threadId: "t1",
+ state: nil,
+ includeSystemPrompt: false
+ ))
+
+ let history = await agent.history(for: "t1")
+ let assistantMsgs = history.compactMap { $0 as? AssistantMessage }
+ XCTAssertEqual(assistantMsgs.count, 1)
+ XCTAssertEqual(assistantMsgs[0].id, "msg1")
+ XCTAssertEqual(assistantMsgs[0].content, "Hello, world!")
+ }
+
+ func testTextMessageContentConcatenated() async throws {
+ let (agent, transport) = makeAgent()
+ await transport.setMockEvents([
+ TextMessageStartEvent(messageId: "m1"),
+ TextMessageContentEvent(messageId: "m1", delta: "A"),
+ TextMessageContentEvent(messageId: "m1", delta: "B"),
+ TextMessageContentEvent(messageId: "m1", delta: "C"),
+ TextMessageEndEvent(messageId: "m1"),
+ ])
+
+ _ = try await drain(agent.sendMessage(
+ message: "Hi",
+ threadId: "t1",
+ state: nil,
+ includeSystemPrompt: false
+ ))
+
+ let history = await agent.history(for: "t1")
+ let assistantMsg = try XCTUnwrap(history.compactMap { $0 as? AssistantMessage }.first)
+ XCTAssertEqual(assistantMsg.content, "ABC")
+ }
+
+ func testEmptyTextMessageStillRecordedInHistory() async throws {
+ let (agent, transport) = makeAgent()
+ await transport.setMockEvents([
+ TextMessageStartEvent(messageId: "m2"),
+ TextMessageEndEvent(messageId: "m2"),
+ ])
+
+ _ = try await drain(agent.sendMessage(
+ message: "Hi",
+ threadId: "t1",
+ state: nil,
+ includeSystemPrompt: false
+ ))
+
+ let history = await agent.history(for: "t1")
+ let assistantMsgs = history.compactMap { $0 as? AssistantMessage }
+ XCTAssertEqual(assistantMsgs.count, 1)
+ XCTAssertEqual(assistantMsgs[0].content, "")
+ }
+
+ // MARK: - Tool call tracking (trackHistoryAndState)
+
+ func testToolCallResultAppendsToolMessageToHistory() async throws {
+ let (agent, transport) = makeAgent()
+ await transport.setMockEvents([
+ ToolCallStartEvent(toolCallId: "tc1", toolCallName: "get_weather"),
+ ToolCallEndEvent(toolCallId: "tc1"),
+ ToolCallResultEvent(messageId: "res1", toolCallId: "tc1", content: "72°F, sunny"),
+ ])
+
+ _ = try await drain(agent.sendMessage(
+ message: "Weather?",
+ threadId: "t1",
+ state: nil,
+ includeSystemPrompt: false
+ ))
+
+ let history = await agent.history(for: "t1")
+ let toolMessages = history.compactMap { $0 as? ToolMessage }
+ XCTAssertEqual(toolMessages.count, 1)
+ XCTAssertEqual(toolMessages[0].toolCallId, "tc1")
+ XCTAssertEqual(toolMessages[0].content, "72°F, sunny")
+ }
+
+ func testToolCallResultFlushesPendingAssistantMessage() async throws {
+ let (agent, transport) = makeAgent()
+ await transport.setMockEvents([
+ ToolCallStartEvent(toolCallId: "tc1", toolCallName: "get_weather"),
+ ToolCallArgsEvent(toolCallId: "tc1", delta: "{\"city\":\"London\"}"),
+ ToolCallEndEvent(toolCallId: "tc1"),
+ ToolCallResultEvent(messageId: "res1", toolCallId: "tc1", content: "Rainy"),
+ ])
+
+ _ = try await drain(agent.sendMessage(
+ message: "Weather?",
+ threadId: "t1",
+ state: nil,
+ includeSystemPrompt: false
+ ))
+
+ let history = await agent.history(for: "t1")
+ let assistantMsgs = history.compactMap { $0 as? AssistantMessage }
+ XCTAssertEqual(assistantMsgs.count, 1)
+ let calls = try XCTUnwrap(assistantMsgs[0].toolCalls)
+ XCTAssertEqual(calls[0].id, "tc1")
+ XCTAssertEqual(calls[0].function.name, "get_weather")
+ XCTAssertEqual(calls[0].function.arguments, "{\"city\":\"London\"}")
+ }
+
+ func testToolCallEndDoesNotDuplicateAssistantMessage() async throws {
+ let (agent, transport) = makeAgent()
+ // Two tool calls complete before a ToolCallResultEvent — must not prematurely flush
+ await transport.setMockEvents([
+ ToolCallStartEvent(toolCallId: "tc1", toolCallName: "tool_a"),
+ ToolCallEndEvent(toolCallId: "tc1"),
+ ToolCallStartEvent(toolCallId: "tc2", toolCallName: "tool_b"),
+ ToolCallEndEvent(toolCallId: "tc2"),
+ ToolCallResultEvent(messageId: "res1", toolCallId: "tc1", content: "ok"),
+ ])
+
+ _ = try await drain(agent.sendMessage(
+ message: "Go",
+ threadId: "t1",
+ state: nil,
+ includeSystemPrompt: false
+ ))
+
+ let history = await agent.history(for: "t1")
+ let assistantMsgs = history.compactMap { $0 as? AssistantMessage }
+ XCTAssertEqual(assistantMsgs.count, 1, "ToolCallEnd must not flush assistant message early")
+ }
+
+ // MARK: - State event tracking (trackHistoryAndState)
+
+ func testStateSnapshotUpdatesStateForNextRun() async throws {
+ let (agent, transport) = makeAgent()
+ let newState = Data("{\"count\":42}".utf8)
+ await transport.setMockEvents([StateSnapshotEvent(snapshot: newState)])
+
+ _ = try await drain(agent.sendMessage(
+ message: "First",
+ threadId: "t1",
+ state: nil,
+ includeSystemPrompt: false
+ ))
+
+ await transport.setMockEvents([])
+ _ = try await drain(agent.sendMessage(
+ message: "Second",
+ threadId: "t1",
+ state: nil,
+ includeSystemPrompt: false
+ ))
+
+ let inputs = await transport.capturedInputs
+ XCTAssertEqual(inputs[1].state, newState)
+ }
+
+ func testStateDeltaAppliedToCurrentState() async throws {
+ let (agent, transport) = makeAgent()
+ let snapshot = Data("{\"count\":0}".utf8)
+ let patch = Data("[{\"op\":\"replace\",\"path\":\"/count\",\"value\":7}]".utf8)
+ await transport.setMockEvents([
+ StateSnapshotEvent(snapshot: snapshot),
+ StateDeltaEvent(delta: patch),
+ ])
+
+ _ = try await drain(agent.sendMessage(
+ message: "First",
+ threadId: "t1",
+ state: nil,
+ includeSystemPrompt: false
+ ))
+
+ await transport.setMockEvents([])
+ _ = try await drain(agent.sendMessage(
+ message: "Second",
+ threadId: "t1",
+ state: nil,
+ includeSystemPrompt: false
+ ))
+
+ let inputs = await transport.capturedInputs
+ let stateData = inputs[1].state
+ guard let json = try? JSONSerialization.jsonObject(with: stateData) as? [String: Any],
+ let count = json["count"] as? Int else {
+ XCTFail("Could not parse state JSON")
+ return
+ }
+ XCTAssertEqual(count, 7)
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUIClientTests/AGUIClientTests.swift b/sdks/community/swift/Tests/AGUIClientTests/AGUIClientTests.swift
new file mode 100644
index 0000000000..c73f5034dc
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUIClientTests/AGUIClientTests.swift
@@ -0,0 +1,15 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUIClient
+
+final class AGUIClientTests: XCTestCase {
+ func testModuleVersion() {
+ XCTAssertEqual(AGUIClient.version, "0.1.0")
+ }
+
+ func testModuleExists() {
+ // Verify the module can be imported and used
+ XCTAssertNotNil(AGUIClient.self)
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUIClientTests/HttpAgentTests.swift b/sdks/community/swift/Tests/AGUIClientTests/HttpAgentTests.swift
new file mode 100644
index 0000000000..cf415b33f0
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUIClientTests/HttpAgentTests.swift
@@ -0,0 +1,450 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUIClient
+@testable import AGUICore
+
+/// Comprehensive tests for HttpAgent public API.
+///
+/// Tests the high-level API for AG-UI agent communication including:
+/// - Initialization and configuration
+/// - Run methods with different input styles
+/// - Builder pattern integration
+/// - Event streaming
+/// - Error handling
+final class HttpAgentTests: XCTestCase {
+ // MARK: - Initialization Tests
+
+ func testInitWithURL() {
+ let url = URL(string: "https://agent.example.com")!
+ let agent = HttpAgent(baseURL: url)
+
+ // Verify agent was created
+ XCTAssertNotNil(agent)
+ }
+
+ func testInitWithConfiguration() {
+ let url = URL(string: "https://agent.example.com")!
+ var config = HttpAgentConfiguration(baseURL: url)
+ config.timeout = 60.0
+ config.headers = ["X-Custom": "Value"]
+
+ let agent = HttpAgent(configuration: config)
+
+ XCTAssertNotNil(agent)
+ }
+
+ func testInitWithCustomHTTPClient() async {
+ let url = URL(string: "https://agent.example.com")!
+ let config = HttpAgentConfiguration(baseURL: url)
+ let mockClient = MockHTTPClient()
+
+ let agent = HttpAgent(configuration: config, httpClient: mockClient)
+
+ XCTAssertNotNil(agent)
+ }
+
+ // MARK: - Run Method Tests
+
+ func testRunWithRunAgentInput() async throws {
+ let url = URL(string: "https://agent.example.com")!
+ let mockClient = MockHTTPClient()
+ let agent = HttpAgent(
+ configuration: HttpAgentConfiguration(baseURL: url),
+ httpClient: mockClient
+ )
+
+ // Configure mock response
+ let sseData = """
+ data: {"type":"RUN_STARTED","threadId":"t1","runId":"r1"}
+
+ data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1"}
+
+
+ """
+ let mockResponse = try await HTTPResponse.mock(
+ data: Data(sseData.utf8),
+ statusCode: 200
+ )
+ await mockClient.setResponse(mockResponse)
+
+ // Execute
+ let input = try RunAgentInput.builder()
+ .threadId("t1")
+ .runId("r1")
+ .build()
+
+ let stream = try await agent.run(input)
+
+ var events: [any AGUIEvent] = []
+ for try await event in stream {
+ events.append(event)
+ }
+
+ XCTAssertEqual(events.count, 2)
+ XCTAssertTrue(events[0] is RunStartedEvent)
+ XCTAssertTrue(events[1] is RunFinishedEvent)
+ }
+
+ func testRunWithBuilder() async throws {
+ let url = URL(string: "https://agent.example.com")!
+ let mockClient = MockHTTPClient()
+ let agent = HttpAgent(
+ configuration: HttpAgentConfiguration(baseURL: url),
+ httpClient: mockClient
+ )
+
+ // Configure mock response
+ let sseData = """
+ data: {"type":"RUN_STARTED","threadId":"thread-1","runId":"run-1"}
+
+ data: {"type":"TEXT_MESSAGE_START","messageId":"msg1","role":"assistant"}
+
+ data: {"type":"TEXT_MESSAGE_CHUNK","messageId":"msg1","delta":"Hello"}
+
+ data: {"type":"TEXT_MESSAGE_END","messageId":"msg1"}
+
+ data: {"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-1"}
+
+
+ """
+ let mockResponse = try await HTTPResponse.mock(
+ data: Data(sseData.utf8),
+ statusCode: 200
+ )
+ await mockClient.setResponse(mockResponse)
+
+ // Execute with builder
+ let stream = try await agent.run(threadId: "thread-1", runId: "run-1") { builder in
+ builder.message(UserMessage(
+ id: "user1",
+ content: "Hello"
+ ))
+ }
+
+ var events: [any AGUIEvent] = []
+ var textChunks: [String] = []
+
+ for try await event in stream {
+ events.append(event)
+ if let chunk = event as? TextMessageChunkEvent, let delta = chunk.delta {
+ textChunks.append(delta)
+ }
+ }
+
+ XCTAssertEqual(events.count, 5)
+ XCTAssertEqual(textChunks.joined(), "Hello")
+ }
+
+ func testRunWithMinimalInput() async throws {
+ let url = URL(string: "https://agent.example.com")!
+ let mockClient = MockHTTPClient()
+ let agent = HttpAgent(
+ configuration: HttpAgentConfiguration(baseURL: url),
+ httpClient: mockClient
+ )
+
+ // Configure mock response
+ let sseData = """
+ data: {"type":"RUN_STARTED","threadId":"t1","runId":"r1"}
+
+ data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1"}
+
+
+ """
+ let mockResponse = try await HTTPResponse.mock(
+ data: Data(sseData.utf8),
+ statusCode: 200
+ )
+ await mockClient.setResponse(mockResponse)
+
+ // Execute with just thread and run IDs
+ let stream = try await agent.run(threadId: "t1", runId: "r1")
+
+ var eventCount = 0
+ for try await _ in stream {
+ eventCount += 1
+ }
+
+ XCTAssertEqual(eventCount, 2)
+ }
+
+ // MARK: - Error Handling Tests
+
+ func testRunHandlesHTTPError() async throws {
+ let url = URL(string: "https://agent.example.com")!
+ let mockClient = MockHTTPClient()
+ let agent = HttpAgent(
+ configuration: HttpAgentConfiguration(baseURL: url),
+ httpClient: mockClient
+ )
+
+ // Configure error response
+ await mockClient.setError(ClientError.httpError(statusCode: 500))
+
+ let input = try RunAgentInput.builder()
+ .threadId("t1")
+ .runId("r1")
+ .build()
+
+ do {
+ _ = try await agent.run(input)
+ XCTFail("Expected error to be thrown")
+ } catch let error as ClientError {
+ if case .httpError(let statusCode) = error {
+ XCTAssertEqual(statusCode, 500)
+ } else {
+ XCTFail("Expected httpError, got \(error)")
+ }
+ }
+ }
+
+ func testRunHandlesNetworkError() async throws {
+ let url = URL(string: "https://agent.example.com")!
+ let mockClient = MockHTTPClient()
+ let agent = HttpAgent(
+ configuration: HttpAgentConfiguration(baseURL: url),
+ httpClient: mockClient
+ )
+
+ // Configure network error
+ await mockClient.setError(ClientError.timeout)
+
+ let input = try RunAgentInput.builder()
+ .threadId("t1")
+ .runId("r1")
+ .build()
+
+ do {
+ _ = try await agent.run(input)
+ XCTFail("Expected timeout error")
+ } catch let error as ClientError {
+ if case .timeout = error {
+ // Success
+ } else {
+ XCTFail("Expected timeout error, got \(error)")
+ }
+ }
+ }
+
+ // MARK: - Integration Tests
+
+ func testRunWithMessagesAndTools() async throws {
+ let url = URL(string: "https://agent.example.com")!
+ let mockClient = MockHTTPClient()
+ let agent = HttpAgent(
+ configuration: HttpAgentConfiguration(baseURL: url),
+ httpClient: mockClient
+ )
+
+ // Configure mock response
+ let sseData = """
+ data: {"type":"RUN_STARTED","threadId":"t1","runId":"r1"}
+
+ data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1"}
+
+
+ """
+ let mockResponse = try await HTTPResponse.mock(
+ data: Data(sseData.utf8),
+ statusCode: 200
+ )
+ await mockClient.setResponse(mockResponse)
+
+ // Create tool
+ let weatherTool = Tool(
+ name: "get_weather",
+ description: "Get weather for a location",
+ parameters: Data("{}".utf8)
+ )
+
+ // Execute with messages and tools
+ let stream = try await agent.run(threadId: "t1", runId: "r1") { builder in
+ builder
+ .message(DeveloperMessage(
+ id: "dev1",
+ content: "You are a helpful assistant"
+ ))
+ .message(UserMessage(
+ id: "user1",
+ content: "What's the weather?"
+ ))
+ .tool(weatherTool)
+ }
+
+ var eventCount = 0
+ for try await _ in stream {
+ eventCount += 1
+ }
+
+ XCTAssertGreaterThan(eventCount, 0)
+
+ // Verify request was made with correct data
+ let callCount = await mockClient.executeCallCount
+ XCTAssertEqual(callCount, 1)
+ }
+
+ func testRunWithContext() async throws {
+ let url = URL(string: "https://agent.example.com")!
+ let mockClient = MockHTTPClient()
+ let agent = HttpAgent(
+ configuration: HttpAgentConfiguration(baseURL: url),
+ httpClient: mockClient
+ )
+
+ // Configure mock response
+ let sseData = """
+ data: {"type":"RUN_STARTED","threadId":"t1","runId":"r1"}
+
+ data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1"}
+
+
+ """
+ let mockResponse = try await HTTPResponse.mock(
+ data: Data(sseData.utf8),
+ statusCode: 200
+ )
+ await mockClient.setResponse(mockResponse)
+
+ // Execute with context
+ let stream = try await agent.run(threadId: "t1", runId: "r1") { builder in
+ builder.context([Context(
+ description: "User timezone",
+ value: "America/New_York"
+ )])
+ }
+
+ var eventCount = 0
+ for try await _ in stream {
+ eventCount += 1
+ }
+
+ XCTAssertGreaterThan(eventCount, 0)
+ }
+
+ // MARK: - Custom Endpoint Tests
+
+ func testRunWithCustomEndpoint() async throws {
+ let url = URL(string: "https://agent.example.com")!
+ let mockClient = MockHTTPClient()
+ let agent = HttpAgent(
+ configuration: HttpAgentConfiguration(baseURL: url),
+ httpClient: mockClient
+ )
+
+ // Configure mock response
+ let sseData = """
+ data: {"type":"RUN_STARTED","threadId":"t1","runId":"r1"}
+
+ data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1"}
+
+
+ """
+ let mockResponse = try await HTTPResponse.mock(
+ data: Data(sseData.utf8),
+ statusCode: 200
+ )
+ await mockClient.setResponse(mockResponse)
+
+ let input = try RunAgentInput.builder()
+ .threadId("t1")
+ .runId("r1")
+ .build()
+
+ // Execute with custom endpoint
+ let stream = try await agent.run(input, endpoint: "/custom/run")
+
+ var eventCount = 0
+ for try await _ in stream {
+ eventCount += 1
+ }
+
+ XCTAssertGreaterThan(eventCount, 0)
+
+ // Verify custom endpoint was used
+ let lastRequest = await mockClient.lastRequest
+ XCTAssertEqual(lastRequest?.url?.path, "/custom/run")
+ }
+
+ // MARK: - Stream Cancellation Tests
+
+ func testRunStreamCanBeCancelled() async throws {
+ let url = URL(string: "https://agent.example.com")!
+ let mockClient = MockHTTPClient()
+ let agent = HttpAgent(
+ configuration: HttpAgentConfiguration(baseURL: url),
+ httpClient: mockClient
+ )
+
+ // Configure mock response with many events
+ var sseData = ""
+ for i in 0..<100 {
+ sseData += "data: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"messageId\":\"msg1\",\"delta\":\"\(i)\"}\n\n"
+ }
+
+ let mockResponse = try await HTTPResponse.mock(
+ data: Data(sseData.utf8),
+ statusCode: 200
+ )
+ await mockClient.setResponse(mockResponse)
+
+ let input = try RunAgentInput.builder()
+ .threadId("t1")
+ .runId("r1")
+ .build()
+
+ let stream = try await agent.run(input)
+
+ // Cancel after 5 events
+ var eventCount = 0
+ for try await _ in stream {
+ eventCount += 1
+ if eventCount >= 5 {
+ break
+ }
+ }
+
+ XCTAssertEqual(eventCount, 5)
+ }
+
+ // MARK: - Thread Safety Tests
+
+ func testSequentialRuns() async throws {
+ let url = URL(string: "https://agent.example.com")!
+ let mockClient = MockHTTPClient()
+ let agent = HttpAgent(
+ configuration: HttpAgentConfiguration(baseURL: url),
+ httpClient: mockClient
+ )
+
+ // Run multiple sequential requests to verify HttpAgent is reusable
+ for i in 0..<3 {
+ let sseData = """
+ data: {"type":"RUN_STARTED","threadId":"t\(i)","runId":"r\(i)"}
+
+ data: {"type":"RUN_FINISHED","threadId":"t\(i)","runId":"r\(i)"}
+
+
+ """
+ let mockResponse = try await HTTPResponse.mock(
+ data: Data(sseData.utf8),
+ statusCode: 200
+ )
+ await mockClient.setResponse(mockResponse)
+
+ let input = try RunAgentInput.builder()
+ .threadId("t\(i)")
+ .runId("r\(i)")
+ .build()
+
+ let stream = try await agent.run(input)
+
+ var eventCount = 0
+ for try await _ in stream {
+ eventCount += 1
+ }
+
+ XCTAssertEqual(eventCount, 2, "Run \(i) should have 2 events")
+ }
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUIClientTests/SseReconnectionTests.swift b/sdks/community/swift/Tests/AGUIClientTests/SseReconnectionTests.swift
new file mode 100644
index 0000000000..83f8470c67
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUIClientTests/SseReconnectionTests.swift
@@ -0,0 +1,501 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUIClient
+@testable import AGUICore
+
+// MARK: - SequencedHTTPClient
+
+/// Test double that returns a pre-configured queue of responses/errors.
+///
+/// Enables deterministic retry scenarios without real network calls:
+/// - `.success(HTTPResponse)` — returns the response normally
+/// - `.failure(Error)` — throws before streaming begins (pre-stream failure)
+/// - `.midStreamFailure(sseText:error:)` — streams bytes then throws mid-stream
+actor SequencedHTTPClient: HTTPClient {
+
+ enum Response: Sendable {
+ case success(HTTPResponse)
+ case failure(Error)
+ /// Streams `sseText` as bytes, then throws `error` — simulates a network drop
+ /// after the server has already sent some events.
+ case midStreamFailure(sseText: String, error: Error)
+ }
+
+ private var queue: [Response]
+ private(set) var requestHistory: [URLRequest] = []
+
+ var executeCallCount: Int { requestHistory.count }
+
+ init(responses: [Response]) {
+ self.queue = responses
+ }
+
+ func execute(_ request: URLRequest) async throws -> HTTPResponse {
+ requestHistory.append(request)
+ precondition(!queue.isEmpty, "SequencedHTTPClient: ran out of responses")
+ switch queue.removeFirst() {
+ case .success(let response):
+ return response
+ case .failure(let error):
+ throw error
+ case .midStreamFailure(let sseText, let error):
+ return Self.midStreamResponse(sseText: sseText, trailingError: error)
+ }
+ }
+
+ /// Builds an HTTPResponse whose byte stream delivers `sseText` then throws.
+ private static func midStreamResponse(sseText: String, trailingError: Error) -> HTTPResponse {
+ let bytes = Data(sseText.utf8)
+ let stream = AsyncThrowingStream { continuation in
+ for byte in bytes {
+ continuation.yield(byte)
+ }
+ continuation.finish(throwing: trailingError)
+ }
+ let url = URL(string: "https://mock.local")!
+ let httpResponse = HTTPURLResponse(
+ url: url,
+ statusCode: 200,
+ httpVersion: "HTTP/1.1",
+ headerFields: nil
+ )!
+ return HTTPResponse(bytes: stream, httpResponse: httpResponse)
+ }
+}
+
+// MARK: - SseReconnectionTests
+
+final class SseReconnectionTests: XCTestCase {
+
+ // MARK: - Shared helpers
+
+ private let baseURL = URL(string: "https://agent.example.com")!
+
+ /// Minimal two-event SSE payload (RUN_STARTED + RUN_FINISHED).
+ private var minimalSSE: String {
+ """
+ data: {"type":"RUN_STARTED","threadId":"t1","runId":"r1"}
+
+ data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1"}
+
+
+ """
+ }
+
+ private func makeSuccessResponse() async throws -> HTTPResponse {
+ try await HTTPResponse.mock(data: Data(minimalSSE.utf8), statusCode: 200)
+ }
+
+ private func makeAgent(
+ retryPolicy: HttpAgentConfiguration.RetryPolicy,
+ httpClient: any HTTPClient
+ ) -> HttpAgent {
+ let config = HttpAgentConfiguration(baseURL: baseURL, retryPolicy: retryPolicy)
+ return HttpAgent(configuration: config, httpClient: httpClient)
+ }
+
+ private func makeInput() throws -> RunAgentInput {
+ try RunAgentInput.builder().threadId("t1").runId("r1").build()
+ }
+
+ // MARK: - RetryPolicy.none — no retry on transient errors
+
+ func testRetryPolicy_none_doesNotRetry_onTimeout() async throws {
+ let mockClient = SequencedHTTPClient(responses: [
+ .failure(ClientError.timeout),
+ ])
+ let agent = makeAgent(retryPolicy: .none, httpClient: mockClient)
+ let stream = agent.run(input: try makeInput())
+
+ do {
+ for try await _ in stream {}
+ XCTFail("Expected ClientError.timeout to be thrown")
+ } catch let error as ClientError {
+ XCTAssertEqual(error, .timeout)
+ }
+
+ let callCount = await mockClient.executeCallCount
+ XCTAssertEqual(callCount, 1, ".none policy must not retry")
+ }
+
+ func testRetryPolicy_none_doesNotRetry_onNetworkError() async throws {
+ let mockClient = SequencedHTTPClient(responses: [
+ .failure(ClientError.networkError(URLError(.networkConnectionLost))),
+ ])
+ let agent = makeAgent(retryPolicy: .none, httpClient: mockClient)
+ let stream = agent.run(input: try makeInput())
+
+ do {
+ for try await _ in stream {}
+ XCTFail("Expected error")
+ } catch {}
+
+ let callCount = await mockClient.executeCallCount
+ XCTAssertEqual(callCount, 1)
+ }
+
+ // MARK: - RetryPolicy.fixed — retries on transient errors
+
+ func testRetryPolicy_fixed_retriesOnTimeout_andSucceeds() async throws {
+ let mockClient = SequencedHTTPClient(responses: [
+ .failure(ClientError.timeout),
+ .success(try await makeSuccessResponse()),
+ ])
+ let agent = makeAgent(retryPolicy: .fixed(maxAttempts: 2, delay: 0), httpClient: mockClient)
+ let stream = agent.run(input: try makeInput())
+
+ var events: [any AGUIEvent] = []
+ for try await event in stream {
+ events.append(event)
+ }
+
+ let callCount = await mockClient.executeCallCount
+ XCTAssertEqual(callCount, 2, "Should retry once after timeout and succeed")
+ XCTAssertFalse(events.isEmpty, "Events must be delivered after successful retry")
+ }
+
+ func testRetryPolicy_fixed_retriesOnNetworkError_andSucceeds() async throws {
+ let mockClient = SequencedHTTPClient(responses: [
+ .failure(ClientError.networkError(URLError(.networkConnectionLost))),
+ .success(try await makeSuccessResponse()),
+ ])
+ let agent = makeAgent(retryPolicy: .fixed(maxAttempts: 2, delay: 0), httpClient: mockClient)
+ let stream = agent.run(input: try makeInput())
+
+ var events: [any AGUIEvent] = []
+ for try await event in stream {
+ events.append(event)
+ }
+
+ let callCount = await mockClient.executeCallCount
+ XCTAssertEqual(callCount, 2, "Should retry once on network error")
+ XCTAssertFalse(events.isEmpty)
+ }
+
+ func testRetryPolicy_fixed_exhaustsMaxAttempts_thenThrows() async throws {
+ // maxAttempts: 2 → initial attempt + 2 retries = 3 total execute calls
+ let mockClient = SequencedHTTPClient(responses: [
+ .failure(ClientError.timeout),
+ .failure(ClientError.timeout),
+ .failure(ClientError.timeout),
+ ])
+ let agent = makeAgent(retryPolicy: .fixed(maxAttempts: 2, delay: 0), httpClient: mockClient)
+ let stream = agent.run(input: try makeInput())
+
+ do {
+ for try await _ in stream {}
+ XCTFail("Expected error after exhausting retries")
+ } catch let error as ClientError {
+ XCTAssertEqual(error, .timeout, "Final error must be the last transient error")
+ }
+
+ let callCount = await mockClient.executeCallCount
+ XCTAssertEqual(callCount, 3, "1 initial + 2 retries = 3 total")
+ }
+
+ func testRetryPolicy_fixed_doesNotRetry_onHTTPError() async throws {
+ // HTTP errors (4xx/5xx) are not retryable — server explicitly rejected the request
+ let mockClient = SequencedHTTPClient(responses: [
+ .failure(ClientError.httpError(statusCode: 500)),
+ ])
+ let agent = makeAgent(retryPolicy: .fixed(maxAttempts: 3, delay: 0), httpClient: mockClient)
+ let stream = agent.run(input: try makeInput())
+
+ do {
+ for try await _ in stream {}
+ XCTFail("Expected httpError")
+ } catch let error as ClientError {
+ if case .httpError(let code) = error {
+ XCTAssertEqual(code, 500)
+ } else {
+ XCTFail("Expected httpError, got \(error)")
+ }
+ }
+
+ let callCount = await mockClient.executeCallCount
+ XCTAssertEqual(callCount, 1, "HTTP errors must not trigger retry")
+ }
+
+ func testRetryPolicy_fixed_doesNotRetry_onInvalidResponse() async throws {
+ let mockClient = SequencedHTTPClient(responses: [
+ .failure(ClientError.invalidResponse),
+ ])
+ let agent = makeAgent(retryPolicy: .fixed(maxAttempts: 3, delay: 0), httpClient: mockClient)
+ let stream = agent.run(input: try makeInput())
+
+ do {
+ for try await _ in stream {}
+ XCTFail("Expected error")
+ } catch {}
+
+ let callCount = await mockClient.executeCallCount
+ XCTAssertEqual(callCount, 1, "invalidResponse must not trigger retry")
+ }
+
+ // MARK: - RetryPolicy.exponentialBackoff
+
+ func testRetryPolicy_exponentialBackoff_retriesAndSucceeds() async throws {
+ // Use baseDelay: 0 to keep the test fast
+ let mockClient = SequencedHTTPClient(responses: [
+ .failure(ClientError.timeout),
+ .failure(ClientError.timeout),
+ .success(try await makeSuccessResponse()),
+ ])
+ let agent = makeAgent(
+ retryPolicy: .exponentialBackoff(maxAttempts: 3, baseDelay: 0),
+ httpClient: mockClient
+ )
+ let stream = agent.run(input: try makeInput())
+
+ var events: [any AGUIEvent] = []
+ for try await event in stream {
+ events.append(event)
+ }
+
+ let callCount = await mockClient.executeCallCount
+ XCTAssertEqual(callCount, 3, "Should retry twice and succeed on third attempt")
+ XCTAssertFalse(events.isEmpty)
+ }
+
+ func testRetryPolicy_exponentialBackoff_exhaustsAttempts_thenThrows() async throws {
+ let mockClient = SequencedHTTPClient(responses: [
+ .failure(ClientError.timeout),
+ .failure(ClientError.timeout),
+ .failure(ClientError.timeout),
+ ])
+ let agent = makeAgent(
+ retryPolicy: .exponentialBackoff(maxAttempts: 2, baseDelay: 0),
+ httpClient: mockClient
+ )
+ let stream = agent.run(input: try makeInput())
+
+ do {
+ for try await _ in stream {}
+ XCTFail("Expected error")
+ } catch let error as ClientError {
+ XCTAssertEqual(error, .timeout)
+ }
+
+ let callCount = await mockClient.executeCallCount
+ XCTAssertEqual(callCount, 3)
+ }
+
+ // MARK: - Last-Event-ID header on reconnect
+
+ func testReconnect_sendsLastEventIdHeader_afterMidStreamFailure() async throws {
+ // First response: delivers SSE with an id:, then drops mid-stream
+ let sseWithId = """
+ id: event-42
+ data: {"type":"RUN_STARTED","threadId":"t1","runId":"r1"}
+
+
+ """
+
+ let mockClient = SequencedHTTPClient(responses: [
+ .midStreamFailure(sseText: sseWithId, error: ClientError.timeout),
+ .success(try await makeSuccessResponse()),
+ ])
+ let agent = makeAgent(retryPolicy: .fixed(maxAttempts: 1, delay: 0), httpClient: mockClient)
+ let stream = agent.run(input: try makeInput())
+
+ // Consume the stream — retry happens transparently
+ for try await _ in stream {}
+
+ let requests = await mockClient.requestHistory
+ XCTAssertEqual(requests.count, 2, "Should have made 2 requests (1 initial + 1 retry)")
+ XCTAssertEqual(
+ requests[1].value(forHTTPHeaderField: "Last-Event-ID"),
+ "event-42",
+ "Retry request must carry the last seen SSE event id"
+ )
+ }
+
+ func testReconnect_noLastEventIdHeader_whenFirstStreamHadNoId() async throws {
+ // First response: SSE without any id: fields
+ let sseWithoutId = """
+ data: {"type":"RUN_STARTED","threadId":"t1","runId":"r1"}
+
+
+ """
+
+ let mockClient = SequencedHTTPClient(responses: [
+ .midStreamFailure(sseText: sseWithoutId, error: ClientError.timeout),
+ .success(try await makeSuccessResponse()),
+ ])
+ let agent = makeAgent(retryPolicy: .fixed(maxAttempts: 1, delay: 0), httpClient: mockClient)
+ let stream = agent.run(input: try makeInput())
+
+ for try await _ in stream {}
+
+ let requests = await mockClient.requestHistory
+ XCTAssertEqual(requests.count, 2)
+ XCTAssertNil(
+ requests[1].value(forHTTPHeaderField: "Last-Event-ID"),
+ "No Last-Event-ID header when stream contained no SSE ids"
+ )
+ }
+
+ func testReconnect_lastEventIdUpdatesToMostRecentId() async throws {
+ // Stream delivers two events with different ids
+ let sseMultipleIds = """
+ id: id-001
+ data: {"type":"RUN_STARTED","threadId":"t1","runId":"r1"}
+
+ id: id-002
+ data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1"}
+
+
+ """
+
+ let mockClient = SequencedHTTPClient(responses: [
+ .midStreamFailure(sseText: sseMultipleIds, error: ClientError.timeout),
+ .success(try await makeSuccessResponse()),
+ ])
+ let agent = makeAgent(retryPolicy: .fixed(maxAttempts: 1, delay: 0), httpClient: mockClient)
+ let stream = agent.run(input: try makeInput())
+
+ for try await _ in stream {}
+
+ let requests = await mockClient.requestHistory
+ XCTAssertEqual(
+ requests[1].value(forHTTPHeaderField: "Last-Event-ID"),
+ "id-002",
+ "Must use the LAST seen id, not the first"
+ )
+ }
+
+ // MARK: - HttpTransport Last-Event-ID parameter
+
+ func testHttpTransport_sendsLastEventIdHeader_whenProvided() async throws {
+ let mockClient = MockHTTPClient()
+ let response = try await HTTPResponse.mock(data: Data(minimalSSE.utf8), statusCode: 200)
+ await mockClient.setResponse(response)
+
+ let config = HttpAgentConfiguration(baseURL: baseURL)
+ let transport = HttpTransport(configuration: config, httpClient: mockClient)
+ let input = try makeInput()
+
+ _ = try await transport.execute(endpoint: "/run", input: input, lastEventId: "event-99")
+
+ let lastRequest = await mockClient.lastRequest
+ XCTAssertEqual(
+ lastRequest?.value(forHTTPHeaderField: "Last-Event-ID"),
+ "event-99",
+ "Transport must set Last-Event-ID header when lastEventId is provided"
+ )
+ }
+
+ func testHttpTransport_doesNotSendLastEventIdHeader_whenNil() async throws {
+ let mockClient = MockHTTPClient()
+ let response = try await HTTPResponse.mock(data: Data(minimalSSE.utf8), statusCode: 200)
+ await mockClient.setResponse(response)
+
+ let config = HttpAgentConfiguration(baseURL: baseURL)
+ let transport = HttpTransport(configuration: config, httpClient: mockClient)
+ let input = try makeInput()
+
+ _ = try await transport.execute(endpoint: "/run", input: input, lastEventId: nil)
+
+ let lastRequest = await mockClient.lastRequest
+ XCTAssertNil(
+ lastRequest?.value(forHTTPHeaderField: "Last-Event-ID"),
+ "Transport must not set Last-Event-ID header when lastEventId is nil"
+ )
+ }
+
+ // MARK: - EventStream lastEventId property
+
+ func testEventStream_tracksLastSseEventId() async throws {
+ let sseData = """
+ id: first-id
+ data: {"type":"RUN_STARTED","threadId":"t1","runId":"r1"}
+
+ id: second-id
+ data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1"}
+
+
+ """
+ let bytes = asyncBytes(from: sseData)
+ let stream = EventStream(bytes: bytes, decoder: AGUIEventDecoder())
+
+ for try await _ in stream {}
+
+ let lastId = await stream.lastEventId
+ XCTAssertEqual(lastId, "second-id", "Must track the LAST seen SSE id")
+ }
+
+ func testEventStream_lastEventIdIsNil_whenStreamHasNoIds() async throws {
+ let sseData = """
+ data: {"type":"RUN_STARTED","threadId":"t1","runId":"r1"}
+
+
+ """
+ let bytes = asyncBytes(from: sseData)
+ let stream = EventStream(bytes: bytes, decoder: AGUIEventDecoder())
+
+ for try await _ in stream {}
+
+ let lastId = await stream.lastEventId
+ XCTAssertNil(lastId, "lastEventId must be nil when no id: fields appear")
+ }
+
+ func testEventStream_lastEventIdUpdatesAcrossEvents() async throws {
+ let sseData = """
+ id: alpha
+ data: {"type":"RUN_STARTED","threadId":"t1","runId":"r1"}
+
+ id: beta
+ data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1"}
+
+
+ """
+ let bytes = asyncBytes(from: sseData)
+ let stream = EventStream(bytes: bytes, decoder: AGUIEventDecoder())
+
+ var idAfterFirst: String? = nil
+ var eventCount = 0
+ for try await _ in stream {
+ eventCount += 1
+ if eventCount == 1 {
+ idAfterFirst = await stream.lastEventId
+ }
+ }
+
+ XCTAssertEqual(idAfterFirst, "alpha", "After first event, id should be 'alpha'")
+ let finalId = await stream.lastEventId
+ XCTAssertEqual(finalId, "beta", "After all events, id should be 'beta'")
+ }
+
+ func testEventStream_lastEventIdIsSetEvenWhenEventFailsToDecode() async throws {
+ // The id: field must be captured even if the data: JSON is undecodable
+ let sseData = """
+ id: orphan-id
+ data: this-is-not-valid-json
+
+
+ """
+ let bytes = asyncBytes(from: sseData)
+ let stream = EventStream(bytes: bytes, decoder: AGUIEventDecoder())
+
+ for try await _ in stream {}
+
+ let lastId = await stream.lastEventId
+ XCTAssertEqual(
+ lastId,
+ "orphan-id",
+ "id must be captured even when event data fails to decode"
+ )
+ }
+
+ // MARK: - Private helpers
+
+ private func asyncBytes(from string: String) -> AsyncThrowingStream {
+ AsyncThrowingStream { continuation in
+ for byte in Data(string.utf8) {
+ continuation.yield(byte)
+ }
+ continuation.finish()
+ }
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUIClientTests/State/DefaultApplyEventsTests.swift b/sdks/community/swift/Tests/AGUIClientTests/State/DefaultApplyEventsTests.swift
new file mode 100644
index 0000000000..ffc52fc726
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUIClientTests/State/DefaultApplyEventsTests.swift
@@ -0,0 +1,246 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUICore
+@testable import AGUIClient
+import XCTest
+
+final class DefaultApplyEventsTests: XCTestCase {
+
+ // MARK: - Helpers
+
+ private func makeInput(messages: [any Message] = []) -> RunAgentInput {
+ RunAgentInput(
+ threadId: "t1",
+ runId: "r1",
+ messages: messages
+ )
+ }
+
+ private func collectStates(
+ _ stream: AsyncThrowingStream
+ ) async throws -> [AgentState] {
+ var states: [AgentState] = []
+ for try await state in stream {
+ states.append(state)
+ }
+ return states
+ }
+
+ // MARK: - Text Message Tests
+
+ func testTextMessageSequenceBuildsCorrectAssistantMessage() async throws {
+ // Given: A text message event sequence
+ let events: [any AGUIEvent] = [
+ RunStartedEvent(threadId: "t1", runId: "r1"),
+ TextMessageStartEvent(messageId: "msg1", role: "assistant"),
+ TextMessageContentEvent(messageId: "msg1", delta: "Hello"),
+ TextMessageContentEvent(messageId: "msg1", delta: ", world!"),
+ TextMessageEndEvent(messageId: "msg1"),
+ ]
+ let input = makeInput()
+
+ // When: Applying events
+ let states = try await collectStates(events.asyncStream.applyEvents(input: input))
+
+ // Then: Find the last messages emission
+ let lastMessagesState = states.last(where: { $0.messages != nil })
+ let msgs = lastMessagesState?.messages
+ XCTAssertNotNil(msgs)
+ XCTAssertEqual(msgs?.count, 1)
+
+ let assistantMsg = msgs?[0] as? AssistantMessage
+ XCTAssertNotNil(assistantMsg)
+ XCTAssertEqual(assistantMsg?.id, "msg1")
+ XCTAssertEqual(assistantMsg?.content, "Hello, world!")
+ }
+
+ func testTextMessageStartAppendsEmptyMessage() async throws {
+ // Given: Just a TEXT_MESSAGE_START
+ let events: [any AGUIEvent] = [
+ RunStartedEvent(threadId: "t1", runId: "r1"),
+ TextMessageStartEvent(messageId: "msg1", role: "assistant"),
+ ]
+ let input = makeInput()
+
+ // When
+ let states = try await collectStates(events.asyncStream.applyEvents(input: input))
+
+ // Then: Message appended with empty content
+ let messagesState = states.first(where: { $0.messages != nil })
+ let assistantMsg = messagesState?.messages?.first as? AssistantMessage
+ XCTAssertNotNil(assistantMsg)
+ XCTAssertEqual(assistantMsg?.content, "")
+ }
+
+ // MARK: - Tool Call Tests
+
+ func testToolCallSequenceBuildsAssistantMessageToolCalls() async throws {
+ // Given: A tool call event sequence
+ let events: [any AGUIEvent] = [
+ RunStartedEvent(threadId: "t1", runId: "r1"),
+ TextMessageStartEvent(messageId: "msg1", role: "assistant"),
+ ToolCallStartEvent(
+ toolCallId: "tc1",
+ toolCallName: "get_weather",
+ parentMessageId: "msg1"
+ ),
+ ToolCallArgsEvent(toolCallId: "tc1", delta: "{\"loc\":"),
+ ToolCallArgsEvent(toolCallId: "tc1", delta: "\"SF\"}"),
+ ToolCallEndEvent(toolCallId: "tc1"),
+ TextMessageEndEvent(messageId: "msg1"),
+ ]
+ let input = makeInput()
+
+ // When
+ let states = try await collectStates(events.asyncStream.applyEvents(input: input))
+
+ // Then: Find the last messages state
+ let lastMessagesState = states.last(where: { $0.messages != nil })
+ let assistantMsg = lastMessagesState?.messages?.first as? AssistantMessage
+ XCTAssertNotNil(assistantMsg)
+ XCTAssertEqual(assistantMsg?.toolCalls?.count, 1)
+
+ let toolCall = assistantMsg?.toolCalls?.first
+ XCTAssertEqual(toolCall?.id, "tc1")
+ XCTAssertEqual(toolCall?.function.name, "get_weather")
+ XCTAssertEqual(toolCall?.function.arguments, "{\"loc\":\"SF\"}")
+ }
+
+ // MARK: - ToolCallResultEvent Tests
+
+ func testToolCallResultEventAppendsToolMessage() async throws {
+ // Given: A ToolCallResultEvent
+ let events: [any AGUIEvent] = [
+ RunStartedEvent(threadId: "t1", runId: "r1"),
+ ToolCallResultEvent(
+ messageId: "result-msg1",
+ toolCallId: "tc1",
+ content: "Sunny, 22C"
+ ),
+ ]
+ let input = makeInput()
+
+ // When
+ let states = try await collectStates(events.asyncStream.applyEvents(input: input))
+
+ // Then
+ let lastMessagesState = states.last(where: { $0.messages != nil })
+ let toolMsg = lastMessagesState?.messages?.last as? ToolMessage
+ XCTAssertNotNil(toolMsg)
+ XCTAssertEqual(toolMsg?.id, "result-msg1")
+ XCTAssertEqual(toolMsg?.toolCallId, "tc1")
+ XCTAssertEqual(toolMsg?.content, "Sunny, 22C")
+ }
+
+ // MARK: - StateSnapshotEvent Tests
+
+ func testStateSnapshotEventReplacesState() async throws {
+ // Given: A StateSnapshotEvent
+ let newStateData = Data("{\"count\":42}".utf8)
+ let events: [any AGUIEvent] = [
+ RunStartedEvent(threadId: "t1", runId: "r1"),
+ StateSnapshotEvent(snapshot: newStateData),
+ ]
+ let input = makeInput()
+
+ // When
+ let states = try await collectStates(events.asyncStream.applyEvents(input: input))
+
+ // Then: Find the state emission
+ let stateEmission = states.first(where: { $0.state != nil })
+ XCTAssertNotNil(stateEmission)
+ XCTAssertEqual(stateEmission?.state, newStateData)
+ }
+
+ // MARK: - StateDeltaEvent Tests
+
+ func testStateDeltaEventAppliesPatch() async throws {
+ // Given: An initial state and a delta
+ let initialState = Data("{\"count\":0}".utf8)
+ let patch = Data("[{\"op\":\"replace\",\"path\":\"/count\",\"value\":10}]".utf8)
+ let events: [any AGUIEvent] = [
+ RunStartedEvent(threadId: "t1", runId: "r1"),
+ StateDeltaEvent(delta: patch),
+ ]
+ let input = RunAgentInput(threadId: "t1", runId: "r1", state: initialState)
+
+ // When
+ let states = try await collectStates(events.asyncStream.applyEvents(input: input))
+
+ // Then: State should be patched
+ let stateEmission = states.first(where: { $0.state != nil })
+ XCTAssertNotNil(stateEmission)
+
+ if let updatedState = stateEmission?.state,
+ let json = try? JSONSerialization.jsonObject(with: updatedState) as? [String: Any],
+ let count = json["count"] as? Int {
+ XCTAssertEqual(count, 10)
+ } else {
+ XCTFail("Failed to parse updated state")
+ }
+ }
+
+ // MARK: - Initial Messages Tests
+
+ func testInitialMessagesAreEmittedOnFirstEvent() async throws {
+ // Given: Input with pre-existing messages
+ let existingMessages: [any Message] = [
+ UserMessage(id: "user1", content: "Hello"),
+ ]
+ let events: [any AGUIEvent] = [
+ RunStartedEvent(threadId: "t1", runId: "r1"),
+ ]
+ let input = makeInput(messages: existingMessages)
+
+ // When
+ let states = try await collectStates(events.asyncStream.applyEvents(input: input))
+
+ // Then: Initial messages emitted on first event
+ let firstMessagesState = states.first(where: { $0.messages != nil })
+ XCTAssertNotNil(firstMessagesState)
+ XCTAssertEqual(firstMessagesState?.messages?.count, 1)
+ XCTAssertEqual(firstMessagesState?.messages?.first?.id, "user1")
+ }
+
+ // MARK: - RawEvent and CustomEvent Tests
+
+ func testRawEventAppendsToRawEvents() async throws {
+ // Given: A RawEvent
+ let rawData = Data("{\"raw\":true}".utf8)
+ let events: [any AGUIEvent] = [
+ RunStartedEvent(threadId: "t1", runId: "r1"),
+ RawEvent(data: rawData),
+ ]
+ let input = makeInput()
+
+ // When
+ let states = try await collectStates(events.asyncStream.applyEvents(input: input))
+
+ // Then
+ let rawState = states.first(where: { $0.rawEvents != nil })
+ XCTAssertNotNil(rawState)
+ XCTAssertEqual(rawState?.rawEvents?.count, 1)
+ XCTAssertEqual(rawState?.rawEvents?.first?.data, rawData)
+ }
+
+ func testCustomEventAppendsToCustomEvents() async throws {
+ // Given: A CustomEvent
+ let customData = Data("{\"action\":\"click\"}".utf8)
+ let events: [any AGUIEvent] = [
+ RunStartedEvent(threadId: "t1", runId: "r1"),
+ CustomEvent(name: "com.example.click", value: customData),
+ ]
+ let input = makeInput()
+
+ // When
+ let states = try await collectStates(events.asyncStream.applyEvents(input: input))
+
+ // Then
+ let customState = states.first(where: { $0.customEvents != nil })
+ XCTAssertNotNil(customState)
+ XCTAssertEqual(customState?.customEvents?.count, 1)
+ XCTAssertEqual(customState?.customEvents?.first?.name, "com.example.click")
+ }
+}
+
+// Note: asyncStream is provided by the extension in ChunkTransformTests.swift
diff --git a/sdks/community/swift/Tests/AGUIClientTests/State/PatchApplicatorTests.swift b/sdks/community/swift/Tests/AGUIClientTests/State/PatchApplicatorTests.swift
new file mode 100644
index 0000000000..aaa9de2e15
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUIClientTests/State/PatchApplicatorTests.swift
@@ -0,0 +1,528 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUIClient
+
+/// Comprehensive tests for RFC 6902 JSON Patch implementation.
+///
+/// Tests all standard JSON Patch operations:
+/// - add: Adds a value to an object or array
+/// - remove: Removes a value from an object or array
+/// - replace: Replaces a value
+/// - move: Moves a value from one location to another
+/// - copy: Copies a value from one location to another
+/// - test: Tests that a value at a path equals specified value
+///
+/// Each operation is tested with:
+/// - Root-level paths
+/// - Nested paths
+/// - Array operations (index, append with "-")
+/// - Edge cases and error conditions
+final class PatchApplicatorTests: XCTestCase {
+ var applicator: PatchApplicator!
+
+ override func setUp() {
+ super.setUp()
+ applicator = PatchApplicator()
+ }
+
+ // MARK: - Add Operation Tests
+
+ func testAddToEmptyObject() throws {
+ let state = Data("{}".utf8)
+ let patch = Data("""
+ [{"op":"add","path":"/name","value":"alice"}]
+ """.utf8)
+
+ let result = try applicator.apply(patch: patch, to: state)
+ let json = try JSONSerialization.jsonObject(with: result) as! [String: Any]
+
+ XCTAssertEqual(json["name"] as? String, "alice")
+ }
+
+ func testAddMultipleFields() throws {
+ let state = Data("{}".utf8)
+ let patch = Data("""
+ [
+ {"op":"add","path":"/name","value":"alice"},
+ {"op":"add","path":"/age","value":30}
+ ]
+ """.utf8)
+
+ let result = try applicator.apply(patch: patch, to: state)
+ let json = try JSONSerialization.jsonObject(with: result) as! [String: Any]
+
+ XCTAssertEqual(json["name"] as? String, "alice")
+ XCTAssertEqual(json["age"] as? Int, 30)
+ }
+
+ func testAddNestedField() throws {
+ let state = Data("{\"user\":{}}".utf8)
+ let patch = Data("""
+ [{"op":"add","path":"/user/name","value":"alice"}]
+ """.utf8)
+
+ let result = try applicator.apply(patch: patch, to: state)
+ let json = try JSONSerialization.jsonObject(with: result) as! [String: Any]
+ let user = json["user"] as! [String: Any]
+
+ XCTAssertEqual(user["name"] as? String, "alice")
+ }
+
+ func testAddToArrayAtIndex() throws {
+ let state = Data("{\"items\":[\"a\",\"b\"]}".utf8)
+ let patch = Data("""
+ [{"op":"add","path":"/items/1","value":"x"}]
+ """.utf8)
+
+ let result = try applicator.apply(patch: patch, to: state)
+ let json = try JSONSerialization.jsonObject(with: result) as! [String: Any]
+ let items = json["items"] as! [String]
+
+ XCTAssertEqual(items, ["a", "x", "b"])
+ }
+
+ func testAddToArrayEnd() throws {
+ let state = Data("{\"items\":[\"a\",\"b\"]}".utf8)
+ let patch = Data("""
+ [{"op":"add","path":"/items/-","value":"c"}]
+ """.utf8)
+
+ let result = try applicator.apply(patch: patch, to: state)
+ let json = try JSONSerialization.jsonObject(with: result) as! [String: Any]
+ let items = json["items"] as! [String]
+
+ XCTAssertEqual(items, ["a", "b", "c"])
+ }
+
+ func testAddComplexValue() throws {
+ let state = Data("{}".utf8)
+ let patch = Data("""
+ [{"op":"add","path":"/user","value":{"name":"alice","age":30}}]
+ """.utf8)
+
+ let result = try applicator.apply(patch: patch, to: state)
+ let json = try JSONSerialization.jsonObject(with: result) as! [String: Any]
+ let user = json["user"] as! [String: Any]
+
+ XCTAssertEqual(user["name"] as? String, "alice")
+ XCTAssertEqual(user["age"] as? Int, 30)
+ }
+
+ // MARK: - Remove Operation Tests
+
+ func testRemoveField() throws {
+ let state = Data("{\"name\":\"alice\",\"age\":30}".utf8)
+ let patch = Data("""
+ [{"op":"remove","path":"/age"}]
+ """.utf8)
+
+ let result = try applicator.apply(patch: patch, to: state)
+ let json = try JSONSerialization.jsonObject(with: result) as! [String: Any]
+
+ XCTAssertEqual(json["name"] as? String, "alice")
+ XCTAssertNil(json["age"])
+ }
+
+ func testRemoveNestedField() throws {
+ let state = Data("{\"user\":{\"name\":\"alice\",\"age\":30}}".utf8)
+ let patch = Data("""
+ [{"op":"remove","path":"/user/age"}]
+ """.utf8)
+
+ let result = try applicator.apply(patch: patch, to: state)
+ let json = try JSONSerialization.jsonObject(with: result) as! [String: Any]
+ let user = json["user"] as! [String: Any]
+
+ XCTAssertEqual(user["name"] as? String, "alice")
+ XCTAssertNil(user["age"])
+ }
+
+ func testRemoveArrayElement() throws {
+ let state = Data("{\"items\":[\"a\",\"b\",\"c\"]}".utf8)
+ let patch = Data("""
+ [{"op":"remove","path":"/items/1"}]
+ """.utf8)
+
+ let result = try applicator.apply(patch: patch, to: state)
+ let json = try JSONSerialization.jsonObject(with: result) as! [String: Any]
+ let items = json["items"] as! [String]
+
+ XCTAssertEqual(items, ["a", "c"])
+ }
+
+ func testRemoveNonExistentFieldThrowsError() throws {
+ let state = Data("{\"name\":\"alice\"}".utf8)
+ let patch = Data("""
+ [{"op":"remove","path":"/age"}]
+ """.utf8)
+
+ XCTAssertThrowsError(try applicator.apply(patch: patch, to: state))
+ }
+
+ // MARK: - Replace Operation Tests
+
+ func testReplaceField() throws {
+ let state = Data("{\"count\":5}".utf8)
+ let patch = Data("""
+ [{"op":"replace","path":"/count","value":10}]
+ """.utf8)
+
+ let result = try applicator.apply(patch: patch, to: state)
+ let json = try JSONSerialization.jsonObject(with: result) as! [String: Any]
+
+ XCTAssertEqual(json["count"] as? Int, 10)
+ }
+
+ func testReplaceNestedField() throws {
+ let state = Data("{\"user\":{\"name\":\"alice\"}}".utf8)
+ let patch = Data("""
+ [{"op":"replace","path":"/user/name","value":"bob"}]
+ """.utf8)
+
+ let result = try applicator.apply(patch: patch, to: state)
+ let json = try JSONSerialization.jsonObject(with: result) as! [String: Any]
+ let user = json["user"] as! [String: Any]
+
+ XCTAssertEqual(user["name"] as? String, "bob")
+ }
+
+ func testReplaceArrayElement() throws {
+ let state = Data("{\"items\":[\"a\",\"b\",\"c\"]}".utf8)
+ let patch = Data("""
+ [{"op":"replace","path":"/items/1","value":"x"}]
+ """.utf8)
+
+ let result = try applicator.apply(patch: patch, to: state)
+ let json = try JSONSerialization.jsonObject(with: result) as! [String: Any]
+ let items = json["items"] as! [String]
+
+ XCTAssertEqual(items, ["a", "x", "c"])
+ }
+
+ func testReplaceWithDifferentType() throws {
+ let state = Data("{\"value\":\"string\"}".utf8)
+ let patch = Data("""
+ [{"op":"replace","path":"/value","value":42}]
+ """.utf8)
+
+ let result = try applicator.apply(patch: patch, to: state)
+ let json = try JSONSerialization.jsonObject(with: result) as! [String: Any]
+
+ XCTAssertEqual(json["value"] as? Int, 42)
+ }
+
+ func testReplaceNonExistentFieldThrowsError() throws {
+ let state = Data("{\"name\":\"alice\"}".utf8)
+ let patch = Data("""
+ [{"op":"replace","path":"/age","value":30}]
+ """.utf8)
+
+ XCTAssertThrowsError(try applicator.apply(patch: patch, to: state))
+ }
+
+ // MARK: - Move Operation Tests
+
+ func testMoveField() throws {
+ let state = Data("{\"a\":1,\"b\":2}".utf8)
+ let patch = Data("""
+ [{"op":"move","from":"/a","path":"/c"}]
+ """.utf8)
+
+ let result = try applicator.apply(patch: patch, to: state)
+ let json = try JSONSerialization.jsonObject(with: result) as! [String: Any]
+
+ XCTAssertNil(json["a"])
+ XCTAssertEqual(json["b"] as? Int, 2)
+ XCTAssertEqual(json["c"] as? Int, 1)
+ }
+
+ func testMoveArrayElement() throws {
+ let state = Data("{\"items\":[\"a\",\"b\",\"c\"]}".utf8)
+ let patch = Data("""
+ [{"op":"move","from":"/items/0","path":"/items/2"}]
+ """.utf8)
+
+ let result = try applicator.apply(patch: patch, to: state)
+ let json = try JSONSerialization.jsonObject(with: result) as! [String: Any]
+ let items = json["items"] as! [String]
+
+ XCTAssertEqual(items, ["b", "c", "a"])
+ }
+
+ func testMoveToNestedPath() throws {
+ let state = Data("{\"value\":42,\"nested\":{}}".utf8)
+ let patch = Data("""
+ [{"op":"move","from":"/value","path":"/nested/value"}]
+ """.utf8)
+
+ let result = try applicator.apply(patch: patch, to: state)
+ let json = try JSONSerialization.jsonObject(with: result) as! [String: Any]
+ let nested = json["nested"] as! [String: Any]
+
+ XCTAssertNil(json["value"])
+ XCTAssertEqual(nested["value"] as? Int, 42)
+ }
+
+ // MARK: - Copy Operation Tests
+
+ func testCopyField() throws {
+ let state = Data("{\"original\":\"value\"}".utf8)
+ let patch = Data("""
+ [{"op":"copy","from":"/original","path":"/copy"}]
+ """.utf8)
+
+ let result = try applicator.apply(patch: patch, to: state)
+ let json = try JSONSerialization.jsonObject(with: result) as! [String: Any]
+
+ XCTAssertEqual(json["original"] as? String, "value")
+ XCTAssertEqual(json["copy"] as? String, "value")
+ }
+
+ func testCopyArrayElement() throws {
+ let state = Data("{\"items\":[\"a\",\"b\"]}".utf8)
+ let patch = Data("""
+ [{"op":"copy","from":"/items/0","path":"/items/-"}]
+ """.utf8)
+
+ let result = try applicator.apply(patch: patch, to: state)
+ let json = try JSONSerialization.jsonObject(with: result) as! [String: Any]
+ let items = json["items"] as! [String]
+
+ XCTAssertEqual(items, ["a", "b", "a"])
+ }
+
+ func testCopyComplexValue() throws {
+ let state = Data("{\"user\":{\"name\":\"alice\",\"age\":30}}".utf8)
+ let patch = Data("""
+ [{"op":"copy","from":"/user","path":"/backup"}]
+ """.utf8)
+
+ let result = try applicator.apply(patch: patch, to: state)
+ let json = try JSONSerialization.jsonObject(with: result) as! [String: Any]
+ let user = json["user"] as! [String: Any]
+ let backup = json["backup"] as! [String: Any]
+
+ XCTAssertEqual(user["name"] as? String, "alice")
+ XCTAssertEqual(backup["name"] as? String, "alice")
+ XCTAssertEqual(backup["age"] as? Int, 30)
+ }
+
+ // MARK: - Test Operation Tests
+
+ func testTestOperationSuccess() throws {
+ let state = Data("{\"value\":42}".utf8)
+ let patch = Data("""
+ [{"op":"test","path":"/value","value":42}]
+ """.utf8)
+
+ // Should not throw
+ let result = try applicator.apply(patch: patch, to: state)
+ let json = try JSONSerialization.jsonObject(with: result) as! [String: Any]
+ XCTAssertEqual(json["value"] as? Int, 42)
+ }
+
+ func testTestOperationFailure() throws {
+ let state = Data("{\"value\":42}".utf8)
+ let patch = Data("""
+ [{"op":"test","path":"/value","value":100}]
+ """.utf8)
+
+ XCTAssertThrowsError(try applicator.apply(patch: patch, to: state))
+ }
+
+ func testTestOperationWithString() throws {
+ let state = Data("{\"name\":\"alice\"}".utf8)
+ let patch = Data("""
+ [{"op":"test","path":"/name","value":"alice"}]
+ """.utf8)
+
+ let result = try applicator.apply(patch: patch, to: state)
+ let json = try JSONSerialization.jsonObject(with: result) as! [String: Any]
+ XCTAssertEqual(json["name"] as? String, "alice")
+ }
+
+ func testTestOperationWithNull() throws {
+ let state = Data("{\"value\":null}".utf8)
+ let patch = Data("""
+ [{"op":"test","path":"/value","value":null}]
+ """.utf8)
+
+ let result = try applicator.apply(patch: patch, to: state)
+ let json = try JSONSerialization.jsonObject(with: result) as! [String: Any]
+ XCTAssertTrue(json["value"] is NSNull)
+ }
+
+ // MARK: - Complex Scenarios
+
+ func testMultipleOperationsInSequence() throws {
+ let state = Data("{\"a\":1,\"b\":2}".utf8)
+ let patch = Data("""
+ [
+ {"op":"add","path":"/c","value":3},
+ {"op":"replace","path":"/a","value":10},
+ {"op":"remove","path":"/b"},
+ {"op":"add","path":"/d","value":4}
+ ]
+ """.utf8)
+
+ let result = try applicator.apply(patch: patch, to: state)
+ let json = try JSONSerialization.jsonObject(with: result) as! [String: Any]
+
+ XCTAssertEqual(json["a"] as? Int, 10)
+ XCTAssertNil(json["b"])
+ XCTAssertEqual(json["c"] as? Int, 3)
+ XCTAssertEqual(json["d"] as? Int, 4)
+ }
+
+ func testDeeplyNestedOperations() throws {
+ let state = Data("{\"level1\":{\"level2\":{\"level3\":{\"value\":1}}}}".utf8)
+ let patch = Data("""
+ [{"op":"replace","path":"/level1/level2/level3/value","value":999}]
+ """.utf8)
+
+ let result = try applicator.apply(patch: patch, to: state)
+ let json = try JSONSerialization.jsonObject(with: result) as! [String: Any]
+ let level1 = json["level1"] as! [String: Any]
+ let level2 = level1["level2"] as! [String: Any]
+ let level3 = level2["level3"] as! [String: Any]
+
+ XCTAssertEqual(level3["value"] as? Int, 999)
+ }
+
+ func testMixedArrayAndObjectOperations() throws {
+ let state = Data("{\"users\":[{\"name\":\"alice\"},{\"name\":\"bob\"}]}".utf8)
+ let patch = Data("""
+ [{"op":"replace","path":"/users/0/name","value":"charlie"}]
+ """.utf8)
+
+ let result = try applicator.apply(patch: patch, to: state)
+ let json = try JSONSerialization.jsonObject(with: result) as! [String: Any]
+ let users = json["users"] as! [[String: Any]]
+
+ XCTAssertEqual(users[0]["name"] as? String, "charlie")
+ XCTAssertEqual(users[1]["name"] as? String, "bob")
+ }
+
+ // MARK: - Error Handling Tests
+
+ func testInvalidJSONThrowsError() throws {
+ let state = Data("{invalid json}".utf8)
+ let patch = Data("[{\"op\":\"add\",\"path\":\"/a\",\"value\":1}]".utf8)
+
+ XCTAssertThrowsError(try applicator.apply(patch: patch, to: state))
+ }
+
+ func testInvalidPatchJSONThrowsError() throws {
+ let state = Data("{}".utf8)
+ let patch = Data("{not an array}".utf8)
+
+ XCTAssertThrowsError(try applicator.apply(patch: patch, to: state))
+ }
+
+ func testMissingOpFieldThrowsError() throws {
+ let state = Data("{}".utf8)
+ let patch = Data("[{\"path\":\"/a\",\"value\":1}]".utf8)
+
+ XCTAssertThrowsError(try applicator.apply(patch: patch, to: state))
+ }
+
+ func testMissingPathFieldThrowsError() throws {
+ let state = Data("{}".utf8)
+ let patch = Data("[{\"op\":\"add\",\"value\":1}]".utf8)
+
+ XCTAssertThrowsError(try applicator.apply(patch: patch, to: state))
+ }
+
+ func testUnknownOperationThrowsError() throws {
+ let state = Data("{}".utf8)
+ let patch = Data("[{\"op\":\"invalid\",\"path\":\"/a\",\"value\":1}]".utf8)
+
+ XCTAssertThrowsError(try applicator.apply(patch: patch, to: state))
+ }
+
+ func testInvalidPathThrowsError() throws {
+ let state = Data("{}".utf8)
+ let patch = Data("[{\"op\":\"add\",\"path\":\"invalid\",\"value\":1}]".utf8)
+
+ XCTAssertThrowsError(try applicator.apply(patch: patch, to: state))
+ }
+
+ func testArrayIndexOutOfBoundsThrowsError() throws {
+ let state = Data("{\"items\":[\"a\"]}".utf8)
+ let patch = Data("[{\"op\":\"remove\",\"path\":\"/items/5\"}]".utf8)
+
+ XCTAssertThrowsError(try applicator.apply(patch: patch, to: state))
+ }
+
+ // MARK: - Edge Cases
+
+ func testEmptyPatchReturnsOriginalState() throws {
+ let state = Data("{\"value\":42}".utf8)
+ let patch = Data("[]".utf8)
+
+ let result = try applicator.apply(patch: patch, to: state)
+
+ XCTAssertEqual(result, state)
+ }
+
+ func testAddToRootPath() throws {
+ let state = Data("{}".utf8)
+ let patch = Data("""
+ [{"op":"add","path":"/","value":"myValue"}]
+ """.utf8)
+
+ // Per RFC 6901: "/" refers to the empty-string key "", not the root document.
+ // add with path "/" sets the member keyed by "" in the root object.
+ let result = try applicator.apply(patch: patch, to: state)
+ let json = try JSONSerialization.jsonObject(with: result) as! [String: Any]
+
+ XCTAssertEqual(json[""] as? String, "myValue")
+ }
+
+ // MARK: - RFC 6901: empty-string path segments
+
+ func test_rfc6901_emptySegmentInPath_isPreserved() throws {
+ // /a//b decodes to ["a", "", "b"] per RFC 6901.
+ // Before fix: split(separator:) with default omittingEmptySubsequences: true
+ // collapses the empty segment to ["a", "b"], silently targeting the wrong key.
+ let state = Data("""
+ {"a":{"":{"b":0}}}
+ """.utf8)
+ let patch = Data("""
+ [{"op":"replace","path":"/a//b","value":42}]
+ """.utf8)
+
+ let result = try applicator.apply(patch: patch, to: state)
+ let json = try JSONSerialization.jsonObject(with: result) as! [String: Any]
+ let inner = json["a"] as! [String: Any]
+ let empty = inner[""] as! [String: Any]
+ XCTAssertEqual(empty["b"] as? Int, 42)
+ }
+
+ func testEscapedPathCharacters() throws {
+ let state = Data("{}".utf8)
+ let patch = Data("""
+ [{"op":"add","path":"/a~1b","value":1}]
+ """.utf8)
+
+ let result = try applicator.apply(patch: patch, to: state)
+ let json = try JSONSerialization.jsonObject(with: result) as! [String: Any]
+
+ // ~1 should be unescaped to /
+ XCTAssertEqual(json["a/b"] as? Int, 1)
+ }
+
+ func testTildeEscaping() throws {
+ let state = Data("{}".utf8)
+ let patch = Data("""
+ [{"op":"add","path":"/a~0b","value":1}]
+ """.utf8)
+
+ let result = try applicator.apply(patch: patch, to: state)
+ let json = try JSONSerialization.jsonObject(with: result) as! [String: Any]
+
+ // ~0 should be unescaped to ~
+ XCTAssertEqual(json["a~b"] as? Int, 1)
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUIClientTests/State/StateManagerTests.swift b/sdks/community/swift/Tests/AGUIClientTests/State/StateManagerTests.swift
new file mode 100644
index 0000000000..90375a7d0f
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUIClientTests/State/StateManagerTests.swift
@@ -0,0 +1,417 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUIClient
+@testable import AGUICore
+
+/// Comprehensive tests for StateManager.
+///
+/// Tests state synchronization including:
+/// - Snapshot handling and state replacement
+/// - Delta (JSON Patch) application
+/// - State retrieval and reset
+/// - Error handling for invalid patches
+/// - Thread safety with concurrent operations
+final class StateManagerTests: XCTestCase {
+ // MARK: - Snapshot Tests
+
+ func testHandleSnapshotReplacesState() async throws {
+ let manager = StateManager()
+
+ // Create snapshot with initial state
+ let initialState = """
+ {"users":["alice","bob"],"count":2}
+ """
+ let snapshot = StateSnapshotEvent(
+ snapshot: Data(initialState.utf8)
+ )
+
+ // Apply snapshot
+ await manager.handleSnapshot(snapshot)
+
+ // Verify state was set
+ let state = await manager.getState()
+ let stateJSON = try JSONSerialization.jsonObject(with: state) as! [String: Any]
+
+ XCTAssertEqual(stateJSON["count"] as? Int, 2)
+ let users = stateJSON["users"] as! [String]
+ XCTAssertEqual(users.count, 2)
+ XCTAssertEqual(users[0], "alice")
+ XCTAssertEqual(users[1], "bob")
+ }
+
+ func testHandleSnapshotOverwritesPreviousState() async throws {
+ let manager = StateManager()
+
+ // Set initial state
+ let firstSnapshot = StateSnapshotEvent(
+ snapshot: Data("{\"value\":1}".utf8)
+ )
+ await manager.handleSnapshot(firstSnapshot)
+
+ // Replace with new snapshot
+ let secondSnapshot = StateSnapshotEvent(
+ snapshot: Data("{\"value\":2}".utf8)
+ )
+ await manager.handleSnapshot(secondSnapshot)
+
+ // Verify only second snapshot remains
+ let state = await manager.getState()
+ let stateJSON = try JSONSerialization.jsonObject(with: state) as! [String: Any]
+ XCTAssertEqual(stateJSON["value"] as? Int, 2)
+ }
+
+ func testHandleSnapshotWithEmptyObject() async throws {
+ let manager = StateManager()
+
+ let snapshot = StateSnapshotEvent(snapshot: Data("{}".utf8))
+ await manager.handleSnapshot(snapshot)
+
+ let state = await manager.getState()
+ let stateJSON = try JSONSerialization.jsonObject(with: state) as! [String: Any]
+ XCTAssertTrue(stateJSON.isEmpty)
+ }
+
+ func testHandleSnapshotWithComplexNestedState() async throws {
+ let manager = StateManager()
+
+ let complexState = """
+ {
+ "users": {
+ "alice": {"age": 30, "role": "admin"},
+ "bob": {"age": 25, "role": "user"}
+ },
+ "settings": {
+ "theme": "dark",
+ "notifications": true
+ }
+ }
+ """
+ let snapshot = StateSnapshotEvent(snapshot: Data(complexState.utf8))
+ await manager.handleSnapshot(snapshot)
+
+ let state = await manager.getState()
+ let stateJSON = try JSONSerialization.jsonObject(with: state) as! [String: Any]
+
+ let users = stateJSON["users"] as! [String: [String: Any]]
+ XCTAssertEqual(users["alice"]?["age"] as? Int, 30)
+ XCTAssertEqual(users["bob"]?["role"] as? String, "user")
+ }
+
+ // MARK: - Delta Tests
+
+ func testHandleDeltaAddOperation() async throws {
+ let manager = StateManager()
+
+ // Initialize with empty object
+ await manager.handleSnapshot(StateSnapshotEvent(snapshot: Data("{}".utf8)))
+
+ // Apply add operation
+ let addPatch = """
+ [{"op":"add","path":"/name","value":"alice"}]
+ """
+ let delta = StateDeltaEvent(delta: Data(addPatch.utf8))
+ try await manager.handleDelta(delta)
+
+ // Verify field was added
+ let state = await manager.getState()
+ let stateJSON = try JSONSerialization.jsonObject(with: state) as! [String: Any]
+ XCTAssertEqual(stateJSON["name"] as? String, "alice")
+ }
+
+ func testHandleDeltaReplaceOperation() async throws {
+ let manager = StateManager()
+
+ // Initialize state
+ await manager.handleSnapshot(StateSnapshotEvent(
+ snapshot: Data("{\"count\":5}".utf8)
+ ))
+
+ // Apply replace operation
+ let replacePatch = """
+ [{"op":"replace","path":"/count","value":10}]
+ """
+ let delta = StateDeltaEvent(delta: Data(replacePatch.utf8))
+ try await manager.handleDelta(delta)
+
+ // Verify value was replaced
+ let state = await manager.getState()
+ let stateJSON = try JSONSerialization.jsonObject(with: state) as! [String: Any]
+ XCTAssertEqual(stateJSON["count"] as? Int, 10)
+ }
+
+ func testHandleDeltaRemoveOperation() async throws {
+ let manager = StateManager()
+
+ // Initialize state
+ await manager.handleSnapshot(StateSnapshotEvent(
+ snapshot: Data("{\"name\":\"alice\",\"age\":30}".utf8)
+ ))
+
+ // Apply remove operation
+ let removePatch = """
+ [{"op":"remove","path":"/age"}]
+ """
+ let delta = StateDeltaEvent(delta: Data(removePatch.utf8))
+ try await manager.handleDelta(delta)
+
+ // Verify field was removed
+ let state = await manager.getState()
+ let stateJSON = try JSONSerialization.jsonObject(with: state) as! [String: Any]
+ XCTAssertEqual(stateJSON["name"] as? String, "alice")
+ XCTAssertNil(stateJSON["age"])
+ }
+
+ func testHandleDeltaMultipleOperations() async throws {
+ let manager = StateManager()
+
+ // Initialize state
+ await manager.handleSnapshot(StateSnapshotEvent(
+ snapshot: Data("{\"a\":1,\"b\":2}".utf8)
+ ))
+
+ // Apply multiple operations
+ let multiPatch = """
+ [
+ {"op":"replace","path":"/a","value":10},
+ {"op":"add","path":"/c","value":3},
+ {"op":"remove","path":"/b"}
+ ]
+ """
+ let delta = StateDeltaEvent(delta: Data(multiPatch.utf8))
+ try await manager.handleDelta(delta)
+
+ // Verify all operations were applied
+ let state = await manager.getState()
+ let stateJSON = try JSONSerialization.jsonObject(with: state) as! [String: Any]
+ XCTAssertEqual(stateJSON["a"] as? Int, 10)
+ XCTAssertEqual(stateJSON["c"] as? Int, 3)
+ XCTAssertNil(stateJSON["b"])
+ }
+
+ func testHandleDeltaNestedPath() async throws {
+ let manager = StateManager()
+
+ // Initialize with nested structure
+ await manager.handleSnapshot(StateSnapshotEvent(
+ snapshot: Data("{\"user\":{\"name\":\"alice\"}}".utf8)
+ ))
+
+ // Update nested field
+ let nestedPatch = """
+ [{"op":"replace","path":"/user/name","value":"bob"}]
+ """
+ let delta = StateDeltaEvent(delta: Data(nestedPatch.utf8))
+ try await manager.handleDelta(delta)
+
+ // Verify nested field was updated
+ let state = await manager.getState()
+ let stateJSON = try JSONSerialization.jsonObject(with: state) as! [String: Any]
+ let user = stateJSON["user"] as! [String: Any]
+ XCTAssertEqual(user["name"] as? String, "bob")
+ }
+
+ func testHandleDeltaArrayOperations() async throws {
+ let manager = StateManager()
+
+ // Initialize with array
+ await manager.handleSnapshot(StateSnapshotEvent(
+ snapshot: Data("{\"items\":[\"a\",\"b\"]}".utf8)
+ ))
+
+ // Add to array
+ let arrayPatch = """
+ [{"op":"add","path":"/items/-","value":"c"}]
+ """
+ let delta = StateDeltaEvent(delta: Data(arrayPatch.utf8))
+ try await manager.handleDelta(delta)
+
+ // Verify array was updated
+ let state = await manager.getState()
+ let stateJSON = try JSONSerialization.jsonObject(with: state) as! [String: Any]
+ let items = stateJSON["items"] as! [String]
+ XCTAssertEqual(items.count, 3)
+ XCTAssertEqual(items[2], "c")
+ }
+
+ func testHandleDeltaInvalidPatchThrowsError() async throws {
+ let manager = StateManager()
+
+ await manager.handleSnapshot(StateSnapshotEvent(snapshot: Data("{}".utf8)))
+
+ // Invalid patch - remove non-existent path
+ let invalidPatch = """
+ [{"op":"remove","path":"/nonexistent"}]
+ """
+ let delta = StateDeltaEvent(delta: Data(invalidPatch.utf8))
+
+ do {
+ try await manager.handleDelta(delta)
+ XCTFail("Expected error for invalid patch")
+ } catch {
+ // Expected error
+ }
+ }
+
+ func testHandleDeltaInvalidJSONThrowsError() async throws {
+ let manager = StateManager()
+
+ await manager.handleSnapshot(StateSnapshotEvent(snapshot: Data("{}".utf8)))
+
+ // Invalid JSON
+ let delta = StateDeltaEvent(delta: Data("{invalid json}".utf8))
+
+ do {
+ try await manager.handleDelta(delta)
+ XCTFail("Expected error for invalid JSON")
+ } catch {
+ // Expected error
+ }
+ }
+
+ // MARK: - State Retrieval Tests
+
+ func testGetStateReturnsCurrentState() async throws {
+ let manager = StateManager()
+
+ let stateData = Data("{\"test\":\"value\"}".utf8)
+ await manager.handleSnapshot(StateSnapshotEvent(snapshot: stateData))
+
+ let retrieved = await manager.getState()
+ let retrievedJSON = try JSONSerialization.jsonObject(with: retrieved) as! [String: Any]
+ XCTAssertEqual(retrievedJSON["test"] as? String, "value")
+ }
+
+ func testGetStateBeforeAnySnapshotReturnsEmptyObject() async throws {
+ let manager = StateManager()
+
+ let state = await manager.getState()
+ let stateJSON = try JSONSerialization.jsonObject(with: state) as! [String: Any]
+ XCTAssertTrue(stateJSON.isEmpty)
+ }
+
+ // MARK: - Reset Tests
+
+ func testResetClearsState() async throws {
+ let manager = StateManager()
+
+ // Set state
+ await manager.handleSnapshot(StateSnapshotEvent(
+ snapshot: Data("{\"data\":\"value\"}".utf8)
+ ))
+
+ // Reset
+ await manager.reset()
+
+ // Verify state is empty
+ let state = await manager.getState()
+ let stateJSON = try JSONSerialization.jsonObject(with: state) as! [String: Any]
+ XCTAssertTrue(stateJSON.isEmpty)
+ }
+
+ func testResetAllowsNewStateAfter() async throws {
+ let manager = StateManager()
+
+ // Set and reset
+ await manager.handleSnapshot(StateSnapshotEvent(
+ snapshot: Data("{\"old\":1}".utf8)
+ ))
+ await manager.reset()
+
+ // Set new state
+ await manager.handleSnapshot(StateSnapshotEvent(
+ snapshot: Data("{\"new\":2}".utf8)
+ ))
+
+ // Verify new state
+ let state = await manager.getState()
+ let stateJSON = try JSONSerialization.jsonObject(with: state) as! [String: Any]
+ XCTAssertEqual(stateJSON["new"] as? Int, 2)
+ XCTAssertNil(stateJSON["old"])
+ }
+
+ // MARK: - Thread Safety Tests
+
+ func testConcurrentSnapshotHandling() async throws {
+ let manager = StateManager()
+
+ // Apply snapshots concurrently
+ await withTaskGroup(of: Void.self) { group in
+ for i in 0..<10 {
+ group.addTask {
+ let snapshot = StateSnapshotEvent(
+ snapshot: Data("{\"value\":\(i)}".utf8)
+ )
+ await manager.handleSnapshot(snapshot)
+ }
+ }
+ }
+
+ // Verify state is valid (one of the values)
+ let state = await manager.getState()
+ let stateJSON = try JSONSerialization.jsonObject(with: state) as! [String: Any]
+ let value = stateJSON["value"] as! Int
+ XCTAssertTrue((0..<10).contains(value))
+ }
+
+ func testConcurrentDeltaOperations() async throws {
+ let manager = StateManager()
+
+ // Initialize with counter
+ await manager.handleSnapshot(StateSnapshotEvent(
+ snapshot: Data("{\"counters\":{}}".utf8)
+ ))
+
+ // Apply deltas concurrently
+ await withTaskGroup(of: Void.self) { group in
+ for i in 0..<5 {
+ group.addTask {
+ let patch = """
+ [{"op":"add","path":"/counters/c\(i)","value":\(i)}]
+ """
+ let delta = StateDeltaEvent(delta: Data(patch.utf8))
+ try? await manager.handleDelta(delta)
+ }
+ }
+ }
+
+ // Verify all deltas were applied (actor serializes them)
+ let state = await manager.getState()
+ let stateJSON = try JSONSerialization.jsonObject(with: state) as! [String: Any]
+ let counters = stateJSON["counters"] as! [String: Any]
+ XCTAssertGreaterThan(counters.count, 0)
+ }
+
+ // MARK: - Integration Tests
+
+ func testSnapshotThenDeltaSequence() async throws {
+ let manager = StateManager()
+
+ // Start with snapshot
+ await manager.handleSnapshot(StateSnapshotEvent(
+ snapshot: Data("{\"users\":[],\"count\":0}".utf8)
+ ))
+
+ // Apply series of deltas
+ let deltas = [
+ "[{\"op\":\"add\",\"path\":\"/users/-\",\"value\":\"alice\"}]",
+ "[{\"op\":\"replace\",\"path\":\"/count\",\"value\":1}]",
+ "[{\"op\":\"add\",\"path\":\"/users/-\",\"value\":\"bob\"}]",
+ "[{\"op\":\"replace\",\"path\":\"/count\",\"value\":2}]"
+ ]
+
+ for patchJSON in deltas {
+ let delta = StateDeltaEvent(delta: Data(patchJSON.utf8))
+ try await manager.handleDelta(delta)
+ }
+
+ // Verify final state
+ let state = await manager.getState()
+ let stateJSON = try JSONSerialization.jsonObject(with: state) as! [String: Any]
+ XCTAssertEqual(stateJSON["count"] as? Int, 2)
+ let users = stateJSON["users"] as! [String]
+ XCTAssertEqual(users.count, 2)
+ XCTAssertEqual(users[0], "alice")
+ XCTAssertEqual(users[1], "bob")
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUIClientTests/Streaming/BufferingTests.swift b/sdks/community/swift/Tests/AGUIClientTests/Streaming/BufferingTests.swift
new file mode 100644
index 0000000000..f541c1ae50
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUIClientTests/Streaming/BufferingTests.swift
@@ -0,0 +1,544 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUIClient
+@testable import AGUICore
+
+/// Comprehensive tests for backpressure and buffering operators.
+///
+/// Tests flow control mechanisms for handling varying producer/consumer speeds:
+/// - Bounded buffering with overflow strategies
+/// - Event batching by count or time
+/// - Throttling and sampling
+/// - Memory safety guarantees
+final class BufferingTests: XCTestCase {
+ // MARK: - Buffering Strategy Tests
+
+ func testBufferedDropOldest() async throws {
+ // Create fast producer (10 events)
+ let events = (0..<10).map { "event-\($0)" }
+ let source = AsyncStream { continuation in
+ Task {
+ for event in events {
+ continuation.yield(event)
+ try? await Task.sleep(nanoseconds: 1_000_000) // 1ms
+ }
+ continuation.finish()
+ }
+ }
+
+ // Buffer with limit 3, drop oldest
+ let buffered = source.buffered(limit: 3, strategy: .dropOldest)
+
+ // Slow consumer (50ms per event)
+ var received: [String] = []
+ for try await event in buffered {
+ received.append(event)
+ try await Task.sleep(nanoseconds: 50_000_000) // 50ms - intentionally slow
+ }
+
+ // Should have dropped some oldest events due to buffer limit
+ XCTAssertLessThan(received.count, events.count)
+ // Should have most recent events
+ XCTAssertTrue(received.contains("event-9"))
+ }
+
+ func testBufferedDropNewest() async throws {
+ // Create fast producer
+ let events = (0..<10).map { "event-\($0)" }
+ let source = AsyncStream { continuation in
+ Task {
+ for event in events {
+ continuation.yield(event)
+ try? await Task.sleep(nanoseconds: 1_000_000)
+ }
+ continuation.finish()
+ }
+ }
+
+ // Buffer with limit 3, drop newest
+ let buffered = source.buffered(limit: 3, strategy: .dropNewest)
+
+ var received: [String] = []
+ for try await event in buffered {
+ received.append(event)
+ try await Task.sleep(nanoseconds: 50_000_000)
+ }
+
+ // Should have dropped some newest events
+ XCTAssertLessThan(received.count, events.count)
+ // Should have oldest events
+ XCTAssertTrue(received.contains("event-0"))
+ }
+
+ func testBufferedWithinLimit() async throws {
+ // Producer and consumer at similar speeds
+ let events = (0..<5).map { "event-\($0)" }
+ let source = AsyncStream { continuation in
+ Task {
+ for event in events {
+ continuation.yield(event)
+ }
+ continuation.finish()
+ }
+ }
+
+ let buffered = source.buffered(limit: 10, strategy: .dropOldest)
+
+ var received: [String] = []
+ for try await event in buffered {
+ received.append(event)
+ }
+
+ // Buffer never fills, should receive all
+ XCTAssertEqual(received.count, events.count)
+ XCTAssertEqual(received, events)
+ }
+
+ // MARK: - Batching Tests
+
+ func testBatchedByCount() async throws {
+ // Create stream of individual events
+ let events = (0..<10).map { "event-\($0)" }
+ let source = AsyncStream { continuation in
+ Task {
+ for event in events {
+ continuation.yield(event)
+ try? await Task.sleep(nanoseconds: 5_000_000) // 5ms
+ }
+ continuation.finish()
+ }
+ }
+
+ // Batch into groups of 3
+ let batched = source.batched(count: 3)
+
+ var batches: [[String]] = []
+ for try await batch in batched {
+ batches.append(batch)
+ }
+
+ // Should have 4 batches: [3, 3, 3, 1]
+ XCTAssertEqual(batches.count, 4)
+ XCTAssertEqual(batches[0].count, 3)
+ XCTAssertEqual(batches[1].count, 3)
+ XCTAssertEqual(batches[2].count, 3)
+ XCTAssertEqual(batches[3].count, 1) // Remainder
+ }
+
+ func testBatchedByTime() async throws {
+ let source = AsyncStream { continuation in
+ Task {
+ // Send events over 150ms
+ for i in 0..<6 {
+ continuation.yield("event-\(i)")
+ try? await Task.sleep(nanoseconds: 25_000_000) // 25ms each
+ }
+ continuation.finish()
+ }
+ }
+
+ // Batch every 50ms
+ let batched = source.batched(timeWindow: 0.05)
+
+ var batches: [[String]] = []
+ for try await batch in batched {
+ batches.append(batch)
+ }
+
+ // Should have ~3 batches (50ms windows over 150ms)
+ // Note: Timing can vary in CI, so we allow a wider range
+ XCTAssertGreaterThanOrEqual(batches.count, 2)
+ XCTAssertLessThanOrEqual(batches.count, 6)
+
+ // Each batch should have events
+ for batch in batches {
+ XCTAssertGreaterThan(batch.count, 0)
+ }
+ }
+
+ // MARK: - Throttling Tests
+
+ func testThrottled() async throws {
+ let source = AsyncStream { continuation in
+ Task {
+ // Emit 20 events rapidly
+ for i in 0..<20 {
+ continuation.yield("event-\(i)")
+ try? await Task.sleep(nanoseconds: 10_000_000) // 10ms
+ }
+ continuation.finish()
+ }
+ }
+
+ // Throttle to max 1 per 50ms
+ let throttled = source.throttled(interval: 0.05)
+
+ var received: [String] = []
+ let startTime = Date()
+
+ for try await event in throttled {
+ received.append(event)
+ }
+
+ let duration = Date().timeIntervalSince(startTime)
+
+ // Should receive fewer events than produced
+ XCTAssertLessThan(received.count, 20)
+
+ // Should take at least (receivedCount * interval) seconds
+ let expectedMinDuration = Double(received.count - 1) * 0.05
+ XCTAssertGreaterThan(duration, expectedMinDuration * 0.8) // 80% tolerance
+ }
+
+ func testSampled() async throws {
+ let source = AsyncStream { continuation in
+ Task {
+ // Emit 100 events
+ for i in 0..<100 {
+ continuation.yield(i)
+ try? await Task.sleep(nanoseconds: 1_000_000) // 1ms
+ }
+ continuation.finish()
+ }
+ }
+
+ // Sample every 10th event
+ let sampled = source.sampled(every: 10)
+
+ var received: [Int] = []
+ for try await event in sampled {
+ received.append(event)
+ }
+
+ // Should have ~10 events (every 10th)
+ XCTAssertGreaterThanOrEqual(received.count, 8)
+ XCTAssertLessThanOrEqual(received.count, 12)
+ }
+
+ // MARK: - Memory Safety Tests
+
+ func testBufferedBoundedMemory() async throws {
+ // Create large event stream
+ let source = AsyncStream { continuation in
+ Task {
+ // 1000 events of 1KB each = 1MB total
+ for _ in 0..<1000 {
+ continuation.yield(Data(repeating: 0, count: 1024))
+ try? await Task.sleep(nanoseconds: 100_000) // 0.1ms - fast
+ }
+ continuation.finish()
+ }
+ }
+
+ // Buffer only 10 events max = 10KB max memory
+ let buffered = source.buffered(limit: 10, strategy: .dropOldest)
+
+ var processedCount = 0
+ for try await _ in buffered {
+ // Slow consumer
+ try await Task.sleep(nanoseconds: 10_000_000) // 10ms
+ processedCount += 1
+ }
+
+ // Should have processed fewer than total due to buffer dropping
+ XCTAssertLessThan(processedCount, 1000)
+ // But should have processed something
+ XCTAssertGreaterThan(processedCount, 0)
+ }
+
+ // MARK: - EventStream Integration Tests
+
+ func testEventStreamBuffered() async throws {
+ let sseData = """
+ data: {"type":"RUN_STARTED","threadId":"t1","runId":"r1"}
+
+ data: {"type":"TEXT_MESSAGE_START","messageId":"msg1","role":"assistant"}
+
+ data: {"type":"TEXT_MESSAGE_CHUNK","messageId":"msg1","delta":"A"}
+
+ data: {"type":"TEXT_MESSAGE_CHUNK","messageId":"msg1","delta":"B"}
+
+ data: {"type":"TEXT_MESSAGE_CHUNK","messageId":"msg1","delta":"C"}
+
+ data: {"type":"TEXT_MESSAGE_END","messageId":"msg1"}
+
+ data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1"}
+
+
+ """
+ let bytes = MockAsyncBytes(data: Data(sseData.utf8))
+ let decoder = AGUIEventDecoder()
+
+ let stream = EventStream(bytes: bytes, decoder: decoder)
+ let buffered = stream.buffered(limit: 3, strategy: .dropOldest)
+
+ var events: [any AGUIEvent] = []
+ for try await event in buffered {
+ events.append(event)
+ }
+
+ // Should receive events (may drop some if buffer fills)
+ XCTAssertGreaterThan(events.count, 0)
+ }
+
+ func testEventStreamBatched() async throws {
+ let sseData = String(repeating: "data: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"messageId\":\"msg1\",\"delta\":\"x\"}\n\n", count: 10)
+ let bytes = MockAsyncBytes(data: Data(sseData.utf8))
+ let decoder = AGUIEventDecoder()
+
+ let stream = EventStream(bytes: bytes, decoder: decoder)
+ let batched = stream.batched(count: 3)
+
+ var batches: [[any AGUIEvent]] = []
+ for try await batch in batched {
+ batches.append(batch)
+ }
+
+ // Should have multiple batches
+ XCTAssertGreaterThan(batches.count, 1)
+
+ // Most batches should have 3 events
+ let fullBatches = batches.filter { $0.count == 3 }
+ XCTAssertGreaterThan(fullBatches.count, 0)
+ }
+
+ // MARK: - Composition Tests
+
+ func testBufferedThenBatched() async throws {
+ let events = Array(0..<20)
+ let source = AsyncStream { continuation in
+ Task {
+ for event in events {
+ continuation.yield(event)
+ }
+ continuation.finish()
+ }
+ }
+
+ // Compose: buffer then batch
+ let composed = source
+ .buffered(limit: 10, strategy: .dropOldest)
+ .batched(count: 3)
+
+ var batches: [[Int]] = []
+ for try await batch in composed {
+ batches.append(batch)
+ }
+
+ XCTAssertGreaterThan(batches.count, 0)
+ }
+
+ func testBatchedThenThrottled() async throws {
+ let source = AsyncStream { continuation in
+ Task {
+ for i in 0..<30 {
+ continuation.yield("event-\(i)")
+ try? await Task.sleep(nanoseconds: 5_000_000)
+ }
+ continuation.finish()
+ }
+ }
+
+ // Batch into groups of 5, then throttle batches
+ let composed = source
+ .batched(count: 5)
+ .throttled(interval: 0.05)
+
+ var receivedBatches = 0
+ for try await _ in composed {
+ receivedBatches += 1
+ }
+
+ // Should have fewer than or equal to 6 batches (30 events / 5 per batch)
+ // Throttling may or may not drop batches depending on CI timing
+ XCTAssertLessThanOrEqual(receivedBatches, 6)
+ XCTAssertGreaterThan(receivedBatches, 0)
+ }
+
+ // MARK: - Time-Window Correctness Tests
+
+ func testTimeBatchedWindowFiresEvenWhenUpstreamIsIdle() async throws {
+ // Upstream yields one element then pauses 500 ms before the second.
+ // A 50 ms window must return the first batch after ~50 ms — NOT block for 500 ms.
+ let source = AsyncThrowingStream { continuation in
+ Task {
+ continuation.yield("a")
+ try await Task.sleep(for: .milliseconds(500))
+ continuation.yield("b")
+ continuation.finish()
+ }
+ }
+
+ let start = ContinuousClock.now
+ var batches: [[String]] = []
+ for try await batch in source.batched(timeWindow: 0.05) {
+ batches.append(batch)
+ // After the first batch arrives, check elapsed wall time
+ if batches.count == 1 {
+ let elapsed = ContinuousClock.now - start
+ // Must have returned within 200 ms (50 ms window + generous tolerance)
+ XCTAssertLessThan(
+ elapsed,
+ .milliseconds(200),
+ "First batch must not block for the full 500 ms upstream pause"
+ )
+ }
+ }
+
+ XCTAssertGreaterThanOrEqual(batches.count, 2)
+ XCTAssertEqual(batches.flatMap { $0 }, ["a", "b"])
+ }
+
+ func testTimeBatchedGroupsFastElementsIntoBatch() async throws {
+ // Upstream yields 6 elements at 10 ms intervals — well within a 50 ms window.
+ // At least some elements should land in the same batch.
+ let source = AsyncStream { continuation in
+ Task {
+ for i in 0 ..< 6 {
+ continuation.yield(i)
+ try? await Task.sleep(for: .milliseconds(10))
+ }
+ continuation.finish()
+ }
+ }
+
+ var batches: [[Int]] = []
+ for try await batch in source.batched(timeWindow: 0.05) {
+ batches.append(batch)
+ }
+
+ // At 10 ms per element and a 50 ms window, at least 2 elements should batch together
+ let hasMultiElementBatch = batches.contains { $0.count > 1 }
+ XCTAssertTrue(hasMultiElementBatch, "Fast elements must be grouped within the window")
+ XCTAssertEqual(batches.flatMap { $0 }, [0, 1, 2, 3, 4, 5])
+ }
+
+ // MARK: - Streaming correctness (verifies elements arrive during iteration, not after)
+
+ func test_buffered_yieldsElementsDuringIteration() async throws {
+ // The core bug was: buffered() accumulated all elements and only yielded
+ // after upstream finished. For an SSE stream that never closes, this means
+ // no events ever arrive. This test verifies elements flow through immediately.
+ let firstArrived = expectation(description: "first element arrives before upstream finishes")
+ firstArrived.assertForOverFulfill = false
+
+ let source = AsyncThrowingStream { continuation in
+ Task {
+ continuation.yield(1)
+ // Pause before yielding second element — consumer must receive 1 before this
+ try await Task.sleep(for: .milliseconds(200))
+ continuation.yield(2)
+ continuation.yield(3)
+ continuation.finish()
+ }
+ }
+
+ let buffered = source.buffered(limit: 10, strategy: .dropOldest)
+ var received: [Int] = []
+
+ for try await element in buffered {
+ received.append(element)
+ if element == 1 {
+ firstArrived.fulfill()
+ }
+ }
+
+ await fulfillment(of: [firstArrived], timeout: 1.0)
+ XCTAssertEqual(received, [1, 2, 3])
+ }
+
+ func test_buffered_propagatesUpstreamError() async throws {
+ // Elements received before the error must not be discarded.
+ struct UpstreamFailure: Error {}
+
+ let source = AsyncThrowingStream { continuation in
+ Task {
+ continuation.yield(1)
+ continuation.yield(2)
+ continuation.finish(throwing: UpstreamFailure())
+ }
+ }
+
+ let buffered = source.buffered(limit: 10, strategy: .dropOldest)
+ var received: [Int] = []
+ var caughtError: Error?
+
+ do {
+ for try await element in buffered {
+ received.append(element)
+ }
+ } catch {
+ caughtError = error
+ }
+
+ XCTAssertEqual(received, [1, 2], "Elements before error must be delivered")
+ XCTAssertTrue(caughtError is UpstreamFailure)
+ }
+
+ func test_buffered_cancellation_stopsProduction() async throws {
+ // When the consumer breaks, the inner Task must be cancelled.
+ // We verify by counting how many elements the upstream actually produced.
+ let produced = ActorCounter()
+
+ let source = AsyncThrowingStream { continuation in
+ Task {
+ for i in 1...1000 {
+ await produced.increment()
+ continuation.yield(i)
+ try await Task.sleep(for: .milliseconds(5))
+ }
+ continuation.finish()
+ }
+ }
+
+ let buffered = source.buffered(limit: 5, strategy: .dropOldest)
+ for try await _ in buffered {
+ break // cancel immediately after first element
+ }
+
+ // Give inner Task a moment to honour cancellation.
+ try await Task.sleep(for: .milliseconds(50))
+ let count = await produced.value
+ // The inner Task must have been cancelled; far fewer than 1000 elements produced.
+ XCTAssertLessThan(count, 50, "Cancellation must stop the inner Task promptly")
+ }
+
+ // MARK: - Cancellation Tests
+
+ func testBufferedCancellation() async throws {
+ let source = AsyncStream { continuation in
+ Task {
+ for i in 0..<1000 {
+ continuation.yield(i)
+ try? await Task.sleep(nanoseconds: 1_000_000)
+ }
+ continuation.finish()
+ }
+ }
+
+ let buffered = source.buffered(limit: 10, strategy: .dropOldest)
+
+ let task = Task {
+ var count = 0
+ for try await _ in buffered {
+ count += 1
+ if count >= 5 {
+ break
+ }
+ }
+ return count
+ }
+
+ let count = try await task.value
+ XCTAssertEqual(count, 5)
+ }
+}
+
+// MARK: - Helpers
+
+/// Thread-safe counter for verifying production counts across task boundaries.
+actor ActorCounter {
+ private(set) var value = 0
+ func increment() { value += 1 }
+}
diff --git a/sdks/community/swift/Tests/AGUIClientTests/Streaming/ChunkTransformTests.swift b/sdks/community/swift/Tests/AGUIClientTests/Streaming/ChunkTransformTests.swift
new file mode 100644
index 0000000000..0071d70b9d
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUIClientTests/Streaming/ChunkTransformTests.swift
@@ -0,0 +1,351 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUICore
+@testable import AGUIClient
+import XCTest
+
+final class ChunkTransformTests: XCTestCase {
+
+ // MARK: - Text Message Chunk Tests
+
+ func testTransformSingleTextChunk() async throws {
+ // Given: A stream with a single text chunk
+ let events: [any AGUIEvent] = [
+ TextMessageChunkEvent(
+ messageId: "msg1",
+ role: "assistant",
+ delta: "Hello",
+ timestamp: 1000
+ ),
+ ]
+
+ // When: Transforming chunks
+ let transformed = try await collectEvents(events.asyncStream.transformChunks())
+
+ // Then: Should emit start, content, and end
+ XCTAssertEqual(transformed.count, 3)
+
+ guard let start = transformed[0] as? TextMessageStartEvent else {
+ XCTFail("Expected TextMessageStartEvent")
+ return
+ }
+ XCTAssertEqual(start.messageId, "msg1")
+ XCTAssertEqual(start.role, "assistant")
+ XCTAssertEqual(start.timestamp, 1000)
+
+ guard let content = transformed[1] as? TextMessageContentEvent else {
+ XCTFail("Expected TextMessageContentEvent")
+ return
+ }
+ XCTAssertEqual(content.messageId, "msg1")
+ XCTAssertEqual(content.delta, "Hello")
+
+ guard let end = transformed[2] as? TextMessageEndEvent else {
+ XCTFail("Expected TextMessageEndEvent")
+ return
+ }
+ XCTAssertEqual(end.messageId, "msg1")
+ }
+
+ func testTransformMultipleTextChunks() async throws {
+ // Given: A stream with multiple text chunks for same message
+ let events: [any AGUIEvent] = [
+ TextMessageChunkEvent(messageId: "msg1", role: "assistant", delta: "Hello", timestamp: 1000),
+ TextMessageChunkEvent(delta: " world", timestamp: 1001),
+ TextMessageChunkEvent(delta: "!", timestamp: 1002),
+ ]
+
+ // When: Transforming chunks
+ let transformed = try await collectEvents(events.asyncStream.transformChunks())
+
+ // Then: Should emit start, multiple contents, and end
+ XCTAssertEqual(transformed.count, 5) // start + 3 contents + end
+
+ XCTAssertTrue(transformed[0] is TextMessageStartEvent)
+ XCTAssertTrue(transformed[1] is TextMessageContentEvent)
+ XCTAssertTrue(transformed[2] is TextMessageContentEvent)
+ XCTAssertTrue(transformed[3] is TextMessageContentEvent)
+ XCTAssertTrue(transformed[4] is TextMessageEndEvent)
+
+ guard let content1 = transformed[1] as? TextMessageContentEvent,
+ let content2 = transformed[2] as? TextMessageContentEvent,
+ let content3 = transformed[3] as? TextMessageContentEvent else {
+ XCTFail("Expected TextMessageContentEvent")
+ return
+ }
+
+ XCTAssertEqual(content1.delta, "Hello")
+ XCTAssertEqual(content2.delta, " world")
+ XCTAssertEqual(content3.delta, "!")
+ }
+
+ func testTransformTextChunkWithEmptyDelta() async throws {
+ // Given: Text chunk with empty delta
+ let events: [any AGUIEvent] = [
+ TextMessageChunkEvent(messageId: "msg1", role: "assistant", delta: "", timestamp: 1000),
+ ]
+
+ // When: Transforming chunks
+ let transformed = try await collectEvents(events.asyncStream.transformChunks())
+
+ // Then: Should only emit start and end (no content for empty delta)
+ XCTAssertEqual(transformed.count, 2)
+ XCTAssertTrue(transformed[0] is TextMessageStartEvent)
+ XCTAssertTrue(transformed[1] is TextMessageEndEvent)
+ }
+
+ func testTransformTextChunkWithNilDelta() async throws {
+ // Given: Text chunk with nil delta
+ let events: [any AGUIEvent] = [
+ TextMessageChunkEvent(messageId: "msg1", role: "assistant", delta: nil, timestamp: 1000),
+ ]
+
+ // When: Transforming chunks
+ let transformed = try await collectEvents(events.asyncStream.transformChunks())
+
+ // Then: Should only emit start and end
+ XCTAssertEqual(transformed.count, 2)
+ XCTAssertTrue(transformed[0] is TextMessageStartEvent)
+ XCTAssertTrue(transformed[1] is TextMessageEndEvent)
+ }
+
+ // MARK: - Tool Call Chunk Tests
+
+ func testTransformSingleToolCallChunk() async throws {
+ // Given: A stream with a single tool call chunk
+ let events: [any AGUIEvent] = [
+ ToolCallChunkEvent(
+ toolCallId: "tool1",
+ toolCallName: "calculator",
+ delta: "{\"x\":5}",
+ timestamp: 2000
+ ),
+ ]
+
+ // When: Transforming chunks
+ let transformed = try await collectEvents(events.asyncStream.transformChunks())
+
+ // Then: Should emit start, args, and end
+ XCTAssertEqual(transformed.count, 3)
+
+ guard let start = transformed[0] as? ToolCallStartEvent else {
+ XCTFail("Expected ToolCallStartEvent")
+ return
+ }
+ XCTAssertEqual(start.toolCallId, "tool1")
+ XCTAssertEqual(start.toolCallName, "calculator")
+ XCTAssertEqual(start.timestamp, 2000)
+
+ guard let args = transformed[1] as? ToolCallArgsEvent else {
+ XCTFail("Expected ToolCallArgsEvent")
+ return
+ }
+ XCTAssertEqual(args.toolCallId, "tool1")
+ XCTAssertEqual(args.delta, "{\"x\":5}")
+
+ guard let end = transformed[2] as? ToolCallEndEvent else {
+ XCTFail("Expected ToolCallEndEvent")
+ return
+ }
+ XCTAssertEqual(end.toolCallId, "tool1")
+ }
+
+ func testTransformMultipleToolCallChunks() async throws {
+ // Given: Multiple tool call chunks for same tool
+ let events: [any AGUIEvent] = [
+ ToolCallChunkEvent(toolCallId: "tool1", toolCallName: "calculator", delta: "{", timestamp: 2000),
+ ToolCallChunkEvent(delta: "\"x\"", timestamp: 2001),
+ ToolCallChunkEvent(delta: ":5}", timestamp: 2002),
+ ]
+
+ // When: Transforming chunks
+ let transformed = try await collectEvents(events.asyncStream.transformChunks())
+
+ // Then: Should emit start, multiple args, and end
+ XCTAssertEqual(transformed.count, 5)
+
+ XCTAssertTrue(transformed[0] is ToolCallStartEvent)
+ XCTAssertTrue(transformed[1] is ToolCallArgsEvent)
+ XCTAssertTrue(transformed[2] is ToolCallArgsEvent)
+ XCTAssertTrue(transformed[3] is ToolCallArgsEvent)
+ XCTAssertTrue(transformed[4] is ToolCallEndEvent)
+ }
+
+ // MARK: - Mixed Chunk Tests
+
+ func testTransformInterleavedTextAndToolChunks() async throws {
+ // Given: Interleaved text and tool chunks
+ let events: [any AGUIEvent] = [
+ TextMessageChunkEvent(messageId: "msg1", role: "assistant", delta: "Let me calculate", timestamp: 1000),
+ ToolCallChunkEvent(toolCallId: "tool1", toolCallName: "calculator", delta: "{\"x\":5}", timestamp: 2000),
+ TextMessageChunkEvent(messageId: "msg2", role: "assistant", delta: "The result is", timestamp: 3000),
+ ]
+
+ // When: Transforming chunks
+ let transformed = try await collectEvents(events.asyncStream.transformChunks())
+
+ // Then: Should properly close previous sequences
+ XCTAssertTrue(transformed[0] is TextMessageStartEvent) // msg1 start
+ XCTAssertTrue(transformed[1] is TextMessageContentEvent) // msg1 content
+ XCTAssertTrue(transformed[2] is TextMessageEndEvent) // msg1 end (closed by tool)
+ XCTAssertTrue(transformed[3] is ToolCallStartEvent) // tool1 start
+ XCTAssertTrue(transformed[4] is ToolCallArgsEvent) // tool1 args
+ XCTAssertTrue(transformed[5] is ToolCallEndEvent) // tool1 end (closed by msg2)
+ XCTAssertTrue(transformed[6] is TextMessageStartEvent) // msg2 start
+ XCTAssertTrue(transformed[7] is TextMessageContentEvent) // msg2 content
+ XCTAssertTrue(transformed[8] is TextMessageEndEvent) // msg2 end (stream end)
+ }
+
+ // MARK: - Passthrough Tests
+
+ func testPassthroughExistingStartContentEnd() async throws {
+ // Given: Stream with existing start/content/end events
+ let events: [any AGUIEvent] = [
+ TextMessageStartEvent(messageId: "msg1", role: "assistant", timestamp: 1000),
+ TextMessageContentEvent(messageId: "msg1", delta: "Hello", timestamp: 1001),
+ TextMessageEndEvent(messageId: "msg1", timestamp: 1002),
+ ]
+
+ // When: Transforming (should pass through)
+ let transformed = try await collectEvents(events.asyncStream.transformChunks())
+
+ // Then: Events should pass through unchanged
+ XCTAssertEqual(transformed.count, 3)
+ XCTAssertTrue(transformed[0] is TextMessageStartEvent)
+ XCTAssertTrue(transformed[1] is TextMessageContentEvent)
+ XCTAssertTrue(transformed[2] is TextMessageEndEvent)
+ }
+
+ func testPassthroughMixedChunksAndExistingEvents() async throws {
+ // Given: Mix of chunks and existing events
+ let events: [any AGUIEvent] = [
+ TextMessageChunkEvent(messageId: "msg1", role: "assistant", delta: "Chunk", timestamp: 1000),
+ TextMessageStartEvent(messageId: "msg2", role: "assistant", timestamp: 2000),
+ TextMessageContentEvent(messageId: "msg2", delta: "Direct", timestamp: 2001),
+ TextMessageEndEvent(messageId: "msg2", timestamp: 2002),
+ ]
+
+ // When: Transforming
+ let transformed = try await collectEvents(events.asyncStream.transformChunks())
+
+ // Then: Chunk should be transformed, existing events pass through
+ XCTAssertTrue(transformed[0] is TextMessageStartEvent) // from chunk
+ XCTAssertTrue(transformed[1] is TextMessageContentEvent) // from chunk
+ XCTAssertTrue(transformed[2] is TextMessageEndEvent) // from chunk (closed by msg2 start)
+ XCTAssertTrue(transformed[3] is TextMessageStartEvent) // msg2 passthrough
+ XCTAssertTrue(transformed[4] is TextMessageContentEvent) // msg2 passthrough
+ XCTAssertTrue(transformed[5] is TextMessageEndEvent) // msg2 passthrough
+ }
+
+ // MARK: - Error Tests
+
+ func testTransformTextChunkWithoutMessageIdThrows() async throws {
+ // Given: Text chunk without messageId
+ let events: [any AGUIEvent] = [
+ TextMessageChunkEvent(messageId: nil, role: "assistant", delta: "Hello", timestamp: 1000),
+ ]
+
+ // When/Then: Should throw error
+ do {
+ _ = try await collectEvents(events.asyncStream.transformChunks())
+ XCTFail("Expected ChunkTransformError.missingMessageId")
+ } catch ChunkTransformError.missingMessageId {
+ // Expected
+ } catch {
+ XCTFail("Unexpected error: \(error)")
+ }
+ }
+
+ func testTransformToolCallChunkWithoutIdThrows() async throws {
+ // Given: Tool chunk without toolCallId
+ let events: [any AGUIEvent] = [
+ ToolCallChunkEvent(toolCallId: nil, toolCallName: "calculator", delta: "{}", timestamp: 2000),
+ ]
+
+ // When/Then: Should throw error
+ do {
+ _ = try await collectEvents(events.asyncStream.transformChunks())
+ XCTFail("Expected ChunkTransformError.missingToolCallInfo")
+ } catch ChunkTransformError.missingToolCallInfo {
+ // Expected
+ } catch {
+ XCTFail("Unexpected error: \(error)")
+ }
+ }
+
+ func testTransformToolCallChunkWithoutNameThrows() async throws {
+ // Given: Tool chunk without toolCallName
+ let events: [any AGUIEvent] = [
+ ToolCallChunkEvent(toolCallId: "tool1", toolCallName: nil, delta: "{}", timestamp: 2000),
+ ]
+
+ // When/Then: Should throw error
+ do {
+ _ = try await collectEvents(events.asyncStream.transformChunks())
+ XCTFail("Expected ChunkTransformError.missingToolCallInfo")
+ } catch ChunkTransformError.missingToolCallInfo {
+ // Expected
+ } catch {
+ XCTFail("Unexpected error: \(error)")
+ }
+ }
+
+ // MARK: - Role Preservation Tests
+
+ func testRolePreservationFromChunk() async throws {
+ // Given: Chunk with custom role
+ let events: [any AGUIEvent] = [
+ TextMessageChunkEvent(messageId: "msg1", role: "user", delta: "Hello", timestamp: 1000),
+ ]
+
+ // When: Transforming
+ let transformed = try await collectEvents(events.asyncStream.transformChunks())
+
+ // Then: Role should be preserved
+ guard let start = transformed[0] as? TextMessageStartEvent else {
+ XCTFail("Expected TextMessageStartEvent")
+ return
+ }
+ XCTAssertEqual(start.role, "user")
+ }
+
+ func testRoleDefaultsToAssistant() async throws {
+ // Given: Chunk without role
+ let events: [any AGUIEvent] = [
+ TextMessageChunkEvent(messageId: "msg1", role: nil, delta: "Hello", timestamp: 1000),
+ ]
+
+ // When: Transforming
+ let transformed = try await collectEvents(events.asyncStream.transformChunks())
+
+ // Then: Role should default to assistant
+ guard let start = transformed[0] as? TextMessageStartEvent else {
+ XCTFail("Expected TextMessageStartEvent")
+ return
+ }
+ XCTAssertEqual(start.role, "assistant")
+ }
+
+ // MARK: - Helper Methods
+
+ private func collectEvents(_ stream: AsyncThrowingStream) async throws -> [any AGUIEvent] {
+ var events: [any AGUIEvent] = []
+ for try await event in stream {
+ events.append(event)
+ }
+ return events
+ }
+}
+
+// MARK: - Array AsyncSequence Extension
+
+extension Array {
+ var asyncStream: AsyncThrowingStream {
+ AsyncThrowingStream { continuation in
+ for element in self {
+ continuation.yield(element)
+ }
+ continuation.finish()
+ }
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUIClientTests/Streaming/EventStreamTests.swift b/sdks/community/swift/Tests/AGUIClientTests/Streaming/EventStreamTests.swift
new file mode 100644
index 0000000000..2803149405
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUIClientTests/Streaming/EventStreamTests.swift
@@ -0,0 +1,464 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUIClient
+@testable import AGUICore
+
+/// Comprehensive tests for EventStream AsyncSequence.
+///
+/// Tests the integration of:
+/// - HTTP streaming (AsyncBytes)
+/// - SSE parsing
+/// - AG-UI event decoding
+/// - Error handling
+/// - Stream lifecycle
+final class EventStreamTests: XCTestCase {
+ // MARK: - Basic Streaming Tests
+
+ func testStreamSingleEvent() async throws {
+ let sseData = "data: {\"type\":\"RUN_STARTED\",\"threadId\":\"t1\",\"runId\":\"r1\"}\n\n"
+ let bytes = MockAsyncBytes(data: Data(sseData.utf8))
+ let decoder = AGUIEventDecoder()
+
+ let stream = EventStream(bytes: bytes, decoder: decoder)
+
+ var events: [any AGUIEvent] = []
+ for try await event in stream {
+ events.append(event)
+ }
+
+ XCTAssertEqual(events.count, 1)
+ XCTAssertTrue(events[0] is RunStartedEvent)
+ let runStarted = events[0] as! RunStartedEvent
+ XCTAssertEqual(runStarted.threadId, "t1")
+ XCTAssertEqual(runStarted.runId, "r1")
+ }
+
+ func testStreamMultipleEvents() async throws {
+ let sseData = """
+ data: {"type":"RUN_STARTED","threadId":"t1","runId":"r1"}
+
+ data: {"type":"TEXT_MESSAGE_START","messageId":"msg1","role":"assistant"}
+
+ data: {"type":"TEXT_MESSAGE_CHUNK","messageId":"msg1","delta":"Hello"}
+
+ data: {"type":"TEXT_MESSAGE_END","messageId":"msg1"}
+
+ data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1"}
+
+
+ """
+ let bytes = MockAsyncBytes(data: Data(sseData.utf8))
+ let decoder = AGUIEventDecoder()
+
+ let stream = EventStream(bytes: bytes, decoder: decoder)
+
+ var events: [any AGUIEvent] = []
+ for try await event in stream {
+ events.append(event)
+ }
+
+ XCTAssertEqual(events.count, 5)
+ guard events.count >= 5 else {
+ XCTFail("Expected 5 events but got \(events.count)")
+ return
+ }
+ XCTAssertTrue(events[0] is RunStartedEvent)
+ XCTAssertTrue(events[1] is TextMessageStartEvent)
+ XCTAssertTrue(events[2] is TextMessageChunkEvent)
+ XCTAssertTrue(events[3] is TextMessageEndEvent)
+ XCTAssertTrue(events[4] is RunFinishedEvent)
+ }
+
+ func testStreamEmptyStream() async throws {
+ let bytes = MockAsyncBytes(data: Data())
+ let decoder = AGUIEventDecoder()
+
+ let stream = EventStream(bytes: bytes, decoder: decoder)
+
+ var events: [any AGUIEvent] = []
+ for try await event in stream {
+ events.append(event)
+ }
+
+ XCTAssertEqual(events.count, 0)
+ }
+
+ // MARK: - Partial Chunk Tests
+
+ func testStreamHandlesPartialChunks() async throws {
+ // Simulate bytes arriving in small chunks
+ let part1 = "data: {\"type\":\"TEXT_"
+ let part2 = "MESSAGE_CHUNK\",\"messageId\""
+ let part3 = ":\"msg1\",\"delta\":\"Hi\"}\n\n"
+
+ let combinedData = Data((part1 + part2 + part3).utf8)
+ let bytes = MockAsyncBytes(data: combinedData, chunkSize: 10)
+ let decoder = AGUIEventDecoder()
+
+ let stream = EventStream(bytes: bytes, decoder: decoder)
+
+ var events: [any AGUIEvent] = []
+ for try await event in stream {
+ events.append(event)
+ }
+
+ XCTAssertEqual(events.count, 1)
+ XCTAssertTrue(events[0] is TextMessageChunkEvent)
+ }
+
+ func testStreamHandlesUTF8AcrossBoundaries() async throws {
+ // Emoji split across chunk boundaries
+ let emoji = "👋"
+ let sseData = "data: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"messageId\":\"msg1\",\"delta\":\"\(emoji)\"}\n\n"
+ let bytes = MockAsyncBytes(data: Data(sseData.utf8), chunkSize: 5)
+ let decoder = AGUIEventDecoder()
+
+ let stream = EventStream(bytes: bytes, decoder: decoder)
+
+ var events: [any AGUIEvent] = []
+ for try await event in stream {
+ events.append(event)
+ }
+
+ XCTAssertEqual(events.count, 1)
+ let chunk = events[0] as! TextMessageChunkEvent
+ XCTAssertEqual(chunk.delta, emoji)
+ }
+
+ // MARK: - Error Handling Tests
+
+ func testStreamHandlesMalformedJSON() async throws {
+ let sseData = """
+ data: {"type":"RUN_STARTED","threadId":"t1","runId":"r1"}
+
+ data: {invalid json}
+
+ data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1"}
+
+
+ """
+ let bytes = MockAsyncBytes(data: Data(sseData.utf8))
+ let decoder = AGUIEventDecoder()
+
+ let stream = EventStream(bytes: bytes, decoder: decoder)
+
+ var events: [any AGUIEvent] = []
+ for try await event in stream {
+ events.append(event)
+ }
+
+ // Should skip malformed event and continue
+ XCTAssertEqual(events.count, 2)
+ XCTAssertTrue(events[0] is RunStartedEvent)
+ XCTAssertTrue(events[1] is RunFinishedEvent)
+ }
+
+ func testStreamHandlesUnknownEventType() async throws {
+ // In strict mode (default), an unrecognised event type causes the decoder to
+ // throw EventDecodingError.unknownEventType. The TypeScript reference implementation
+ // calls eventSubject.error(err) for all decode failures — the stream terminates.
+ // EventStream must propagate, not swallow, the error.
+ let sseData = """
+ data: {"type":"RUN_STARTED","threadId":"t1","runId":"r1"}
+
+ data: {"type":"UNKNOWN_EVENT_TYPE","data":"something"}
+
+ data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1"}
+
+
+ """
+ let bytes = MockAsyncBytes(data: Data(sseData.utf8))
+ let decoder = AGUIEventDecoder() // strict mode by default
+
+ let stream = EventStream(bytes: bytes, decoder: decoder)
+
+ var eventsBeforeThrow: [any AGUIEvent] = []
+ do {
+ for try await event in stream {
+ eventsBeforeThrow.append(event)
+ }
+ XCTFail("Expected stream to throw EventDecodingError.unknownEventType")
+ } catch let error as EventDecodingError {
+ guard case .unknownEventType(let typeString) = error else {
+ XCTFail("Expected .unknownEventType, got \(error)")
+ return
+ }
+ XCTAssertEqual(typeString, "UNKNOWN_EVENT_TYPE")
+ // The first event was yielded before the error occurred.
+ XCTAssertEqual(eventsBeforeThrow.count, 1)
+ XCTAssertTrue(eventsBeforeThrow[0] is RunStartedEvent)
+ }
+ }
+
+ func test_tolerantDecoder_returnsUnknownEventForUnrecognisedType() async throws {
+ // With .returnUnknown strategy, unknown event types are wrapped in UnknownEvent
+ // and forwarded instead of being silently dropped.
+ let sseData = """
+ data: {"type":"RUN_STARTED","threadId":"t1","runId":"r1"}
+
+ data: {"type":"UNKNOWN_EVENT_TYPE","data":"something"}
+
+ data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1"}
+
+
+ """
+ let bytes = MockAsyncBytes(data: Data(sseData.utf8))
+ var config = AGUIEventDecoder.Configuration()
+ config.unknownEventStrategy = .returnUnknown
+ let decoder = AGUIEventDecoder(config: config)
+
+ let stream = EventStream(bytes: bytes, decoder: decoder)
+
+ var events: [any AGUIEvent] = []
+ for try await event in stream {
+ events.append(event)
+ }
+
+ // Tolerant decoder yields all 3 events; the middle one is UnknownEvent.
+ XCTAssertEqual(events.count, 3)
+ XCTAssertTrue(events[0] is RunStartedEvent)
+ XCTAssertTrue(events[1] is UnknownEvent)
+ let unknown = events[1] as! UnknownEvent
+ XCTAssertEqual(unknown.typeRaw, "UNKNOWN_EVENT_TYPE")
+ XCTAssertTrue(events[2] is RunFinishedEvent)
+ }
+
+ // MARK: - Lifecycle Tests
+
+ func testStreamCanBeIteratedMultipleTimes() async throws {
+ let sseData = "data: {\"type\":\"RUN_STARTED\",\"threadId\":\"t1\",\"runId\":\"r1\"}\n\n"
+ let bytes = MockAsyncBytes(data: Data(sseData.utf8))
+ let decoder = AGUIEventDecoder()
+
+ let stream = EventStream(bytes: bytes, decoder: decoder)
+
+ // First iteration
+ var count1 = 0
+ for try await _ in stream {
+ count1 += 1
+ }
+
+ // Second iteration should start fresh
+ var count2 = 0
+ for try await _ in stream {
+ count2 += 1
+ }
+
+ XCTAssertEqual(count1, 1)
+ XCTAssertEqual(count2, 1)
+ }
+
+ func testStreamEndsWhenBytesEnd() async throws {
+ let sseData = "data: {\"type\":\"RUN_STARTED\",\"threadId\":\"t1\",\"runId\":\"r1\"}\n\n"
+ let bytes = MockAsyncBytes(data: Data(sseData.utf8))
+ let decoder = AGUIEventDecoder()
+
+ let stream = EventStream(bytes: bytes, decoder: decoder)
+
+ var eventCount = 0
+ for try await _ in stream {
+ eventCount += 1
+ }
+
+ // Should have received exactly one event and then ended
+ XCTAssertEqual(eventCount, 1)
+ }
+
+ // MARK: - Cancellation Tests
+
+ func testStreamCanBeCancelled() async throws {
+ let sseData = String(repeating: "data: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"messageId\":\"msg1\",\"delta\":\"x\"}\n\n", count: 100)
+ let bytes = MockAsyncBytes(data: Data(sseData.utf8))
+ let decoder = AGUIEventDecoder()
+
+ let stream = EventStream(bytes: bytes, decoder: decoder)
+
+ let task = Task {
+ var count = 0
+ for try await _ in stream {
+ count += 1
+ if count >= 5 {
+ break // Stop early
+ }
+ }
+ return count
+ }
+
+ let count = try await task.value
+ XCTAssertEqual(count, 5)
+ }
+
+ // MARK: - Multi-line Data Tests
+
+ func testStreamHandlesMultiLineSSEData() async throws {
+ let sseData = """
+ data: {"type":"TEXT_MESSAGE_CHUNK",
+ data: "messageId":"msg1",
+ data: "delta":"Hello"}
+
+
+ """
+ let bytes = MockAsyncBytes(data: Data(sseData.utf8))
+ let decoder = AGUIEventDecoder()
+
+ let stream = EventStream(bytes: bytes, decoder: decoder)
+
+ var events: [any AGUIEvent] = []
+ for try await event in stream {
+ events.append(event)
+ }
+
+ XCTAssertEqual(events.count, 1)
+ XCTAssertTrue(events[0] is TextMessageChunkEvent)
+ }
+
+ // MARK: - Performance Tests
+
+ func testStreamHandlesLargeNumberOfEvents() async throws {
+ var sseData = ""
+ for i in 0..<1000 {
+ sseData += "data: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"messageId\":\"msg1\",\"delta\":\"\(i)\"}\n\n"
+ }
+
+ let bytes = MockAsyncBytes(data: Data(sseData.utf8))
+ let decoder = AGUIEventDecoder()
+
+ let stream = EventStream(bytes: bytes, decoder: decoder)
+
+ var eventCount = 0
+ for try await _ in stream {
+ eventCount += 1
+ }
+
+ XCTAssertEqual(eventCount, 1000)
+ }
+
+ // MARK: - Real-world AG-UI Scenarios
+
+ func testStreamHandlesTypicalAgentConversation() async throws {
+ let sseData = """
+ data: {"type":"RUN_STARTED","threadId":"thread-1","runId":"run-1"}
+
+ data: {"type":"TEXT_MESSAGE_START","messageId":"msg-1","role":"assistant"}
+
+ data: {"type":"TEXT_MESSAGE_CHUNK","messageId":"msg-1","delta":"The"}
+
+ data: {"type":"TEXT_MESSAGE_CHUNK","messageId":"msg-1","delta":" weather"}
+
+ data: {"type":"TEXT_MESSAGE_CHUNK","messageId":"msg-1","delta":" is"}
+
+ data: {"type":"TEXT_MESSAGE_END","messageId":"msg-1"}
+
+ data: {"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-1"}
+
+
+ """
+ let bytes = MockAsyncBytes(data: Data(sseData.utf8))
+ let decoder = AGUIEventDecoder()
+
+ let stream = EventStream(bytes: bytes, decoder: decoder)
+
+ var events: [any AGUIEvent] = []
+ var textChunks: [String] = []
+
+ for try await event in stream {
+ events.append(event)
+ if let chunk = event as? TextMessageChunkEvent, let delta = chunk.delta {
+ textChunks.append(delta)
+ }
+ }
+
+ XCTAssertEqual(events.count, 7)
+ XCTAssertEqual(textChunks.joined(), "The weather is")
+ }
+
+ func testStreamHandlesToolCallScenario() async throws {
+ // Wire format uses "toolCallName" (not "toolName") per ToolCallStartEvent.CodingKeys.
+ // All 5 events must decode successfully and be yielded in order.
+ let sseData = """
+ data: {"type":"RUN_STARTED","threadId":"t1","runId":"r1"}
+
+ data: {"type":"TOOL_CALL_START","toolCallId":"tc1","toolCallName":"weather"}
+
+ data: {"type":"TOOL_CALL_ARGS","toolCallId":"tc1","delta":"{\\\"location\\\":\\\"NYC\\\"}"}
+
+ data: {"type":"TOOL_CALL_END","toolCallId":"tc1"}
+
+ data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1"}
+
+
+ """
+ let bytes = MockAsyncBytes(data: Data(sseData.utf8))
+ let decoder = AGUIEventDecoder()
+
+ let stream = EventStream(bytes: bytes, decoder: decoder)
+
+ var events: [any AGUIEvent] = []
+ for try await event in stream {
+ events.append(event)
+ }
+
+ XCTAssertEqual(events.count, 5)
+ XCTAssertTrue(events[0] is RunStartedEvent)
+ XCTAssertTrue(events[1] is ToolCallStartEvent)
+ let toolStart = events[1] as! ToolCallStartEvent
+ XCTAssertEqual(toolStart.toolCallId, "tc1")
+ XCTAssertEqual(toolStart.toolCallName, "weather")
+ XCTAssertTrue(events[2] is ToolCallArgsEvent)
+ XCTAssertTrue(events[3] is ToolCallEndEvent)
+ XCTAssertTrue(events[4] is RunFinishedEvent)
+ }
+
+ // MARK: - Edge Cases
+
+ func testStreamHandlesHeartbeats() async throws {
+ let sseData = """
+ data: {"type":"RUN_STARTED","threadId":"t1","runId":"r1"}
+
+ :heartbeat
+
+ :heartbeat
+
+ data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1"}
+
+
+ """
+ let bytes = MockAsyncBytes(data: Data(sseData.utf8))
+ let decoder = AGUIEventDecoder()
+
+ let stream = EventStream(bytes: bytes, decoder: decoder)
+
+ var events: [any AGUIEvent] = []
+ for try await event in stream {
+ events.append(event)
+ }
+
+ // Heartbeats shouldn't produce events
+ XCTAssertEqual(events.count, 2)
+ }
+
+ func testStreamHandlesEmptyDataFields() async throws {
+ let sseData = """
+ data: {"type":"RUN_STARTED","threadId":"t1","runId":"r1"}
+
+ data:
+
+ data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1"}
+
+
+ """
+ let bytes = MockAsyncBytes(data: Data(sseData.utf8))
+ let decoder = AGUIEventDecoder()
+
+ let stream = EventStream(bytes: bytes, decoder: decoder)
+
+ var events: [any AGUIEvent] = []
+ for try await event in stream {
+ events.append(event)
+ }
+
+ // Empty data fields should be skipped
+ XCTAssertEqual(events.count, 2)
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUIClientTests/Streaming/EventVerifierTests.swift b/sdks/community/swift/Tests/AGUIClientTests/Streaming/EventVerifierTests.swift
new file mode 100644
index 0000000000..b0d829a189
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUIClientTests/Streaming/EventVerifierTests.swift
@@ -0,0 +1,228 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import AGUICore
+@testable import AGUIClient
+import XCTest
+
+final class EventVerifierTests: XCTestCase {
+
+ // MARK: - Valid Run Tests
+
+ func testValidCompleteRunPassesThrough() async throws {
+ // Given: A well-formed event sequence
+ let events: [any AGUIEvent] = [
+ RunStartedEvent(threadId: "t1", runId: "r1"),
+ TextMessageStartEvent(messageId: "msg1", role: "assistant"),
+ TextMessageContentEvent(messageId: "msg1", delta: "Hello"),
+ TextMessageEndEvent(messageId: "msg1"),
+ RunFinishedEvent(threadId: "t1", runId: "r1"),
+ ]
+
+ // When: Verifying the stream
+ let verified = try await collectEvents(events.asyncStream.verifyEvents())
+
+ // Then: All events pass through
+ XCTAssertEqual(verified.count, 5)
+ XCTAssertTrue(verified[0] is RunStartedEvent)
+ XCTAssertTrue(verified[1] is TextMessageStartEvent)
+ XCTAssertTrue(verified[2] is TextMessageContentEvent)
+ XCTAssertTrue(verified[3] is TextMessageEndEvent)
+ XCTAssertTrue(verified[4] is RunFinishedEvent)
+ }
+
+ // MARK: - First Event Validation Tests
+
+ func testWrongFirstEventThrowsAGUIProtocolError() async throws {
+ // Given: A stream that starts with a non-RUN_STARTED event
+ let events: [any AGUIEvent] = [
+ TextMessageStartEvent(messageId: "msg1", role: "assistant"),
+ ]
+
+ // When/Then: Should throw AGUIProtocolError
+ do {
+ _ = try await collectEvents(events.asyncStream.verifyEvents())
+ XCTFail("Expected AGUIProtocolError to be thrown")
+ } catch let error as AGUIProtocolError {
+ XCTAssertEqual(error.message, "First event must be 'RUN_STARTED'")
+ } catch {
+ XCTFail("Unexpected error type: \(error)")
+ }
+ }
+
+ func testRunErrorAsFirstEventIsAllowed() async throws {
+ // Given: A stream starting with RUN_ERROR
+ let events: [any AGUIEvent] = [
+ RunErrorEvent(message: "Something failed", code: "ERR"),
+ ]
+
+ // When: Verifying
+ let verified = try await collectEvents(events.asyncStream.verifyEvents())
+
+ // Then: RUN_ERROR passes through as first event
+ XCTAssertEqual(verified.count, 1)
+ XCTAssertTrue(verified[0] is RunErrorEvent)
+ }
+
+ // MARK: - Duplicate Message Tests
+
+ func testDuplicateTextMessageStartThrowsError() async throws {
+ // Given: Two TEXT_MESSAGE_START events with the same messageId
+ let events: [any AGUIEvent] = [
+ RunStartedEvent(threadId: "t1", runId: "r1"),
+ TextMessageStartEvent(messageId: "msg1", role: "assistant"),
+ TextMessageStartEvent(messageId: "msg1", role: "assistant"),
+ ]
+
+ // When/Then: Should throw AGUIProtocolError
+ do {
+ _ = try await collectEvents(events.asyncStream.verifyEvents())
+ XCTFail("Expected AGUIProtocolError to be thrown")
+ } catch let error as AGUIProtocolError {
+ XCTAssertTrue(error.message.contains("msg1"))
+ XCTAssertTrue(error.message.contains("already in progress"))
+ } catch {
+ XCTFail("Unexpected error type: \(error)")
+ }
+ }
+
+ // MARK: - Message Without Start Tests
+
+ func testTextMessageContentWithoutStartThrowsError() async throws {
+ // Given: TEXT_MESSAGE_CONTENT without matching START
+ let events: [any AGUIEvent] = [
+ RunStartedEvent(threadId: "t1", runId: "r1"),
+ TextMessageContentEvent(messageId: "msg-orphan", delta: "Hello"),
+ ]
+
+ // When/Then: Should throw AGUIProtocolError
+ do {
+ _ = try await collectEvents(events.asyncStream.verifyEvents())
+ XCTFail("Expected AGUIProtocolError to be thrown")
+ } catch let error as AGUIProtocolError {
+ XCTAssertTrue(error.message.contains("msg-orphan"))
+ XCTAssertTrue(error.message.contains("No active text message"))
+ } catch {
+ XCTFail("Unexpected error type: \(error)")
+ }
+ }
+
+ // MARK: - RUN_FINISHED with Active Message Tests
+
+ func testRunFinishedWithActiveMessageThrowsError() async throws {
+ // Given: RUN_FINISHED while a message is still open
+ let events: [any AGUIEvent] = [
+ RunStartedEvent(threadId: "t1", runId: "r1"),
+ TextMessageStartEvent(messageId: "msg1", role: "assistant"),
+ // Missing TextMessageEndEvent
+ RunFinishedEvent(threadId: "t1", runId: "r1"),
+ ]
+
+ // When/Then: Should throw AGUIProtocolError
+ do {
+ _ = try await collectEvents(events.asyncStream.verifyEvents())
+ XCTFail("Expected AGUIProtocolError to be thrown")
+ } catch let error as AGUIProtocolError {
+ XCTAssertTrue(error.message.contains("RUN_FINISHED"))
+ XCTAssertTrue(error.message.contains("messages are still active"))
+ } catch {
+ XCTFail("Unexpected error type: \(error)")
+ }
+ }
+
+ // MARK: - Sequential Runs Tests
+
+ func testSequentialRunsResetState() async throws {
+ // Given: Two complete runs in sequence
+ let events: [any AGUIEvent] = [
+ // First run
+ RunStartedEvent(threadId: "t1", runId: "r1"),
+ TextMessageStartEvent(messageId: "msg1", role: "assistant"),
+ TextMessageContentEvent(messageId: "msg1", delta: "Hello"),
+ TextMessageEndEvent(messageId: "msg1"),
+ RunFinishedEvent(threadId: "t1", runId: "r1"),
+ // Second run
+ RunStartedEvent(threadId: "t1", runId: "r2"),
+ TextMessageStartEvent(messageId: "msg2", role: "assistant"),
+ TextMessageContentEvent(messageId: "msg2", delta: "World"),
+ TextMessageEndEvent(messageId: "msg2"),
+ RunFinishedEvent(threadId: "t1", runId: "r2"),
+ ]
+
+ // When: Verifying
+ let verified = try await collectEvents(events.asyncStream.verifyEvents())
+
+ // Then: All 10 events pass through without error
+ XCTAssertEqual(verified.count, 10)
+ }
+
+ func testEventsAfterRunErrorThrows() async throws {
+ // Given: An event after RUN_ERROR
+ let events: [any AGUIEvent] = [
+ RunErrorEvent(message: "Failure", code: "ERR"),
+ TextMessageStartEvent(messageId: "msg1", role: "assistant"),
+ ]
+
+ // When/Then: Should throw
+ do {
+ _ = try await collectEvents(events.asyncStream.verifyEvents())
+ XCTFail("Expected AGUIProtocolError to be thrown")
+ } catch let error as AGUIProtocolError {
+ XCTAssertTrue(error.message.contains("already errored"))
+ } catch {
+ XCTFail("Unexpected error type: \(error)")
+ }
+ }
+
+ // MARK: - Tool Call Tests
+
+ func testValidToolCallSequencePassesThrough() async throws {
+ // Given: A valid tool call sequence
+ let events: [any AGUIEvent] = [
+ RunStartedEvent(threadId: "t1", runId: "r1"),
+ ToolCallStartEvent(toolCallId: "tc1", toolCallName: "get_weather"),
+ ToolCallArgsEvent(toolCallId: "tc1", delta: "{\"loc\":"),
+ ToolCallArgsEvent(toolCallId: "tc1", delta: "\"SF\"}"),
+ ToolCallEndEvent(toolCallId: "tc1"),
+ RunFinishedEvent(threadId: "t1", runId: "r1"),
+ ]
+
+ // When: Verifying
+ let verified = try await collectEvents(events.asyncStream.verifyEvents())
+
+ // Then: All events pass through
+ XCTAssertEqual(verified.count, 6)
+ }
+
+ func testToolCallArgsWithoutStartThrowsError() async throws {
+ // Given: TOOL_CALL_ARGS without matching START
+ let events: [any AGUIEvent] = [
+ RunStartedEvent(threadId: "t1", runId: "r1"),
+ ToolCallArgsEvent(toolCallId: "tc-orphan", delta: "{}"),
+ ]
+
+ // When/Then: Should throw
+ do {
+ _ = try await collectEvents(events.asyncStream.verifyEvents())
+ XCTFail("Expected AGUIProtocolError to be thrown")
+ } catch let error as AGUIProtocolError {
+ XCTAssertTrue(error.message.contains("tc-orphan"))
+ XCTAssertTrue(error.message.contains("No active tool call"))
+ } catch {
+ XCTFail("Unexpected error type: \(error)")
+ }
+ }
+
+ // MARK: - Helper Methods
+
+ private func collectEvents(
+ _ stream: AsyncThrowingStream
+ ) async throws -> [any AGUIEvent] {
+ var collected: [any AGUIEvent] = []
+ for try await event in stream {
+ collected.append(event)
+ }
+ return collected
+ }
+}
+
+// Note: asyncStream is provided by the extension in ChunkTransformTests.swift
diff --git a/sdks/community/swift/Tests/AGUIClientTests/Streaming/SseParserTests.swift b/sdks/community/swift/Tests/AGUIClientTests/Streaming/SseParserTests.swift
new file mode 100644
index 0000000000..6a52de28c7
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUIClientTests/Streaming/SseParserTests.swift
@@ -0,0 +1,495 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUIClient
+
+/// Comprehensive tests for Server-Sent Events (SSE) parser.
+///
+/// Tests follow the SSE specification from WHATWG:
+/// https://html.spec.whatwg.org/multipage/server-sent-events.html
+///
+/// Key scenarios:
+/// - Single complete events
+/// - Multiple events in one chunk
+/// - Partial events across chunks
+/// - Multi-line data fields
+/// - Event IDs and types
+/// - Empty events (heartbeat)
+/// - UTF-8 edge cases
+final class SseParserTests: XCTestCase {
+ // MARK: - Basic Parsing Tests
+
+ func testParseSingleEvent() throws {
+ var parser = SseParser()
+ let events = try parser.parse("data: {\"test\":\"value\"}\n\n")
+
+ XCTAssertEqual(events.count, 1)
+ XCTAssertEqual(events[0].data, "{\"test\":\"value\"}")
+ XCTAssertNil(events[0].id)
+ XCTAssertEqual(events[0].event, "message")
+ }
+
+ func testParseMultipleEvents() throws {
+ var parser = SseParser()
+ let input = """
+ data: event1
+
+ data: event2
+
+ data: event3
+
+
+ """
+ let events = try parser.parse(input)
+
+ XCTAssertEqual(events.count, 3)
+ XCTAssertEqual(events[0].data, "event1")
+ XCTAssertEqual(events[1].data, "event2")
+ XCTAssertEqual(events[2].data, "event3")
+ }
+
+ func testParseEmptyDataField() throws {
+ var parser = SseParser()
+ let events = try parser.parse("data:\n\n")
+
+ XCTAssertEqual(events.count, 1)
+ XCTAssertEqual(events[0].data, "")
+ }
+
+ // MARK: - Multi-line Data Tests
+
+ func testParseMultiLineData() throws {
+ var parser = SseParser()
+ let input = """
+ data: line1
+ data: line2
+ data: line3
+
+
+ """
+ let events = try parser.parse(input)
+
+ XCTAssertEqual(events.count, 1)
+ XCTAssertEqual(events[0].data, "line1\nline2\nline3")
+ }
+
+ func testParseMultiLineDataWithEmptyLines() throws {
+ var parser = SseParser()
+ let input = """
+ data: first
+ data:
+ data: third
+
+
+ """
+ let events = try parser.parse(input)
+
+ XCTAssertEqual(events.count, 1)
+ XCTAssertEqual(events[0].data, "first\n\nthird")
+ }
+
+ // MARK: - Event ID Tests
+
+ func testParseEventWithId() throws {
+ var parser = SseParser()
+ let events = try parser.parse("id: 123\ndata: test\n\n")
+
+ XCTAssertEqual(events.count, 1)
+ XCTAssertEqual(events[0].data, "test")
+ XCTAssertEqual(events[0].id, "123")
+ }
+
+ func testParseEventWithIdAfterData() throws {
+ var parser = SseParser()
+ let events = try parser.parse("data: test\nid: 456\n\n")
+
+ XCTAssertEqual(events.count, 1)
+ XCTAssertEqual(events[0].data, "test")
+ XCTAssertEqual(events[0].id, "456")
+ }
+
+ // MARK: - Event Type Tests
+
+ func testParseEventWithCustomType() throws {
+ var parser = SseParser()
+ let events = try parser.parse("event: custom\ndata: payload\n\n")
+
+ XCTAssertEqual(events.count, 1)
+ XCTAssertEqual(events[0].data, "payload")
+ XCTAssertEqual(events[0].event, "custom")
+ }
+
+ func testParseEventWithAllFields() throws {
+ var parser = SseParser()
+ let input = """
+ event: notification
+ id: 789
+ data: {"message":"hello"}
+
+
+ """
+ let events = try parser.parse(input)
+
+ XCTAssertEqual(events.count, 1)
+ XCTAssertEqual(events[0].event, "notification")
+ XCTAssertEqual(events[0].id, "789")
+ XCTAssertEqual(events[0].data, "{\"message\":\"hello\"}")
+ }
+
+ // MARK: - Partial Chunk Tests
+
+ func testParsePartialEventReturnsNoEvents() throws {
+ var parser = SseParser()
+ let events = try parser.parse("data: incomplete")
+
+ // No complete event yet (missing double newline)
+ XCTAssertEqual(events.count, 0)
+ }
+
+ func testParsePartialEventCompletedInNextChunk() throws {
+ var parser = SseParser()
+
+ // First chunk: incomplete event
+ var events = try parser.parse("data: {\"te")
+ XCTAssertEqual(events.count, 0)
+
+ // Second chunk: complete the event
+ events = try parser.parse("st\":\"value\"}\n\n")
+ XCTAssertEqual(events.count, 1)
+ XCTAssertEqual(events[0].data, "{\"test\":\"value\"}")
+ }
+
+ func testParseMultiplePartialChunks() throws {
+ var parser = SseParser()
+
+ var events = try parser.parse("da")
+ XCTAssertEqual(events.count, 0)
+
+ events = try parser.parse("ta: first")
+ XCTAssertEqual(events.count, 0)
+
+ events = try parser.parse("\n\ndata: ")
+ XCTAssertEqual(events.count, 1)
+ XCTAssertEqual(events[0].data, "first")
+
+ events = try parser.parse("second\n\n")
+ XCTAssertEqual(events.count, 1)
+ XCTAssertEqual(events[0].data, "second")
+ }
+
+ func testParseEventSplitAcrossDoubleNewline() throws {
+ var parser = SseParser()
+
+ var events = try parser.parse("data: test\n")
+ XCTAssertEqual(events.count, 0)
+
+ events = try parser.parse("\n")
+ XCTAssertEqual(events.count, 1)
+ XCTAssertEqual(events[0].data, "test")
+ }
+
+ // MARK: - Comment Tests
+
+ func testParseIgnoresComments() throws {
+ var parser = SseParser()
+ let input = """
+ : this is a comment
+ data: payload
+
+
+ """
+ let events = try parser.parse(input)
+
+ XCTAssertEqual(events.count, 1)
+ XCTAssertEqual(events[0].data, "payload")
+ }
+
+ func testParseIgnoresCommentsBeforeAndAfterData() throws {
+ var parser = SseParser()
+ let input = """
+ : comment 1
+ data: value
+ : comment 2
+
+
+ """
+ let events = try parser.parse(input)
+
+ XCTAssertEqual(events.count, 1)
+ XCTAssertEqual(events[0].data, "value")
+ }
+
+ // MARK: - Empty Event Tests
+
+ func testParseEmptyEventAsHeartbeat() throws {
+ var parser = SseParser()
+ // Double newline with no data is typically a heartbeat
+ let events = try parser.parse("\n\n")
+
+ // Empty events are ignored (no data field)
+ XCTAssertEqual(events.count, 0)
+ }
+
+ func testParseMultipleHeartbeats() throws {
+ var parser = SseParser()
+ let input = """
+
+
+
+
+ data: real_event
+
+
+ """
+ let events = try parser.parse(input)
+
+ XCTAssertEqual(events.count, 1)
+ XCTAssertEqual(events[0].data, "real_event")
+ }
+
+ // MARK: - Whitespace Handling Tests
+
+ func testParseTrimsWhitespaceAfterColon() throws {
+ var parser = SseParser()
+ // SSE spec: Remove only ONE leading space after colon
+ let events = try parser.parse("data: value with spaces \n\n")
+
+ XCTAssertEqual(events.count, 1)
+ // After removing colon and ONE space, " value with spaces " remains
+ XCTAssertEqual(events[0].data, " value with spaces ")
+ }
+
+ func testParseHandlesNoSpaceAfterColon() throws {
+ var parser = SseParser()
+ let events = try parser.parse("data:value\n\n")
+
+ XCTAssertEqual(events.count, 1)
+ XCTAssertEqual(events[0].data, "value")
+ }
+
+ func testParsePreservesTrailingWhitespaceInData() throws {
+ var parser = SseParser()
+ let events = try parser.parse("data: trailing \n\n")
+
+ XCTAssertEqual(events.count, 1)
+ XCTAssertEqual(events[0].data, "trailing ")
+ }
+
+ // MARK: - Special Character Tests
+
+ func testParseDataWithNewlines() throws {
+ var parser = SseParser()
+ let input = """
+ data: line1
+ data: line2
+
+
+ """
+ let events = try parser.parse(input)
+
+ XCTAssertEqual(events.count, 1)
+ XCTAssertTrue(events[0].data.contains("\n"))
+ }
+
+ func testParseDataWithColons() throws {
+ var parser = SseParser()
+ let events = try parser.parse("data: {\"url\":\"https://example.com\"}\n\n")
+
+ XCTAssertEqual(events.count, 1)
+ XCTAssertEqual(events[0].data, "{\"url\":\"https://example.com\"}")
+ }
+
+ func testParseDataWithEmoji() throws {
+ var parser = SseParser()
+ let events = try parser.parse("data: Hello 👋 World 🌍\n\n")
+
+ XCTAssertEqual(events.count, 1)
+ XCTAssertEqual(events[0].data, "Hello 👋 World 🌍")
+ }
+
+ // MARK: - Edge Cases
+
+ func testParseLongDataLine() throws {
+ var parser = SseParser()
+ let longData = String(repeating: "a", count: 10000)
+ let events = try parser.parse("data: \(longData)\n\n")
+
+ XCTAssertEqual(events.count, 1)
+ XCTAssertEqual(events[0].data, longData)
+ }
+
+ func testParseManySmallChunks() throws {
+ var parser = SseParser()
+ let input = "data: test\n\n"
+
+ // Parse one character at a time
+ var allEvents: [SseEvent] = []
+ for char in input {
+ let events = try parser.parse(String(char))
+ allEvents.append(contentsOf: events)
+ }
+
+ XCTAssertEqual(allEvents.count, 1)
+ XCTAssertEqual(allEvents[0].data, "test")
+ }
+
+ func testParseUnknownFieldsAreIgnored() throws {
+ var parser = SseParser()
+ let input = """
+ data: payload
+ unknown: field
+ retry: 5000
+
+
+ """
+ let events = try parser.parse(input)
+
+ XCTAssertEqual(events.count, 1)
+ XCTAssertEqual(events[0].data, "payload")
+ }
+
+ func testParseFieldWithoutColon() throws {
+ var parser = SseParser()
+ let input = """
+ data: valid
+ invalidfield
+
+
+ """
+ let events = try parser.parse(input)
+
+ XCTAssertEqual(events.count, 1)
+ XCTAssertEqual(events[0].data, "valid")
+ }
+
+ // MARK: - Reset Tests
+
+ func testResetClearsBuffer() throws {
+ var parser = SseParser()
+
+ // Add partial event
+ _ = try parser.parse("data: incomplete")
+
+ // Reset
+ parser.reset()
+
+ // New event should not include old buffer
+ let events = try parser.parse("data: new\n\n")
+ XCTAssertEqual(events.count, 1)
+ XCTAssertEqual(events[0].data, "new")
+ }
+
+ // MARK: - Real-world AG-UI Event Tests
+
+ func testParseAGUIRunStartedEvent() throws {
+ var parser = SseParser()
+ let input = """
+ data: {"type":"RUN_STARTED","threadId":"thread-1","runId":"run-1"}
+
+
+ """
+ let events = try parser.parse(input)
+
+ XCTAssertEqual(events.count, 1)
+ XCTAssertTrue(events[0].data.contains("RUN_STARTED"))
+ }
+
+ func testParseAGUITextMessageChunk() throws {
+ var parser = SseParser()
+ let input = """
+ data: {"type":"TEXT_MESSAGE_CHUNK","messageId":"msg-1","delta":"Hello"}
+
+
+ """
+ let events = try parser.parse(input)
+
+ XCTAssertEqual(events.count, 1)
+ XCTAssertTrue(events[0].data.contains("TEXT_MESSAGE_CHUNK"))
+ }
+
+ func testParseMultipleAGUIEvents() throws {
+ var parser = SseParser()
+ let input = """
+ data: {"type":"RUN_STARTED","threadId":"t1","runId":"r1"}
+
+ data: {"type":"TEXT_MESSAGE_START","messageId":"msg1"}
+
+ data: {"type":"TEXT_MESSAGE_CHUNK","messageId":"msg1","delta":"Hi"}
+
+ data: {"type":"TEXT_MESSAGE_END","messageId":"msg1"}
+
+ data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1"}
+
+
+ """
+ let events = try parser.parse(input)
+
+ XCTAssertEqual(events.count, 5)
+ XCTAssertTrue(events[0].data.contains("RUN_STARTED"))
+ XCTAssertTrue(events[1].data.contains("TEXT_MESSAGE_START"))
+ XCTAssertTrue(events[2].data.contains("TEXT_MESSAGE_CHUNK"))
+ XCTAssertTrue(events[3].data.contains("TEXT_MESSAGE_END"))
+ XCTAssertTrue(events[4].data.contains("RUN_FINISHED"))
+ }
+
+ // MARK: - retry field
+
+ func test_sseParser_retryField_parsedAsMilliseconds() throws {
+ // WHATWG SSE spec §9.2.6: the retry field sets the reconnection time in ms.
+ var parser = SseParser()
+ let events = try parser.parse("retry: 5000\ndata: ping\n\n")
+
+ XCTAssertEqual(events.count, 1)
+ XCTAssertEqual(events[0].retry, 5000)
+ }
+
+ func test_sseParser_retryField_nonInteger_isIgnored() throws {
+ // Per spec: if the value is not an ASCII integer, ignore the field entirely.
+ var parser = SseParser()
+ let events = try parser.parse("retry: abc\ndata: ping\n\n")
+
+ XCTAssertEqual(events.count, 1)
+ XCTAssertNil(events[0].retry)
+ }
+
+ // MARK: - Thread Safety Tests
+
+ func testParserInstancesHaveIndependentBufferState() throws {
+ // SseParser is a value type (struct). Each instance maintains its own buffer.
+ // Feeding a partial chunk into one instance must not affect the other.
+ var parserA = SseParser()
+ var parserB = SseParser()
+
+ // Feed parserA a partial event (no double-newline yet)
+ let eventsA1 = try parserA.parse("data: from-a")
+ XCTAssertEqual(eventsA1.count, 0, "Partial input should not produce events")
+
+ // parserB starts clean — a complete event completes immediately
+ let eventsB = try parserB.parse("data: from-b\n\n")
+ XCTAssertEqual(eventsB.count, 1)
+ XCTAssertEqual(eventsB[0].data, "from-b")
+
+ // Completing parserA's partial event returns only A's buffered data
+ let eventsA2 = try parserA.parse("\n\n")
+ XCTAssertEqual(eventsA2.count, 1)
+ XCTAssertEqual(eventsA2[0].data, "from-a",
+ "parserA must not contain data from parserB")
+ }
+
+ // MARK: - Performance Tests
+
+ func testParseLargeStreamEfficiently() throws {
+ var parser = SseParser()
+
+ // Simulate large stream
+ let iterations = 1000
+ var totalEvents = 0
+
+ for i in 0.. AgentStateMutation? {
+ await tracker.record(index)
+ return nil
+ }
+ }
+
+ let testInput = try RunAgentInput.builder()
+ .threadId("order-thread")
+ .runId("order-run")
+ .build()
+
+ // When: 5 subscribers registered in order 0..4
+ for i in 0 ..< 5 {
+ _ = await manager.subscribe(IndexedSubscriber(index: i, tracker: tracker))
+ }
+
+ // Drive each subscriber in allSubscribers() order
+ let params = AgentSubscriberParams(messages: [], state: Data("{}".utf8), input: testInput)
+ for sub in await manager.allSubscribers() {
+ _ = await sub.onRunInitialized(params: params)
+ }
+
+ // Then: callbacks must arrive in registration order, not dict iteration order
+ let callOrder = await tracker.order
+ XCTAssertEqual(callOrder, [0, 1, 2, 3, 4])
+ }
+
+ func testUnsubscribePreservesRemainingOrder() async throws {
+ // Given: 4 subscribers registered in order 0..3
+ let manager = SubscriberManager()
+
+ actor CallOrderTracker {
+ var order: [Int] = []
+ func record(_ index: Int) { order.append(index) }
+ }
+ let tracker = CallOrderTracker()
+
+ struct IndexedSubscriber: AgentSubscriber {
+ let index: Int
+ let tracker: CallOrderTracker
+ func onRunInitialized(params: AgentSubscriberParams) async -> AgentStateMutation? {
+ await tracker.record(index)
+ return nil
+ }
+ }
+
+ let testInput = try RunAgentInput.builder()
+ .threadId("t").runId("r").build()
+
+ var ids: [UUID] = []
+ for i in 0 ..< 4 {
+ let id = await manager.subscribe(IndexedSubscriber(index: i, tracker: tracker))
+ ids.append(id)
+ }
+
+ // When: unsubscribe the second subscriber (index 1)
+ await manager.unsubscribe(ids[1])
+
+ let params = AgentSubscriberParams(messages: [], state: Data("{}".utf8), input: testInput)
+ for sub in await manager.allSubscribers() {
+ _ = await sub.onRunInitialized(params: params)
+ }
+
+ // Then: remaining subscribers fire in insertion order: [0, 2, 3]
+ let callOrder = await tracker.order
+ XCTAssertEqual(callOrder, [0, 2, 3])
+ }
+
+ func testSubscriptionHandle() async throws {
+ // Given: A subscription with tracked state
+ actor UnsubscribeTracker {
+ var unsubscribed = false
+ func markUnsubscribed() {
+ unsubscribed = true
+ }
+ }
+
+ let tracker = UnsubscribeTracker()
+ let subscription = DefaultAgentSubscription {
+ await tracker.markUnsubscribed()
+ }
+
+ // When: Unsubscribing
+ await subscription.unsubscribe()
+
+ // Then: Should call unsubscribe closure
+ let wasUnsubscribed = await tracker.unsubscribed
+ XCTAssertTrue(wasUnsubscribed)
+ }
+
+ func testSubscriptionIdempotence() async throws {
+ // Given: A subscription with tracked call count
+ actor CallTracker {
+ var callCount = 0
+ func increment() {
+ callCount += 1
+ }
+ }
+
+ let tracker = CallTracker()
+ let subscription = DefaultAgentSubscription {
+ await tracker.increment()
+ }
+ await subscription.unsubscribe()
+
+ // When: Unsubscribing again
+ await subscription.unsubscribe()
+
+ // Then: Should only call once
+ let calls = await tracker.callCount
+ XCTAssertEqual(calls, 1)
+ }
+}
+
+// MARK: - Mock Subscribers
+
+private actor MockAgentSubscriber: AgentSubscriber {
+ var initializeCalls = 0
+ var eventCalls = 0
+ var finalizeCalls = 0
+ var failureCalls = 0
+ var messagesChangedCalls = 0
+ var stateChangedCalls = 0
+
+ func onRunInitialized(params: AgentSubscriberParams) async -> AgentStateMutation? {
+ initializeCalls += 1
+ return nil
+ }
+
+ func onEvent(params: AgentEventParams) async -> AgentStateMutation? {
+ eventCalls += 1
+ return nil
+ }
+
+ func onRunFinalized(params: AgentSubscriberParams) async -> AgentStateMutation? {
+ finalizeCalls += 1
+ return nil
+ }
+
+ func onRunFailed(params: AgentRunFailureParams) async -> AgentStateMutation? {
+ failureCalls += 1
+ return nil
+ }
+
+ func onMessagesChanged(params: AgentStateChangedParams) async {
+ messagesChangedCalls += 1
+ }
+
+ func onStateChanged(params: AgentStateChangedParams) async {
+ stateChangedCalls += 1
+ }
+}
+
+private struct MutatingSubscriber: AgentSubscriber {
+ let messagesToAdd: [any Message]?
+ let newState: State?
+ let stopPropagation: Bool
+
+ init(
+ messagesToAdd: [any Message]? = nil,
+ newState: State? = nil,
+ stopPropagation: Bool = false
+ ) {
+ self.messagesToAdd = messagesToAdd
+ self.newState = newState
+ self.stopPropagation = stopPropagation
+ }
+
+ func onRunInitialized(params: AgentSubscriberParams) async -> AgentStateMutation? {
+ var updatedMessages: [any Message]?
+ if let toAdd = messagesToAdd {
+ updatedMessages = params.messages + toAdd
+ }
+
+ return AgentStateMutation(
+ messages: updatedMessages,
+ state: newState,
+ stopPropagation: stopPropagation
+ )
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUIClientTests/Transport/ClientErrorTests.swift b/sdks/community/swift/Tests/AGUIClientTests/Transport/ClientErrorTests.swift
new file mode 100644
index 0000000000..169cb5eece
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUIClientTests/Transport/ClientErrorTests.swift
@@ -0,0 +1,98 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUIClient
+
+final class ClientErrorTests: XCTestCase {
+ // MARK: - Error Description Tests
+
+ func testInvalidURLErrorDescription() {
+ let error = ClientError.invalidURL
+ XCTAssertEqual(error.errorDescription, "Invalid URL configuration")
+ }
+
+ func testInvalidResponseErrorDescription() {
+ let error = ClientError.invalidResponse
+ XCTAssertEqual(error.errorDescription, "Received invalid response from server")
+ }
+
+ func testHttpErrorDescription() {
+ let error = ClientError.httpError(statusCode: 404)
+ XCTAssertEqual(error.errorDescription, "HTTP error: 404")
+ }
+
+ func testNetworkErrorDescription() {
+ let networkError = NSError(domain: "TestDomain", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connection failed"])
+ let error = ClientError.networkError(networkError)
+ XCTAssertTrue(error.errorDescription?.contains("Network error") == true)
+ }
+
+ func testDecodingErrorDescription() {
+ let decodingError = NSError(domain: "DecodingDomain", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid JSON"])
+ let error = ClientError.decodingError(decodingError)
+ XCTAssertTrue(error.errorDescription?.contains("Failed to decode event") == true)
+ }
+
+ func testStreamErrorDescription() {
+ let error = ClientError.streamError("Buffer overflow")
+ XCTAssertEqual(error.errorDescription, "Stream error: Buffer overflow")
+ }
+
+ func testTimeoutErrorDescription() {
+ let error = ClientError.timeout
+ XCTAssertEqual(error.errorDescription, "Request timed out")
+ }
+
+ func testCancelledErrorDescription() {
+ let error = ClientError.cancelled
+ XCTAssertEqual(error.errorDescription, "Request was cancelled")
+ }
+
+ // MARK: - Equatable Tests
+
+ func testInvalidURLEquality() {
+ XCTAssertEqual(ClientError.invalidURL, ClientError.invalidURL)
+ }
+
+ func testInvalidResponseEquality() {
+ XCTAssertEqual(ClientError.invalidResponse, ClientError.invalidResponse)
+ }
+
+ func testHttpErrorEquality() {
+ XCTAssertEqual(ClientError.httpError(statusCode: 404), ClientError.httpError(statusCode: 404))
+ XCTAssertNotEqual(ClientError.httpError(statusCode: 404), ClientError.httpError(statusCode: 500))
+ }
+
+ func testStreamErrorEquality() {
+ XCTAssertEqual(ClientError.streamError("test"), ClientError.streamError("test"))
+ XCTAssertNotEqual(ClientError.streamError("test"), ClientError.streamError("other"))
+ }
+
+ func testTimeoutEquality() {
+ XCTAssertEqual(ClientError.timeout, ClientError.timeout)
+ }
+
+ func testCancelledEquality() {
+ XCTAssertEqual(ClientError.cancelled, ClientError.cancelled)
+ }
+
+ func testDifferentErrorsNotEqual() {
+ XCTAssertNotEqual(ClientError.invalidURL, ClientError.invalidResponse)
+ XCTAssertNotEqual(ClientError.timeout, ClientError.cancelled)
+ }
+
+ // MARK: - Error Throwing Tests
+
+ func testErrorCanBeThrown() {
+ XCTAssertThrowsError(try throwingFunction()) { error in
+ XCTAssertTrue(error is ClientError)
+ if let clientError = error as? ClientError {
+ XCTAssertEqual(clientError, ClientError.invalidURL)
+ }
+ }
+ }
+
+ private func throwingFunction() throws {
+ throw ClientError.invalidURL
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUIClientTests/Transport/HttpAgentConfigurationTests.swift b/sdks/community/swift/Tests/AGUIClientTests/Transport/HttpAgentConfigurationTests.swift
new file mode 100644
index 0000000000..b1c78346f7
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUIClientTests/Transport/HttpAgentConfigurationTests.swift
@@ -0,0 +1,189 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUIClient
+
+final class HttpAgentConfigurationTests: XCTestCase {
+ // MARK: - Initialization Tests
+
+ func testDefaultInitialization() {
+ let url = URL(string: "https://agent.example.com")!
+ let config = HttpAgentConfiguration(baseURL: url)
+
+ XCTAssertEqual(config.baseURL, url)
+ XCTAssertEqual(config.timeout, 120.0)
+ XCTAssertTrue(config.headers.isEmpty)
+
+ if case .none = config.retryPolicy {
+ // Success
+ } else {
+ XCTFail("Expected retry policy to be .none")
+ }
+ }
+
+ func testCustomTimeout() {
+ let url = URL(string: "https://agent.example.com")!
+ let config = HttpAgentConfiguration(baseURL: url, timeout: 60.0)
+
+ XCTAssertEqual(config.timeout, 60.0)
+ }
+
+ func testCustomHeaders() {
+ let url = URL(string: "https://agent.example.com")!
+ let headers = ["Authorization": "Bearer token", "Custom-Header": "value"]
+ let config = HttpAgentConfiguration(baseURL: url, headers: headers)
+
+ XCTAssertEqual(config.headers, headers)
+ }
+
+ func testFixedRetryPolicy() {
+ let url = URL(string: "https://agent.example.com")!
+ let config = HttpAgentConfiguration(
+ baseURL: url,
+ retryPolicy: .fixed(maxAttempts: 3, delay: 1.0)
+ )
+
+ if case .fixed(let attempts, let delay) = config.retryPolicy {
+ XCTAssertEqual(attempts, 3)
+ XCTAssertEqual(delay, 1.0)
+ } else {
+ XCTFail("Expected fixed retry policy")
+ }
+ }
+
+ func testExponentialBackoffRetryPolicy() {
+ let url = URL(string: "https://agent.example.com")!
+ let config = HttpAgentConfiguration(
+ baseURL: url,
+ retryPolicy: .exponentialBackoff(maxAttempts: 5, baseDelay: 2.0)
+ )
+
+ if case .exponentialBackoff(let attempts, let delay) = config.retryPolicy {
+ XCTAssertEqual(attempts, 5)
+ XCTAssertEqual(delay, 2.0)
+ } else {
+ XCTFail("Expected exponential backoff retry policy")
+ }
+ }
+
+ // MARK: - Factory Method Tests
+
+ func testCreateFromValidURLString() throws {
+ let config = try HttpAgentConfiguration.create(baseURLString: "https://agent.example.com")
+
+ XCTAssertEqual(config.baseURL.absoluteString, "https://agent.example.com")
+ }
+
+ func testCreateFromInvalidURLString() {
+ // Note: URL(string:) is very permissive and creates relative URLs
+ // Testing with a string that has invalid characters
+ XCTAssertThrowsError(try HttpAgentConfiguration.create(baseURLString: "ht tp://invalid")) { error in
+ XCTAssertEqual(error as? ClientError, ClientError.invalidURL)
+ }
+ }
+
+ func testCreateFromEmptyString() {
+ XCTAssertThrowsError(try HttpAgentConfiguration.create(baseURLString: "")) { error in
+ XCTAssertEqual(error as? ClientError, ClientError.invalidURL)
+ }
+ }
+
+ // MARK: - Mutability Tests
+
+ func testConfigurationIsMutable() {
+ let url = URL(string: "https://agent.example.com")!
+ var config = HttpAgentConfiguration(baseURL: url)
+
+ config.timeout = 30.0
+ XCTAssertEqual(config.timeout, 30.0)
+
+ config.headers["Custom"] = "Value"
+ XCTAssertEqual(config.headers["Custom"], "Value")
+ }
+
+ // MARK: - Sendable Conformance
+
+ func testConfigurationIsSendable() async {
+ let url = URL(string: "https://agent.example.com")!
+ let config = HttpAgentConfiguration(baseURL: url)
+
+ // Test that config can be sent across actor boundaries
+ await withCheckedContinuation { continuation in
+ Task {
+ _ = config // Capture in async context
+ continuation.resume()
+ }
+ }
+ }
+
+ // MARK: - buildHeaders() Tests
+
+ func testBuildHeadersIncludesBearerToken() {
+ var config = HttpAgentConfiguration(baseURL: URL(string: "https://example.com")!)
+ config.bearerToken = "sk-test-token"
+ XCTAssertEqual(config.buildHeaders()["Authorization"], "Bearer sk-test-token")
+ }
+
+ func testBuildHeadersIncludesApiKey() {
+ var config = HttpAgentConfiguration(baseURL: URL(string: "https://example.com")!)
+ config.apiKey = "my-api-key"
+ XCTAssertEqual(config.buildHeaders()["X-API-Key"], "my-api-key")
+ }
+
+ func testBuildHeadersUsesCustomApiKeyHeader() {
+ var config = HttpAgentConfiguration(baseURL: URL(string: "https://example.com")!)
+ config.apiKeyHeader = "X-Custom-Key"
+ config.apiKey = "val"
+ XCTAssertEqual(config.buildHeaders()["X-Custom-Key"], "val")
+ XCTAssertNil(config.buildHeaders()["X-API-Key"])
+ }
+
+ func testBuildHeadersIncludesExplicitHeaders() {
+ var config = HttpAgentConfiguration(
+ baseURL: URL(string: "https://example.com")!,
+ headers: ["X-Trace": "abc123"]
+ )
+ XCTAssertEqual(config.buildHeaders()["X-Trace"], "abc123")
+ }
+
+ func testBearerTokenDoesNotSideEffectRawHeadersDict() {
+ // Setting bearerToken must NOT mutate the .headers dict directly —
+ // auth headers are only visible through buildHeaders().
+ var config = HttpAgentConfiguration(baseURL: URL(string: "https://example.com")!)
+ config.bearerToken = "sk-secret"
+ XCTAssertNil(config.headers["Authorization"],
+ "bearerToken must not mutate headers via didSet; use buildHeaders()")
+ }
+
+ func testApiKeyDoesNotSideEffectRawHeadersDict() {
+ var config = HttpAgentConfiguration(baseURL: URL(string: "https://example.com")!)
+ config.apiKey = "key-secret"
+ XCTAssertNil(config.headers["X-API-Key"],
+ "apiKey must not mutate headers via didSet; use buildHeaders()")
+ }
+
+ func testBuildHeadersMergesTokenOverApiKey() {
+ var config = HttpAgentConfiguration(baseURL: URL(string: "https://example.com")!)
+ config.bearerToken = "token"
+ config.apiKey = "key"
+ let built = config.buildHeaders()
+ XCTAssertEqual(built["Authorization"], "Bearer token")
+ XCTAssertEqual(built["X-API-Key"], "key")
+ }
+
+ // MARK: - URL Construction Tests
+
+ func testBaseURLWithPath() {
+ let url = URL(string: "https://agent.example.com/api")!
+ let config = HttpAgentConfiguration(baseURL: url)
+
+ XCTAssertEqual(config.baseURL.path, "/api")
+ }
+
+ func testBaseURLWithPort() {
+ let url = URL(string: "https://agent.example.com:8080")!
+ let config = HttpAgentConfiguration(baseURL: url)
+
+ XCTAssertEqual(config.baseURL.port, 8080)
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUIClientTests/Transport/HttpTransportTests.swift b/sdks/community/swift/Tests/AGUIClientTests/Transport/HttpTransportTests.swift
new file mode 100644
index 0000000000..4fd3b4457e
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUIClientTests/Transport/HttpTransportTests.swift
@@ -0,0 +1,280 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUIClient
+@testable import AGUICore
+
+final class HttpTransportTests: XCTestCase {
+ // MARK: - Initialization Tests
+
+ func testTransportInitialization() async {
+ let url = URL(string: "https://agent.example.com")!
+ let config = HttpAgentConfiguration(baseURL: url)
+ let transport = HttpTransport(configuration: config)
+
+ // Verify transport was created (actor, so we need async)
+ await withCheckedContinuation { continuation in
+ Task {
+ _ = transport
+ continuation.resume()
+ }
+ }
+ }
+
+ func testTransportWithCustomTimeout() async {
+ let url = URL(string: "https://agent.example.com")!
+ let config = HttpAgentConfiguration(baseURL: url, timeout: 30.0)
+ let transport = HttpTransport(configuration: config)
+
+ await withCheckedContinuation { continuation in
+ Task {
+ _ = transport
+ continuation.resume()
+ }
+ }
+ }
+
+ func testTransportWithCustomHeaders() async {
+ let url = URL(string: "https://agent.example.com")!
+ let headers = ["Authorization": "Bearer token"]
+ let config = HttpAgentConfiguration(baseURL: url, headers: headers)
+ let transport = HttpTransport(configuration: config)
+
+ await withCheckedContinuation { continuation in
+ Task {
+ _ = transport
+ continuation.resume()
+ }
+ }
+ }
+
+ // MARK: - URL Construction Tests
+
+ func testEndpointURLConstruction() {
+ let baseURL = URL(string: "https://agent.example.com")!
+ let endpoint = "/run"
+
+ let expectedURL = baseURL.appendingPathComponent(endpoint)
+ XCTAssertEqual(expectedURL.absoluteString, "https://agent.example.com/run")
+ }
+
+ func testEndpointWithExistingPath() {
+ let baseURL = URL(string: "https://agent.example.com/api")!
+ let endpoint = "/run"
+
+ let expectedURL = baseURL.appendingPathComponent(endpoint)
+ XCTAssertTrue(expectedURL.absoluteString.contains("/api/run"))
+ }
+
+ // MARK: - Input Encoding Tests
+
+ func testRunAgentInputEncoding() throws {
+ let input = try RunAgentInput.builder()
+ .threadId("thread-1")
+ .runId("run-1")
+ .build()
+
+ let encoder = JSONEncoder()
+ let data = try encoder.encode(input)
+
+ XCTAssertFalse(data.isEmpty)
+
+ // Verify it contains expected fields
+ let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
+ XCTAssertNotNil(json)
+ XCTAssertEqual(json?["threadId"] as? String, "thread-1")
+ XCTAssertEqual(json?["runId"] as? String, "run-1")
+ }
+
+ func testRunAgentInputWithMessagesEncoding() throws {
+ let input = try RunAgentInput.builder()
+ .threadId("thread-1")
+ .runId("run-1")
+ .message(UserMessage(id: "msg-1", content: "Hello"))
+ .build()
+
+ let encoder = JSONEncoder()
+ let data = try encoder.encode(input)
+
+ XCTAssertFalse(data.isEmpty)
+ }
+
+ // MARK: - Error Mapping Tests
+
+ func testURLErrorTimeoutMapping() {
+ let urlError = URLError(.timedOut)
+ let transport = HttpTransport(configuration: HttpAgentConfiguration(baseURL: URL(string: "https://test.com")!))
+
+ // We can't directly test the private mapURLError method,
+ // but we know it maps timedOut to .timeout
+ // This would be tested in integration tests
+ }
+
+ func testURLErrorCancelledMapping() {
+ let urlError = URLError(.cancelled)
+ // Similar to above, would be tested in integration
+ }
+
+ // MARK: - Actor Isolation Tests
+
+ func testTransportIsActor() async {
+ let url = URL(string: "https://agent.example.com")!
+ let config = HttpAgentConfiguration(baseURL: url)
+ let transport = HttpTransport(configuration: config)
+
+ // Verify we can call async methods on the transport
+ await withCheckedContinuation { continuation in
+ Task {
+ _ = transport
+ continuation.resume()
+ }
+ }
+ }
+
+ func testMultipleTransportsCanExist() async {
+ let url = URL(string: "https://agent.example.com")!
+ let config = HttpAgentConfiguration(baseURL: url)
+
+ let transport1 = HttpTransport(configuration: config)
+ let transport2 = HttpTransport(configuration: config)
+
+ // Verify both exist independently
+ await withCheckedContinuation { continuation in
+ Task {
+ _ = transport1
+ _ = transport2
+ continuation.resume()
+ }
+ }
+ }
+
+ // MARK: - Dependency Injection Tests
+
+ func testTransportWithCustomHTTPClient() async {
+ let url = URL(string: "https://agent.example.com")!
+ let config = HttpAgentConfiguration(baseURL: url)
+ let mockClient = MockHTTPClient()
+
+ let transport = HttpTransport(configuration: config, httpClient: mockClient)
+
+ // Verify transport was created with custom client
+ await withCheckedContinuation { continuation in
+ Task {
+ _ = transport
+ continuation.resume()
+ }
+ }
+ }
+
+ func testTransportExecutesWithMockClient() async throws {
+ // Setup
+ let url = URL(string: "https://agent.example.com")!
+ let config = HttpAgentConfiguration(baseURL: url)
+ let mockClient = MockHTTPClient()
+
+ // Configure mock response
+ let mockData = Data("event: test\ndata: {}\n\n".utf8)
+ let mockResponse = try await HTTPResponse.mock(data: mockData, statusCode: 200)
+ await mockClient.setResponse(mockResponse)
+
+ let transport = HttpTransport(configuration: config, httpClient: mockClient)
+
+ // Execute
+ let input = try RunAgentInput.builder()
+ .threadId("thread-1")
+ .runId("run-1")
+ .build()
+
+ let bytes = try await transport.execute(endpoint: "/run", input: input)
+
+ // Verify request was made
+ let callCount = await mockClient.executeCallCount
+ XCTAssertEqual(callCount, 1)
+
+ let lastRequest = await mockClient.lastRequest
+ XCTAssertNotNil(lastRequest)
+ XCTAssertEqual(lastRequest?.url?.path, "/run")
+ XCTAssertEqual(lastRequest?.httpMethod, "POST")
+ XCTAssertEqual(lastRequest?.value(forHTTPHeaderField: "Content-Type"), "application/json")
+ XCTAssertEqual(lastRequest?.value(forHTTPHeaderField: "Accept"), "text/event-stream")
+
+ // Verify bytes are returned
+ _ = bytes
+ }
+
+ func testTransportPropagatesClientErrors() async throws {
+ // Setup
+ let url = URL(string: "https://agent.example.com")!
+ let config = HttpAgentConfiguration(baseURL: url)
+ let mockClient = MockHTTPClient()
+
+ // Configure mock to throw error
+ await mockClient.setError(ClientError.timeout)
+
+ let transport = HttpTransport(configuration: config, httpClient: mockClient)
+
+ // Execute
+ let input = try RunAgentInput.builder()
+ .threadId("thread-1")
+ .runId("run-1")
+ .build()
+
+ // Verify error is propagated
+ do {
+ _ = try await transport.execute(endpoint: "/run", input: input)
+ XCTFail("Expected error to be thrown")
+ } catch let error as ClientError {
+ if case .timeout = error {
+ // Success
+ } else {
+ XCTFail("Expected timeout error, got \(error)")
+ }
+ }
+ }
+
+ func testTransportHandlesHTTPErrors() async throws {
+ // Setup
+ let url = URL(string: "https://agent.example.com")!
+ let config = HttpAgentConfiguration(baseURL: url)
+ let mockClient = MockHTTPClient()
+
+ // Configure mock response with error status
+ let mockResponse = try await HTTPResponse.mock(statusCode: 500)
+ await mockClient.setResponse(mockResponse)
+
+ let transport = HttpTransport(configuration: config, httpClient: mockClient)
+
+ // Execute
+ let input = try RunAgentInput.builder()
+ .threadId("thread-1")
+ .runId("run-1")
+ .build()
+
+ // Verify HTTP error is thrown
+ do {
+ _ = try await transport.execute(endpoint: "/run", input: input)
+ XCTFail("Expected HTTP error to be thrown")
+ } catch let error as ClientError {
+ if case .httpError(let statusCode) = error {
+ XCTAssertEqual(statusCode, 500)
+ } else {
+ XCTFail("Expected httpError, got \(error)")
+ }
+ }
+ }
+
+ func testTransportUsesDefaultClientWhenNoneProvided() async {
+ let url = URL(string: "https://agent.example.com")!
+ let config = HttpAgentConfiguration(baseURL: url)
+
+ // Create without providing httpClient - should use default URLSessionHTTPClient
+ let transport = HttpTransport(configuration: config)
+
+ await withCheckedContinuation { continuation in
+ Task {
+ _ = transport
+ continuation.resume()
+ }
+ }
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUIClientTests/Transport/MockHTTPClient.swift b/sdks/community/swift/Tests/AGUIClientTests/Transport/MockHTTPClient.swift
new file mode 100644
index 0000000000..04358a9676
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUIClientTests/Transport/MockHTTPClient.swift
@@ -0,0 +1,488 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+@testable import AGUIClient
+
+/// Mock HTTP client for testing.
+///
+/// `MockHTTPClient` enables testing of HTTP transport without network calls.
+/// Configure it to return specific responses or throw errors.
+///
+/// ## Example
+///
+/// ```swift
+/// let mock = MockHTTPClient()
+/// mock.responseToReturn = HTTPResponse(
+/// bytes: mockBytes,
+/// httpResponse: HTTPURLResponse(...)
+/// )
+///
+/// let transport = HttpTransport(
+/// configuration: config,
+/// httpClient: mock
+/// )
+///
+/// // Now transport.execute() returns the mocked response
+/// ```
+actor MockHTTPClient: HTTPClient {
+ /// Response to return from execute().
+ var responseToReturn: HTTPResponse?
+
+ /// Error to throw from execute().
+ var errorToThrow: Error?
+
+ /// Records all requests made to execute().
+ var requestsReceived: [URLRequest] = []
+
+ /// Number of times execute() was called.
+ var executeCallCount: Int {
+ requestsReceived.count
+ }
+
+ /// The most recent request received.
+ var lastRequest: URLRequest? {
+ requestsReceived.last
+ }
+
+ func execute(_ request: URLRequest) async throws -> HTTPResponse {
+ requestsReceived.append(request)
+
+ if let error = errorToThrow {
+ throw error
+ }
+
+ guard let response = responseToReturn else {
+ fatalError("MockHTTPClient: responseToReturn not set")
+ }
+
+ return response
+ }
+
+ /// Resets the mock to initial state.
+ func reset() {
+ responseToReturn = nil
+ errorToThrow = nil
+ requestsReceived.removeAll()
+ }
+
+ /// Convenience method to set the response to return.
+ func setResponse(_ response: HTTPResponse) {
+ responseToReturn = response
+ errorToThrow = nil
+ }
+
+ /// Convenience method to set the error to throw.
+ func setError(_ error: Error) {
+ errorToThrow = error
+ responseToReturn = nil
+ }
+}
+
+/// Mock bytes sequence for testing.
+///
+/// Creates an AsyncSequence of bytes from a Data object with controllable chunking.
+public struct MockAsyncBytes: AsyncSequence {
+ public typealias Element = UInt8
+
+ private let data: Data
+ private let chunkSize: Int
+
+ /// Creates a mock async bytes sequence.
+ ///
+ /// - Parameters:
+ /// - data: The data to stream as bytes
+ /// - chunkSize: Number of bytes per chunk for simulating network delays (default: 1)
+ public init(data: Data, chunkSize: Int = 1) {
+ self.data = data
+ self.chunkSize = chunkSize
+ }
+
+ public func makeAsyncIterator() -> AsyncIterator {
+ AsyncIterator(data: data, chunkSize: chunkSize)
+ }
+
+ public struct AsyncIterator: AsyncIteratorProtocol {
+ private let data: Data
+ private let chunkSize: Int
+ private var index: Int = 0
+
+ init(data: Data, chunkSize: Int) {
+ self.data = data
+ self.chunkSize = chunkSize
+ }
+
+ public mutating func next() async throws -> UInt8? {
+ guard index < data.count else { return nil }
+
+ // Simulate variable delay to mimic network
+ if index > 0, index % chunkSize == 0 {
+ try await Task.sleep(nanoseconds: 100_000) // 0.1ms
+ }
+
+ let byte = data[index]
+ index += 1
+ return byte
+ }
+ }
+}
+
+/// Thread-safe registry for mock URL responses.
+///
+/// `MockURLProtocolRegistry` provides isolated, thread-safe mock configuration
+/// per URLSession configuration. Each registry instance is independent, preventing
+/// test interference.
+///
+/// ## Example
+///
+/// ```swift
+/// let registry = MockURLProtocolRegistry()
+/// await registry.register(
+/// url: URL(string: "https://test.com")!,
+/// data: Data("test".utf8),
+/// statusCode: 200
+/// )
+///
+/// let session = URLSession.makeMockSession(registry: registry)
+/// // Requests to https://test.com will return the mocked response
+/// ```
+actor MockURLProtocolRegistry {
+ /// Unique identifier for this registry
+ let id = UUID()
+
+ private var responses: [String: MockResponse] = [:]
+
+ struct MockResponse: Sendable {
+ let data: Data?
+ let httpResponse: HTTPURLResponse?
+ let error: Error?
+ }
+
+ /// Registers a mock response for a specific URL.
+ func register(
+ url: URL,
+ data: Data? = nil,
+ statusCode: Int = 200,
+ headers: [String: String] = [:],
+ error: Error? = nil
+ ) {
+ let httpResponse = HTTPURLResponse(
+ url: url,
+ statusCode: statusCode,
+ httpVersion: "HTTP/1.1",
+ headerFields: headers
+ )
+
+ responses[url.absoluteString] = MockResponse(
+ data: data,
+ httpResponse: httpResponse,
+ error: error
+ )
+ }
+
+ /// Retrieves the mock response for a URL.
+ func response(for url: URL) -> MockResponse? {
+ responses[url.absoluteString]
+ }
+
+ /// Clears all registered responses.
+ func reset() {
+ responses.removeAll()
+ }
+}
+
+/// Global registry manager for MockURLProtocol.
+///
+/// This actor maintains a thread-safe mapping of registry IDs to their
+/// corresponding MockURLProtocolRegistry instances. MockURLProtocol uses
+/// this to look up the correct registry for each request.
+actor MockURLProtocolRegistryManager {
+ static let shared = MockURLProtocolRegistryManager()
+
+ private var registries: [UUID: MockURLProtocolRegistry] = [:]
+
+ /// Registers a registry and returns its ID.
+ func register(_ registry: MockURLProtocolRegistry) async -> UUID {
+ let id = await registry.id
+ registries[id] = registry
+ return id
+ }
+
+ /// Retrieves a registry by ID.
+ func registry(for id: UUID) -> MockURLProtocolRegistry? {
+ registries[id]
+ }
+
+ /// Unregisters a registry.
+ func unregister(_ id: UUID) {
+ registries.removeIf(id: id)
+ }
+
+ /// Clears all registries.
+ func reset() {
+ registries.removeAll()
+ }
+}
+
+extension Dictionary where Key == UUID {
+ mutating func removeIf(id: UUID) {
+ removeValue(forKey: id)
+ }
+}
+
+/// Mock URLProtocol for testing HTTP responses.
+///
+/// This protocol intercepts URLSession requests and returns mock data from a
+/// thread-safe registry, enabling isolated testing without network calls.
+///
+/// ## Usage
+///
+/// Don't instantiate this directly. Use `URLSession.makeMockSession(registry:)`
+/// to create a properly configured test session.
+///
+/// ## Example
+///
+/// ```swift
+/// let registry = MockURLProtocolRegistry()
+/// await registry.register(
+/// url: URL(string: "https://api.example.com/data")!,
+/// data: Data("response".utf8),
+/// statusCode: 200
+/// )
+///
+/// let session = URLSession.makeMockSession(registry: registry)
+/// let client = URLSessionHTTPClient(session: session)
+///
+/// // Requests will use mocked responses from registry
+/// let (data, _) = try await session.data(from: URL(string: "https://api.example.com/data")!)
+/// ```
+final class MockURLProtocol: URLProtocol {
+ /// Header key for passing the registry ID
+ static let registryIDHeader = "X-MockURLProtocol-Registry-ID"
+
+ override class func canInit(with request: URLRequest) -> Bool {
+ // Only handle requests that have a registry ID header
+ request.value(forHTTPHeaderField: registryIDHeader) != nil
+ }
+
+ override class func canonicalRequest(for request: URLRequest) -> URLRequest {
+ request
+ }
+
+ override func startLoading() {
+ guard let url = request.url else {
+ client?.urlProtocol(self, didFailWithError: URLError(.badURL))
+ return
+ }
+
+ // Get registry ID from header
+ guard let registryIDString = request.value(forHTTPHeaderField: Self.registryIDHeader),
+ let registryID = UUID(uuidString: registryIDString)
+ else {
+ client?.urlProtocol(self, didFailWithError: URLError(.unknown))
+ return
+ }
+
+ // Retrieve mock response
+ Task {
+ // Get registry from global manager
+ guard let registry = await MockURLProtocolRegistryManager.shared.registry(for: registryID) else {
+ self.client?.urlProtocol(self, didFailWithError: URLError(.unknown))
+ return
+ }
+
+ let mockResponse = await registry.response(for: url)
+
+ // If no mock response found, return error
+ guard let mockResponse = mockResponse else {
+ let error = URLError(
+ .resourceUnavailable,
+ userInfo: [NSLocalizedDescriptionKey: "No mock response registered for \(url)"]
+ )
+ self.client?.urlProtocol(self, didFailWithError: error)
+ return
+ }
+
+ // Handle error
+ if let error = mockResponse.error {
+ self.client?.urlProtocol(self, didFailWithError: error)
+ return
+ }
+
+ // Send response
+ if let httpResponse = mockResponse.httpResponse {
+ self.client?.urlProtocol(
+ self,
+ didReceive: httpResponse,
+ cacheStoragePolicy: .notAllowed
+ )
+ }
+
+ // Send data
+ if let data = mockResponse.data {
+ self.client?.urlProtocol(self, didLoad: data)
+ }
+
+ self.client?.urlProtocolDidFinishLoading(self)
+ }
+ }
+
+ override func stopLoading() {
+ // No-op
+ }
+}
+
+extension HTTPResponse {
+ /// Creates a mock HTTP response for testing with URLProtocol.
+ ///
+ /// - Parameters:
+ /// - data: Response body data
+ /// - statusCode: HTTP status code (default: 200)
+ /// - headers: HTTP headers (default: empty)
+ /// - Returns: A mock HTTPResponse with real URLSession.AsyncBytes
+ ///
+ /// This method creates a real HTTPResponse using MockURLProtocol with an
+ /// isolated registry. The returned response contains actual URLSession.AsyncBytes
+ /// that can be consumed in tests.
+ ///
+ /// ## Example
+ ///
+ /// ```swift
+ /// let response = try await HTTPResponse.mock(
+ /// data: Data("test data".utf8),
+ /// statusCode: 200,
+ /// headers: ["Content-Type": "text/plain"]
+ /// )
+ ///
+ /// for try await byte in response.bytes {
+ /// print(byte)
+ /// }
+ /// ```
+ static func mock(
+ data: Data = Data(),
+ statusCode: Int = 200,
+ headers: [String: String] = [:]
+ ) async throws -> HTTPResponse {
+ let url = URL(string: "https://test-mock.local/response")!
+
+ // Create isolated registry for this mock
+ let registry = MockURLProtocolRegistry()
+ await registry.register(
+ url: url,
+ data: data,
+ statusCode: statusCode,
+ headers: headers
+ )
+
+ // Create session with mock protocol
+ let session = await URLSession.makeMockSession(registry: registry)
+
+ // Execute request to get bytes
+ let (asyncBytes, response) = try await session.bytes(for: URLRequest(url: url))
+
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw ClientError.invalidResponse
+ }
+
+ // Bridge URLSession.AsyncBytes → AsyncThrowingStream
+ let stream = AsyncThrowingStream { continuation in
+ Task {
+ do {
+ for try await byte in asyncBytes {
+ continuation.yield(byte)
+ }
+ continuation.finish()
+ } catch {
+ continuation.finish(throwing: error)
+ }
+ }
+ }
+
+ return HTTPResponse(bytes: stream, httpResponse: httpResponse)
+ }
+}
+
+extension URLSession {
+ /// Creates a URLSession configured with MockURLProtocol for testing.
+ ///
+ /// - Parameter registry: The mock response registry to use
+ /// - Returns: A configured URLSession that uses mocked responses
+ ///
+ /// This factory method creates an ephemeral URLSession that intercepts
+ /// requests using MockURLProtocol and returns responses from the provided
+ /// registry. Each session is isolated with its own registry, preventing
+ /// test interference.
+ ///
+ /// The registry is automatically registered with the global registry manager
+ /// and cleaned up when appropriate.
+ ///
+ /// ## Example
+ ///
+ /// ```swift
+ /// let registry = MockURLProtocolRegistry()
+ /// await registry.register(
+ /// url: URL(string: "https://api.test.com/data")!,
+ /// data: Data("response".utf8),
+ /// statusCode: 200
+ /// )
+ ///
+ /// let session = await URLSession.makeMockSession(registry: registry)
+ /// let client = URLSessionHTTPClient(session: session)
+ ///
+ /// // All requests through this client will use mocked responses
+ /// let response = try await client.execute(request)
+ /// ```
+ static func makeMockSession(registry: MockURLProtocolRegistry) async -> URLSession {
+ // Register the registry with the global manager
+ let registryID = await MockURLProtocolRegistryManager.shared.register(registry)
+
+ // Create configuration with MockURLProtocol
+ let config = URLSessionConfiguration.ephemeral
+ config.protocolClasses = [MockURLProtocol.self]
+
+ // Add registry ID to default headers so all requests include it
+ config.httpAdditionalHeaders = [
+ MockURLProtocol.registryIDHeader: registryID.uuidString,
+ ]
+
+ return URLSession(configuration: config)
+ }
+}
+
+extension URLSession {
+ /// Creates a test URLSession with mocked responses for specific URLs.
+ ///
+ /// This is a convenience method for creating a mock session with pre-configured
+ /// responses. For more control, use `makeMockSession(registry:)` directly.
+ ///
+ /// - Parameters:
+ /// - mockResponses: Dictionary mapping URLs to mock response data
+ /// - defaultStatusCode: Default status code for all responses (default: 200)
+ /// - Returns: A configured URLSession with mocked responses
+ ///
+ /// ## Example
+ ///
+ /// ```swift
+ /// let session = await URLSession.makeMockSession(
+ /// mockResponses: [
+ /// URL(string: "https://api.test.com/users")!: Data("[...]".utf8),
+ /// URL(string: "https://api.test.com/posts")!: Data("[...]".utf8)
+ /// ]
+ /// )
+ /// ```
+ static func makeMockSession(
+ mockResponses: [URL: Data],
+ defaultStatusCode: Int = 200
+ ) async -> URLSession {
+ let registry = MockURLProtocolRegistry()
+
+ for (url, data) in mockResponses {
+ await registry.register(
+ url: url,
+ data: data,
+ statusCode: defaultStatusCode
+ )
+ }
+
+ return await makeMockSession(registry: registry)
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUIClientTests/Transport/URLSessionHTTPClientTests.swift b/sdks/community/swift/Tests/AGUIClientTests/Transport/URLSessionHTTPClientTests.swift
new file mode 100644
index 0000000000..6f388844ba
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUIClientTests/Transport/URLSessionHTTPClientTests.swift
@@ -0,0 +1,262 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUIClient
+
+final class URLSessionHTTPClientTests: XCTestCase {
+ // MARK: - Initialization Tests
+
+ func testInitWithSession() async {
+ let session = URLSession.shared
+ let client = URLSessionHTTPClient(session: session)
+
+ // Verify client was created
+ await withCheckedContinuation { continuation in
+ Task {
+ _ = client
+ continuation.resume()
+ }
+ }
+ }
+
+ func testCreateWithDefaultConfiguration() {
+ let client = URLSessionHTTPClient.create()
+
+ // Verify client was created with default config
+ XCTAssertNotNil(client)
+ }
+
+ func testCreateWithCustomConfiguration() {
+ let config = URLSessionConfiguration.ephemeral
+ config.timeoutIntervalForRequest = 30
+
+ let client = URLSessionHTTPClient.create(configuration: config)
+
+ XCTAssertNotNil(client)
+ }
+
+ // MARK: - Session Injection Tests
+
+ func testCustomSessionInjection() async {
+ // Create custom session configuration
+ let config = URLSessionConfiguration.default
+ config.timeoutIntervalForRequest = 60
+ config.httpAdditionalHeaders = ["Custom-Header": "Test"]
+
+ let session = URLSession(configuration: config)
+ let client = URLSessionHTTPClient(session: session)
+
+ // Verify we can use the client
+ await withCheckedContinuation { continuation in
+ Task {
+ _ = client
+ continuation.resume()
+ }
+ }
+ }
+
+ func testSharedSessionReuse() async {
+ // Multiple clients can share the same session
+ let sharedSession = URLSession.shared
+
+ let client1 = URLSessionHTTPClient(session: sharedSession)
+ let client2 = URLSessionHTTPClient(session: sharedSession)
+
+ await withCheckedContinuation { continuation in
+ Task {
+ _ = client1
+ _ = client2
+ continuation.resume()
+ }
+ }
+ }
+
+ // MARK: - HTTPClient Protocol Conformance
+
+ func testConformsToHTTPClient() {
+ let client: any HTTPClient = URLSessionHTTPClient.create()
+ XCTAssertNotNil(client)
+ }
+
+ func testProtocolTypeErasure() async {
+ let client: any HTTPClient = URLSessionHTTPClient.create()
+
+ // Can be used through protocol
+ await withCheckedContinuation { continuation in
+ Task {
+ _ = client
+ continuation.resume()
+ }
+ }
+ }
+
+ // MARK: - Error Mapping Tests
+
+ func testURLErrorMapping() async {
+ // Test error mapping without actual network calls
+ // (Full integration tests would use URLProtocol)
+
+ let client = URLSessionHTTPClient.create()
+
+ // Verify timeout error would be mapped
+ let timeoutError = URLError(.timedOut)
+ XCTAssertEqual(timeoutError.code, .timedOut)
+
+ // Verify cancelled error would be mapped
+ let cancelledError = URLError(.cancelled)
+ XCTAssertEqual(cancelledError.code, .cancelled)
+ }
+
+ // MARK: - Actor Isolation Tests
+
+ func testClientIsActor() async {
+ let client = URLSessionHTTPClient.create()
+
+ // Verify actor isolation by using in async context
+ await withCheckedContinuation { continuation in
+ Task {
+ _ = client
+ continuation.resume()
+ }
+ }
+ }
+
+ func testMultipleClientsCanExist() async {
+ let client1 = URLSessionHTTPClient.create()
+ let client2 = URLSessionHTTPClient.create()
+ let client3 = URLSessionHTTPClient.create()
+
+ // All should exist independently
+ await withCheckedContinuation { continuation in
+ Task {
+ _ = client1
+ _ = client2
+ _ = client3
+ continuation.resume()
+ }
+ }
+ }
+
+ // MARK: - Factory Method Tests
+
+ func testFactoryCreatesNewInstances() {
+ let client1 = URLSessionHTTPClient.create()
+ let client2 = URLSessionHTTPClient.create()
+
+ // Each factory call creates a new instance
+ // (Can't test identity with actors, but we can verify they exist)
+ XCTAssertNotNil(client1)
+ XCTAssertNotNil(client2)
+ }
+
+ func testFactoryWithDifferentConfigurations() {
+ let defaultClient = URLSessionHTTPClient.create()
+
+ let ephemeralConfig = URLSessionConfiguration.ephemeral
+ let ephemeralClient = URLSessionHTTPClient.create(configuration: ephemeralConfig)
+
+ let backgroundConfig = URLSessionConfiguration.background(withIdentifier: "test")
+ let backgroundClient = URLSessionHTTPClient.create(configuration: backgroundConfig)
+
+ XCTAssertNotNil(defaultClient)
+ XCTAssertNotNil(ephemeralClient)
+ XCTAssertNotNil(backgroundClient)
+ }
+
+ // MARK: - Cancellation propagation
+
+ func test_cancellation_stopsUnderlyingTask() async throws {
+ // When the consumer cancels the stream, the inner Task must be cancelled via
+ // continuation.onTermination so the URLSession data task is torn down.
+ // StallingURLProtocol sends response headers then stalls on the body,
+ // letting us verify stopLoading() is called after consumer cancellation.
+ let config = URLSessionConfiguration.ephemeral
+ config.protocolClasses = [StallingURLProtocol.self]
+ let session = URLSession(configuration: config)
+ let client = URLSessionHTTPClient(session: session)
+
+ StallingURLProtocol.reset()
+
+ let consumingTask = Task {
+ let request = URLRequest(url: URL(string: "https://stall.test/stream")!)
+ let response = try await client.execute(request)
+ // Start consuming — the stream body never arrives, so this suspends.
+ for try await _ in response.bytes { break }
+ }
+
+ // Wait for the URLProtocol to signal it has started serving the body.
+ await StallingURLProtocol.waitUntilStarted()
+ // Cancel the consumer — this should trigger onTermination → task.cancel().
+ consumingTask.cancel()
+
+ let cancelled = await StallingURLProtocol.waitUntilCancelledOrTimeout()
+ XCTAssertTrue(cancelled, "URLSession task must be cancelled when consumer cancels the stream")
+ }
+}
+
+// MARK: - StallingURLProtocol
+
+/// URLProtocol that sends 200 response headers immediately, then stalls on the body.
+/// Records when URLSession cancels the task via stopLoading().
+final class StallingURLProtocol: URLProtocol, @unchecked Sendable {
+ private static let startedContinuations = NSLock()
+ private static var _startedContinuation: CheckedContinuation?
+ private static var _cancelledContinuation: CheckedContinuation?
+ private static var _started = false
+ private static var _cancelled = false
+
+ static func reset() {
+ startedContinuations.lock(); defer { startedContinuations.unlock() }
+ _startedContinuation = nil
+ _cancelledContinuation = nil
+ _started = false
+ _cancelled = false
+ }
+
+ static func waitUntilStarted() async {
+ await withCheckedContinuation { cont in
+ startedContinuations.lock(); defer { startedContinuations.unlock() }
+ if _started { cont.resume() } else { _startedContinuation = cont }
+ }
+ }
+
+ static func waitUntilCancelledOrTimeout() async -> Bool {
+ await withCheckedContinuation { cont in
+ startedContinuations.lock(); defer { startedContinuations.unlock() }
+ if _cancelled { cont.resume(returning: true) } else { _cancelledContinuation = cont }
+ }
+ }
+
+ private static func signalStarted() {
+ startedContinuations.lock(); defer { startedContinuations.unlock() }
+ _started = true
+ _startedContinuation?.resume()
+ _startedContinuation = nil
+ }
+
+ private static func signalCancelled() {
+ startedContinuations.lock(); defer { startedContinuations.unlock() }
+ _cancelled = true
+ _cancelledContinuation?.resume(returning: true)
+ _cancelledContinuation = nil
+ }
+
+ override class func canInit(with request: URLRequest) -> Bool { true }
+ override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
+
+ override func startLoading() {
+ let response = HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: "HTTP/1.1",
+ headerFields: ["Content-Type": "text/event-stream"]
+ )!
+ client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
+ // Signal that headers were sent — body never arrives (intentional stall).
+ StallingURLProtocol.signalStarted()
+ }
+
+ override func stopLoading() {
+ StallingURLProtocol.signalCancelled()
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/ActivityEvents/ActivityDeltaEventTests.swift b/sdks/community/swift/Tests/AGUICoreTests/ActivityEvents/ActivityDeltaEventTests.swift
new file mode 100644
index 0000000000..8c6345c772
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/ActivityEvents/ActivityDeltaEventTests.swift
@@ -0,0 +1,176 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+final class ActivityDeltaEventTests: XCTestCase,
+ AGUIEventDecoderTestHelpers,
+ EventDecodingErrorTests {
+
+ // MARK: - EventDecodingErrorTests Protocol Requirements
+
+ var validEventFieldsWithoutType: [String: Any] {
+ [
+ "messageId": EventTestData.messageId,
+ "activityType": "a2ui-surface",
+ "patch": [["op": "add", "path": "/foo", "value": "bar"]]
+ ]
+ }
+
+ var eventTypeString: String { "ACTIVITY_DELTA" }
+ var expectedEventType: EventType { .activityDelta }
+ var unknownEventTypeString: String { "ACTIVITY_PAUSED" }
+
+ // MARK: - Feature: Decode ACTIVITY_DELTA
+
+ func test_decodeValidActivityDelta_returnsActivityDeltaEvent() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "ACTIVITY_DELTA",
+ "messageId": "\(EventTestData.messageId)",
+ "activityType": "a2ui-surface",
+ "patch": [{"op": "add", "path": "/foo", "value": "bar"}]
+ }
+ """)
+
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ guard let delta = event as? ActivityDeltaEvent else {
+ return XCTFail("Expected ActivityDeltaEvent, got \(type(of: event))")
+ }
+ XCTAssertEqual(delta.eventType, .activityDelta)
+ XCTAssertEqual(delta.messageId, EventTestData.messageId)
+ XCTAssertEqual(delta.activityType, "a2ui-surface")
+ // Compare parsed patch content instead of raw Data (JSON serialization may differ)
+ let parsedPatch = try delta.parsedPatch() as? [[String: Any]]
+ XCTAssertNotNil(parsedPatch)
+ XCTAssertEqual(parsedPatch?.count, 1)
+ XCTAssertEqual(parsedPatch?.first?["op"] as? String, "add")
+ XCTAssertEqual(parsedPatch?.first?["path"] as? String, "/foo")
+ XCTAssertEqual(parsedPatch?.first?["value"] as? String, "bar")
+ XCTAssertNil(delta.timestamp)
+ }
+
+ func test_decodeActivityDeltaWithTimestamp_returnsActivityDeltaEvent() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "ACTIVITY_DELTA",
+ "messageId": "\(EventTestData.messageId)",
+ "activityType": "a2ui-surface",
+ "patch": [{"op": "add", "path": "/foo", "value": "bar"}],
+ "timestamp": \(EventTestData.timestamp)
+ }
+ """)
+
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ guard let delta = event as? ActivityDeltaEvent else {
+ return XCTFail("Expected ActivityDeltaEvent, got \(type(of: event))")
+ }
+ XCTAssertEqual(delta.timestamp, EventTestData.timestamp)
+ }
+
+ func test_activityDeltaEvent_eventTypeIsAlwaysActivityDelta() throws {
+ // Given
+ let patch = try JSONSerialization.data(withJSONObject: [["op": "add", "path": "/foo", "value": "bar"]], options: [])
+ let event = ActivityDeltaEvent(
+ messageId: EventTestData.messageId,
+ activityType: "a2ui-surface",
+ patch: patch
+ )
+
+ // Then
+ XCTAssertEqual(event.eventType, .activityDelta)
+ }
+
+ func test_activityDeltaEvent_parsedPatch_returnsParsedArray() throws {
+ // Given
+ let patch = [["op": "add", "path": "/foo", "value": "bar"]]
+ let patchData = try JSONSerialization.data(withJSONObject: patch, options: [])
+ let event = ActivityDeltaEvent(
+ messageId: EventTestData.messageId,
+ activityType: "a2ui-surface",
+ patch: patchData
+ )
+
+ // When
+ let parsed = try event.parsedPatch() as? [[String: Any]]
+
+ // Then
+ XCTAssertNotNil(parsed)
+ XCTAssertEqual(parsed?.count, 1)
+ XCTAssertEqual(parsed?.first?["op"] as? String, "add")
+ XCTAssertEqual(parsed?.first?["path"] as? String, "/foo")
+ }
+
+ // MARK: - Equatable Tests
+
+ func test_activityDeltaEvent_equatable_sameFields_areEqual() throws {
+ // Given
+ let patch = try JSONSerialization.data(withJSONObject: [["op": "add", "path": "/foo", "value": "bar"]], options: [])
+ let event1 = ActivityDeltaEvent(
+ messageId: EventTestData.messageId,
+ activityType: "a2ui-surface",
+ patch: patch,
+ timestamp: EventTestData.timestamp,
+ rawEvent: nil
+ )
+ let event2 = ActivityDeltaEvent(
+ messageId: EventTestData.messageId,
+ activityType: "a2ui-surface",
+ patch: patch,
+ timestamp: EventTestData.timestamp,
+ rawEvent: nil
+ )
+
+ // Then
+ XCTAssertEqual(event1, event2)
+ }
+
+ func test_activityDeltaEvent_equatable_differentMessageIds_areNotEqual() throws {
+ // Given
+ let patch = try JSONSerialization.data(withJSONObject: [["op": "add", "path": "/foo", "value": "bar"]], options: [])
+ let event1 = ActivityDeltaEvent(
+ messageId: EventTestData.messageId,
+ activityType: "a2ui-surface",
+ patch: patch
+ )
+ let event2 = ActivityDeltaEvent(
+ messageId: EventTestData.messageId2,
+ activityType: "a2ui-surface",
+ patch: patch
+ )
+
+ // Then
+ XCTAssertNotEqual(event1, event2)
+ }
+
+ func test_activityDeltaEvent_equatable_differentPatches_areNotEqual() throws {
+ // Given
+ let patch1 = try JSONSerialization.data(withJSONObject: [["op": "add", "path": "/foo", "value": "bar"]], options: [])
+ let patch2 = try JSONSerialization.data(withJSONObject: [["op": "remove", "path": "/foo"]], options: [])
+ let event1 = ActivityDeltaEvent(
+ messageId: EventTestData.messageId,
+ activityType: "a2ui-surface",
+ patch: patch1
+ )
+ let event2 = ActivityDeltaEvent(
+ messageId: EventTestData.messageId,
+ activityType: "a2ui-surface",
+ patch: patch2
+ )
+
+ // Then
+ XCTAssertNotEqual(event1, event2)
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/ActivityEvents/ActivitySnapshotEventTests.swift b/sdks/community/swift/Tests/AGUICoreTests/ActivityEvents/ActivitySnapshotEventTests.swift
new file mode 100644
index 0000000000..0a22019d0f
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/ActivityEvents/ActivitySnapshotEventTests.swift
@@ -0,0 +1,218 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+final class ActivitySnapshotEventTests: XCTestCase,
+ AGUIEventDecoderTestHelpers,
+ EventDecodingErrorTests {
+
+ // MARK: - EventDecodingErrorTests Protocol Requirements
+
+ var validEventFieldsWithoutType: [String: Any] {
+ [
+ "messageId": EventTestData.messageId,
+ "activityType": "a2ui-surface",
+ "content": ["key": "value"]
+ ]
+ }
+
+ var eventTypeString: String { "ACTIVITY_SNAPSHOT" }
+ var expectedEventType: EventType { .activitySnapshot }
+ var unknownEventTypeString: String { "ACTIVITY_PAUSED" }
+
+ // MARK: - Feature: Decode ACTIVITY_SNAPSHOT
+
+ func test_decodeValidActivitySnapshot_returnsActivitySnapshotEvent() throws {
+ // Given
+ let content = ["key": "value"]
+ let contentData = try JSONSerialization.data(withJSONObject: content, options: [])
+
+ let data = jsonData("""
+ {
+ "type": "ACTIVITY_SNAPSHOT",
+ "messageId": "\(EventTestData.messageId)",
+ "activityType": "a2ui-surface",
+ "content": {"key": "value"}
+ }
+ """)
+
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ guard let snapshot = event as? ActivitySnapshotEvent else {
+ return XCTFail("Expected ActivitySnapshotEvent, got \(type(of: event))")
+ }
+ XCTAssertEqual(snapshot.eventType, .activitySnapshot)
+ XCTAssertEqual(snapshot.messageId, EventTestData.messageId)
+ XCTAssertEqual(snapshot.activityType, "a2ui-surface")
+ XCTAssertEqual(snapshot.content, contentData)
+ XCTAssertEqual(snapshot.replace, true) // Default value
+ XCTAssertNil(snapshot.timestamp)
+ }
+
+ func test_decodeActivitySnapshotWithReplaceFalse_returnsActivitySnapshotEvent() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "ACTIVITY_SNAPSHOT",
+ "messageId": "\(EventTestData.messageId)",
+ "activityType": "a2ui-surface",
+ "content": {"key": "value"},
+ "replace": false
+ }
+ """)
+
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ guard let snapshot = event as? ActivitySnapshotEvent else {
+ return XCTFail("Expected ActivitySnapshotEvent, got \(type(of: event))")
+ }
+ XCTAssertEqual(snapshot.replace, false)
+ }
+
+ func test_decodeActivitySnapshotWithTimestamp_returnsActivitySnapshotEvent() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "ACTIVITY_SNAPSHOT",
+ "messageId": "\(EventTestData.messageId)",
+ "activityType": "a2ui-surface",
+ "content": {"key": "value"},
+ "timestamp": \(EventTestData.timestamp)
+ }
+ """)
+
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ guard let snapshot = event as? ActivitySnapshotEvent else {
+ return XCTFail("Expected ActivitySnapshotEvent, got \(type(of: event))")
+ }
+ XCTAssertEqual(snapshot.timestamp, EventTestData.timestamp)
+ }
+
+ func test_activitySnapshotEvent_eventTypeIsAlwaysActivitySnapshot() throws {
+ // Given
+ let content = try JSONSerialization.data(withJSONObject: ["key": "value"], options: [])
+ let event = ActivitySnapshotEvent(
+ messageId: EventTestData.messageId,
+ activityType: "a2ui-surface",
+ content: content
+ )
+
+ // Then
+ XCTAssertEqual(event.eventType, .activitySnapshot)
+ }
+
+ func test_activitySnapshotEvent_parsedContent_returnsParsedJSON() throws {
+ // Given
+ let content: [String: Any] = ["key": "value", "number": 42]
+ let contentData = try JSONSerialization.data(withJSONObject: content, options: [])
+ let event = ActivitySnapshotEvent(
+ messageId: EventTestData.messageId,
+ activityType: "a2ui-surface",
+ content: contentData
+ )
+
+ // When
+ let parsed = try event.parsedContent() as? [String: Any]
+
+ // Then
+ XCTAssertNotNil(parsed)
+ XCTAssertEqual(parsed?["key"] as? String, "value")
+ XCTAssertEqual(parsed?["number"] as? Int, 42)
+ }
+
+ // MARK: - Equatable Tests
+
+ func test_activitySnapshotEvent_equatable_sameFields_areEqual() throws {
+ // Given
+ let content = try JSONSerialization.data(withJSONObject: ["key": "value"], options: [])
+ let event1 = ActivitySnapshotEvent(
+ messageId: EventTestData.messageId,
+ activityType: "a2ui-surface",
+ content: content,
+ replace: true,
+ timestamp: EventTestData.timestamp,
+ rawEvent: nil
+ )
+ let event2 = ActivitySnapshotEvent(
+ messageId: EventTestData.messageId,
+ activityType: "a2ui-surface",
+ content: content,
+ replace: true,
+ timestamp: EventTestData.timestamp,
+ rawEvent: nil
+ )
+
+ // Then
+ XCTAssertEqual(event1, event2)
+ }
+
+ func test_activitySnapshotEvent_equatable_differentMessageIds_areNotEqual() throws {
+ // Given
+ let content = try JSONSerialization.data(withJSONObject: ["key": "value"], options: [])
+ let event1 = ActivitySnapshotEvent(
+ messageId: EventTestData.messageId,
+ activityType: "a2ui-surface",
+ content: content
+ )
+ let event2 = ActivitySnapshotEvent(
+ messageId: EventTestData.messageId2,
+ activityType: "a2ui-surface",
+ content: content
+ )
+
+ // Then
+ XCTAssertNotEqual(event1, event2)
+ }
+
+ func test_activitySnapshotEvent_equatable_differentActivityTypes_areNotEqual() throws {
+ // Given
+ let content = try JSONSerialization.data(withJSONObject: ["key": "value"], options: [])
+ let event1 = ActivitySnapshotEvent(
+ messageId: EventTestData.messageId,
+ activityType: "a2ui-surface",
+ content: content
+ )
+ let event2 = ActivitySnapshotEvent(
+ messageId: EventTestData.messageId,
+ activityType: "other-type",
+ content: content
+ )
+
+ // Then
+ XCTAssertNotEqual(event1, event2)
+ }
+
+ func test_activitySnapshotEvent_equatable_differentReplaceValues_areNotEqual() throws {
+ // Given
+ let content = try JSONSerialization.data(withJSONObject: ["key": "value"], options: [])
+ let event1 = ActivitySnapshotEvent(
+ messageId: EventTestData.messageId,
+ activityType: "a2ui-surface",
+ content: content,
+ replace: true
+ )
+ let event2 = ActivitySnapshotEvent(
+ messageId: EventTestData.messageId,
+ activityType: "a2ui-surface",
+ content: content,
+ replace: false
+ )
+
+ // Then
+ XCTAssertNotEqual(event1, event2)
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/Decoding/ThinkingRemapTests.swift b/sdks/community/swift/Tests/AGUICoreTests/Decoding/ThinkingRemapTests.swift
new file mode 100644
index 0000000000..2b936e109f
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/Decoding/ThinkingRemapTests.swift
@@ -0,0 +1,151 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+/// Tests for the THINKING_* → REASONING_* backward-compatibility remap.
+///
+/// The TypeScript `BackwardCompatibility_0_0_45` middleware maintains stateful
+/// `currentReasoningId` and `currentMessageId` so that all events in a sequence
+/// share stable, correlated IDs. These tests verify the Swift decoder does the same.
+final class ThinkingRemapTests: XCTestCase, AGUIEventDecoderTestHelpers {
+
+ // MARK: - Reasoning envelope (THINKING_START / THINKING_END)
+
+ func test_thinkingStart_remapsToReasoningStart() throws {
+ let data = jsonData(#"{"type":"THINKING_START","threadId":"t1","runId":"r1"}"#)
+ let event = try makeStrictDecoder().decode(data)
+ XCTAssertTrue(event is ReasoningStartEvent, "Expected ReasoningStartEvent, got \(type(of: event))")
+ }
+
+ func test_thinkingEnd_remapsToReasoningEnd() throws {
+ let data = jsonData(#"{"type":"THINKING_END","threadId":"t1","runId":"r1"}"#)
+ let event = try makeStrictDecoder().decode(data)
+ XCTAssertTrue(event is ReasoningEndEvent, "Expected ReasoningEndEvent, got \(type(of: event))")
+ }
+
+ func test_thinkingStartAndEnd_shareMessageId() throws {
+ // THINKING_START and THINKING_END must carry the same messageId so that
+ // consumers can correlate the open/close of a reasoning envelope.
+ let decoder = makeStrictDecoder()
+
+ let startData = jsonData(#"{"type":"THINKING_START","threadId":"t1","runId":"r1"}"#)
+ let endData = jsonData(#"{"type":"THINKING_END","threadId":"t1","runId":"r1"}"#)
+
+ let startEvent = try XCTUnwrap(try decoder.decode(startData) as? ReasoningStartEvent)
+ let endEvent = try XCTUnwrap(try decoder.decode(endData) as? ReasoningEndEvent)
+
+ XCTAssertFalse(startEvent.messageId.isEmpty, "THINKING_START must produce a non-empty messageId")
+ XCTAssertEqual(startEvent.messageId, endEvent.messageId,
+ "THINKING_START and THINKING_END must share the same messageId")
+ }
+
+ // MARK: - Text message sequence (THINKING_TEXT_MESSAGE_*)
+
+ func test_thinkingTextMessageStart_remapsToReasoningMessageStart() throws {
+ let data = jsonData(#"{"type":"THINKING_TEXT_MESSAGE_START","threadId":"t1","runId":"r1"}"#)
+ let event = try makeStrictDecoder().decode(data)
+ XCTAssertTrue(event is ReasoningMessageStartEvent,
+ "Expected ReasoningMessageStartEvent, got \(type(of: event))")
+ }
+
+ func test_thinkingTextMessageContent_remapsToReasoningMessageContent() throws {
+ // Sequence: START must be decoded first to establish currentMessageId.
+ let decoder = makeStrictDecoder()
+ _ = try decoder.decode(jsonData(#"{"type":"THINKING_TEXT_MESSAGE_START","threadId":"t1","runId":"r1"}"#))
+
+ let data = jsonData(#"{"type":"THINKING_TEXT_MESSAGE_CONTENT","threadId":"t1","runId":"r1","delta":"hello"}"#)
+ let event = try decoder.decode(data)
+ XCTAssertTrue(event is ReasoningMessageContentEvent,
+ "Expected ReasoningMessageContentEvent, got \(type(of: event))")
+ }
+
+ func test_thinkingTextMessageEnd_remapsToReasoningMessageEnd() throws {
+ let decoder = makeStrictDecoder()
+ _ = try decoder.decode(jsonData(#"{"type":"THINKING_TEXT_MESSAGE_START","threadId":"t1","runId":"r1"}"#))
+
+ let data = jsonData(#"{"type":"THINKING_TEXT_MESSAGE_END","threadId":"t1","runId":"r1"}"#)
+ let event = try decoder.decode(data)
+ XCTAssertTrue(event is ReasoningMessageEndEvent,
+ "Expected ReasoningMessageEndEvent, got \(type(of: event))")
+ }
+
+ func test_thinkingTextMessageSequence_allShareMessageId() throws {
+ // THINKING_TEXT_MESSAGE_START, _CONTENT, and _END must all carry the same
+ // messageId so consumers can assemble the full text message.
+ let decoder = makeStrictDecoder()
+
+ let startData = jsonData(#"{"type":"THINKING_TEXT_MESSAGE_START","threadId":"t1","runId":"r1"}"#)
+ let contentData = jsonData(#"{"type":"THINKING_TEXT_MESSAGE_CONTENT","threadId":"t1","runId":"r1","delta":"hi"}"#)
+ let endData = jsonData(#"{"type":"THINKING_TEXT_MESSAGE_END","threadId":"t1","runId":"r1"}"#)
+
+ let startEvent = try XCTUnwrap(try decoder.decode(startData) as? ReasoningMessageStartEvent)
+ let contentEvent = try XCTUnwrap(try decoder.decode(contentData) as? ReasoningMessageContentEvent)
+ let endEvent = try XCTUnwrap(try decoder.decode(endData) as? ReasoningMessageEndEvent)
+
+ XCTAssertFalse(startEvent.messageId.isEmpty)
+ XCTAssertEqual(startEvent.messageId, contentEvent.messageId,
+ "START and CONTENT must share messageId")
+ XCTAssertEqual(startEvent.messageId, endEvent.messageId,
+ "START and END must share messageId")
+ }
+
+ // MARK: - Two ID axes are independent
+
+ func test_reasoningEnvelopeAndTextMessageIds_areIndependent() throws {
+ // The reasoning envelope ID (THINKING_START/END) and the text message ID
+ // (THINKING_TEXT_MESSAGE_*) are two separate axes and must not be equal.
+ let decoder = makeStrictDecoder()
+
+ let reasoningStart = try XCTUnwrap(
+ try decoder.decode(jsonData(#"{"type":"THINKING_START","threadId":"t1","runId":"r1"}"#))
+ as? ReasoningStartEvent
+ )
+ let msgStart = try XCTUnwrap(
+ try decoder.decode(jsonData(#"{"type":"THINKING_TEXT_MESSAGE_START","threadId":"t1","runId":"r1"}"#))
+ as? ReasoningMessageStartEvent
+ )
+
+ XCTAssertNotEqual(reasoningStart.messageId, msgStart.messageId,
+ "Reasoning envelope ID and text message ID must be independent")
+ }
+
+ // MARK: - Decoder isolation
+
+ func test_separateDecoders_produceDifferentIds() throws {
+ // Each AGUIEventDecoder instance must have its own ID state.
+ // Two decoders processing identical THINKING_START bytes must produce different messageIds.
+ let decoderA = makeStrictDecoder()
+ let decoderB = makeStrictDecoder()
+
+ let data = jsonData(#"{"type":"THINKING_START","threadId":"t1","runId":"r1"}"#)
+
+ let eventA = try XCTUnwrap(try decoderA.decode(data) as? ReasoningStartEvent)
+ let eventB = try XCTUnwrap(try decoderB.decode(data) as? ReasoningStartEvent)
+
+ XCTAssertNotEqual(eventA.messageId, eventB.messageId,
+ "Independent decoders must produce independent messageIds")
+ }
+
+ // MARK: - ID resets between sequences
+
+ func test_newThinkingStart_generatesNewId() throws {
+ // After THINKING_END closes a reasoning envelope, a new THINKING_START must
+ // generate a fresh ID — not reuse the previous one.
+ let decoder = makeStrictDecoder()
+
+ let start1 = try XCTUnwrap(
+ try decoder.decode(jsonData(#"{"type":"THINKING_START","threadId":"t1","runId":"r1"}"#))
+ as? ReasoningStartEvent
+ )
+ _ = try decoder.decode(jsonData(#"{"type":"THINKING_END","threadId":"t1","runId":"r1"}"#))
+
+ let start2 = try XCTUnwrap(
+ try decoder.decode(jsonData(#"{"type":"THINKING_START","threadId":"t1","runId":"r1"}"#))
+ as? ReasoningStartEvent
+ )
+
+ XCTAssertNotEqual(start1.messageId, start2.messageId,
+ "A new THINKING_START sequence must produce a new messageId")
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/Encoding/MessageEncoderTests.swift b/sdks/community/swift/Tests/AGUICoreTests/Encoding/MessageEncoderTests.swift
new file mode 100644
index 0000000000..bddadf1dd8
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/Encoding/MessageEncoderTests.swift
@@ -0,0 +1,455 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+final class MessageEncoderTests: XCTestCase {
+
+ private let encoder = MessageEncoder()
+
+ // MARK: - Helper
+
+ private func json(from message: any Message) throws -> [String: Any] {
+ let data = try encoder.encode(message)
+ return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
+ }
+
+ // MARK: - Feature: DeveloperMessage encoding
+
+ func test_encodeDeveloperMessage_producesCorrectJSON() throws {
+ // Given
+ let message = DeveloperMessage(id: "dev-1", content: "Enable debug logging.")
+
+ // When
+ let json = try json(from: message)
+
+ // Then
+ XCTAssertEqual(json["id"] as? String, "dev-1")
+ XCTAssertEqual(json["role"] as? String, "developer")
+ XCTAssertEqual(json["content"] as? String, "Enable debug logging.")
+ }
+
+ func test_encodeDeveloperMessage_withName_includesName() throws {
+ // Given
+ let message = DeveloperMessage(id: "dev-2", content: "Config.", name: "SystemConfigurator")
+
+ // When
+ let json = try json(from: message)
+
+ // Then
+ XCTAssertEqual(json["name"] as? String, "SystemConfigurator")
+ }
+
+ func test_encodeDeveloperMessage_withoutName_omitsName() throws {
+ // Given
+ let message = DeveloperMessage(id: "dev-3", content: "Config.")
+
+ // When
+ let json = try json(from: message)
+
+ // Then
+ XCTAssertNil(json["name"])
+ }
+
+ // MARK: - Feature: SystemMessage encoding
+
+ func test_encodeSystemMessage_producesCorrectJSON() throws {
+ // Given
+ let message = SystemMessage(id: "sys-1", content: "You are a helpful assistant.")
+
+ // When
+ let json = try json(from: message)
+
+ // Then
+ XCTAssertEqual(json["id"] as? String, "sys-1")
+ XCTAssertEqual(json["role"] as? String, "system")
+ XCTAssertEqual(json["content"] as? String, "You are a helpful assistant.")
+ }
+
+ func test_encodeSystemMessage_emptyContent_includedInJSON() throws {
+ // Given — content defaults to "" when omitted
+ let message = SystemMessage(id: "sys-2")
+
+ // When
+ let json = try json(from: message)
+
+ // Then: content is always included (non-optional String)
+ XCTAssertEqual(json["content"] as? String, "")
+ }
+
+ func test_encodeSystemMessage_withName_includesName() throws {
+ // Given
+ let message = SystemMessage(id: "sys-3", content: "Act professionally.", name: "ProfMode")
+
+ // When
+ let json = try json(from: message)
+
+ // Then
+ XCTAssertEqual(json["name"] as? String, "ProfMode")
+ }
+
+ func test_encodeSystemMessage_withoutName_omitsName() throws {
+ // Given
+ let message = SystemMessage(id: "sys-4", content: "Act professionally.")
+
+ // When
+ let json = try json(from: message)
+
+ // Then
+ XCTAssertNil(json["name"])
+ }
+
+ // MARK: - Feature: UserMessage encoding
+
+ func test_encodeUserMessage_textOnly_producesCorrectJSON() throws {
+ // Given
+ let message = UserMessage(id: "user-1", content: "Hello!")
+
+ // When
+ let json = try json(from: message)
+
+ // Then
+ XCTAssertEqual(json["id"] as? String, "user-1")
+ XCTAssertEqual(json["role"] as? String, "user")
+ XCTAssertEqual(json["content"] as? String, "Hello!")
+ }
+
+ func test_encodeUserMessage_withName_includesName() throws {
+ // Given
+ let message = UserMessage(id: "user-2", content: "Hi", name: "Alice")
+
+ // When
+ let json = try json(from: message)
+
+ // Then
+ XCTAssertEqual(json["name"] as? String, "Alice")
+ }
+
+ func test_encodeUserMessage_withoutName_omitsName() throws {
+ // Given
+ let message = UserMessage(id: "user-3", content: "Hi")
+
+ // When
+ let json = try json(from: message)
+
+ // Then
+ XCTAssertNil(json["name"])
+ }
+
+ func test_encodeUserMessage_multimodal_contentIsArray() throws {
+ // Given: multimodal message with a single text part
+ let message = UserMessage.multimodal(
+ id: "user-4",
+ parts: [TextInputContent(text: "What's in this image?")]
+ )
+
+ // When
+ let json = try json(from: message)
+
+ // Then: content must be a JSON array, not a string
+ let contentArray = try XCTUnwrap(json["content"] as? [[String: Any]])
+ XCTAssertEqual(contentArray.count, 1)
+ XCTAssertEqual(contentArray[0]["type"] as? String, "text")
+ XCTAssertEqual(contentArray[0]["text"] as? String, "What's in this image?")
+ }
+
+ // MARK: - Feature: AssistantMessage encoding
+
+ func test_encodeAssistantMessage_producesCorrectJSON() throws {
+ // Given
+ let message = AssistantMessage(id: "asst-1", content: "I can help with that.")
+
+ // When
+ let json = try json(from: message)
+
+ // Then
+ XCTAssertEqual(json["id"] as? String, "asst-1")
+ XCTAssertEqual(json["role"] as? String, "assistant")
+ XCTAssertEqual(json["content"] as? String, "I can help with that.")
+ }
+
+ func test_encodeAssistantMessage_nilContent_omittedFromJSON() throws {
+ // Given
+ let message = AssistantMessage(id: "asst-2", content: nil)
+
+ // When
+ let json = try json(from: message)
+
+ // Then
+ XCTAssertNil(json["content"])
+ }
+
+ func test_encodeAssistantMessage_nilToolCalls_omittedFromJSON() throws {
+ // Given
+ let message = AssistantMessage(id: "asst-3", content: "Text only")
+
+ // When
+ let json = try json(from: message)
+
+ // Then
+ XCTAssertNil(json["toolCalls"])
+ }
+
+ func test_encodeAssistantMessage_withToolCalls_encodesToolCallsArray() throws {
+ // Given
+ let toolCall = ToolCall(
+ id: "call-1",
+ function: FunctionCall(name: "get_weather", arguments: "{\"city\":\"NYC\"}")
+ )
+ let message = AssistantMessage(id: "asst-4", toolCalls: [toolCall])
+
+ // When
+ let json = try json(from: message)
+
+ // Then: tool calls encoded as array with id and function fields
+ let toolCallsArray = try XCTUnwrap(json["toolCalls"] as? [[String: Any]])
+ XCTAssertEqual(toolCallsArray.count, 1)
+ XCTAssertEqual(toolCallsArray[0]["id"] as? String, "call-1")
+ let function = try XCTUnwrap(toolCallsArray[0]["function"] as? [String: Any])
+ XCTAssertEqual(function["name"] as? String, "get_weather")
+ XCTAssertEqual(function["arguments"] as? String, "{\"city\":\"NYC\"}")
+ }
+
+ func test_encodeAssistantMessage_withName_includesName() throws {
+ // Given
+ let message = AssistantMessage(id: "asst-5", content: "Hi", name: "Claude")
+
+ // When
+ let json = try json(from: message)
+
+ // Then
+ XCTAssertEqual(json["name"] as? String, "Claude")
+ }
+
+ func test_encodeAssistantMessage_withoutName_omitsName() throws {
+ // Given
+ let message = AssistantMessage(id: "asst-6", content: "Hi")
+
+ // When
+ let json = try json(from: message)
+
+ // Then
+ XCTAssertNil(json["name"])
+ }
+
+ // MARK: - Feature: ToolMessage encoding
+
+ func test_encodeToolMessage_producesCorrectJSON() throws {
+ // Given
+ let message = ToolMessage(id: "tool-1", content: "72°F, sunny", toolCallId: "call-1")
+
+ // When
+ let json = try json(from: message)
+
+ // Then
+ XCTAssertEqual(json["id"] as? String, "tool-1")
+ XCTAssertEqual(json["role"] as? String, "tool")
+ XCTAssertEqual(json["toolCallId"] as? String, "call-1")
+ XCTAssertEqual(json["content"] as? String, "72°F, sunny")
+ }
+
+ func test_encodeToolMessage_withError_includesError() throws {
+ // Given
+ let message = ToolMessage(
+ id: "tool-2",
+ content: "Failed",
+ toolCallId: "call-2",
+ error: "Connection timeout"
+ )
+
+ // When
+ let json = try json(from: message)
+
+ // Then
+ XCTAssertEqual(json["error"] as? String, "Connection timeout")
+ }
+
+ func test_encodeToolMessage_withoutError_omitsError() throws {
+ // Given
+ let message = ToolMessage(id: "tool-3", content: "OK", toolCallId: "call-3")
+
+ // When
+ let json = try json(from: message)
+
+ // Then
+ XCTAssertNil(json["error"])
+ }
+
+ func test_encodeToolMessage_withName_includesName() throws {
+ // Given
+ let message = ToolMessage(
+ id: "tool-4",
+ content: "Result",
+ toolCallId: "call-4",
+ name: "weather_tool"
+ )
+
+ // When
+ let json = try json(from: message)
+
+ // Then
+ XCTAssertEqual(json["name"] as? String, "weather_tool")
+ }
+
+ func test_encodeToolMessage_withoutName_omitsName() throws {
+ // Given
+ let message = ToolMessage(id: "tool-5", content: "Result", toolCallId: "call-5")
+
+ // When
+ let json = try json(from: message)
+
+ // Then
+ XCTAssertNil(json["name"])
+ }
+
+ // MARK: - Feature: ActivityMessage encoding
+
+ func test_encodeActivityMessage_producesCorrectJSON() throws {
+ // Given
+ let message = ActivityMessage(
+ id: "act-1",
+ activityType: "progress",
+ content: Data("{\"percent\":75}".utf8)
+ )
+
+ // When
+ let json = try json(from: message)
+
+ // Then
+ XCTAssertEqual(json["id"] as? String, "act-1")
+ XCTAssertEqual(json["role"] as? String, "activity")
+ XCTAssertEqual(json["activityType"] as? String, "progress")
+ }
+
+ func test_encodeActivityMessage_contentEmbeddedAsJsonObject() throws {
+ // Given: content must be inlined as a JSON object, not base64-encoded
+ let message = ActivityMessage(
+ id: "act-2",
+ activityType: "progress",
+ content: Data("{\"percent\":75,\"message\":\"Uploading\"}".utf8)
+ )
+
+ // When
+ let json = try json(from: message)
+
+ // Then: content key holds a dictionary, not a Data blob
+ let content = try XCTUnwrap(json["content"] as? [String: Any])
+ XCTAssertEqual(content["percent"] as? Int, 75)
+ XCTAssertEqual(content["message"] as? String, "Uploading")
+ }
+
+ func test_encodeActivityMessage_nameAlwaysOmitted() throws {
+ // Given: ActivityMessage.name is always nil per protocol
+ let message = ActivityMessage(
+ id: "act-3",
+ activityType: "status",
+ content: Data("{}".utf8)
+ )
+
+ // When
+ let json = try json(from: message)
+
+ // Then
+ XCTAssertNil(json["name"])
+ }
+
+ func test_encodeActivityMessage_encryptedValueAlwaysOmitted() throws {
+ // Given: ActivityMessage.encryptedValue is always nil per protocol
+ let message = ActivityMessage(
+ id: "act-4",
+ activityType: "status",
+ content: Data("{}".utf8)
+ )
+
+ // When
+ let json = try json(from: message)
+
+ // Then
+ XCTAssertNil(json["encryptedValue"])
+ }
+
+ // MARK: - Feature: ReasoningMessage encoding
+
+ func test_encodeReasoningMessage_producesCorrectJSON() throws {
+ // Given
+ let message = ReasoningMessage(id: "reasoning-1", content: "Let me think step by step.")
+
+ // When
+ let json = try json(from: message)
+
+ // Then
+ XCTAssertEqual(json["id"] as? String, "reasoning-1")
+ XCTAssertEqual(json["role"] as? String, "reasoning")
+ XCTAssertEqual(json["content"] as? String, "Let me think step by step.")
+ }
+
+ func test_encodeReasoningMessage_withEncryptedValue() throws {
+ // Given
+ let message = ReasoningMessage(
+ id: "reasoning-2",
+ content: "Analysing inputs...",
+ encryptedValue: "enc-token-abc"
+ )
+
+ // When
+ let json = try json(from: message)
+
+ // Then
+ XCTAssertEqual(json["encryptedValue"] as? String, "enc-token-abc")
+ }
+
+ func test_encodeReasoningMessage_nameAlwaysOmitted() throws {
+ // Given: ReasoningMessage.name is always nil per protocol spec
+ let message = ReasoningMessage(id: "reasoning-3", content: "Reasoning...")
+
+ // When
+ let json = try json(from: message)
+
+ // Then
+ XCTAssertNil(json["name"])
+ }
+
+ func test_encodeReasoningMessage_nilEncryptedValue_omittedFromJSON() throws {
+ // Given
+ let message = ReasoningMessage(id: "reasoning-4", content: "Thinking...")
+
+ // When
+ let json = try json(from: message)
+
+ // Then
+ XCTAssertNil(json["encryptedValue"])
+ }
+
+ // MARK: - Feature: Error handling
+
+ func test_unsupportedRole_throws() {
+ // Given: registry with no handlers guarantees an unsupported role error
+ let emptyEncoder = MessageEncoder(registry: [:])
+ let message = ReasoningMessage(id: "r-1", content: "test")
+
+ // When / Then
+ XCTAssertThrowsError(try emptyEncoder.encode(message)) { error in
+ guard case MessageEncodingError.unsupportedRole(let role) = error else {
+ return XCTFail("Expected unsupportedRole, got \(error)")
+ }
+ XCTAssertEqual(role, .reasoning)
+ }
+ }
+
+ func test_invalidMessageType_throws() {
+ // Given: route .user role through the .system handler → type mismatch at cast site
+ let registry: [Role: MessageEncoder.EncodeHandler] = [
+ .user: MessageEncoder.defaultRegistry()[.system]!
+ ]
+ let mismatchedEncoder = MessageEncoder(registry: registry)
+ let message = UserMessage(id: "u-1", content: "Hello")
+
+ // When / Then
+ XCTAssertThrowsError(try mismatchedEncoder.encode(message)) { error in
+ guard case MessageEncodingError.invalidMessageType(let role, _) = error else {
+ return XCTFail("Expected invalidMessageType, got \(error)")
+ }
+ XCTAssertEqual(role, .system)
+ }
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/Helpers/AGUIEventDecoderTestHelpers.swift b/sdks/community/swift/Tests/AGUICoreTests/Helpers/AGUIEventDecoderTestHelpers.swift
new file mode 100644
index 0000000000..43283bb019
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/Helpers/AGUIEventDecoderTestHelpers.swift
@@ -0,0 +1,112 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+/// Protocol providing shared decoder creation helpers for event tests.
+///
+/// Conforming test classes automatically gain access to standardized
+/// decoder factory methods, ensuring consistency across all event tests.
+///
+/// ## Usage
+///
+/// Add this protocol to your test class:
+/// ```swift
+/// final class MyEventTests: XCTestCase, AGUIEventDecoderTestHelpers {
+/// func test_decodeEvent() {
+/// let decoder = makeStrictDecoder()
+/// let data = jsonData("""{"type": "MY_EVENT"}""")
+/// // ...
+/// }
+/// }
+/// ```
+///
+/// ## Benefits
+///
+/// - **Single Source of Truth**: All decoder configuration in one place
+/// - **Consistent Behavior**: All tests use identical decoder setup
+/// - **Easy Maintenance**: Update once, applies to all conforming tests
+/// - **No Duplication**: Eliminates 39+ duplicated helper methods
+protocol AGUIEventDecoderTestHelpers: XCTestCase {
+ // Protocol requires no implementation - all methods have default implementations
+}
+
+extension AGUIEventDecoderTestHelpers {
+
+ /// Creates a decoder in strict mode (throws errors for unknown events).
+ ///
+ /// Strict mode is appropriate for tests that expect specific event types
+ /// and should fail on unexpected or unknown events.
+ ///
+ /// - Parameter registry: Optional custom registry. Defaults to full default registry.
+ /// Pass an empty registry `[:]` to test missing handler scenarios.
+ /// - Returns: AGUIEventDecoder configured for strict mode
+ ///
+ /// ## Example
+ /// ```swift
+ /// // Standard usage
+ /// let decoder = makeStrictDecoder()
+ ///
+ /// // Test missing handler
+ /// let emptyDecoder = makeStrictDecoder(registry: [:])
+ /// ```
+ func makeStrictDecoder(
+ registry: [EventType: AGUIEventDecoder.DecodeHandler]? = nil
+ ) -> AGUIEventDecoder {
+ var config = AGUIEventDecoder.Configuration()
+ config.unknownEventStrategy = .throwError
+ return AGUIEventDecoder(
+ config: config,
+ makeDecoder: { JSONDecoder() },
+ registry: registry ?? AGUIEventDecoder.defaultRegistry()
+ )
+ }
+
+ /// Creates a decoder in tolerant mode (returns UnknownEvent for unknown types).
+ ///
+ /// Tolerant mode is appropriate for tests that need to handle unknown or
+ /// future event types gracefully without throwing errors.
+ ///
+ /// - Parameter registry: Optional custom registry. Defaults to full default registry.
+ /// Pass an empty registry `[:]` to test unknown event handling.
+ /// - Returns: AGUIEventDecoder configured for tolerant mode
+ ///
+ /// ## Example
+ /// ```swift
+ /// let decoder = makeTolerantDecoder()
+ /// let event = try decoder.decode(unknownEventData)
+ /// XCTAssertTrue(event is UnknownEvent)
+ /// ```
+ func makeTolerantDecoder(
+ registry: [EventType: AGUIEventDecoder.DecodeHandler]? = nil
+ ) -> AGUIEventDecoder {
+ var config = AGUIEventDecoder.Configuration()
+ config.unknownEventStrategy = .returnUnknown
+ return AGUIEventDecoder(
+ config: config,
+ makeDecoder: { JSONDecoder() },
+ registry: registry ?? AGUIEventDecoder.defaultRegistry()
+ )
+ }
+
+ /// Converts a JSON string to Data for testing.
+ ///
+ /// This is a convenience method to reduce boilerplate in test methods.
+ /// The JSON string is converted using UTF-8 encoding.
+ ///
+ /// - Parameter json: JSON string to convert
+ /// - Returns: Data representation of the JSON string
+ ///
+ /// ## Example
+ /// ```swift
+ /// let data = jsonData("""
+ /// {
+ /// "type": "RUN_STARTED",
+ /// "threadId": "thread-123"
+ /// }
+ /// """)
+ /// ```
+ func jsonData(_ json: String) -> Data {
+ Data(json.utf8)
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/Helpers/EventDecodingErrorTests.swift b/sdks/community/swift/Tests/AGUICoreTests/Helpers/EventDecodingErrorTests.swift
new file mode 100644
index 0000000000..b89d7e1082
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/Helpers/EventDecodingErrorTests.swift
@@ -0,0 +1,216 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+/// Protocol providing standard error handling tests for all event types.
+///
+/// Event test classes conforming to this protocol must implement required properties
+/// describing their event type, then automatically inherit 6 standard error tests.
+///
+/// ## Usage
+///
+/// ```swift
+/// final class RunStartedEventTests: XCTestCase,
+/// AGUIEventDecoderTestHelpers,
+/// EventDecodingErrorTests {
+///
+/// var validEventFieldsWithoutType: [String: Any] {
+/// ["threadId": EventTestData.threadId, "runId": EventTestData.runId]
+/// }
+///
+/// var eventTypeString: String { "RUN_STARTED" }
+/// var expectedEventType: EventType { .runStarted }
+///
+/// // 6 error tests are automatically inherited!
+/// }
+/// ```
+///
+/// ## Benefits
+///
+/// - **Eliminates Duplication**: Removes 78+ duplicated error tests across 13 files
+/// - **Consistency**: All event types test errors the same way
+/// - **Maintainability**: Update error test logic once, applies everywhere
+/// - **Scalability**: New event types get error tests automatically
+protocol EventDecodingErrorTests: AGUIEventDecoderTestHelpers {
+
+ /// Provides valid event fields (excluding the "type" field).
+ ///
+ /// Used to construct test cases for error scenarios. Should contain
+ /// all required fields for this event type with valid values.
+ ///
+ /// Example:
+ /// ```swift
+ /// var validEventFieldsWithoutType: [String: Any] {
+ /// [
+ /// "threadId": EventTestData.threadId,
+ /// "runId": EventTestData.runId,
+ /// "messageId": EventTestData.messageId
+ /// ]
+ /// }
+ /// ```
+ var validEventFieldsWithoutType: [String: Any] { get }
+
+ /// The event type string as it appears in JSON (e.g., "RUN_STARTED").
+ var eventTypeString: String { get }
+
+ /// The expected EventType enum value (e.g., `.runStarted`).
+ var expectedEventType: EventType { get }
+
+ /// An unknown event type string for testing error handling.
+ ///
+ /// Should be semantically related to your event but not registered.
+ /// For example, "RUN_PAUSED" for RUN_STARTED tests, or "TOOL_CALL_CANCELLED" for tool call events.
+ ///
+ /// Default implementation provides a generic unknown type.
+ var unknownEventTypeString: String { get }
+}
+
+extension EventDecodingErrorTests {
+
+ // MARK: - Default Implementation
+
+ /// Default implementation provides a generic unknown event type.
+ ///
+ /// Override this property to provide a more semantically meaningful
+ /// unknown type string for your specific event domain.
+ var unknownEventTypeString: String {
+ "UNKNOWN_EVENT_TYPE"
+ }
+
+ // MARK: - Standard Error Tests
+
+ /// Tests that decoding fails with `.missingTypeField` when "type" field is absent.
+ ///
+ /// This validates that the decoder correctly identifies and reports
+ /// when the required "type" field is missing from event JSON.
+ func test_decodeMissingType_throwsMissingTypeField() throws {
+ // Given
+ let json = validEventFieldsWithoutType
+ let data = try JSONSerialization.data(withJSONObject: json, options: [])
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ XCTAssertEqual(error as? EventDecodingError,
+ .missingTypeField,
+ "Expected .missingTypeField error when 'type' field is missing")
+ }
+ }
+
+ /// Tests that decoding an unknown type in strict mode throws `.unknownEventType`.
+ ///
+ /// This validates that the decoder in strict mode correctly rejects
+ /// event types that are not recognized, throwing a specific error.
+ func test_decodeUnknownType_inStrictMode_throwsUnknownEventType() throws {
+ // Given
+ var json = validEventFieldsWithoutType
+ json["type"] = unknownEventTypeString
+ let data = try JSONSerialization.data(withJSONObject: json, options: [])
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .unknownEventType(let type) = error as? EventDecodingError else {
+ return XCTFail("Expected .unknownEventType error, got \(error)")
+ }
+ XCTAssertEqual(type,
+ unknownEventTypeString,
+ "Error should report the unknown type string")
+ }
+ }
+
+ /// Tests that decoding an unknown type in tolerant mode returns `UnknownEvent`.
+ ///
+ /// This validates that the decoder in tolerant mode gracefully handles
+ /// unknown event types by returning an UnknownEvent instance rather than throwing.
+ func test_decodeUnknownType_inTolerantMode_returnsUnknownEvent() throws {
+ // Given
+ var json = validEventFieldsWithoutType
+ json["type"] = unknownEventTypeString
+ let data = try JSONSerialization.data(withJSONObject: json, options: [])
+ let decoder = makeTolerantDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let unknown = try XCTUnwrap(
+ event as? UnknownEvent,
+ "Tolerant decoder should return UnknownEvent for unknown types"
+ )
+ XCTAssertEqual(unknown.typeRaw,
+ unknownEventTypeString,
+ "UnknownEvent should preserve the original type string")
+ XCTAssertEqual(unknown.rawEvent,
+ data,
+ "UnknownEvent should preserve the raw event data")
+ }
+
+ /// Tests that decoding a known type with no handler in strict mode throws `.unsupportedEventType`.
+ ///
+ /// This validates that the decoder correctly identifies when an event type
+ /// is recognized but no handler is registered to decode it.
+ func test_decodeKnownTypeButNoHandler_inStrictMode_throwsUnsupportedEventType() throws {
+ // Given
+ var json = validEventFieldsWithoutType
+ json["type"] = eventTypeString
+ let data = try JSONSerialization.data(withJSONObject: json, options: [])
+
+ // Registry intentionally empty -> handler missing
+ let decoder = makeStrictDecoder(registry: [:])
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ XCTAssertEqual(error as? EventDecodingError,
+ .unsupportedEventType(expectedEventType),
+ "Expected .unsupportedEventType error when handler is missing")
+ }
+ }
+
+ /// Tests that decoding a known type with no handler in tolerant mode returns `UnknownEvent`.
+ ///
+ /// This validates that the decoder in tolerant mode gracefully handles
+ /// missing handlers by returning an UnknownEvent instance.
+ func test_decodeKnownTypeButNoHandler_inTolerantMode_returnsUnknownEvent() throws {
+ // Given
+ var json = validEventFieldsWithoutType
+ json["type"] = eventTypeString
+ let data = try JSONSerialization.data(withJSONObject: json, options: [])
+
+ // Registry intentionally empty -> handler missing
+ let decoder = makeTolerantDecoder(registry: [:])
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let unknown = try XCTUnwrap(
+ event as? UnknownEvent,
+ "Tolerant decoder should return UnknownEvent when handler is missing"
+ )
+ XCTAssertEqual(unknown.typeRaw,
+ eventTypeString,
+ "UnknownEvent should preserve the event type string")
+ XCTAssertEqual(unknown.rawEvent,
+ data,
+ "UnknownEvent should preserve the raw event data")
+ }
+
+ /// Tests that invalid JSON throws `.invalidJSON`.
+ ///
+ /// This validates that the decoder correctly identifies and reports
+ /// malformed JSON that cannot be parsed.
+ func test_decodeInvalidJSON_throwsInvalidJSON() {
+ // Given
+ let data = Data("invalid json".utf8)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ XCTAssertEqual(error as? EventDecodingError,
+ .invalidJSON,
+ "Expected .invalidJSON error when JSON is malformed")
+ }
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/Helpers/EventTestData.swift b/sdks/community/swift/Tests/AGUICoreTests/Helpers/EventTestData.swift
new file mode 100644
index 0000000000..755ecd3198
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/Helpers/EventTestData.swift
@@ -0,0 +1,80 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import Foundation
+@testable import AGUICore
+
+/// Shared test data constants and factory methods for event tests.
+///
+/// Provides centralized test data to ensure consistency across all event tests
+/// and eliminate magic numbers.
+enum EventTestData {
+
+ // MARK: - Standard Test IDs
+
+ /// Standard thread ID for tests
+ static let threadId = "thread-123"
+
+ /// Standard run ID for tests
+ static let runId = "run-456"
+
+ /// Standard message ID for tests
+ static let messageId = "msg-123"
+
+ /// Alternative message ID for tests requiring multiple IDs
+ static let messageId2 = "msg-456"
+
+ /// Standard tool call ID for tests
+ static let toolCallId = "call-123"
+
+ // MARK: - Standard Timestamps
+
+ /// Standard timestamp for tests (January 1, 2024 00:00:00 UTC).
+ ///
+ /// Represents 1704067200000 milliseconds since Unix epoch.
+ static let timestamp: Int64 = 1704067200000
+
+ /// Alternative timestamp for tests requiring multiple timestamps.
+ static let timestamp2: Int64 = 1704067200001
+
+ /// Creates a JSON dictionary for event testing
+ /// - Parameters:
+ /// - type: The event type string (e.g., "RUN_STARTED", "RUN_FINISHED", "RUN_ERROR")
+ /// - threadId: The thread ID (default: EventTestData.threadId, ignored if additionalFields provided)
+ /// - runId: The run ID (default: EventTestData.runId, ignored if additionalFields provided)
+ /// - timestamp: Optional timestamp
+ /// - additionalFields: Additional fields to include in JSON (overrides threadId/runId if provided)
+ /// - Returns: A dictionary representing the event JSON
+ static func makeJSON(
+ type: String,
+ threadId: String = EventTestData.threadId,
+ runId: String = EventTestData.runId,
+ timestamp: Int64? = nil,
+ additionalFields: [String: Any]? = nil
+ ) -> [String: Any] {
+ var json: [String: Any] = [
+ "type": type
+ ]
+
+ // Use additionalFields if provided, otherwise use threadId/runId
+ if let additionalFields = additionalFields {
+ json.merge(additionalFields) { _, new in new }
+ } else {
+ json["threadId"] = threadId
+ json["runId"] = runId
+ }
+
+ if let timestamp = timestamp {
+ json["timestamp"] = timestamp
+ }
+ return json
+ }
+
+ /// Creates JSON Data from a dictionary.
+ ///
+ /// - Parameter dictionary: Dictionary to convert to JSON Data
+ /// - Returns: JSON Data representation
+ /// - Throws: An error if the dictionary cannot be serialized to JSON
+ static func jsonData(from dictionary: [String: Any]) throws -> Data {
+ try JSONSerialization.data(withJSONObject: dictionary, options: [])
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/LifeCycleEvents/RunErrorEventTests.swift b/sdks/community/swift/Tests/AGUICoreTests/LifeCycleEvents/RunErrorEventTests.swift
new file mode 100644
index 0000000000..75a5a84e2a
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/LifeCycleEvents/RunErrorEventTests.swift
@@ -0,0 +1,164 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+final class RunErrorEventTests: XCTestCase,
+ AGUIEventDecoderTestHelpers,
+ EventDecodingErrorTests {
+
+ // MARK: - EventDecodingErrorTests Protocol Requirements
+
+ // Per AG-UI protocol, RUN_ERROR only requires `message`. No threadId/runId.
+ var validEventFieldsWithoutType: [String: Any] {
+ ["message": "An error occurred"]
+ }
+
+ var eventTypeString: String { "RUN_ERROR" }
+ var expectedEventType: EventType { .runError }
+ var unknownEventTypeString: String { "RUN_CANCELLED" }
+
+ // MARK: - Feature: Decode RUN_ERROR
+
+ func test_decodeValidRunError_returnsRunErrorEvent() throws {
+ // Given — protocol-conformant flat structure
+ let data = jsonData("""
+ {
+ "type": "RUN_ERROR",
+ "message": "An error occurred",
+ "code": "ERROR_CODE"
+ }
+ """)
+
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ guard let runError = event as? RunErrorEvent else {
+ return XCTFail("Expected RunErrorEvent, got \(type(of: event))")
+ }
+ XCTAssertEqual(runError.eventType, .runError)
+ XCTAssertEqual(runError.message, "An error occurred")
+ XCTAssertEqual(runError.code, "ERROR_CODE")
+ XCTAssertNil(runError.timestamp)
+ }
+
+ func test_decodeRunError_withTimestamp_populatesTimestamp() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "RUN_ERROR",
+ "message": "An error occurred",
+ "timestamp": \(EventTestData.timestamp)
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let runError = try XCTUnwrap(event as? RunErrorEvent)
+ XCTAssertEqual(runError.timestamp, EventTestData.timestamp)
+ }
+
+ func test_decodeRunError_withoutCode_codeIsNil() throws {
+ // Given — code is optional per protocol
+ let data = jsonData("""
+ {
+ "type": "RUN_ERROR",
+ "message": "Something went wrong"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let runError = try XCTUnwrap(event as? RunErrorEvent)
+ XCTAssertEqual(runError.message, "Something went wrong")
+ XCTAssertNil(runError.code)
+ }
+
+ func test_decodeRunError_preservesRawEventBytes() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "RUN_ERROR",
+ "message": "An error occurred",
+ "timestamp": \(EventTestData.timestamp)
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let runError = try XCTUnwrap(event as? RunErrorEvent)
+ XCTAssertEqual(runError.rawEvent, data)
+ }
+
+ func test_decodeRunError_ignoresUnknownExtraFields() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "RUN_ERROR",
+ "message": "An error occurred",
+ "extraField": "ignored",
+ "nested": { "x": 1 }
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let runError = try XCTUnwrap(event as? RunErrorEvent)
+ XCTAssertEqual(runError.message, "An error occurred")
+ }
+
+ // MARK: - Feature: Error handling (event-specific)
+
+ func test_decodeRunError_missingMessage_throwsDecodingFailed() {
+ // Given — message is required per protocol
+ let data = jsonData("""
+ {
+ "type": "RUN_ERROR",
+ "code": "ERROR_CODE"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = (error as? EventDecodingError) else {
+ return XCTFail("Expected .decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("message"), "Expected error to mention 'message'. Got: \(message)")
+ }
+ }
+
+ // MARK: - Feature: Model behaviors
+
+ func test_runErrorEvent_eventTypeIsAlwaysRunError() {
+ let event = RunErrorEvent(message: "Something failed", code: "CODE")
+ XCTAssertEqual(event.eventType, .runError)
+ }
+
+ func test_runErrorEvent_equatable_sameFields_areEqual() {
+ let event1 = RunErrorEvent(message: "Error", code: "CODE", timestamp: 1)
+ let event2 = RunErrorEvent(message: "Error", code: "CODE", timestamp: 1)
+ XCTAssertEqual(event1, event2)
+ }
+
+ func test_runErrorEvent_equatable_differentMessage_areNotEqual() {
+ let event1 = RunErrorEvent(message: "Error A", code: "CODE")
+ let event2 = RunErrorEvent(message: "Error B", code: "CODE")
+ XCTAssertNotEqual(event1, event2)
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/LifeCycleEvents/RunFinishedEventTests.swift b/sdks/community/swift/Tests/AGUICoreTests/LifeCycleEvents/RunFinishedEventTests.swift
new file mode 100644
index 0000000000..6abcd8840f
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/LifeCycleEvents/RunFinishedEventTests.swift
@@ -0,0 +1,386 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+final class RunFinishedEventTests: XCTestCase,
+ AGUIEventDecoderTestHelpers,
+ EventDecodingErrorTests {
+
+ // MARK: - EventDecodingErrorTests Protocol Requirements
+
+ var validEventFieldsWithoutType: [String: Any] {
+ [
+ "threadId": EventTestData.threadId,
+ "runId": EventTestData.runId
+ ]
+ }
+
+ var eventTypeString: String { "RUN_FINISHED" }
+ var expectedEventType: EventType { .runFinished }
+ var unknownEventTypeString: String { "RUN_PAUSED" }
+
+ // MARK: - Feature: Decode RUN_FINISHED (base fields)
+
+ func test_decodeValidRunFinished_returnsRunFinishedEvent() throws {
+ let data = jsonData("""
+ {
+ "type": "RUN_FINISHED",
+ "threadId": "\(EventTestData.threadId)",
+ "runId": "\(EventTestData.runId)"
+ }
+ """)
+
+ let event = try makeStrictDecoder().decode(data)
+
+ let runFinished = try XCTUnwrap(event as? RunFinishedEvent)
+ XCTAssertEqual(runFinished.eventType, .runFinished)
+ XCTAssertEqual(runFinished.threadId, EventTestData.threadId)
+ XCTAssertEqual(runFinished.runId, EventTestData.runId)
+ XCTAssertNil(runFinished.timestamp)
+ }
+
+ func test_decodeRunFinished_withTimestamp_populatesTimestamp() throws {
+ let data = jsonData("""
+ {
+ "type": "RUN_FINISHED",
+ "threadId": "\(EventTestData.threadId)",
+ "runId": "\(EventTestData.runId)",
+ "timestamp": \(EventTestData.timestamp)
+ }
+ """)
+
+ let event = try makeStrictDecoder().decode(data)
+
+ let runFinished = try XCTUnwrap(event as? RunFinishedEvent)
+ XCTAssertEqual(runFinished.timestamp, EventTestData.timestamp)
+ }
+
+ func test_decodeRunFinished_preservesRawEventBytes() throws {
+ let data = jsonData("""
+ {
+ "type": "RUN_FINISHED",
+ "threadId": "\(EventTestData.threadId)",
+ "runId": "\(EventTestData.runId)",
+ "timestamp": \(EventTestData.timestamp)
+ }
+ """)
+
+ let event = try makeStrictDecoder().decode(data)
+
+ let runFinished = try XCTUnwrap(event as? RunFinishedEvent)
+ XCTAssertEqual(runFinished.rawEvent, data)
+ }
+
+ func test_decodeRunFinished_ignoresUnknownExtraFields() throws {
+ let data = jsonData("""
+ {
+ "type": "RUN_FINISHED",
+ "threadId": "\(EventTestData.threadId)",
+ "runId": "\(EventTestData.runId)",
+ "extraField": "ignored",
+ "nested": { "x": 1 }
+ }
+ """)
+
+ let event = try makeStrictDecoder().decode(data)
+
+ let runFinished = try XCTUnwrap(event as? RunFinishedEvent)
+ XCTAssertEqual(runFinished.threadId, EventTestData.threadId)
+ XCTAssertEqual(runFinished.runId, EventTestData.runId)
+ }
+
+ // MARK: - Feature: Error handling (event-specific)
+
+ func test_decodeRunFinished_missingThreadId_throwsDecodingFailed() {
+ let data = jsonData("""
+ {
+ "type": "RUN_FINISHED",
+ "runId": "run-456"
+ }
+ """)
+
+ XCTAssertThrowsError(try makeStrictDecoder().decode(data)) { error in
+ guard case .decodingFailed(let message) = (error as? EventDecodingError) else {
+ return XCTFail("Expected .decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("threadId"), "Expected message to mention 'threadId'. Got: \(message)")
+ }
+ }
+
+ func test_decodeRunFinished_threadIdWrongType_throwsDecodingFailed() {
+ let data = jsonData("""
+ {
+ "type": "RUN_FINISHED",
+ "threadId": 123,
+ "runId": "run-456"
+ }
+ """)
+
+ XCTAssertThrowsError(try makeStrictDecoder().decode(data)) { error in
+ guard case .decodingFailed(let message) = (error as? EventDecodingError) else {
+ return XCTFail("Expected .decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(
+ message.lowercased().contains("type mismatch") || message.contains("Type mismatch"),
+ "Expected a type mismatch message. Got: \(message)"
+ )
+ }
+ }
+
+ // MARK: - Feature: Decode outcome field (wire format: discriminated union object)
+
+ func test_decodeRunFinished_withSuccessOutcome_decodesCorrectly() throws {
+ // The AG-UI wire format sends outcome as { "type": "success" }, not a string.
+ let data = jsonData("""
+ {
+ "type": "RUN_FINISHED",
+ "threadId": "\(EventTestData.threadId)",
+ "runId": "\(EventTestData.runId)",
+ "outcome": { "type": "success" }
+ }
+ """)
+
+ let event = try makeStrictDecoder().decode(data)
+
+ let runFinished = try XCTUnwrap(event as? RunFinishedEvent)
+ XCTAssertEqual(runFinished.outcome, .success)
+ }
+
+ func test_decodeRunFinished_withInterruptOutcome_decodesInterrupts() throws {
+ // Wire format: { "type": "interrupt", "interrupts": [...] }
+ let data = jsonData("""
+ {
+ "type": "RUN_FINISHED",
+ "threadId": "\(EventTestData.threadId)",
+ "runId": "\(EventTestData.runId)",
+ "outcome": {
+ "type": "interrupt",
+ "interrupts": [
+ {
+ "id": "int-1",
+ "reason": "Approval required",
+ "message": "Please approve the action",
+ "toolCallId": "tool-call-abc"
+ }
+ ]
+ }
+ }
+ """)
+
+ let event = try makeStrictDecoder().decode(data)
+
+ let runFinished = try XCTUnwrap(event as? RunFinishedEvent)
+ guard case .interrupt(let interrupts) = runFinished.outcome else {
+ return XCTFail("Expected .interrupt outcome, got \(String(describing: runFinished.outcome))")
+ }
+ XCTAssertEqual(interrupts.count, 1)
+ XCTAssertEqual(interrupts[0].id, "int-1")
+ XCTAssertEqual(interrupts[0].reason, "Approval required")
+ XCTAssertEqual(interrupts[0].message, "Please approve the action")
+ XCTAssertEqual(interrupts[0].toolCallId, "tool-call-abc")
+ }
+
+ func test_decodeRunFinished_withMultipleInterrupts_decodesAll() throws {
+ let data = jsonData("""
+ {
+ "type": "RUN_FINISHED",
+ "threadId": "\(EventTestData.threadId)",
+ "runId": "\(EventTestData.runId)",
+ "outcome": {
+ "type": "interrupt",
+ "interrupts": [
+ { "id": "int-1", "reason": "Step 1 approval" },
+ { "id": "int-2", "reason": "Step 2 approval" }
+ ]
+ }
+ }
+ """)
+
+ let event = try makeStrictDecoder().decode(data)
+
+ let runFinished = try XCTUnwrap(event as? RunFinishedEvent)
+ guard case .interrupt(let interrupts) = runFinished.outcome else {
+ return XCTFail("Expected .interrupt outcome")
+ }
+ XCTAssertEqual(interrupts.count, 2)
+ XCTAssertEqual(interrupts[0].id, "int-1")
+ XCTAssertEqual(interrupts[1].id, "int-2")
+ }
+
+ func test_decodeRunFinished_withMissingOutcome_yieldsNilOutcome() throws {
+ // Absent outcome field → nil (treat as normal completion)
+ let data = jsonData("""
+ {
+ "type": "RUN_FINISHED",
+ "threadId": "\(EventTestData.threadId)",
+ "runId": "\(EventTestData.runId)"
+ }
+ """)
+
+ let event = try makeStrictDecoder().decode(data)
+
+ let runFinished = try XCTUnwrap(event as? RunFinishedEvent)
+ XCTAssertNil(runFinished.outcome)
+ }
+
+ func test_decodeRunFinished_withNullOutcome_yieldsNilOutcome() throws {
+ // Python SDK compat: model_dump() without exclude_none=True emits "outcome": null
+ let data = jsonData("""
+ {
+ "type": "RUN_FINISHED",
+ "threadId": "\(EventTestData.threadId)",
+ "runId": "\(EventTestData.runId)",
+ "outcome": null
+ }
+ """)
+
+ let event = try makeStrictDecoder().decode(data)
+
+ let runFinished = try XCTUnwrap(event as? RunFinishedEvent)
+ XCTAssertNil(runFinished.outcome)
+ }
+
+ func test_decodeRunFinished_withUnknownOutcomeType_yieldsNilOutcome() throws {
+ // Forward compatibility: unknown future outcome types fall through to nil
+ let data = jsonData("""
+ {
+ "type": "RUN_FINISHED",
+ "threadId": "\(EventTestData.threadId)",
+ "runId": "\(EventTestData.runId)",
+ "outcome": { "type": "suspended" }
+ }
+ """)
+
+ let event = try makeStrictDecoder().decode(data)
+
+ let runFinished = try XCTUnwrap(event as? RunFinishedEvent)
+ XCTAssertNil(runFinished.outcome)
+ }
+
+ func test_decodeRunFinished_withInterruptOutcome_emptyInterrupts_yieldsNilOutcome() throws {
+ // Malformed: interrupts array is empty — must have at least one per the spec
+ let data = jsonData("""
+ {
+ "type": "RUN_FINISHED",
+ "threadId": "\(EventTestData.threadId)",
+ "runId": "\(EventTestData.runId)",
+ "outcome": { "type": "interrupt", "interrupts": [] }
+ }
+ """)
+
+ let event = try makeStrictDecoder().decode(data)
+
+ let runFinished = try XCTUnwrap(event as? RunFinishedEvent)
+ XCTAssertNil(runFinished.outcome)
+ }
+
+ // MARK: - Feature: Model behaviors
+
+ func test_runFinishedEvent_eventTypeIsAlwaysRunFinished() {
+ let event = RunFinishedEvent(threadId: "t", runId: "r")
+ XCTAssertEqual(event.eventType, .runFinished)
+ }
+
+ func test_runFinishedEvent_defaultOutcomeIsNil() {
+ let event = RunFinishedEvent(threadId: "t", runId: "r")
+ XCTAssertNil(event.outcome)
+ }
+
+ func test_runFinishedEvent_outcomeCanBeSetToSuccess() {
+ let event = RunFinishedEvent(threadId: "t", runId: "r", outcome: .success)
+ XCTAssertEqual(event.outcome, .success)
+ }
+
+ func test_runFinishedEvent_outcomeCanBeSetToInterrupt() {
+ let interrupt = Interrupt(id: "int-1", reason: "Review needed")
+ let event = RunFinishedEvent(threadId: "t", runId: "r", outcome: .interrupt([interrupt]))
+ XCTAssertEqual(event.outcome, .interrupt([interrupt]))
+ }
+
+ func test_runFinishedEvent_equatable_sameFields_areEqual() {
+ let event1 = RunFinishedEvent(threadId: "t", runId: "r", outcome: .success, timestamp: 1, rawEvent: nil)
+ let event2 = RunFinishedEvent(threadId: "t", runId: "r", outcome: .success, timestamp: 1, rawEvent: nil)
+ XCTAssertEqual(event1, event2)
+ }
+
+ func test_runFinishedEvent_equatable_successVsNilOutcome_notEqual() {
+ let event1 = RunFinishedEvent(threadId: "t", runId: "r", outcome: .success)
+ let event2 = RunFinishedEvent(threadId: "t", runId: "r", outcome: nil)
+ XCTAssertNotEqual(event1, event2)
+ }
+
+ func test_runFinishedEvent_equatable_successVsInterrupt_notEqual() {
+ let interrupt = Interrupt(id: "int-1", reason: "R")
+ let event1 = RunFinishedEvent(threadId: "t", runId: "r", outcome: .success)
+ let event2 = RunFinishedEvent(threadId: "t", runId: "r", outcome: .interrupt([interrupt]))
+ XCTAssertNotEqual(event1, event2)
+ }
+
+ func test_runFinishedEvent_description_containsOutcome() {
+ let event = RunFinishedEvent(threadId: "t", runId: "r", outcome: .success)
+ XCTAssertTrue(event.description.contains("success"))
+ }
+
+ func test_runFinishedEvent_description_interruptShowsCount() {
+ let interrupts = [
+ Interrupt(id: "int-1", reason: "R1"),
+ Interrupt(id: "int-2", reason: "R2")
+ ]
+ let event = RunFinishedEvent(threadId: "t", runId: "r", outcome: .interrupt(interrupts))
+ XCTAssertTrue(event.description.contains("2"))
+ }
+
+ // MARK: - Feature: Interrupt field completeness (end-to-end decode)
+
+ func test_decodeRunFinished_interruptOutcome_allOptionalFields_surviveDecoding() throws {
+ // Verifies that all optional Interrupt fields survive the full wire → domain decode path.
+ let data = jsonData("""
+ {
+ "type": "RUN_FINISHED",
+ "threadId": "\(EventTestData.threadId)",
+ "runId": "\(EventTestData.runId)",
+ "outcome": {
+ "type": "interrupt",
+ "interrupts": [
+ {
+ "id": "int-full",
+ "reason": "Needs human approval",
+ "message": "Please review the proposed action",
+ "toolCallId": "tool-call-xyz",
+ "responseSchema": { "approved": { "type": "boolean" } },
+ "expiresAt": "2025-12-31T23:59:59Z",
+ "metadata": { "source": "tool-executor", "priority": "high" }
+ }
+ ]
+ }
+ }
+ """)
+
+ let event = try makeStrictDecoder().decode(data)
+
+ let runFinished = try XCTUnwrap(event as? RunFinishedEvent)
+ guard case .interrupt(let interrupts) = runFinished.outcome else {
+ return XCTFail("Expected .interrupt outcome")
+ }
+ XCTAssertEqual(interrupts.count, 1)
+
+ let interrupt = interrupts[0]
+ XCTAssertEqual(interrupt.id, "int-full")
+ XCTAssertEqual(interrupt.reason, "Needs human approval")
+ XCTAssertEqual(interrupt.message, "Please review the proposed action")
+ XCTAssertEqual(interrupt.toolCallId, "tool-call-xyz")
+ XCTAssertEqual(interrupt.expiresAt, "2025-12-31T23:59:59Z")
+
+ // responseSchema survives as non-nil Data containing valid JSON
+ let responseSchemaData = try XCTUnwrap(interrupt.responseSchema)
+ let responseSchema = try JSONSerialization.jsonObject(with: responseSchemaData) as? [String: Any]
+ XCTAssertNotNil(responseSchema?["approved"])
+
+ // metadata survives as non-nil Data containing valid JSON
+ let metadataData = try XCTUnwrap(interrupt.metadata)
+ let metadata = try JSONSerialization.jsonObject(with: metadataData) as? [String: Any]
+ XCTAssertEqual(metadata?["source"] as? String, "tool-executor")
+ XCTAssertEqual(metadata?["priority"] as? String, "high")
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/LifeCycleEvents/RunStartedEventTests.swift b/sdks/community/swift/Tests/AGUICoreTests/LifeCycleEvents/RunStartedEventTests.swift
new file mode 100644
index 0000000000..97dc1c3ecd
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/LifeCycleEvents/RunStartedEventTests.swift
@@ -0,0 +1,172 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+final class RunStartedEventTests: XCTestCase,
+ AGUIEventDecoderTestHelpers,
+ EventDecodingErrorTests {
+
+ // MARK: - EventDecodingErrorTests Protocol Requirements
+
+ var validEventFieldsWithoutType: [String: Any] {
+ [
+ "threadId": EventTestData.threadId,
+ "runId": EventTestData.runId
+ ]
+ }
+
+ var eventTypeString: String { "RUN_STARTED" }
+ var expectedEventType: EventType { .runStarted }
+ var unknownEventTypeString: String { "RUN_PAUSED" }
+
+ // MARK: - Feature: Decode RUN_STARTED
+
+ func test_decodeValidRunStarted_returnsRunStartedEvent() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "RUN_STARTED",
+ "threadId": "\(EventTestData.threadId)",
+ "runId": "\(EventTestData.runId)"
+ }
+ """)
+
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ guard let runStarted = event as? RunStartedEvent else {
+ return XCTFail("Expected RunStartedEvent, got \(type(of: event))")
+ }
+ XCTAssertEqual(runStarted.eventType, .runStarted)
+ XCTAssertEqual(runStarted.threadId, EventTestData.threadId)
+ XCTAssertEqual(runStarted.runId, EventTestData.runId)
+ XCTAssertNil(runStarted.timestamp)
+ }
+
+ func test_decodeRunStarted_withTimestamp_populatesTimestamp() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "RUN_STARTED",
+ "threadId": "\(EventTestData.threadId)",
+ "runId": "\(EventTestData.runId)",
+ "timestamp": \(EventTestData.timestamp)
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let runStarted = try XCTUnwrap(event as? RunStartedEvent)
+ XCTAssertEqual(runStarted.timestamp, EventTestData.timestamp)
+ }
+
+ func test_decodeRunStarted_preservesRawEventBytes() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "RUN_STARTED",
+ "threadId": "\(EventTestData.threadId)",
+ "runId": "\(EventTestData.runId)",
+ "timestamp": \(EventTestData.timestamp)
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let runStarted = try XCTUnwrap(event as? RunStartedEvent)
+ XCTAssertEqual(runStarted.rawEvent, data)
+ }
+
+ func test_decodeRunStarted_ignoresUnknownExtraFields() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "RUN_STARTED",
+ "threadId": "\(EventTestData.threadId)",
+ "runId": "\(EventTestData.runId)",
+ "extraField": "ignored",
+ "nested": { "x": 1 }
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let runStarted = try XCTUnwrap(event as? RunStartedEvent)
+ XCTAssertEqual(runStarted.threadId, EventTestData.threadId)
+ XCTAssertEqual(runStarted.runId, EventTestData.runId)
+ }
+
+ // MARK: - Feature: Error handling (event-specific)
+
+ func test_decodeRunStarted_missingThreadId_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "RUN_STARTED",
+ "runId": "run-456"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = (error as? EventDecodingError) else {
+ return XCTFail("Expected .decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("threadId"), "Expected message to mention 'threadId'. Got: \(message)")
+ }
+ }
+
+ func test_decodeRunStarted_threadIdWrongType_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "RUN_STARTED",
+ "threadId": 123,
+ "runId": "run-456"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = (error as? EventDecodingError) else {
+ return XCTFail("Expected .decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.lowercased().contains("type mismatch") || message.contains("Type mismatch"),
+ "Expected a type mismatch message. Got: \(message)")
+ }
+ }
+
+ // MARK: - Feature: Model behaviors
+
+ func test_runStartedEvent_eventTypeIsAlwaysRunStarted() {
+ // Given
+ let event = RunStartedEvent(threadId: "t", runId: "r", timestamp: nil, rawEvent: nil)
+
+ // Then
+ XCTAssertEqual(event.eventType, .runStarted)
+ }
+
+ func test_runStartedEvent_equatable_sameFields_areEqual() {
+ // Given
+ let event1 = RunStartedEvent(threadId: "t", runId: "r", timestamp: 1, rawEvent: nil)
+ let event2 = RunStartedEvent(threadId: "t", runId: "r", timestamp: 1, rawEvent: nil)
+
+ // Then
+ XCTAssertEqual(event1, event2)
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/LifeCycleEvents/StepFinishedEventTests.swift b/sdks/community/swift/Tests/AGUICoreTests/LifeCycleEvents/StepFinishedEventTests.swift
new file mode 100644
index 0000000000..2e0c33c1e5
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/LifeCycleEvents/StepFinishedEventTests.swift
@@ -0,0 +1,209 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+final class StepFinishedEventTests: XCTestCase,
+ AGUIEventDecoderTestHelpers,
+ EventDecodingErrorTests {
+
+ // MARK: - EventDecodingErrorTests Protocol Requirements
+
+ var validEventFieldsWithoutType: [String: Any] {
+ ["stepName": "reasoning"]
+ }
+
+ var eventTypeString: String { "STEP_FINISHED" }
+ var expectedEventType: EventType { .stepFinished }
+ var unknownEventTypeString: String { "STEP_CANCELLED" }
+
+ // MARK: - Feature: Decode STEP_FINISHED
+
+ func test_decodeValidStepFinished_returnsStepFinishedEvent() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "STEP_FINISHED",
+ "stepName": "reasoning"
+ }
+ """)
+
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ guard let stepFinished = event as? StepFinishedEvent else {
+ return XCTFail("Expected StepFinishedEvent, got \(type(of: event))")
+ }
+ XCTAssertEqual(stepFinished.eventType, .stepFinished)
+ XCTAssertEqual(stepFinished.stepName, "reasoning")
+ XCTAssertNil(stepFinished.timestamp)
+ }
+
+ func test_decodeStepFinished_withTimestamp_populatesTimestamp() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "STEP_FINISHED",
+ "stepName": "reasoning",
+ "timestamp": \(EventTestData.timestamp)
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let stepFinished = try XCTUnwrap(event as? StepFinishedEvent)
+ XCTAssertEqual(stepFinished.timestamp, EventTestData.timestamp)
+ }
+
+ func test_decodeStepFinished_preservesRawEventBytes() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "STEP_FINISHED",
+ "stepName": "reasoning",
+ "timestamp": \(EventTestData.timestamp)
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let stepFinished = try XCTUnwrap(event as? StepFinishedEvent)
+ XCTAssertEqual(stepFinished.rawEvent, data)
+ }
+
+ func test_decodeStepFinished_ignoresUnknownExtraFields() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "STEP_FINISHED",
+ "stepName": "reasoning",
+ "extraField": "ignored",
+ "nested": { "x": 1 }
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let stepFinished = try XCTUnwrap(event as? StepFinishedEvent)
+ XCTAssertEqual(stepFinished.stepName, "reasoning")
+ }
+
+ func test_decodeStepFinished_withUnicodeStepName_handlesUnicode() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "STEP_FINISHED",
+ "stepName": "推理-🚀-测试"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let stepFinished = try XCTUnwrap(event as? StepFinishedEvent)
+ XCTAssertEqual(stepFinished.stepName, "推理-🚀-测试")
+ }
+
+ // MARK: - Feature: Error handling (event-specific)
+
+ func test_decodeStepFinished_missingStepName_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "STEP_FINISHED"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = (error as? EventDecodingError) else {
+ return XCTFail("Expected .decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("stepName"), "Expected message to mention 'stepName'. Got: \(message)")
+ }
+ }
+
+ func test_decodeStepFinished_stepNameWrongType_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "STEP_FINISHED",
+ "stepName": 123
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = (error as? EventDecodingError) else {
+ return XCTFail("Expected .decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.lowercased().contains("type mismatch") || message.contains("Type mismatch"),
+ "Expected a type mismatch message. Got: \(message)")
+ }
+ }
+
+ func test_decodeStepFinished_timestampWrongType_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "STEP_FINISHED",
+ "stepName": "reasoning",
+ "timestamp": "invalid"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = (error as? EventDecodingError) else {
+ return XCTFail("Expected .decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.lowercased().contains("type mismatch") || message.contains("Type mismatch"),
+ "Expected a type mismatch message. Got: \(message)")
+ }
+ }
+
+ // MARK: - Feature: Model behaviors
+
+ func test_stepFinishedEvent_eventTypeIsAlwaysStepFinished() {
+ // Given
+ let event = StepFinishedEvent(stepName: "reasoning", timestamp: nil, rawEvent: nil)
+
+ // Then
+ XCTAssertEqual(event.eventType, .stepFinished)
+ }
+
+ func test_stepFinishedEvent_equatable_sameFields_areEqual() {
+ // Given
+ let event1 = StepFinishedEvent(stepName: "reasoning", timestamp: 1, rawEvent: nil)
+ let event2 = StepFinishedEvent(stepName: "reasoning", timestamp: 1, rawEvent: nil)
+
+ // Then
+ XCTAssertEqual(event1, event2)
+ }
+
+ func test_stepFinishedEvent_withEmptyStepName_isValid() {
+ // Given
+ let event = StepFinishedEvent(stepName: "", timestamp: nil, rawEvent: nil)
+
+ // Then
+ XCTAssertEqual(event.stepName, "")
+ XCTAssertEqual(event.eventType, .stepFinished)
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/LifeCycleEvents/StepStartedEventTests.swift b/sdks/community/swift/Tests/AGUICoreTests/LifeCycleEvents/StepStartedEventTests.swift
new file mode 100644
index 0000000000..f3aea94e0e
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/LifeCycleEvents/StepStartedEventTests.swift
@@ -0,0 +1,209 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+final class StepStartedEventTests: XCTestCase,
+ AGUIEventDecoderTestHelpers,
+ EventDecodingErrorTests {
+
+ // MARK: - EventDecodingErrorTests Protocol Requirements
+
+ var validEventFieldsWithoutType: [String: Any] {
+ ["stepName": "reasoning"]
+ }
+
+ var eventTypeString: String { "STEP_STARTED" }
+ var expectedEventType: EventType { .stepStarted }
+ var unknownEventTypeString: String { "STEP_PAUSED" }
+
+ // MARK: - Feature: Decode STEP_STARTED
+
+ func test_decodeValidStepStarted_returnsStepStartedEvent() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "STEP_STARTED",
+ "stepName": "reasoning"
+ }
+ """)
+
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ guard let stepStarted = event as? StepStartedEvent else {
+ return XCTFail("Expected StepStartedEvent, got \(type(of: event))")
+ }
+ XCTAssertEqual(stepStarted.eventType, .stepStarted)
+ XCTAssertEqual(stepStarted.stepName, "reasoning")
+ XCTAssertNil(stepStarted.timestamp)
+ }
+
+ func test_decodeStepStarted_withTimestamp_populatesTimestamp() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "STEP_STARTED",
+ "stepName": "reasoning",
+ "timestamp": \(EventTestData.timestamp)
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let stepStarted = try XCTUnwrap(event as? StepStartedEvent)
+ XCTAssertEqual(stepStarted.timestamp, EventTestData.timestamp)
+ }
+
+ func test_decodeStepStarted_preservesRawEventBytes() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "STEP_STARTED",
+ "stepName": "reasoning",
+ "timestamp": \(EventTestData.timestamp)
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let stepStarted = try XCTUnwrap(event as? StepStartedEvent)
+ XCTAssertEqual(stepStarted.rawEvent, data)
+ }
+
+ func test_decodeStepStarted_ignoresUnknownExtraFields() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "STEP_STARTED",
+ "stepName": "reasoning",
+ "extraField": "ignored",
+ "nested": { "x": 1 }
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let stepStarted = try XCTUnwrap(event as? StepStartedEvent)
+ XCTAssertEqual(stepStarted.stepName, "reasoning")
+ }
+
+ func test_decodeStepStarted_withUnicodeStepName_handlesUnicode() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "STEP_STARTED",
+ "stepName": "推理-🚀-测试"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let stepStarted = try XCTUnwrap(event as? StepStartedEvent)
+ XCTAssertEqual(stepStarted.stepName, "推理-🚀-测试")
+ }
+
+ // MARK: - Feature: Error handling (event-specific)
+
+ func test_decodeStepStarted_missingStepName_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "STEP_STARTED"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = (error as? EventDecodingError) else {
+ return XCTFail("Expected .decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("stepName"), "Expected message to mention 'stepName'. Got: \(message)")
+ }
+ }
+
+ func test_decodeStepStarted_stepNameWrongType_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "STEP_STARTED",
+ "stepName": 123
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = (error as? EventDecodingError) else {
+ return XCTFail("Expected .decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.lowercased().contains("type mismatch") || message.contains("Type mismatch"),
+ "Expected a type mismatch message. Got: \(message)")
+ }
+ }
+
+ func test_decodeStepStarted_timestampWrongType_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "STEP_STARTED",
+ "stepName": "reasoning",
+ "timestamp": "invalid"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = (error as? EventDecodingError) else {
+ return XCTFail("Expected .decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.lowercased().contains("type mismatch") || message.contains("Type mismatch"),
+ "Expected a type mismatch message. Got: \(message)")
+ }
+ }
+
+ // MARK: - Feature: Model behaviors
+
+ func test_stepStartedEvent_eventTypeIsAlwaysStepStarted() {
+ // Given
+ let event = StepStartedEvent(stepName: "reasoning", timestamp: nil, rawEvent: nil)
+
+ // Then
+ XCTAssertEqual(event.eventType, .stepStarted)
+ }
+
+ func test_stepStartedEvent_equatable_sameFields_areEqual() {
+ // Given
+ let event1 = StepStartedEvent(stepName: "reasoning", timestamp: 1, rawEvent: nil)
+ let event2 = StepStartedEvent(stepName: "reasoning", timestamp: 1, rawEvent: nil)
+
+ // Then
+ XCTAssertEqual(event1, event2)
+ }
+
+ func test_stepStartedEvent_withEmptyStepName_isValid() {
+ // Given
+ let event = StepStartedEvent(stepName: "", timestamp: nil, rawEvent: nil)
+
+ // Then
+ XCTAssertEqual(event.stepName, "")
+ XCTAssertEqual(event.eventType, .stepStarted)
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/ReasoningEvents/ReasoningEncryptedValueEventTests.swift b/sdks/community/swift/Tests/AGUICoreTests/ReasoningEvents/ReasoningEncryptedValueEventTests.swift
new file mode 100644
index 0000000000..aecb7391c0
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/ReasoningEvents/ReasoningEncryptedValueEventTests.swift
@@ -0,0 +1,154 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+final class ReasoningEncryptedValueEventTests: XCTestCase,
+ AGUIEventDecoderTestHelpers,
+ EventDecodingErrorTests {
+
+ private let entityId = "entity-abc"
+ private let encryptedValue = "enc-xyz-token"
+
+ // MARK: - EventDecodingErrorTests
+
+ var validEventFieldsWithoutType: [String: Any] {
+ ["subtype": "tool-call", "entityId": "entity-abc", "encryptedValue": "enc-xyz-token"]
+ }
+
+ var eventTypeString: String { "REASONING_ENCRYPTED_VALUE" }
+ var expectedEventType: EventType { .reasoningEncryptedValue }
+ var unknownEventTypeString: String { "REASONING_ENCRYPTED_CHUNK" }
+
+ // MARK: - Decode
+
+ func test_decodeWithSubtypeToolCall_returnsCorrectEvent() throws {
+ let data = jsonData("""
+ {
+ "type": "REASONING_ENCRYPTED_VALUE",
+ "subtype": "tool-call",
+ "entityId": "\(entityId)",
+ "encryptedValue": "\(encryptedValue)"
+ }
+ """)
+ let event = try XCTUnwrap(try makeStrictDecoder().decode(data) as? ReasoningEncryptedValueEvent)
+ XCTAssertEqual(event.eventType, .reasoningEncryptedValue)
+ XCTAssertEqual(event.subtype, .toolCall)
+ XCTAssertEqual(event.entityId, entityId)
+ XCTAssertEqual(event.encryptedValue, encryptedValue)
+ XCTAssertNil(event.timestamp)
+ }
+
+ func test_decodeWithSubtypeMessage_returnsCorrectEvent() throws {
+ let data = jsonData("""
+ {
+ "type": "REASONING_ENCRYPTED_VALUE",
+ "subtype": "message",
+ "entityId": "\(entityId)",
+ "encryptedValue": "\(encryptedValue)"
+ }
+ """)
+ let event = try XCTUnwrap(try makeStrictDecoder().decode(data) as? ReasoningEncryptedValueEvent)
+ XCTAssertEqual(event.subtype, .message)
+ }
+
+ func test_decodeWithTimestamp_populatesTimestamp() throws {
+ let data = jsonData("""
+ {
+ "type": "REASONING_ENCRYPTED_VALUE",
+ "subtype": "message",
+ "entityId": "\(entityId)",
+ "encryptedValue": "\(encryptedValue)",
+ "timestamp": \(EventTestData.timestamp)
+ }
+ """)
+ let event = try XCTUnwrap(try makeStrictDecoder().decode(data) as? ReasoningEncryptedValueEvent)
+ XCTAssertEqual(event.timestamp, EventTestData.timestamp)
+ }
+
+ func test_decodePreservesRawEvent() throws {
+ let data = jsonData("""
+ {"type":"REASONING_ENCRYPTED_VALUE","subtype":"tool-call","entityId":"\(entityId)","encryptedValue":"\(encryptedValue)"}
+ """)
+ let event = try XCTUnwrap(try makeStrictDecoder().decode(data) as? ReasoningEncryptedValueEvent)
+ XCTAssertEqual(event.rawEvent, data)
+ }
+
+ func test_missingSubtype_throwsDecodingFailed() {
+ let data = jsonData("""
+ {"type":"REASONING_ENCRYPTED_VALUE","entityId":"entity-abc","encryptedValue":"token"}
+ """)
+ XCTAssertThrowsError(try makeStrictDecoder().decode(data)) { error in
+ guard case .decodingFailed = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ }
+ }
+
+ func test_missingEntityId_throwsDecodingFailed() {
+ let data = jsonData("""
+ {"type":"REASONING_ENCRYPTED_VALUE","subtype":"tool-call","encryptedValue":"token"}
+ """)
+ XCTAssertThrowsError(try makeStrictDecoder().decode(data)) { error in
+ guard case .decodingFailed = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ }
+ }
+
+ func test_missingEncryptedValue_throwsDecodingFailed() {
+ let data = jsonData("""
+ {"type":"REASONING_ENCRYPTED_VALUE","subtype":"tool-call","entityId":"entity-abc"}
+ """)
+ XCTAssertThrowsError(try makeStrictDecoder().decode(data)) { error in
+ guard case .decodingFailed = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ }
+ }
+
+ func test_invalidSubtypeValue_throwsInvalidJSON() {
+ let data = jsonData("""
+ {"type":"REASONING_ENCRYPTED_VALUE","subtype":"unknown-subtype","entityId":"entity-abc","encryptedValue":"token"}
+ """)
+ XCTAssertThrowsError(try makeStrictDecoder().decode(data)) { error in
+ XCTAssertEqual(error as? EventDecodingError, .invalidJSON,
+ "Invalid enum raw value maps to .invalidJSON via dataCorrupted")
+ }
+ }
+
+ // MARK: - Model
+
+ func test_eventTypeIsAlwaysReasoningEncryptedValue() {
+ let event = ReasoningEncryptedValueEvent(subtype: .toolCall, entityId: entityId, encryptedValue: encryptedValue)
+ XCTAssertEqual(event.eventType, .reasoningEncryptedValue)
+ }
+
+ func test_equatable_sameFields_areEqual() {
+ let e1 = ReasoningEncryptedValueEvent(subtype: .toolCall, entityId: entityId, encryptedValue: encryptedValue)
+ let e2 = ReasoningEncryptedValueEvent(subtype: .toolCall, entityId: entityId, encryptedValue: encryptedValue)
+ XCTAssertEqual(e1, e2)
+ }
+
+ func test_equatable_differentSubtype_notEqual() {
+ let e1 = ReasoningEncryptedValueEvent(subtype: .toolCall, entityId: entityId, encryptedValue: encryptedValue)
+ let e2 = ReasoningEncryptedValueEvent(subtype: .message, entityId: entityId, encryptedValue: encryptedValue)
+ XCTAssertNotEqual(e1, e2)
+ }
+
+ func test_equatable_differentEncryptedValue_notEqual() {
+ let e1 = ReasoningEncryptedValueEvent(subtype: .toolCall, entityId: entityId, encryptedValue: "token-a")
+ let e2 = ReasoningEncryptedValueEvent(subtype: .toolCall, entityId: entityId, encryptedValue: "token-b")
+ XCTAssertNotEqual(e1, e2)
+ }
+
+ // MARK: - ReasoningEncryptedValueSubtype
+
+ func test_subtypeToolCall_hasCorrectRawValue() {
+ XCTAssertEqual(ReasoningEncryptedValueSubtype.toolCall.rawValue, "tool-call")
+ }
+
+ func test_subtypeMessage_hasCorrectRawValue() {
+ XCTAssertEqual(ReasoningEncryptedValueSubtype.message.rawValue, "message")
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/ReasoningEvents/ReasoningEndEventTests.swift b/sdks/community/swift/Tests/AGUICoreTests/ReasoningEvents/ReasoningEndEventTests.swift
new file mode 100644
index 0000000000..9de6bf0ee0
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/ReasoningEvents/ReasoningEndEventTests.swift
@@ -0,0 +1,77 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+final class ReasoningEndEventTests: XCTestCase,
+ AGUIEventDecoderTestHelpers,
+ EventDecodingErrorTests {
+
+ // MARK: - EventDecodingErrorTests
+
+ var validEventFieldsWithoutType: [String: Any] {
+ ["messageId": EventTestData.messageId]
+ }
+
+ var eventTypeString: String { "REASONING_END" }
+ var expectedEventType: EventType { .reasoningEnd }
+ var unknownEventTypeString: String { "REASONING_CANCELLED" }
+
+ // MARK: - Decode
+
+ func test_decodeValidReasoningEnd_returnsCorrectEvent() throws {
+ let data = jsonData("""
+ {"type":"REASONING_END","messageId":"\(EventTestData.messageId)"}
+ """)
+ let event = try XCTUnwrap(try makeStrictDecoder().decode(data) as? ReasoningEndEvent)
+ XCTAssertEqual(event.eventType, .reasoningEnd)
+ XCTAssertEqual(event.messageId, EventTestData.messageId)
+ XCTAssertNil(event.timestamp)
+ }
+
+ func test_decodeWithTimestamp_populatesTimestamp() throws {
+ let data = jsonData("""
+ {"type":"REASONING_END","messageId":"\(EventTestData.messageId)","timestamp":\(EventTestData.timestamp)}
+ """)
+ let event = try XCTUnwrap(try makeStrictDecoder().decode(data) as? ReasoningEndEvent)
+ XCTAssertEqual(event.timestamp, EventTestData.timestamp)
+ }
+
+ func test_decodePreservesRawEvent() throws {
+ let data = jsonData("""
+ {"type":"REASONING_END","messageId":"\(EventTestData.messageId)"}
+ """)
+ let event = try XCTUnwrap(try makeStrictDecoder().decode(data) as? ReasoningEndEvent)
+ XCTAssertEqual(event.rawEvent, data)
+ }
+
+ func test_missingMessageId_throwsDecodingFailed() {
+ let data = jsonData("""
+ {"type":"REASONING_END"}
+ """)
+ XCTAssertThrowsError(try makeStrictDecoder().decode(data)) { error in
+ guard case .decodingFailed = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ }
+ }
+
+ // MARK: - Model
+
+ func test_eventTypeIsAlwaysReasoningEnd() {
+ let event = ReasoningEndEvent(messageId: EventTestData.messageId)
+ XCTAssertEqual(event.eventType, .reasoningEnd)
+ }
+
+ func test_equatable_sameFields_areEqual() {
+ let e1 = ReasoningEndEvent(messageId: EventTestData.messageId, timestamp: EventTestData.timestamp)
+ let e2 = ReasoningEndEvent(messageId: EventTestData.messageId, timestamp: EventTestData.timestamp)
+ XCTAssertEqual(e1, e2)
+ }
+
+ func test_equatable_differentMessageIds_notEqual() {
+ let e1 = ReasoningEndEvent(messageId: EventTestData.messageId)
+ let e2 = ReasoningEndEvent(messageId: EventTestData.messageId2)
+ XCTAssertNotEqual(e1, e2)
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/ReasoningEvents/ReasoningMessageChunkEventTests.swift b/sdks/community/swift/Tests/AGUICoreTests/ReasoningEvents/ReasoningMessageChunkEventTests.swift
new file mode 100644
index 0000000000..a213b79ae2
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/ReasoningEvents/ReasoningMessageChunkEventTests.swift
@@ -0,0 +1,111 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+final class ReasoningMessageChunkEventTests: XCTestCase,
+ AGUIEventDecoderTestHelpers,
+ EventDecodingErrorTests {
+
+ // MARK: - EventDecodingErrorTests
+
+ // Both messageId and delta are optional; provide them as representative valid fields.
+ var validEventFieldsWithoutType: [String: Any] {
+ ["messageId": EventTestData.messageId, "delta": "chunk"]
+ }
+
+ var eventTypeString: String { "REASONING_MESSAGE_CHUNK" }
+ var expectedEventType: EventType { .reasoningMessageChunk }
+ var unknownEventTypeString: String { "REASONING_MESSAGE_PARTIAL" }
+
+ // MARK: - Decode
+
+ func test_decodeWithBothFields_returnsCorrectEvent() throws {
+ let data = jsonData("""
+ {
+ "type": "REASONING_MESSAGE_CHUNK",
+ "messageId": "\(EventTestData.messageId)",
+ "delta": "chunk"
+ }
+ """)
+ let event = try XCTUnwrap(try makeStrictDecoder().decode(data) as? ReasoningMessageChunkEvent)
+ XCTAssertEqual(event.eventType, .reasoningMessageChunk)
+ XCTAssertEqual(event.messageId, EventTestData.messageId)
+ XCTAssertEqual(event.delta, "chunk")
+ XCTAssertNil(event.timestamp)
+ }
+
+ func test_decodeWithOnlyMessageId_omitsDelta() throws {
+ let data = jsonData("""
+ {"type":"REASONING_MESSAGE_CHUNK","messageId":"\(EventTestData.messageId)"}
+ """)
+ let event = try XCTUnwrap(try makeStrictDecoder().decode(data) as? ReasoningMessageChunkEvent)
+ XCTAssertEqual(event.messageId, EventTestData.messageId)
+ XCTAssertNil(event.delta)
+ }
+
+ func test_decodeWithOnlyDelta_omitsMessageId() throws {
+ let data = jsonData("""
+ {"type":"REASONING_MESSAGE_CHUNK","delta":"partial"}
+ """)
+ let event = try XCTUnwrap(try makeStrictDecoder().decode(data) as? ReasoningMessageChunkEvent)
+ XCTAssertNil(event.messageId)
+ XCTAssertEqual(event.delta, "partial")
+ }
+
+ func test_decodeWithNoOptionalFields_succeeds() throws {
+ let data = jsonData("""
+ {"type":"REASONING_MESSAGE_CHUNK"}
+ """)
+ let event = try XCTUnwrap(try makeStrictDecoder().decode(data) as? ReasoningMessageChunkEvent)
+ XCTAssertNil(event.messageId)
+ XCTAssertNil(event.delta)
+ XCTAssertNil(event.timestamp)
+ }
+
+ func test_decodeWithTimestamp_populatesTimestamp() throws {
+ let data = jsonData("""
+ {
+ "type": "REASONING_MESSAGE_CHUNK",
+ "messageId": "\(EventTestData.messageId)",
+ "delta": "chunk",
+ "timestamp": \(EventTestData.timestamp)
+ }
+ """)
+ let event = try XCTUnwrap(try makeStrictDecoder().decode(data) as? ReasoningMessageChunkEvent)
+ XCTAssertEqual(event.timestamp, EventTestData.timestamp)
+ }
+
+ func test_decodePreservesRawEvent() throws {
+ let data = jsonData("""
+ {"type":"REASONING_MESSAGE_CHUNK","messageId":"\(EventTestData.messageId)","delta":"chunk"}
+ """)
+ let event = try XCTUnwrap(try makeStrictDecoder().decode(data) as? ReasoningMessageChunkEvent)
+ XCTAssertEqual(event.rawEvent, data)
+ }
+
+ // MARK: - Model
+
+ func test_eventTypeIsAlwaysReasoningMessageChunk() {
+ let event = ReasoningMessageChunkEvent()
+ XCTAssertEqual(event.eventType, .reasoningMessageChunk)
+ }
+
+ func test_equatable_sameFields_areEqual() {
+ let e1 = ReasoningMessageChunkEvent(messageId: EventTestData.messageId, delta: "chunk")
+ let e2 = ReasoningMessageChunkEvent(messageId: EventTestData.messageId, delta: "chunk")
+ XCTAssertEqual(e1, e2)
+ }
+
+ func test_equatable_differentDelta_notEqual() {
+ let e1 = ReasoningMessageChunkEvent(messageId: EventTestData.messageId, delta: "a")
+ let e2 = ReasoningMessageChunkEvent(messageId: EventTestData.messageId, delta: "b")
+ XCTAssertNotEqual(e1, e2)
+ }
+
+ func test_equatable_nilVsNonNilMessageId_notEqual() {
+ let e1 = ReasoningMessageChunkEvent(messageId: nil, delta: "chunk")
+ let e2 = ReasoningMessageChunkEvent(messageId: EventTestData.messageId, delta: "chunk")
+ XCTAssertNotEqual(e1, e2)
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/ReasoningEvents/ReasoningMessageContentEventTests.swift b/sdks/community/swift/Tests/AGUICoreTests/ReasoningEvents/ReasoningMessageContentEventTests.swift
new file mode 100644
index 0000000000..b651c0b1ec
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/ReasoningEvents/ReasoningMessageContentEventTests.swift
@@ -0,0 +1,98 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+final class ReasoningMessageContentEventTests: XCTestCase,
+ AGUIEventDecoderTestHelpers,
+ EventDecodingErrorTests {
+
+ // MARK: - EventDecodingErrorTests
+
+ var validEventFieldsWithoutType: [String: Any] {
+ ["messageId": EventTestData.messageId, "delta": "Let me think..."]
+ }
+
+ var eventTypeString: String { "REASONING_MESSAGE_CONTENT" }
+ var expectedEventType: EventType { .reasoningMessageContent }
+ var unknownEventTypeString: String { "REASONING_MESSAGE_PARTIAL" }
+
+ // MARK: - Decode
+
+ func test_decodeValidReasoningMessageContent_returnsCorrectEvent() throws {
+ let data = jsonData("""
+ {
+ "type": "REASONING_MESSAGE_CONTENT",
+ "messageId": "\(EventTestData.messageId)",
+ "delta": "Let me think..."
+ }
+ """)
+ let event = try XCTUnwrap(try makeStrictDecoder().decode(data) as? ReasoningMessageContentEvent)
+ XCTAssertEqual(event.eventType, .reasoningMessageContent)
+ XCTAssertEqual(event.messageId, EventTestData.messageId)
+ XCTAssertEqual(event.delta, "Let me think...")
+ XCTAssertNil(event.timestamp)
+ }
+
+ func test_decodeWithTimestamp_populatesTimestamp() throws {
+ let data = jsonData("""
+ {
+ "type": "REASONING_MESSAGE_CONTENT",
+ "messageId": "\(EventTestData.messageId)",
+ "delta": "chunk",
+ "timestamp": \(EventTestData.timestamp)
+ }
+ """)
+ let event = try XCTUnwrap(try makeStrictDecoder().decode(data) as? ReasoningMessageContentEvent)
+ XCTAssertEqual(event.timestamp, EventTestData.timestamp)
+ }
+
+ func test_decodePreservesRawEvent() throws {
+ let data = jsonData("""
+ {"type":"REASONING_MESSAGE_CONTENT","messageId":"\(EventTestData.messageId)","delta":"chunk"}
+ """)
+ let event = try XCTUnwrap(try makeStrictDecoder().decode(data) as? ReasoningMessageContentEvent)
+ XCTAssertEqual(event.rawEvent, data)
+ }
+
+ func test_missingMessageId_throwsDecodingFailed() {
+ let data = jsonData("""
+ {"type":"REASONING_MESSAGE_CONTENT","delta":"chunk"}
+ """)
+ XCTAssertThrowsError(try makeStrictDecoder().decode(data)) { error in
+ guard case .decodingFailed = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ }
+ }
+
+ func test_missingDelta_throwsDecodingFailed() {
+ let data = jsonData("""
+ {"type":"REASONING_MESSAGE_CONTENT","messageId":"\(EventTestData.messageId)"}
+ """)
+ XCTAssertThrowsError(try makeStrictDecoder().decode(data)) { error in
+ guard case .decodingFailed = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ }
+ }
+
+ // MARK: - Model
+
+ func test_eventTypeIsAlwaysReasoningMessageContent() {
+ let event = ReasoningMessageContentEvent(messageId: EventTestData.messageId, delta: "chunk")
+ XCTAssertEqual(event.eventType, .reasoningMessageContent)
+ }
+
+ func test_equatable_sameFields_areEqual() {
+ let e1 = ReasoningMessageContentEvent(messageId: EventTestData.messageId, delta: "chunk")
+ let e2 = ReasoningMessageContentEvent(messageId: EventTestData.messageId, delta: "chunk")
+ XCTAssertEqual(e1, e2)
+ }
+
+ func test_equatable_differentDelta_notEqual() {
+ let e1 = ReasoningMessageContentEvent(messageId: EventTestData.messageId, delta: "a")
+ let e2 = ReasoningMessageContentEvent(messageId: EventTestData.messageId, delta: "b")
+ XCTAssertNotEqual(e1, e2)
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/ReasoningEvents/ReasoningMessageEndEventTests.swift b/sdks/community/swift/Tests/AGUICoreTests/ReasoningEvents/ReasoningMessageEndEventTests.swift
new file mode 100644
index 0000000000..6ba67ec241
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/ReasoningEvents/ReasoningMessageEndEventTests.swift
@@ -0,0 +1,77 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+final class ReasoningMessageEndEventTests: XCTestCase,
+ AGUIEventDecoderTestHelpers,
+ EventDecodingErrorTests {
+
+ // MARK: - EventDecodingErrorTests
+
+ var validEventFieldsWithoutType: [String: Any] {
+ ["messageId": EventTestData.messageId]
+ }
+
+ var eventTypeString: String { "REASONING_MESSAGE_END" }
+ var expectedEventType: EventType { .reasoningMessageEnd }
+ var unknownEventTypeString: String { "REASONING_MESSAGE_TERMINATED" }
+
+ // MARK: - Decode
+
+ func test_decodeValidReasoningMessageEnd_returnsCorrectEvent() throws {
+ let data = jsonData("""
+ {"type":"REASONING_MESSAGE_END","messageId":"\(EventTestData.messageId)"}
+ """)
+ let event = try XCTUnwrap(try makeStrictDecoder().decode(data) as? ReasoningMessageEndEvent)
+ XCTAssertEqual(event.eventType, .reasoningMessageEnd)
+ XCTAssertEqual(event.messageId, EventTestData.messageId)
+ XCTAssertNil(event.timestamp)
+ }
+
+ func test_decodeWithTimestamp_populatesTimestamp() throws {
+ let data = jsonData("""
+ {"type":"REASONING_MESSAGE_END","messageId":"\(EventTestData.messageId)","timestamp":\(EventTestData.timestamp)}
+ """)
+ let event = try XCTUnwrap(try makeStrictDecoder().decode(data) as? ReasoningMessageEndEvent)
+ XCTAssertEqual(event.timestamp, EventTestData.timestamp)
+ }
+
+ func test_decodePreservesRawEvent() throws {
+ let data = jsonData("""
+ {"type":"REASONING_MESSAGE_END","messageId":"\(EventTestData.messageId)"}
+ """)
+ let event = try XCTUnwrap(try makeStrictDecoder().decode(data) as? ReasoningMessageEndEvent)
+ XCTAssertEqual(event.rawEvent, data)
+ }
+
+ func test_missingMessageId_throwsDecodingFailed() {
+ let data = jsonData("""
+ {"type":"REASONING_MESSAGE_END"}
+ """)
+ XCTAssertThrowsError(try makeStrictDecoder().decode(data)) { error in
+ guard case .decodingFailed = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ }
+ }
+
+ // MARK: - Model
+
+ func test_eventTypeIsAlwaysReasoningMessageEnd() {
+ let event = ReasoningMessageEndEvent(messageId: EventTestData.messageId)
+ XCTAssertEqual(event.eventType, .reasoningMessageEnd)
+ }
+
+ func test_equatable_sameFields_areEqual() {
+ let e1 = ReasoningMessageEndEvent(messageId: EventTestData.messageId, timestamp: EventTestData.timestamp)
+ let e2 = ReasoningMessageEndEvent(messageId: EventTestData.messageId, timestamp: EventTestData.timestamp)
+ XCTAssertEqual(e1, e2)
+ }
+
+ func test_equatable_differentMessageIds_notEqual() {
+ let e1 = ReasoningMessageEndEvent(messageId: EventTestData.messageId)
+ let e2 = ReasoningMessageEndEvent(messageId: EventTestData.messageId2)
+ XCTAssertNotEqual(e1, e2)
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/ReasoningEvents/ReasoningMessageStartEventTests.swift b/sdks/community/swift/Tests/AGUICoreTests/ReasoningEvents/ReasoningMessageStartEventTests.swift
new file mode 100644
index 0000000000..64316b2132
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/ReasoningEvents/ReasoningMessageStartEventTests.swift
@@ -0,0 +1,97 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+final class ReasoningMessageStartEventTests: XCTestCase,
+ AGUIEventDecoderTestHelpers,
+ EventDecodingErrorTests {
+
+ // MARK: - EventDecodingErrorTests
+
+ var validEventFieldsWithoutType: [String: Any] {
+ ["messageId": EventTestData.messageId, "role": "reasoning"]
+ }
+
+ var eventTypeString: String { "REASONING_MESSAGE_START" }
+ var expectedEventType: EventType { .reasoningMessageStart }
+ var unknownEventTypeString: String { "REASONING_MESSAGE_PAUSED" }
+
+ // MARK: - Decode
+
+ func test_decodeValidReasoningMessageStart_returnsCorrectEvent() throws {
+ let data = jsonData("""
+ {
+ "type": "REASONING_MESSAGE_START",
+ "messageId": "\(EventTestData.messageId)",
+ "role": "reasoning"
+ }
+ """)
+ let event = try XCTUnwrap(try makeStrictDecoder().decode(data) as? ReasoningMessageStartEvent)
+ XCTAssertEqual(event.eventType, .reasoningMessageStart)
+ XCTAssertEqual(event.messageId, EventTestData.messageId)
+ XCTAssertEqual(event.role, "reasoning")
+ XCTAssertNil(event.timestamp)
+ }
+
+ func test_decodeWithTimestamp_populatesTimestamp() throws {
+ let data = jsonData("""
+ {
+ "type": "REASONING_MESSAGE_START",
+ "messageId": "\(EventTestData.messageId)",
+ "role": "reasoning",
+ "timestamp": \(EventTestData.timestamp)
+ }
+ """)
+ let event = try XCTUnwrap(try makeStrictDecoder().decode(data) as? ReasoningMessageStartEvent)
+ XCTAssertEqual(event.timestamp, EventTestData.timestamp)
+ }
+
+ func test_decodePreservesRawEvent() throws {
+ let data = jsonData("""
+ {"type":"REASONING_MESSAGE_START","messageId":"\(EventTestData.messageId)","role":"reasoning"}
+ """)
+ let event = try XCTUnwrap(try makeStrictDecoder().decode(data) as? ReasoningMessageStartEvent)
+ XCTAssertEqual(event.rawEvent, data)
+ }
+
+ func test_missingMessageId_throwsDecodingFailed() {
+ let data = jsonData("""
+ {"type":"REASONING_MESSAGE_START","role":"reasoning"}
+ """)
+ XCTAssertThrowsError(try makeStrictDecoder().decode(data)) { error in
+ guard case .decodingFailed = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ }
+ }
+
+ func test_missingRole_throwsDecodingFailed() {
+ let data = jsonData("""
+ {"type":"REASONING_MESSAGE_START","messageId":"\(EventTestData.messageId)"}
+ """)
+ XCTAssertThrowsError(try makeStrictDecoder().decode(data)) { error in
+ guard case .decodingFailed = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ }
+ }
+
+ // MARK: - Model
+
+ func test_eventTypeIsAlwaysReasoningMessageStart() {
+ let event = ReasoningMessageStartEvent(messageId: EventTestData.messageId, role: "reasoning")
+ XCTAssertEqual(event.eventType, .reasoningMessageStart)
+ }
+
+ func test_defaultRole_isReasoning() {
+ let event = ReasoningMessageStartEvent(messageId: EventTestData.messageId)
+ XCTAssertEqual(event.role, "reasoning")
+ }
+
+ func test_equatable_sameFields_areEqual() {
+ let e1 = ReasoningMessageStartEvent(messageId: EventTestData.messageId, role: "reasoning")
+ let e2 = ReasoningMessageStartEvent(messageId: EventTestData.messageId, role: "reasoning")
+ XCTAssertEqual(e1, e2)
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/ReasoningEvents/ReasoningStartEventTests.swift b/sdks/community/swift/Tests/AGUICoreTests/ReasoningEvents/ReasoningStartEventTests.swift
new file mode 100644
index 0000000000..61a52d515c
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/ReasoningEvents/ReasoningStartEventTests.swift
@@ -0,0 +1,77 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+final class ReasoningStartEventTests: XCTestCase,
+ AGUIEventDecoderTestHelpers,
+ EventDecodingErrorTests {
+
+ // MARK: - EventDecodingErrorTests
+
+ var validEventFieldsWithoutType: [String: Any] {
+ ["messageId": EventTestData.messageId]
+ }
+
+ var eventTypeString: String { "REASONING_START" }
+ var expectedEventType: EventType { .reasoningStart }
+ var unknownEventTypeString: String { "REASONING_PAUSED" }
+
+ // MARK: - Decode
+
+ func test_decodeValidReasoningStart_returnsCorrectEvent() throws {
+ let data = jsonData("""
+ {"type":"REASONING_START","messageId":"\(EventTestData.messageId)"}
+ """)
+ let event = try XCTUnwrap(try makeStrictDecoder().decode(data) as? ReasoningStartEvent)
+ XCTAssertEqual(event.eventType, .reasoningStart)
+ XCTAssertEqual(event.messageId, EventTestData.messageId)
+ XCTAssertNil(event.timestamp)
+ }
+
+ func test_decodeWithTimestamp_populatesTimestamp() throws {
+ let data = jsonData("""
+ {"type":"REASONING_START","messageId":"\(EventTestData.messageId)","timestamp":\(EventTestData.timestamp)}
+ """)
+ let event = try XCTUnwrap(try makeStrictDecoder().decode(data) as? ReasoningStartEvent)
+ XCTAssertEqual(event.timestamp, EventTestData.timestamp)
+ }
+
+ func test_decodePreservesRawEvent() throws {
+ let data = jsonData("""
+ {"type":"REASONING_START","messageId":"\(EventTestData.messageId)"}
+ """)
+ let event = try XCTUnwrap(try makeStrictDecoder().decode(data) as? ReasoningStartEvent)
+ XCTAssertEqual(event.rawEvent, data)
+ }
+
+ func test_missingMessageId_throwsDecodingFailed() {
+ let data = jsonData("""
+ {"type":"REASONING_START"}
+ """)
+ XCTAssertThrowsError(try makeStrictDecoder().decode(data)) { error in
+ guard case .decodingFailed = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ }
+ }
+
+ // MARK: - Model
+
+ func test_eventTypeIsAlwaysReasoningStart() {
+ let event = ReasoningStartEvent(messageId: EventTestData.messageId)
+ XCTAssertEqual(event.eventType, .reasoningStart)
+ }
+
+ func test_equatable_sameFields_areEqual() {
+ let e1 = ReasoningStartEvent(messageId: EventTestData.messageId, timestamp: EventTestData.timestamp)
+ let e2 = ReasoningStartEvent(messageId: EventTestData.messageId, timestamp: EventTestData.timestamp)
+ XCTAssertEqual(e1, e2)
+ }
+
+ func test_equatable_differentMessageIds_notEqual() {
+ let e1 = ReasoningStartEvent(messageId: EventTestData.messageId)
+ let e2 = ReasoningStartEvent(messageId: EventTestData.messageId2)
+ XCTAssertNotEqual(e1, e2)
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/SpecialEvents/CustomEventTests.swift b/sdks/community/swift/Tests/AGUICoreTests/SpecialEvents/CustomEventTests.swift
new file mode 100644
index 0000000000..4d739a1289
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/SpecialEvents/CustomEventTests.swift
@@ -0,0 +1,412 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+final class CustomEventTests: XCTestCase,
+ AGUIEventDecoderTestHelpers,
+ EventDecodingErrorTests {
+
+ // MARK: - EventDecodingErrorTests Protocol Requirements
+
+ // AG-UI wire format: "name" (event type) and "value" (payload)
+ var validEventFieldsWithoutType: [String: Any] {
+ [
+ "name": "my.custom.event",
+ "value": ["field1": "value1", "field2": 123]
+ ]
+ }
+
+ var eventTypeString: String { "CUSTOM" }
+ var expectedEventType: EventType { .custom }
+ var unknownEventTypeString: String { "UNKNOWN_CUSTOM_EVENT" }
+
+ // MARK: - Feature: Decode CUSTOM
+
+ func test_decodeValidCustom_withObjectData_returnsCustomEvent() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "CUSTOM",
+ "name": "com.example.userAction",
+ "value": {
+ "action": "buttonClick",
+ "buttonId": "submit",
+ "metadata": {
+ "screen": "login"
+ }
+ }
+ }
+ """)
+
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ guard let customEvent = event as? CustomEvent else {
+ return XCTFail("Expected CustomEvent, got \(type(of: event))")
+ }
+ XCTAssertEqual(customEvent.eventType, .custom)
+ XCTAssertEqual(customEvent.name, "com.example.userAction")
+ XCTAssertNil(customEvent.timestamp)
+
+ // Verify data can be parsed
+ let parsed = try customEvent.parsedData() as? [String: Any]
+ XCTAssertNotNil(parsed)
+ XCTAssertEqual(parsed?["action"] as? String, "buttonClick")
+ XCTAssertEqual(parsed?["buttonId"] as? String, "submit")
+ }
+
+ func test_decodeCustom_withArrayData_handlesCorrectly() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "CUSTOM",
+ "name": "events.batch",
+ "value": [
+ {"id": 1, "val": "a"},
+ {"id": 2, "val": "b"}
+ ]
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let customEvent = try XCTUnwrap(event as? CustomEvent)
+ XCTAssertEqual(customEvent.name, "events.batch")
+ let parsed = try customEvent.parsedData() as? [[String: Any]]
+ XCTAssertNotNil(parsed)
+ XCTAssertEqual(parsed?.count, 2)
+ }
+
+ func test_decodeCustom_withPrimitiveData_handlesCorrectly() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "CUSTOM",
+ "name": "simple.message",
+ "value": "Hello, World!"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let customEvent = try XCTUnwrap(event as? CustomEvent)
+ XCTAssertEqual(customEvent.name,"simple.message")
+ let parsed = try customEvent.parsedData() as? String
+ XCTAssertEqual(parsed, "Hello, World!")
+ }
+
+ func test_decodeCustom_withNullData_handlesCorrectly() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "CUSTOM",
+ "name": "void.event",
+ "value": null
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let customEvent = try XCTUnwrap(event as? CustomEvent)
+ XCTAssertEqual(customEvent.name,"void.event")
+ let parsed = try customEvent.parsedData()
+ XCTAssertTrue(parsed is NSNull)
+ }
+
+ func test_decodeCustom_withDotNotationCustomType_handlesCorrectly() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "CUSTOM",
+ "name": "com.myapp.analytics.pageView",
+ "value": {"page": "/home"}
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let customEvent = try XCTUnwrap(event as? CustomEvent)
+ XCTAssertEqual(customEvent.name,"com.myapp.analytics.pageView")
+ }
+
+ func test_decodeCustom_withTimestamp_populatesTimestamp() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "CUSTOM",
+ "name": "test.event",
+ "value": { "count": 123 },
+ "timestamp": \(EventTestData.timestamp)
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let customEvent = try XCTUnwrap(event as? CustomEvent)
+ XCTAssertEqual(customEvent.timestamp, EventTestData.timestamp)
+ }
+
+ func test_decodeCustom_preservesRawEventBytes() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "CUSTOM",
+ "name": "test.event",
+ "value": { "key": "val" },
+ "timestamp": \(EventTestData.timestamp)
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let customEvent = try XCTUnwrap(event as? CustomEvent)
+ XCTAssertEqual(customEvent.rawEvent, data)
+ }
+
+ func test_decodeCustom_ignoresUnknownExtraFields() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "CUSTOM",
+ "name": "test.event",
+ "value": { "key": "val" },
+ "extraField": "ignored",
+ "nested": { "x": 1 }
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let customEvent = try XCTUnwrap(event as? CustomEvent)
+ let parsed = try customEvent.parsedData() as? [String: Any]
+ XCTAssertEqual(parsed?["key"] as? String, "val")
+ }
+
+ // MARK: - Feature: Error handling
+
+ func test_decodeCustom_missingName_throwsDecodingFailed() {
+ // Given — "name" is required; "value" is optional
+ let data = jsonData("""
+ {
+ "type": "CUSTOM",
+ "value": { "key": "val" }
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("name") || message.contains("Missing key"))
+ }
+ }
+
+ func test_decodeCustom_missingValue_returnsEventWithEmptyData() throws {
+ // Given — "value" is optional per spec; absent value decodes to empty object
+ let data = jsonData("""
+ {
+ "type": "CUSTOM",
+ "name": "test.event",
+ "timestamp": \(EventTestData.timestamp)
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let customEvent = try XCTUnwrap(event as? CustomEvent)
+ XCTAssertEqual(customEvent.name,"test.event")
+ let parsed = try customEvent.parsedData() as? [String: Any]
+ XCTAssertNotNil(parsed)
+ XCTAssertTrue(parsed?.isEmpty == true)
+ }
+
+ func test_decodeCustom_wrongTypeForName_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "CUSTOM",
+ "name": 123,
+ "value": { "key": "val" }
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("name") || message.contains("String"))
+ }
+ }
+
+ func test_decodeCustom_wrongTypeForTimestamp_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "CUSTOM",
+ "name": "test.event",
+ "value": { "key": "val" },
+ "timestamp": "not-a-number"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("timestamp") || message.contains("Type mismatch"))
+ }
+ }
+
+ // MARK: - Feature: Model behaviors
+
+ func test_customEvent_eventTypeIsAlwaysCustom() throws {
+ // Given
+ let eventData = try JSONSerialization.data(withJSONObject: ["key": "value"], options: [])
+ let event = CustomEvent(name: "test.event", value: eventData, timestamp: nil, rawEvent: nil)
+
+ // Then
+ XCTAssertEqual(event.eventType, .custom)
+ }
+
+ func test_customEvent_equatable_sameCustomTypeAndData_areEqual() throws {
+ // Given
+ let eventData = try JSONSerialization.data(withJSONObject: ["key": "value"], options: [])
+ let event1 = CustomEvent(name: "test.event", value: eventData, timestamp: EventTestData.timestamp, rawEvent: nil)
+ let event2 = CustomEvent(name: "test.event", value: eventData, timestamp: EventTestData.timestamp, rawEvent: nil)
+
+ // Then
+ XCTAssertEqual(event1, event2)
+ }
+
+ func test_customEvent_equatable_differentCustomTypes_areNotEqual() throws {
+ // Given
+ let eventData = try JSONSerialization.data(withJSONObject: ["key": "value"], options: [])
+ let event1 = CustomEvent(name: "test.event1", value: eventData, timestamp: EventTestData.timestamp, rawEvent: nil)
+ let event2 = CustomEvent(name: "test.event2", value: eventData, timestamp: EventTestData.timestamp, rawEvent: nil)
+
+ // Then
+ XCTAssertNotEqual(event1, event2)
+ }
+
+ func test_customEvent_equatable_differentData_areNotEqual() throws {
+ // Given
+ let eventData1 = try JSONSerialization.data(withJSONObject: ["key": "value1"], options: [])
+ let eventData2 = try JSONSerialization.data(withJSONObject: ["key": "value2"], options: [])
+ let event1 = CustomEvent(name: "test.event", value: eventData1, timestamp: EventTestData.timestamp, rawEvent: nil)
+ let event2 = CustomEvent(name: "test.event", value: eventData2, timestamp: EventTestData.timestamp, rawEvent: nil)
+
+ // Then
+ XCTAssertNotEqual(event1, event2)
+ }
+
+ func test_customEvent_equatable_differentTimestamps_areNotEqual() throws {
+ // Given
+ let eventData = try JSONSerialization.data(withJSONObject: ["key": "value"], options: [])
+ let event1 = CustomEvent(name: "test.event", value: eventData, timestamp: EventTestData.timestamp, rawEvent: nil)
+ let event2 = CustomEvent(name: "test.event", value: eventData, timestamp: EventTestData.timestamp2, rawEvent: nil)
+
+ // Then
+ XCTAssertNotEqual(event1, event2)
+ }
+
+ func test_customEvent_parsedData_returnsCorrectValue() throws {
+ // Given
+ let originalObject: [String: Any] = ["action": "test", "count": 5]
+ let eventData = try JSONSerialization.data(withJSONObject: originalObject, options: [])
+ let event = CustomEvent(name: "test.event", value: eventData, timestamp: nil, rawEvent: nil)
+
+ // When
+ let parsed = try event.parsedData() as? [String: Any]
+
+ // Then
+ XCTAssertNotNil(parsed)
+ XCTAssertEqual(parsed?["action"] as? String, "test")
+ XCTAssertEqual(parsed?["count"] as? Int, 5)
+ }
+
+ func test_customEvent_parsedData_withInvalidData_throws() throws {
+ // Given
+ let invalidData = Data("not json".utf8)
+ let event = CustomEvent(name: "test.event", value: invalidData, timestamp: nil, rawEvent: nil)
+
+ // When / Then
+ XCTAssertThrowsError(try event.parsedData())
+ }
+
+ func test_customEvent_parsedDataAs_withCodableType_decodesCorrectly() throws {
+ // Given
+ struct CustomPayload: Codable, Equatable {
+ let action: String
+ let userId: Int
+ }
+
+ let originalPayload = CustomPayload(action: "login", userId: 12345)
+ let eventData = try JSONEncoder().encode(originalPayload)
+ let event = CustomEvent(name: "user.action", value: eventData, timestamp: nil, rawEvent: nil)
+
+ // When
+ let decoded = try event.parsedData(as: CustomPayload.self)
+
+ // Then
+ XCTAssertEqual(decoded.action, "login")
+ XCTAssertEqual(decoded.userId, 12345)
+ }
+
+ func test_customEvent_description_containsKeyInformation() throws {
+ // Given
+ let eventData = try JSONSerialization.data(withJSONObject: ["key": "value"], options: [])
+ let event = CustomEvent(name: "test.event", value: eventData, timestamp: EventTestData.timestamp, rawEvent: nil)
+
+ // Then
+ let description = event.description
+ XCTAssertTrue(description.contains("CustomEvent"))
+ XCTAssertTrue(description.contains("test.event"))
+ XCTAssertTrue(description.contains("timestamp"))
+ }
+
+ func test_customEvent_debugDescription_containsDetailedInformation() throws {
+ // Given
+ let eventData = try JSONSerialization.data(withJSONObject: ["key": "value"], options: [])
+ let event = CustomEvent(name: "test.event", value: eventData, timestamp: EventTestData.timestamp, rawEvent: nil)
+
+ // Then
+ let debugDescription = event.debugDescription
+ XCTAssertTrue(debugDescription.contains("CustomEvent"))
+ XCTAssertTrue(debugDescription.contains("test.event"))
+ XCTAssertTrue(debugDescription.contains("value"))
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/SpecialEvents/RawEventTests.swift b/sdks/community/swift/Tests/AGUICoreTests/SpecialEvents/RawEventTests.swift
new file mode 100644
index 0000000000..fe97441d43
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/SpecialEvents/RawEventTests.swift
@@ -0,0 +1,339 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+final class RawEventTests: XCTestCase,
+ AGUIEventDecoderTestHelpers,
+ EventDecodingErrorTests {
+
+ // MARK: - EventDecodingErrorTests Protocol Requirements
+
+ var validEventFieldsWithoutType: [String: Any] {
+ [
+ "event": ["arbitrary": "content", "can": "be", "anything": 123]
+ ]
+ }
+
+ var eventTypeString: String { "RAW" }
+ var expectedEventType: EventType { .raw }
+ var unknownEventTypeString: String { "UNKNOWN_RAW_EVENT" }
+
+ // MARK: - Feature: Decode RAW
+
+ func test_decodeValidRaw_withObjectData_returnsRawEvent() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "RAW",
+ "event": {
+ "customField": "value",
+ "nested": {
+ "prop": 123
+ }
+ }
+ }
+ """)
+
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ guard let rawEvent = event as? RawEvent else {
+ return XCTFail("Expected RawEvent, got \(type(of: event))")
+ }
+ XCTAssertEqual(rawEvent.eventType, .raw)
+ XCTAssertNil(rawEvent.timestamp)
+
+ // Verify data can be parsed
+ let parsed = try rawEvent.parsedData() as? [String: Any]
+ XCTAssertNotNil(parsed)
+ XCTAssertEqual(parsed?["customField"] as? String, "value")
+ }
+
+ func test_decodeRaw_withArrayData_handlesCorrectly() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "RAW",
+ "event": [1, 2, 3, "four", true]
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let rawEvent = try XCTUnwrap(event as? RawEvent)
+ let parsed = try rawEvent.parsedData() as? [Any]
+ XCTAssertNotNil(parsed)
+ XCTAssertEqual(parsed?.count, 5)
+ }
+
+ func test_decodeRaw_withPrimitiveData_handlesCorrectly() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "RAW",
+ "event": "simple string"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let rawEvent = try XCTUnwrap(event as? RawEvent)
+ let parsed = try rawEvent.parsedData() as? String
+ XCTAssertEqual(parsed, "simple string")
+ }
+
+ func test_decodeRaw_withNumberData_handlesCorrectly() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "RAW",
+ "event": 42
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let rawEvent = try XCTUnwrap(event as? RawEvent)
+ let parsed = try rawEvent.parsedData() as? Int
+ XCTAssertEqual(parsed, 42)
+ }
+
+ func test_decodeRaw_withNullData_handlesCorrectly() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "RAW",
+ "event": null
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let rawEvent = try XCTUnwrap(event as? RawEvent)
+ let parsed = try rawEvent.parsedData()
+ XCTAssertTrue(parsed is NSNull)
+ }
+
+ func test_decodeRaw_withTimestamp_populatesTimestamp() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "RAW",
+ "event": { "value": 123 },
+ "timestamp": \(EventTestData.timestamp)
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let rawEvent = try XCTUnwrap(event as? RawEvent)
+ XCTAssertEqual(rawEvent.timestamp, EventTestData.timestamp)
+ }
+
+ func test_decodeRaw_preservesRawEventBytes() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "RAW",
+ "event": { "key": "value" },
+ "timestamp": \(EventTestData.timestamp)
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let rawEvent = try XCTUnwrap(event as? RawEvent)
+ XCTAssertEqual(rawEvent.rawEvent, data)
+ }
+
+ func test_decodeRaw_ignoresUnknownExtraFields() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "RAW",
+ "event": { "key": "value" },
+ "extraField": "ignored",
+ "nested": { "x": 1 }
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let rawEvent = try XCTUnwrap(event as? RawEvent)
+ let parsed = try rawEvent.parsedData() as? [String: Any]
+ XCTAssertEqual(parsed?["key"] as? String, "value")
+ }
+
+ // MARK: - Feature: Error handling
+
+ func test_decodeRaw_missingData_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "RAW",
+ "timestamp": \(EventTestData.timestamp)
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("event") || message.contains("Missing key"))
+ }
+ }
+
+ func test_decodeRaw_wrongTypeForTimestamp_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "RAW",
+ "event": { "key": "value" },
+ "timestamp": "not-a-number"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("timestamp") || message.contains("Type mismatch"))
+ }
+ }
+
+ // MARK: - Feature: Model behaviors
+
+ func test_rawEvent_eventTypeIsAlwaysRaw() throws {
+ // Given
+ let eventData = try JSONSerialization.data(withJSONObject: ["key": "value"], options: [])
+ let event = RawEvent(data: eventData, timestamp: nil, rawEvent: nil)
+
+ // Then
+ XCTAssertEqual(event.eventType, .raw)
+ }
+
+ func test_rawEvent_equatable_sameData_areEqual() throws {
+ // Given
+ let eventData = try JSONSerialization.data(withJSONObject: ["key": "value"], options: [])
+ let event1 = RawEvent(data: eventData, timestamp: EventTestData.timestamp, rawEvent: nil)
+ let event2 = RawEvent(data: eventData, timestamp: EventTestData.timestamp, rawEvent: nil)
+
+ // Then
+ XCTAssertEqual(event1, event2)
+ }
+
+ func test_rawEvent_equatable_differentData_areNotEqual() throws {
+ // Given
+ let eventData1 = try JSONSerialization.data(withJSONObject: ["key": "value1"], options: [])
+ let eventData2 = try JSONSerialization.data(withJSONObject: ["key": "value2"], options: [])
+ let event1 = RawEvent(data: eventData1, timestamp: EventTestData.timestamp, rawEvent: nil)
+ let event2 = RawEvent(data: eventData2, timestamp: EventTestData.timestamp, rawEvent: nil)
+
+ // Then
+ XCTAssertNotEqual(event1, event2)
+ }
+
+ func test_rawEvent_equatable_differentTimestamps_areNotEqual() throws {
+ // Given
+ let eventData = try JSONSerialization.data(withJSONObject: ["key": "value"], options: [])
+ let event1 = RawEvent(data: eventData, timestamp: EventTestData.timestamp, rawEvent: nil)
+ let event2 = RawEvent(data: eventData, timestamp: EventTestData.timestamp2, rawEvent: nil)
+
+ // Then
+ XCTAssertNotEqual(event1, event2)
+ }
+
+ func test_rawEvent_parsedData_returnsCorrectValue() throws {
+ // Given
+ let originalObject: [String: Any] = ["key": "value", "number": 42]
+ let eventData = try JSONSerialization.data(withJSONObject: originalObject, options: [])
+ let event = RawEvent(data: eventData, timestamp: nil, rawEvent: nil)
+
+ // When
+ let parsed = try event.parsedData() as? [String: Any]
+
+ // Then
+ XCTAssertNotNil(parsed)
+ XCTAssertEqual(parsed?["key"] as? String, "value")
+ XCTAssertEqual(parsed?["number"] as? Int, 42)
+ }
+
+ func test_rawEvent_parsedData_withInvalidData_throws() throws {
+ // Given
+ let invalidData = Data("not json".utf8)
+ let event = RawEvent(data: invalidData, timestamp: nil, rawEvent: nil)
+
+ // When / Then
+ XCTAssertThrowsError(try event.parsedData())
+ }
+
+ func test_rawEvent_parsedDataAs_withCodableType_decodesCorrectly() throws {
+ // Given
+ struct CustomData: Codable, Equatable {
+ let field1: String
+ let field2: Int
+ }
+
+ let originalData = CustomData(field1: "test", field2: 123)
+ let eventData = try JSONEncoder().encode(originalData)
+ let event = RawEvent(data: eventData, timestamp: nil, rawEvent: nil)
+
+ // When
+ let decoded = try event.parsedData(as: CustomData.self)
+
+ // Then
+ XCTAssertEqual(decoded.field1, "test")
+ XCTAssertEqual(decoded.field2, 123)
+ }
+
+ func test_rawEvent_description_containsKeyInformation() throws {
+ // Given
+ let eventData = try JSONSerialization.data(withJSONObject: ["key": "value"], options: [])
+ let event = RawEvent(data: eventData, timestamp: EventTestData.timestamp, rawEvent: nil)
+
+ // Then
+ let description = event.description
+ XCTAssertTrue(description.contains("RawEvent"))
+ XCTAssertTrue(description.contains("timestamp"))
+ }
+
+ func test_rawEvent_debugDescription_containsDetailedInformation() throws {
+ // Given
+ let eventData = try JSONSerialization.data(withJSONObject: ["key": "value"], options: [])
+ let event = RawEvent(data: eventData, timestamp: EventTestData.timestamp, rawEvent: nil)
+
+ // Then
+ let debugDescription = event.debugDescription
+ XCTAssertTrue(debugDescription.contains("RawEvent"))
+ XCTAssertTrue(debugDescription.contains("data"))
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/SpecialEvents/UnknownEventTests.swift b/sdks/community/swift/Tests/AGUICoreTests/SpecialEvents/UnknownEventTests.swift
new file mode 100644
index 0000000000..0fd0d3b04b
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/SpecialEvents/UnknownEventTests.swift
@@ -0,0 +1,104 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+final class UnknownEventTests: XCTestCase, AGUIEventDecoderTestHelpers {
+
+ // MARK: - Feature: EventType.unknown sentinel
+
+ func test_unknownEventType_rawValueIsSentinel() {
+ // The sentinel raw value must never appear on the AG-UI wire
+ XCTAssertEqual(EventType.unknown.rawValue, "__UNKNOWN__")
+ }
+
+ func test_unknownEventType_isNotEqualToRaw() {
+ XCTAssertNotEqual(EventType.unknown, EventType.raw)
+ }
+
+ func test_unknownEventType_doesNotEqualAnyKnownWireValue() {
+ // "__UNKNOWN__" must not collide with any real protocol event type string
+ let knownRawValues = EventType.allCases
+ .filter { $0 != .unknown }
+ .map { $0.rawValue }
+ XCTAssertFalse(knownRawValues.contains("__UNKNOWN__"))
+ }
+
+ func test_unknownEventType_isInAllCases() {
+ XCTAssertTrue(EventType.allCases.contains(.unknown))
+ }
+
+ // MARK: - Feature: UnknownEvent.eventType returns .unknown
+
+ func test_unknownEvent_eventTypeIsUnknown() {
+ // Given
+ let rawData = Data("{\"type\":\"SOME_FUTURE_TYPE\"}".utf8)
+ let event = UnknownEvent(typeRaw: "SOME_FUTURE_TYPE", rawEvent: rawData)
+
+ // Then — must return .unknown, NOT .raw
+ XCTAssertEqual(event.eventType, .unknown)
+ }
+
+ func test_unknownEvent_eventTypeIsNotRaw() {
+ // Explicit guard: consumers switching on .raw must not receive UnknownEvent
+ let rawData = Data("{\"type\":\"SOME_FUTURE_TYPE\"}".utf8)
+ let event = UnknownEvent(typeRaw: "SOME_FUTURE_TYPE", rawEvent: rawData)
+ XCTAssertNotEqual(event.eventType, .raw)
+ }
+
+ // MARK: - Feature: Decoder distinguishes genuine RAW events from unknown events
+
+ func test_unknownEvent_distinguishableFromRawEvent() throws {
+ // Given: a truly unknown event type
+ let data = jsonData("""
+ {
+ "type": "SOME_FUTURE_PROTOCOL_TYPE",
+ "someField": "value"
+ }
+ """)
+ let decoder = makeTolerantDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then: must be UnknownEvent with .unknown, not confused with .raw
+ guard let unknownEvent = event as? UnknownEvent else {
+ return XCTFail("Expected UnknownEvent, got \(type(of: event))")
+ }
+ XCTAssertEqual(unknownEvent.eventType, .unknown)
+ XCTAssertNotEqual(unknownEvent.eventType, .raw)
+ XCTAssertEqual(unknownEvent.typeRaw, "SOME_FUTURE_PROTOCOL_TYPE")
+ }
+
+ // MARK: - Regression: Genuine RAW events must still decode as .raw
+
+ func test_rawEvent_eventTypeIsRaw() throws {
+ // Given: a legitimate RAW wire event
+ let data = jsonData("""
+ {
+ "type": "RAW",
+ "event": { "key": "value" }
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then: must still be a RawEvent with .raw eventType
+ XCTAssertEqual(event.eventType, .raw)
+ XCTAssertTrue(event is RawEvent)
+ }
+
+ func test_rawEvent_eventTypeIsNotUnknown() throws {
+ // Explicit regression guard
+ let data = jsonData("""
+ {
+ "type": "RAW",
+ "event": { "key": "value" }
+ }
+ """)
+ let event = try makeStrictDecoder().decode(data)
+ XCTAssertNotEqual(event.eventType, .unknown)
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/StateEvents/MessagesSnapshotEventTests.swift b/sdks/community/swift/Tests/AGUICoreTests/StateEvents/MessagesSnapshotEventTests.swift
new file mode 100644
index 0000000000..e03d49acc7
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/StateEvents/MessagesSnapshotEventTests.swift
@@ -0,0 +1,314 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+final class MessagesSnapshotEventTests: XCTestCase,
+ AGUIEventDecoderTestHelpers,
+ EventDecodingErrorTests {
+
+ // MARK: - EventDecodingErrorTests Protocol Requirements
+
+ var validEventFieldsWithoutType: [String: Any] {
+ [
+ "messages": [
+ ["id": "msg-1", "role": "user", "content": "Hello"],
+ ["id": "msg-2", "role": "assistant", "content": "Hi there!"]
+ ]
+ ]
+ }
+
+ var eventTypeString: String { "MESSAGES_SNAPSHOT" }
+ var expectedEventType: EventType { .messagesSnapshot }
+ var unknownEventTypeString: String { "UNKNOWN_MESSAGES_EVENT" }
+
+ // MARK: - Feature: Decode MESSAGES_SNAPSHOT
+
+ func test_decodeValidMessagesSnapshot_withArrayOfMessages_returnsMessagesSnapshotEvent() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "MESSAGES_SNAPSHOT",
+ "messages": [
+ {
+ "id": "msg-1",
+ "role": "user",
+ "content": "Hello, how are you?"
+ },
+ {
+ "id": "msg-2",
+ "role": "assistant",
+ "content": "I'm doing well, thank you!"
+ }
+ ]
+ }
+ """)
+
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ guard let messagesSnapshot = event as? MessagesSnapshotEvent else {
+ return XCTFail("Expected MessagesSnapshotEvent, got \(type(of: event))")
+ }
+ XCTAssertEqual(messagesSnapshot.eventType, .messagesSnapshot)
+ XCTAssertNil(messagesSnapshot.timestamp)
+ XCTAssertEqual(messagesSnapshot.messages.count, 2)
+ XCTAssertEqual(messagesSnapshot.messages[0].id, "msg-1")
+ XCTAssertEqual(messagesSnapshot.messages[0].role, .user)
+ XCTAssertEqual(messagesSnapshot.messages[1].id, "msg-2")
+ XCTAssertEqual(messagesSnapshot.messages[1].role, .assistant)
+ }
+
+ func test_decodeMessagesSnapshot_withEmptyArray_handlesCorrectly() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "MESSAGES_SNAPSHOT",
+ "messages": []
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let messagesSnapshot = try XCTUnwrap(event as? MessagesSnapshotEvent)
+ XCTAssertEqual(messagesSnapshot.messages.count, 0)
+ }
+
+ func test_decodeMessagesSnapshot_withUnicodeContent_handlesCorrectly() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "MESSAGES_SNAPSHOT",
+ "messages": [
+ {
+ "id": "msg-1",
+ "role": "user",
+ "content": "Hello, 🌍! 你好"
+ }
+ ]
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let messagesSnapshot = try XCTUnwrap(event as? MessagesSnapshotEvent)
+ XCTAssertEqual(messagesSnapshot.messages.count, 1)
+ let userMsg = try XCTUnwrap(messagesSnapshot.messages[0] as? UserMessage)
+ XCTAssertEqual(userMsg.content, "Hello, 🌍! 你好")
+ }
+
+ func test_decodeMessagesSnapshot_withNullMessages_throwsDecodingError() {
+ // Given — null is not a valid message array
+ let data = jsonData("""
+ {
+ "type": "MESSAGES_SNAPSHOT",
+ "messages": null
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data))
+ }
+
+ func test_decodeMessagesSnapshot_withObjectInsteadOfArray_throwsDecodingError() {
+ // Given — object is not a valid message array
+ let data = jsonData("""
+ {
+ "type": "MESSAGES_SNAPSHOT",
+ "messages": {
+ "conversation": [{"id": "msg-1", "content": "test"}],
+ "total": 1
+ }
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data))
+ }
+
+ func test_decodeMessagesSnapshot_withTimestamp_populatesTimestamp() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "MESSAGES_SNAPSHOT",
+ "messages": [
+ {"id": "msg-1", "role": "user", "content": "test"}
+ ],
+ "timestamp": \(EventTestData.timestamp)
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let messagesSnapshot = try XCTUnwrap(event as? MessagesSnapshotEvent)
+ XCTAssertEqual(messagesSnapshot.timestamp, EventTestData.timestamp)
+ }
+
+ func test_decodeMessagesSnapshot_preservesRawEventBytes() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "MESSAGES_SNAPSHOT",
+ "messages": [{"id": "msg-1", "role": "user", "content": "test"}],
+ "timestamp": \(EventTestData.timestamp)
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let messagesSnapshot = try XCTUnwrap(event as? MessagesSnapshotEvent)
+ XCTAssertEqual(messagesSnapshot.rawEvent, data)
+ }
+
+ func test_decodeMessagesSnapshot_ignoresUnknownExtraFields() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "MESSAGES_SNAPSHOT",
+ "messages": [{"id": "msg-1", "role": "user", "content": "test"}],
+ "extraField": "ignored",
+ "nested": { "x": 1 }
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let messagesSnapshot = try XCTUnwrap(event as? MessagesSnapshotEvent)
+ XCTAssertEqual(messagesSnapshot.messages.count, 1)
+ XCTAssertEqual(messagesSnapshot.messages[0].id, "msg-1")
+ }
+
+ func test_decodeMessagesSnapshot_unrecognizedMessageRole_skipsEntry() throws {
+ // Given — messages with unrecognized roles are silently skipped (compactMap)
+ let data = jsonData("""
+ {
+ "type": "MESSAGES_SNAPSHOT",
+ "messages": [
+ {"id": "msg-1", "role": "user", "content": "Hello"},
+ {"id": "msg-bad", "role": "unknown_role"}
+ ]
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let messagesSnapshot = try XCTUnwrap(event as? MessagesSnapshotEvent)
+ // Unrecognized roles are skipped via compactMap; only the valid message survives
+ XCTAssertEqual(messagesSnapshot.messages.count, 1)
+ XCTAssertEqual(messagesSnapshot.messages[0].id, "msg-1")
+ }
+
+ // MARK: - Feature: Error handling
+
+ func test_decodeMessagesSnapshot_missingMessages_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "MESSAGES_SNAPSHOT",
+ "timestamp": \(EventTestData.timestamp)
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("messages") || message.contains("Missing key"))
+ }
+ }
+
+ func test_decodeMessagesSnapshot_wrongTypeForTimestamp_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "MESSAGES_SNAPSHOT",
+ "messages": [{"id": "msg-1", "role": "user", "content": "test"}],
+ "timestamp": "not-a-number"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("timestamp") || message.contains("Type mismatch"))
+ }
+ }
+
+ // MARK: - Feature: Model behaviors
+
+ func test_messagesSnapshotEvent_eventTypeIsAlwaysMessagesSnapshot() {
+ // Given
+ let event = MessagesSnapshotEvent(messages: [], timestamp: nil, rawEvent: nil)
+
+ // Then
+ XCTAssertEqual(event.eventType, .messagesSnapshot)
+ }
+
+ func test_messagesSnapshotEvent_messagesAreTyped() throws {
+ // Given
+ let userMsg = UserMessage(id: "msg-1", content: "Hello")
+ let assistantMsg = AssistantMessage(id: "msg-2", content: "Hi!")
+ let event = MessagesSnapshotEvent(messages: [userMsg, assistantMsg])
+
+ // Then
+ XCTAssertEqual(event.messages.count, 2)
+ XCTAssertTrue(event.messages[0] is UserMessage)
+ XCTAssertTrue(event.messages[1] is AssistantMessage)
+ }
+
+ func test_messagesSnapshotEvent_description_containsKeyInformation() {
+ // Given
+ let event = MessagesSnapshotEvent(
+ messages: [UserMessage(id: "msg-1", content: "Hello")],
+ timestamp: EventTestData.timestamp,
+ rawEvent: nil
+ )
+
+ // Then
+ let description = event.description
+ XCTAssertTrue(description.contains("MessagesSnapshotEvent"))
+ XCTAssertTrue(description.contains("timestamp"))
+ }
+
+ func test_messagesSnapshotEvent_debugDescription_containsDetailedInformation() {
+ // Given
+ let event = MessagesSnapshotEvent(
+ messages: [UserMessage(id: "msg-1", content: "Hello")],
+ timestamp: EventTestData.timestamp,
+ rawEvent: nil
+ )
+
+ // Then
+ let debugDescription = event.debugDescription
+ XCTAssertTrue(debugDescription.contains("MessagesSnapshotEvent"))
+ XCTAssertTrue(debugDescription.contains("messages"))
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/StateEvents/StateDeltaEventTests.swift b/sdks/community/swift/Tests/AGUICoreTests/StateEvents/StateDeltaEventTests.swift
new file mode 100644
index 0000000000..c78c60cfde
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/StateEvents/StateDeltaEventTests.swift
@@ -0,0 +1,473 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+final class StateDeltaEventTests: XCTestCase,
+ AGUIEventDecoderTestHelpers,
+ EventDecodingErrorTests {
+
+ // MARK: - EventDecodingErrorTests Protocol Requirements
+
+ var validEventFieldsWithoutType: [String: Any] {
+ [
+ "delta": [
+ ["op": "add", "path": "/foo", "value": "bar"]
+ ]
+ ]
+ }
+
+ var eventTypeString: String { "STATE_DELTA" }
+ var expectedEventType: EventType { .stateDelta }
+ var unknownEventTypeString: String { "STATE_UPDATE" }
+
+ // MARK: - Feature: Decode STATE_DELTA
+
+ func test_decodeValidStateDelta_withAddOperation_returnsStateDeltaEvent() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "STATE_DELTA",
+ "delta": [
+ { "op": "add", "path": "/foo", "value": "bar" }
+ ]
+ }
+ """)
+
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ guard let stateDelta = event as? StateDeltaEvent else {
+ return XCTFail("Expected StateDeltaEvent, got \(type(of: event))")
+ }
+ XCTAssertEqual(stateDelta.eventType, .stateDelta)
+ XCTAssertNil(stateDelta.timestamp)
+
+ // Verify delta can be parsed
+ let parsed = try stateDelta.parsedDelta()
+ XCTAssertEqual(parsed.count, 1)
+ if let operation = parsed[0] as? [String: Any] {
+ XCTAssertEqual(operation["op"] as? String, "add")
+ XCTAssertEqual(operation["path"] as? String, "/foo")
+ XCTAssertEqual(operation["value"] as? String, "bar")
+ } else {
+ XCTFail("Expected operation to be a dictionary")
+ }
+ }
+
+ func test_decodeStateDelta_withMultipleOperations_handlesCorrectly() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "STATE_DELTA",
+ "delta": [
+ { "op": "add", "path": "/foo", "value": "bar" },
+ { "op": "remove", "path": "/baz" },
+ { "op": "replace", "path": "/foo", "value": "baz" }
+ ]
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let stateDelta = try XCTUnwrap(event as? StateDeltaEvent)
+ let parsed = try stateDelta.parsedDelta()
+ XCTAssertEqual(parsed.count, 3)
+ }
+
+ func test_decodeStateDelta_withAllOperationTypes_handlesCorrectly() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "STATE_DELTA",
+ "delta": [
+ { "op": "add", "path": "/foo", "value": "bar" },
+ { "op": "remove", "path": "/baz" },
+ { "op": "replace", "path": "/foo", "value": "baz" },
+ { "op": "move", "from": "/foo", "path": "/bar" },
+ { "op": "copy", "from": "/bar", "path": "/baz" },
+ { "op": "test", "path": "/foo", "value": "baz" }
+ ]
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let stateDelta = try XCTUnwrap(event as? StateDeltaEvent)
+ let parsed = try stateDelta.parsedDelta()
+ XCTAssertEqual(parsed.count, 6)
+
+ // Verify operation types
+ let operations = parsed.compactMap { $0 as? [String: Any] }
+ let ops = operations.compactMap { $0["op"] as? String }
+ XCTAssertTrue(ops.contains("add"))
+ XCTAssertTrue(ops.contains("remove"))
+ XCTAssertTrue(ops.contains("replace"))
+ XCTAssertTrue(ops.contains("move"))
+ XCTAssertTrue(ops.contains("copy"))
+ XCTAssertTrue(ops.contains("test"))
+ }
+
+ func test_decodeStateDelta_withEmptyArray_handlesCorrectly() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "STATE_DELTA",
+ "delta": []
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let stateDelta = try XCTUnwrap(event as? StateDeltaEvent)
+ let parsed = try stateDelta.parsedDelta()
+ XCTAssertEqual(parsed.count, 0)
+ }
+
+ func test_decodeStateDelta_withComplexOperations_handlesCorrectly() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "STATE_DELTA",
+ "delta": [
+ {
+ "op": "add",
+ "path": "/users/0",
+ "value": { "id": 1, "name": "Alice", "roles": ["admin", "user"] }
+ },
+ {
+ "op": "replace",
+ "path": "/metadata/version",
+ "value": "2.0"
+ }
+ ]
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let stateDelta = try XCTUnwrap(event as? StateDeltaEvent)
+ let parsed = try stateDelta.parsedDelta()
+ XCTAssertEqual(parsed.count, 2)
+
+ if let firstOp = parsed[0] as? [String: Any] {
+ XCTAssertEqual(firstOp["op"] as? String, "add")
+ XCTAssertEqual(firstOp["path"] as? String, "/users/0")
+ if let value = firstOp["value"] as? [String: Any] {
+ XCTAssertEqual(value["name"] as? String, "Alice")
+ } else {
+ XCTFail("Expected value to be a dictionary")
+ }
+ }
+ }
+
+ func test_decodeStateDelta_withTimestamp_populatesTimestamp() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "STATE_DELTA",
+ "delta": [
+ { "op": "add", "path": "/foo", "value": "bar" }
+ ],
+ "timestamp": \(EventTestData.timestamp)
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let stateDelta = try XCTUnwrap(event as? StateDeltaEvent)
+ XCTAssertEqual(stateDelta.timestamp, EventTestData.timestamp)
+ }
+
+ func test_decodeStateDelta_preservesRawEventBytes() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "STATE_DELTA",
+ "delta": [
+ { "op": "add", "path": "/foo", "value": "bar" }
+ ],
+ "timestamp": \(EventTestData.timestamp)
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let stateDelta = try XCTUnwrap(event as? StateDeltaEvent)
+ XCTAssertEqual(stateDelta.rawEvent, data)
+ }
+
+ func test_decodeStateDelta_ignoresUnknownExtraFields() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "STATE_DELTA",
+ "delta": [
+ { "op": "add", "path": "/foo", "value": "bar" }
+ ],
+ "extraField": "ignored",
+ "nested": { "x": 1 }
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let stateDelta = try XCTUnwrap(event as? StateDeltaEvent)
+ let parsed = try stateDelta.parsedDelta()
+ XCTAssertEqual(parsed.count, 1)
+ }
+
+ func test_decodeStateDelta_withUnicodeInOperations_handlesCorrectly() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "STATE_DELTA",
+ "delta": [
+ { "op": "add", "path": "/message", "value": "Hello, 🌍! 你好" },
+ { "op": "add", "path": "/city", "value": "北京" }
+ ]
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let stateDelta = try XCTUnwrap(event as? StateDeltaEvent)
+ let parsed = try stateDelta.parsedDelta()
+ if let firstOp = parsed[0] as? [String: Any] {
+ XCTAssertEqual(firstOp["value"] as? String, "Hello, 🌍! 你好")
+ }
+ if let secondOp = parsed[1] as? [String: Any] {
+ XCTAssertEqual(secondOp["value"] as? String, "北京")
+ }
+ }
+
+ // MARK: - Feature: Error handling
+
+ func test_decodeStateDelta_missingDelta_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "STATE_DELTA",
+ "timestamp": \(EventTestData.timestamp)
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("delta") || message.contains("Missing key"))
+ }
+ }
+
+ func test_decodeStateDelta_deltaNotArray_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "STATE_DELTA",
+ "delta": { "op": "add", "path": "/foo", "value": "bar" }
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("array") || message.contains("Type mismatch"))
+ }
+ }
+
+ func test_decodeStateDelta_wrongTypeForTimestamp_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "STATE_DELTA",
+ "delta": [
+ { "op": "add", "path": "/foo", "value": "bar" }
+ ],
+ "timestamp": "not-a-number"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("timestamp") || message.contains("Type mismatch"))
+ }
+ }
+
+ // MARK: - Feature: Model behaviors
+
+ func test_stateDeltaEvent_eventTypeIsAlwaysStateDelta() throws {
+ // Given
+ let deltaData = try JSONSerialization.data(withJSONObject: [["op": "add", "path": "/foo", "value": "bar"]], options: [])
+ let event = StateDeltaEvent(delta: deltaData, timestamp: nil, rawEvent: nil)
+
+ // Then
+ XCTAssertEqual(event.eventType, .stateDelta)
+ }
+
+ func test_stateDeltaEvent_equatable_sameDeltas_areEqual() throws {
+ // Given
+ let deltaData = try JSONSerialization.data(withJSONObject: [["op": "add", "path": "/foo", "value": "bar"]], options: [])
+ let event1 = StateDeltaEvent(delta: deltaData, timestamp: EventTestData.timestamp, rawEvent: nil)
+ let event2 = StateDeltaEvent(delta: deltaData, timestamp: EventTestData.timestamp, rawEvent: nil)
+
+ // Then
+ XCTAssertEqual(event1, event2)
+ }
+
+ func test_stateDeltaEvent_equatable_differentDeltas_areNotEqual() throws {
+ // Given
+ let deltaData1 = try JSONSerialization.data(withJSONObject: [["op": "add", "path": "/foo", "value": "bar"]], options: [])
+ let deltaData2 = try JSONSerialization.data(withJSONObject: [["op": "remove", "path": "/foo"]], options: [])
+ let event1 = StateDeltaEvent(delta: deltaData1, timestamp: EventTestData.timestamp, rawEvent: nil)
+ let event2 = StateDeltaEvent(delta: deltaData2, timestamp: EventTestData.timestamp, rawEvent: nil)
+
+ // Then
+ XCTAssertNotEqual(event1, event2)
+ }
+
+ func test_stateDeltaEvent_equatable_differentTimestamps_areNotEqual() throws {
+ // Given
+ let deltaData = try JSONSerialization.data(withJSONObject: [["op": "add", "path": "/foo", "value": "bar"]], options: [])
+ let event1 = StateDeltaEvent(delta: deltaData, timestamp: EventTestData.timestamp, rawEvent: nil)
+ let event2 = StateDeltaEvent(delta: deltaData, timestamp: EventTestData.timestamp2, rawEvent: nil)
+
+ // Then
+ XCTAssertNotEqual(event1, event2)
+ }
+
+ func test_stateDeltaEvent_equatable_oneWithTimestampOneWithout_areNotEqual() throws {
+ // Given
+ let deltaData = try JSONSerialization.data(withJSONObject: [["op": "add", "path": "/foo", "value": "bar"]], options: [])
+ let event1 = StateDeltaEvent(delta: deltaData, timestamp: EventTestData.timestamp, rawEvent: nil)
+ let event2 = StateDeltaEvent(delta: deltaData, timestamp: nil, rawEvent: nil)
+
+ // Then
+ XCTAssertNotEqual(event1, event2)
+ }
+
+ func test_stateDeltaEvent_parsedDelta_returnsCorrectValue() throws {
+ // Given
+ let originalDelta: [[String: Any]] = [
+ ["op": "add", "path": "/foo", "value": "bar"],
+ ["op": "remove", "path": "/baz"]
+ ]
+ let deltaData = try JSONSerialization.data(withJSONObject: originalDelta, options: [])
+ let event = StateDeltaEvent(delta: deltaData, timestamp: nil, rawEvent: nil)
+
+ // When
+ let parsed = try event.parsedDelta()
+
+ // Then
+ XCTAssertEqual(parsed.count, 2)
+ if let firstOp = parsed[0] as? [String: Any] {
+ XCTAssertEqual(firstOp["op"] as? String, "add")
+ XCTAssertEqual(firstOp["path"] as? String, "/foo")
+ }
+ }
+
+ func test_stateDeltaEvent_parsedDelta_withInvalidData_throws() throws {
+ // Given
+ let invalidData = Data("not json".utf8)
+ let event = StateDeltaEvent(delta: invalidData, timestamp: nil, rawEvent: nil)
+
+ // When / Then
+ XCTAssertThrowsError(try event.parsedDelta())
+ }
+
+ func test_stateDeltaEvent_parsedDelta_withNonArrayData_throws() throws {
+ // Given
+ let nonArrayData = try JSONSerialization.data(withJSONObject: ["op": "add", "path": "/foo"], options: [])
+ let event = StateDeltaEvent(delta: nonArrayData, timestamp: nil, rawEvent: nil)
+
+ // When / Then
+ XCTAssertThrowsError(try event.parsedDelta()) { error in
+ XCTAssertTrue(error is DecodingError)
+ }
+ }
+
+ func test_stateDeltaEvent_parsedDeltaAs_withCodableType_decodesCorrectly() throws {
+ // Given
+ struct PatchOperation: Codable {
+ let op: String
+ let path: String
+ let value: String?
+ }
+
+ let originalDelta = [
+ PatchOperation(op: "add", path: "/foo", value: "bar"),
+ PatchOperation(op: "remove", path: "/baz", value: nil)
+ ]
+ let deltaData = try JSONEncoder().encode(originalDelta)
+ let event = StateDeltaEvent(delta: deltaData, timestamp: nil, rawEvent: nil)
+
+ // When
+ let decoded = try event.parsedDelta(as: PatchOperation.self)
+
+ // Then
+ XCTAssertEqual(decoded.count, 2)
+ XCTAssertEqual(decoded[0].op, "add")
+ XCTAssertEqual(decoded[0].path, "/foo")
+ XCTAssertEqual(decoded[0].value, "bar")
+ XCTAssertEqual(decoded[1].op, "remove")
+ XCTAssertEqual(decoded[1].path, "/baz")
+ XCTAssertNil(decoded[1].value)
+ }
+
+ func test_stateDeltaEvent_parsedDeltaAs_withWrongType_throws() throws {
+ // Given
+ struct PatchOperation: Codable {
+ let op: String
+ let path: String
+ }
+
+ struct WrongOperation: Decodable {
+ let wrongField: String
+ }
+
+ let originalDelta = [PatchOperation(op: "add", path: "/foo")]
+ let deltaData = try JSONEncoder().encode(originalDelta)
+ let event = StateDeltaEvent(delta: deltaData, timestamp: nil, rawEvent: nil)
+
+ // When / Then
+ XCTAssertThrowsError(try event.parsedDelta(as: WrongOperation.self)) { error in
+ XCTAssertTrue(error is DecodingError)
+ }
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/StateEvents/StateSnapshotEventTests.swift b/sdks/community/swift/Tests/AGUICoreTests/StateEvents/StateSnapshotEventTests.swift
new file mode 100644
index 0000000000..9692c16234
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/StateEvents/StateSnapshotEventTests.swift
@@ -0,0 +1,461 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+final class StateSnapshotEventTests: XCTestCase,
+ AGUIEventDecoderTestHelpers,
+ EventDecodingErrorTests {
+
+ // MARK: - EventDecodingErrorTests Protocol Requirements
+
+ var validEventFieldsWithoutType: [String: Any] {
+ [
+ "snapshot": ["key": "value"]
+ ]
+ }
+
+ var eventTypeString: String { "STATE_SNAPSHOT" }
+ var expectedEventType: EventType { .stateSnapshot }
+ var unknownEventTypeString: String { "UNKNOWN_STATE_EVENT" }
+
+ // MARK: - Feature: Decode STATE_SNAPSHOT
+
+ func test_decodeValidStateSnapshot_withObjectSnapshot_returnsStateSnapshotEvent() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "STATE_SNAPSHOT",
+ "snapshot": {
+ "users": ["alice", "bob"],
+ "count": 2,
+ "active": true
+ }
+ }
+ """)
+
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ guard let stateSnapshot = event as? StateSnapshotEvent else {
+ return XCTFail("Expected StateSnapshotEvent, got \(type(of: event))")
+ }
+ XCTAssertEqual(stateSnapshot.eventType, .stateSnapshot)
+ XCTAssertNil(stateSnapshot.timestamp)
+
+ // Verify snapshot can be parsed
+ let parsed = try stateSnapshot.parsedSnapshot() as? [String: Any]
+ XCTAssertNotNil(parsed)
+ XCTAssertEqual(parsed?["count"] as? Int, 2)
+ XCTAssertEqual(parsed?["active"] as? Bool, true)
+ }
+
+ func test_decodeStateSnapshot_withArraySnapshot_handlesCorrectly() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "STATE_SNAPSHOT",
+ "snapshot": [1, 2, 3, "four", true]
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let stateSnapshot = try XCTUnwrap(event as? StateSnapshotEvent)
+ let parsed = try stateSnapshot.parsedSnapshot() as? [Any]
+ XCTAssertNotNil(parsed)
+ XCTAssertEqual(parsed?.count, 5)
+ }
+
+ func test_decodeStateSnapshot_withPrimitiveSnapshot_handlesCorrectly() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "STATE_SNAPSHOT",
+ "snapshot": "simple string state"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let stateSnapshot = try XCTUnwrap(event as? StateSnapshotEvent)
+ let parsed = try stateSnapshot.parsedSnapshot() as? String
+ XCTAssertEqual(parsed, "simple string state")
+ }
+
+ func test_decodeStateSnapshot_withNumberSnapshot_handlesCorrectly() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "STATE_SNAPSHOT",
+ "snapshot": 42
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let stateSnapshot = try XCTUnwrap(event as? StateSnapshotEvent)
+ let parsed = try stateSnapshot.parsedSnapshot() as? Int
+ XCTAssertEqual(parsed, 42)
+ }
+
+ func test_decodeStateSnapshot_withBooleanSnapshot_handlesCorrectly() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "STATE_SNAPSHOT",
+ "snapshot": false
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let stateSnapshot = try XCTUnwrap(event as? StateSnapshotEvent)
+ let parsed = try stateSnapshot.parsedSnapshot() as? Bool
+ XCTAssertEqual(parsed, false)
+ }
+
+ func test_decodeStateSnapshot_withNullSnapshot_handlesCorrectly() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "STATE_SNAPSHOT",
+ "snapshot": null
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let stateSnapshot = try XCTUnwrap(event as? StateSnapshotEvent)
+ let parsed = try stateSnapshot.parsedSnapshot()
+ XCTAssertTrue(parsed is NSNull)
+ }
+
+ func test_decodeStateSnapshot_withTimestamp_populatesTimestamp() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "STATE_SNAPSHOT",
+ "snapshot": { "value": 123 },
+ "timestamp": \(EventTestData.timestamp)
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let stateSnapshot = try XCTUnwrap(event as? StateSnapshotEvent)
+ XCTAssertEqual(stateSnapshot.timestamp, EventTestData.timestamp)
+ }
+
+ func test_decodeStateSnapshot_preservesRawEventBytes() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "STATE_SNAPSHOT",
+ "snapshot": { "key": "value" },
+ "timestamp": \(EventTestData.timestamp)
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let stateSnapshot = try XCTUnwrap(event as? StateSnapshotEvent)
+ XCTAssertEqual(stateSnapshot.rawEvent, data)
+ }
+
+ func test_decodeStateSnapshot_ignoresUnknownExtraFields() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "STATE_SNAPSHOT",
+ "snapshot": { "key": "value" },
+ "extraField": "ignored",
+ "nested": { "x": 1 }
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let stateSnapshot = try XCTUnwrap(event as? StateSnapshotEvent)
+ let parsed = try stateSnapshot.parsedSnapshot() as? [String: Any]
+ XCTAssertEqual(parsed?["key"] as? String, "value")
+ }
+
+ func test_decodeStateSnapshot_withComplexNestedSnapshot_handlesCorrectly() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "STATE_SNAPSHOT",
+ "snapshot": {
+ "users": [
+ { "id": 1, "name": "Alice", "roles": ["admin", "user"] },
+ { "id": 2, "name": "Bob", "roles": ["user"] }
+ ],
+ "metadata": {
+ "version": "1.0",
+ "timestamp": 1234567890
+ }
+ }
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let stateSnapshot = try XCTUnwrap(event as? StateSnapshotEvent)
+ let parsed = try stateSnapshot.parsedSnapshot() as? [String: Any]
+ let users = parsed?["users"] as? [[String: Any]]
+ XCTAssertEqual(users?.count, 2)
+ XCTAssertEqual(users?[0]["name"] as? String, "Alice")
+ }
+
+ func test_decodeStateSnapshot_withUnicodeInSnapshot_handlesCorrectly() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "STATE_SNAPSHOT",
+ "snapshot": {
+ "message": "Hello, 🌍! 你好",
+ "city": "北京"
+ }
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let stateSnapshot = try XCTUnwrap(event as? StateSnapshotEvent)
+ let parsed = try stateSnapshot.parsedSnapshot() as? [String: Any]
+ XCTAssertEqual(parsed?["message"] as? String, "Hello, 🌍! 你好")
+ XCTAssertEqual(parsed?["city"] as? String, "北京")
+ }
+
+ func test_decodeStateSnapshot_withEmptyObjectSnapshot_handlesCorrectly() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "STATE_SNAPSHOT",
+ "snapshot": {}
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let stateSnapshot = try XCTUnwrap(event as? StateSnapshotEvent)
+ let parsed = try stateSnapshot.parsedSnapshot() as? [String: Any]
+ XCTAssertNotNil(parsed)
+ XCTAssertEqual(parsed?.count, 0)
+ }
+
+ func test_decodeStateSnapshot_withEmptyArraySnapshot_handlesCorrectly() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "STATE_SNAPSHOT",
+ "snapshot": []
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let stateSnapshot = try XCTUnwrap(event as? StateSnapshotEvent)
+ let parsed = try stateSnapshot.parsedSnapshot() as? [Any]
+ XCTAssertNotNil(parsed)
+ XCTAssertEqual(parsed?.count, 0)
+ }
+
+ // MARK: - Feature: Error handling
+
+ func test_decodeStateSnapshot_missingSnapshot_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "STATE_SNAPSHOT",
+ "timestamp": \(EventTestData.timestamp)
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("snapshot") || message.contains("Missing key"))
+ }
+ }
+
+ func test_decodeStateSnapshot_wrongTypeForTimestamp_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "STATE_SNAPSHOT",
+ "snapshot": { "key": "value" },
+ "timestamp": "not-a-number"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("timestamp") || message.contains("Type mismatch"))
+ }
+ }
+
+ // MARK: - Feature: Model behaviors
+
+ func test_stateSnapshotEvent_eventTypeIsAlwaysStateSnapshot() throws {
+ // Given
+ let snapshotData = try JSONSerialization.data(withJSONObject: ["key": "value"], options: [])
+ let event = StateSnapshotEvent(snapshot: snapshotData, timestamp: nil, rawEvent: nil)
+
+ // Then
+ XCTAssertEqual(event.eventType, .stateSnapshot)
+ }
+
+ func test_stateSnapshotEvent_equatable_sameSnapshots_areEqual() throws {
+ // Given
+ let snapshotData = try JSONSerialization.data(withJSONObject: ["key": "value"], options: [])
+ let event1 = StateSnapshotEvent(snapshot: snapshotData, timestamp: EventTestData.timestamp, rawEvent: nil)
+ let event2 = StateSnapshotEvent(snapshot: snapshotData, timestamp: EventTestData.timestamp, rawEvent: nil)
+
+ // Then
+ XCTAssertEqual(event1, event2)
+ }
+
+ func test_stateSnapshotEvent_equatable_differentSnapshots_areNotEqual() throws {
+ // Given
+ let snapshotData1 = try JSONSerialization.data(withJSONObject: ["key": "value1"], options: [])
+ let snapshotData2 = try JSONSerialization.data(withJSONObject: ["key": "value2"], options: [])
+ let event1 = StateSnapshotEvent(snapshot: snapshotData1, timestamp: EventTestData.timestamp, rawEvent: nil)
+ let event2 = StateSnapshotEvent(snapshot: snapshotData2, timestamp: EventTestData.timestamp, rawEvent: nil)
+
+ // Then
+ XCTAssertNotEqual(event1, event2)
+ }
+
+ func test_stateSnapshotEvent_equatable_differentTimestamps_areNotEqual() throws {
+ // Given
+ let snapshotData = try JSONSerialization.data(withJSONObject: ["key": "value"], options: [])
+ let event1 = StateSnapshotEvent(snapshot: snapshotData, timestamp: EventTestData.timestamp, rawEvent: nil)
+ let event2 = StateSnapshotEvent(snapshot: snapshotData, timestamp: EventTestData.timestamp2, rawEvent: nil)
+
+ // Then
+ XCTAssertNotEqual(event1, event2)
+ }
+
+ func test_stateSnapshotEvent_equatable_oneWithTimestampOneWithout_areNotEqual() throws {
+ // Given
+ let snapshotData = try JSONSerialization.data(withJSONObject: ["key": "value"], options: [])
+ let event1 = StateSnapshotEvent(snapshot: snapshotData, timestamp: EventTestData.timestamp, rawEvent: nil)
+ let event2 = StateSnapshotEvent(snapshot: snapshotData, timestamp: nil, rawEvent: nil)
+
+ // Then
+ XCTAssertNotEqual(event1, event2)
+ }
+
+ func test_stateSnapshotEvent_parsedSnapshot_returnsCorrectValue() throws {
+ // Given
+ let originalObject: [String: Any] = ["key": "value", "number": 42]
+ let snapshotData = try JSONSerialization.data(withJSONObject: originalObject, options: [])
+ let event = StateSnapshotEvent(snapshot: snapshotData, timestamp: nil, rawEvent: nil)
+
+ // When
+ let parsed = try event.parsedSnapshot() as? [String: Any]
+
+ // Then
+ XCTAssertNotNil(parsed)
+ XCTAssertEqual(parsed?["key"] as? String, "value")
+ XCTAssertEqual(parsed?["number"] as? Int, 42)
+ }
+
+ func test_stateSnapshotEvent_parsedSnapshot_withInvalidData_throws() throws {
+ // Given
+ let invalidData = Data("not json".utf8)
+ let event = StateSnapshotEvent(snapshot: invalidData, timestamp: nil, rawEvent: nil)
+
+ // When / Then
+ XCTAssertThrowsError(try event.parsedSnapshot())
+ }
+
+ func test_stateSnapshotEvent_parsedSnapshotAs_withCodableType_decodesCorrectly() throws {
+ // Given
+ struct AppState: Codable {
+ let users: [String]
+ let count: Int
+ let active: Bool
+ }
+
+ let originalState = AppState(users: ["alice", "bob"], count: 2, active: true)
+ let snapshotData = try JSONEncoder().encode(originalState)
+ let event = StateSnapshotEvent(snapshot: snapshotData, timestamp: nil, rawEvent: nil)
+
+ // When
+ let decoded = try event.parsedSnapshot(as: AppState.self)
+
+ // Then
+ XCTAssertEqual(decoded.users, ["alice", "bob"])
+ XCTAssertEqual(decoded.count, 2)
+ XCTAssertEqual(decoded.active, true)
+ }
+
+ func test_stateSnapshotEvent_parsedSnapshotAs_withWrongType_throws() throws {
+ // Given
+ struct AppState: Codable {
+ let users: [String]
+ }
+
+ struct WrongState: Decodable {
+ let wrongField: String
+ }
+
+ let originalState = AppState(users: ["alice"])
+ let snapshotData = try JSONEncoder().encode(originalState)
+ let event = StateSnapshotEvent(snapshot: snapshotData, timestamp: nil, rawEvent: nil)
+
+ // When / Then
+ XCTAssertThrowsError(try event.parsedSnapshot(as: WrongState.self)) { error in
+ XCTAssertTrue(error is DecodingError)
+ }
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/TextMessageEvents/TextMessageChunkEventTests.swift b/sdks/community/swift/Tests/AGUICoreTests/TextMessageEvents/TextMessageChunkEventTests.swift
new file mode 100644
index 0000000000..564369b8dd
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/TextMessageEvents/TextMessageChunkEventTests.swift
@@ -0,0 +1,279 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+final class TextMessageChunkEventTests: XCTestCase,
+ AGUIEventDecoderTestHelpers,
+ EventDecodingErrorTests {
+
+ // MARK: - EventDecodingErrorTests Protocol Requirements
+
+ var validEventFieldsWithoutType: [String: Any] {
+ [
+ "messageId": EventTestData.messageId,
+ "delta": "Hello"
+ ]
+ }
+
+ var eventTypeString: String { "TEXT_MESSAGE_CHUNK" }
+ var expectedEventType: EventType { .textMessageChunk }
+ var unknownEventTypeString: String { "TEXT_MESSAGE_PAUSED" }
+
+ // MARK: - Feature: Decode TEXT_MESSAGE_CHUNK
+
+ func test_decodeValidTextMessageChunk_returnsTextMessageChunkEvent() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TEXT_MESSAGE_CHUNK",
+ "messageId": "\(EventTestData.messageId)",
+ "delta": "Hello"
+ }
+ """)
+
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ guard let chunk = event as? TextMessageChunkEvent else {
+ return XCTFail("Expected TextMessageChunkEvent, got \(type(of: event))")
+ }
+ XCTAssertEqual(chunk.eventType, .textMessageChunk)
+ XCTAssertEqual(chunk.messageId, EventTestData.messageId)
+ XCTAssertEqual(chunk.delta, "Hello")
+ XCTAssertNil(chunk.role)
+ XCTAssertNil(chunk.timestamp)
+ }
+
+ func test_decodeTextMessageChunkWithRole_returnsTextMessageChunkEvent() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TEXT_MESSAGE_CHUNK",
+ "messageId": "\(EventTestData.messageId)",
+ "role": "assistant",
+ "delta": "Hello"
+ }
+ """)
+
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ guard let chunk = event as? TextMessageChunkEvent else {
+ return XCTFail("Expected TextMessageChunkEvent, got \(type(of: event))")
+ }
+ XCTAssertEqual(chunk.role, "assistant")
+ XCTAssertEqual(chunk.delta, "Hello")
+ }
+
+ func test_decodeTextMessageChunkWithTimestamp_returnsTextMessageChunkEvent() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TEXT_MESSAGE_CHUNK",
+ "messageId": "\(EventTestData.messageId)",
+ "delta": "Hello",
+ "timestamp": \(EventTestData.timestamp)
+ }
+ """)
+
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ guard let chunk = event as? TextMessageChunkEvent else {
+ return XCTFail("Expected TextMessageChunkEvent, got \(type(of: event))")
+ }
+ XCTAssertEqual(chunk.timestamp, EventTestData.timestamp)
+ }
+
+ func test_decodeTextMessageChunkWithoutMessageId_returnsTextMessageChunkEvent() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TEXT_MESSAGE_CHUNK",
+ "delta": "World"
+ }
+ """)
+
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ guard let chunk = event as? TextMessageChunkEvent else {
+ return XCTFail("Expected TextMessageChunkEvent, got \(type(of: event))")
+ }
+ XCTAssertNil(chunk.messageId)
+ XCTAssertEqual(chunk.delta, "World")
+ }
+
+ func test_textMessageChunkEvent_eventTypeIsAlwaysTextMessageChunk() {
+ // Given
+ let event = TextMessageChunkEvent(
+ messageId: EventTestData.messageId,
+ delta: "Hello"
+ )
+
+ // Then
+ XCTAssertEqual(event.eventType, .textMessageChunk)
+ }
+
+ // MARK: - Equatable Tests
+
+ func test_textMessageChunkEvent_equatable_sameFields_areEqual() {
+ // Given
+ let event1 = TextMessageChunkEvent(
+ messageId: EventTestData.messageId,
+ role: "assistant",
+ delta: "Hello",
+ timestamp: EventTestData.timestamp,
+ rawEvent: nil
+ )
+ let event2 = TextMessageChunkEvent(
+ messageId: EventTestData.messageId,
+ role: "assistant",
+ delta: "Hello",
+ timestamp: EventTestData.timestamp,
+ rawEvent: nil
+ )
+
+ // Then
+ XCTAssertEqual(event1, event2)
+ }
+
+ func test_textMessageChunkEvent_equatable_differentMessageIds_areNotEqual() {
+ // Given
+ let event1 = TextMessageChunkEvent(
+ messageId: EventTestData.messageId,
+ delta: "Hello"
+ )
+ let event2 = TextMessageChunkEvent(
+ messageId: EventTestData.messageId2,
+ delta: "Hello"
+ )
+
+ // Then
+ XCTAssertNotEqual(event1, event2)
+ }
+
+ func test_textMessageChunkEvent_equatable_differentDeltas_areNotEqual() {
+ // Given
+ let event1 = TextMessageChunkEvent(
+ messageId: EventTestData.messageId,
+ delta: "Hello"
+ )
+ let event2 = TextMessageChunkEvent(
+ messageId: EventTestData.messageId,
+ delta: "World"
+ )
+
+ // Then
+ XCTAssertNotEqual(event1, event2)
+ }
+
+ func test_textMessageChunkEvent_equatable_oneNilMessageId_areNotEqual() {
+ // Given
+ let event1 = TextMessageChunkEvent(
+ messageId: EventTestData.messageId,
+ delta: "Hello"
+ )
+ let event2 = TextMessageChunkEvent(
+ messageId: nil,
+ delta: "Hello"
+ )
+
+ // Then
+ XCTAssertNotEqual(event1, event2)
+ }
+
+ func test_textMessageChunkEvent_equatable_bothNilMessageIds_areEqual() {
+ // Given
+ let event1 = TextMessageChunkEvent(
+ messageId: nil,
+ delta: "Hello"
+ )
+ let event2 = TextMessageChunkEvent(
+ messageId: nil,
+ delta: "Hello"
+ )
+
+ // Then
+ XCTAssertEqual(event1, event2)
+ }
+
+ // MARK: - Feature: name field (protocol spec)
+
+ func test_decodeTextMessageChunk_withName_populatesName() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TEXT_MESSAGE_CHUNK",
+ "messageId": "\(EventTestData.messageId)",
+ "role": "assistant",
+ "name": "Alice",
+ "delta": "Hello"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try XCTUnwrap(try decoder.decode(data) as? TextMessageChunkEvent)
+
+ // Then
+ XCTAssertEqual(event.name, "Alice")
+ }
+
+ func test_decodeTextMessageChunk_withoutName_nameIsNil() throws {
+ // Given — JSON with no "name" field
+ let data = jsonData("""
+ {
+ "type": "TEXT_MESSAGE_CHUNK",
+ "messageId": "\(EventTestData.messageId)",
+ "delta": "Hi"
+ }
+ """)
+ let event = try XCTUnwrap(try makeStrictDecoder().decode(data) as? TextMessageChunkEvent)
+
+ // Then
+ XCTAssertNil(event.name)
+ }
+
+ func test_textMessageChunkEvent_init_acceptsName() {
+ // Given
+ let event = TextMessageChunkEvent(
+ messageId: EventTestData.messageId,
+ role: "assistant",
+ name: "Carol",
+ delta: "Hi",
+ timestamp: nil,
+ rawEvent: nil
+ )
+
+ // Then
+ XCTAssertEqual(event.name, "Carol")
+ }
+
+ func test_textMessageChunkEvent_equalityDifferentNames_notEqual() {
+ let e1 = TextMessageChunkEvent(
+ messageId: EventTestData.messageId,
+ name: "Alice",
+ delta: "Hi"
+ )
+ let e2 = TextMessageChunkEvent(
+ messageId: EventTestData.messageId,
+ name: "Bob",
+ delta: "Hi"
+ )
+ XCTAssertNotEqual(e1, e2)
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/TextMessageEvents/TextMessageContentEventTests.swift b/sdks/community/swift/Tests/AGUICoreTests/TextMessageEvents/TextMessageContentEventTests.swift
new file mode 100644
index 0000000000..8dfd0f1c88
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/TextMessageEvents/TextMessageContentEventTests.swift
@@ -0,0 +1,327 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+final class TextMessageContentEventTests: XCTestCase,
+ AGUIEventDecoderTestHelpers,
+ EventDecodingErrorTests {
+
+ // MARK: - EventDecodingErrorTests Protocol Requirements
+
+ var validEventFieldsWithoutType: [String: Any] {
+ [
+ "messageId": EventTestData.messageId,
+ "delta": "Hello, world!"
+ ]
+ }
+
+ var eventTypeString: String { "TEXT_MESSAGE_CONTENT" }
+ var expectedEventType: EventType { .textMessageContent }
+ var unknownEventTypeString: String { "TEXT_MESSAGE_PAUSED" }
+
+ // MARK: - Feature: Decode TEXT_MESSAGE_CONTENT
+
+ func test_decodeValidTextMessageContent_returnsTextMessageContentEvent() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TEXT_MESSAGE_CONTENT",
+ "messageId": "\(EventTestData.messageId)",
+ "delta": "Hello, world!"
+ }
+ """)
+
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ guard let textMessageContent = event as? TextMessageContentEvent else {
+ return XCTFail("Expected TextMessageContentEvent, got \(type(of: event))")
+ }
+ XCTAssertEqual(textMessageContent.eventType, .textMessageContent)
+ XCTAssertEqual(textMessageContent.messageId, EventTestData.messageId)
+ XCTAssertEqual(textMessageContent.delta, "Hello, world!")
+ XCTAssertNil(textMessageContent.timestamp)
+ }
+
+ func test_decodeTextMessageContent_withTimestamp_populatesTimestamp() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TEXT_MESSAGE_CONTENT",
+ "messageId": "\(EventTestData.messageId)",
+ "delta": "Hello, world!",
+ "timestamp": \(EventTestData.timestamp)
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let textMessageContent = try XCTUnwrap(event as? TextMessageContentEvent)
+ XCTAssertEqual(textMessageContent.timestamp, EventTestData.timestamp)
+ }
+
+ func test_decodeTextMessageContent_preservesRawEventBytes() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TEXT_MESSAGE_CONTENT",
+ "messageId": "\(EventTestData.messageId)",
+ "delta": "Hello, world!",
+ "timestamp": \(EventTestData.timestamp)
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let textMessageContent = try XCTUnwrap(event as? TextMessageContentEvent)
+ XCTAssertEqual(textMessageContent.rawEvent, data)
+ }
+
+ func test_decodeTextMessageContent_ignoresUnknownExtraFields() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TEXT_MESSAGE_CONTENT",
+ "messageId": "\(EventTestData.messageId)",
+ "delta": "Hello, world!",
+ "extraField": "ignored",
+ "nested": { "x": 1 }
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let textMessageContent = try XCTUnwrap(event as? TextMessageContentEvent)
+ XCTAssertEqual(textMessageContent.messageId, EventTestData.messageId)
+ XCTAssertEqual(textMessageContent.delta, "Hello, world!")
+ }
+
+ func test_decodeTextMessageContent_withUnicodeDelta_handlesCorrectly() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TEXT_MESSAGE_CONTENT",
+ "messageId": "\(EventTestData.messageId)",
+ "delta": "Hello, 🌍! 你好"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let textMessageContent = try XCTUnwrap(event as? TextMessageContentEvent)
+ XCTAssertEqual(textMessageContent.delta, "Hello, 🌍! 你好")
+ }
+
+ func test_decodeTextMessageContent_withMultilineDelta_handlesCorrectly() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TEXT_MESSAGE_CONTENT",
+ "messageId": "\(EventTestData.messageId)",
+ "delta": "Line 1\\nLine 2\\nLine 3"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let textMessageContent = try XCTUnwrap(event as? TextMessageContentEvent)
+ XCTAssertEqual(textMessageContent.delta, "Line 1\nLine 2\nLine 3")
+ }
+
+ func test_decodeTextMessageContent_withEmptyDelta_allowsEmptyString() throws {
+ // Given
+ // Note: According to protocol, delta should be non-empty, but we allow it
+ // for flexibility. Validation can be done at a higher level if needed.
+ let data = jsonData("""
+ {
+ "type": "TEXT_MESSAGE_CONTENT",
+ "messageId": "\(EventTestData.messageId)",
+ "delta": ""
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let textMessageContent = try XCTUnwrap(event as? TextMessageContentEvent)
+ XCTAssertEqual(textMessageContent.delta, "")
+ }
+
+ // MARK: - Feature: Error handling (event-specific)
+
+ func test_decodeTextMessageContent_missingMessageId_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TEXT_MESSAGE_CONTENT",
+ "delta": "Hello, world!"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("messageId") || message.contains("Missing key"))
+ }
+ }
+
+ func test_decodeTextMessageContent_missingDelta_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TEXT_MESSAGE_CONTENT",
+ "messageId": "\(EventTestData.messageId)"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("delta") || message.contains("Missing key"))
+ }
+ }
+
+ func test_decodeTextMessageContent_wrongTypeForMessageId_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TEXT_MESSAGE_CONTENT",
+ "messageId": 123,
+ "delta": "Hello, world!"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("messageId") || message.contains("Type mismatch"))
+ }
+ }
+
+ func test_decodeTextMessageContent_wrongTypeForDelta_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TEXT_MESSAGE_CONTENT",
+ "messageId": "\(EventTestData.messageId)",
+ "delta": 123
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("delta") || message.contains("Type mismatch"))
+ }
+ }
+
+ func test_decodeTextMessageContent_wrongTypeForTimestamp_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TEXT_MESSAGE_CONTENT",
+ "messageId": "\(EventTestData.messageId)",
+ "delta": "Hello, world!",
+ "timestamp": "invalid"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("timestamp") || message.contains("Type mismatch"))
+ }
+ }
+
+ // MARK: - Feature: Model behaviors
+
+ func test_textMessageContentEvent_eventTypeIsAlwaysTextMessageContent() {
+ // Given
+ let event = TextMessageContentEvent(messageId: EventTestData.messageId, delta: "Hello", timestamp: nil, rawEvent: nil)
+
+ // Then
+ XCTAssertEqual(event.eventType, .textMessageContent)
+ }
+
+ func test_textMessageContentEvent_equatable_sameFields_areEqual() {
+ // Given
+ let event1 = TextMessageContentEvent(messageId: EventTestData.messageId, delta: "Hello", timestamp: EventTestData.timestamp, rawEvent: nil)
+ let event2 = TextMessageContentEvent(messageId: EventTestData.messageId, delta: "Hello", timestamp: EventTestData.timestamp, rawEvent: nil)
+
+ // Then
+ XCTAssertEqual(event1, event2)
+ }
+
+ func test_textMessageContentEvent_equatable_differentMessageIds_areNotEqual() {
+ // Given
+ let event1 = TextMessageContentEvent(messageId: EventTestData.messageId, delta: "Hello", timestamp: EventTestData.timestamp, rawEvent: nil)
+ let event2 = TextMessageContentEvent(messageId: EventTestData.messageId2, delta: "Hello", timestamp: EventTestData.timestamp, rawEvent: nil)
+
+ // Then
+ XCTAssertNotEqual(event1, event2)
+ }
+
+ func test_textMessageContentEvent_equatable_differentDeltas_areNotEqual() {
+ // Given
+ let event1 = TextMessageContentEvent(messageId: EventTestData.messageId, delta: "Hello", timestamp: EventTestData.timestamp, rawEvent: nil)
+ let event2 = TextMessageContentEvent(messageId: EventTestData.messageId, delta: "World", timestamp: EventTestData.timestamp, rawEvent: nil)
+
+ // Then
+ XCTAssertNotEqual(event1, event2)
+ }
+
+ func test_textMessageContentEvent_equatable_differentTimestamps_areNotEqual() {
+ // Given
+ let event1 = TextMessageContentEvent(messageId: EventTestData.messageId, delta: "Hello", timestamp: EventTestData.timestamp, rawEvent: nil)
+ let event2 = TextMessageContentEvent(messageId: EventTestData.messageId, delta: "Hello", timestamp: EventTestData.timestamp2, rawEvent: nil)
+
+ // Then
+ XCTAssertNotEqual(event1, event2)
+ }
+
+ func test_textMessageContentEvent_withEmptyDelta_isValid() {
+ // Given
+ // Note: Protocol says delta should be non-empty, but we allow it for flexibility
+ let event = TextMessageContentEvent(messageId: EventTestData.messageId, delta: "", timestamp: nil, rawEvent: nil)
+
+ // Then
+ XCTAssertEqual(event.delta, "")
+ XCTAssertEqual(event.eventType, .textMessageContent)
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/TextMessageEvents/TextMessageEndEventTests.swift b/sdks/community/swift/Tests/AGUICoreTests/TextMessageEvents/TextMessageEndEventTests.swift
new file mode 100644
index 0000000000..7d2a5b6a6e
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/TextMessageEvents/TextMessageEndEventTests.swift
@@ -0,0 +1,284 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+final class TextMessageEndEventTests: XCTestCase,
+ AGUIEventDecoderTestHelpers,
+ EventDecodingErrorTests {
+
+ // MARK: - EventDecodingErrorTests Protocol Requirements
+
+ var validEventFieldsWithoutType: [String: Any] {
+ ["messageId": EventTestData.messageId]
+ }
+
+ var eventTypeString: String { "TEXT_MESSAGE_END" }
+ var expectedEventType: EventType { .textMessageEnd }
+ var unknownEventTypeString: String { "TEXT_MESSAGE_PAUSED" }
+
+ // MARK: - Feature: Decode TEXT_MESSAGE_END
+
+ func test_decodeValidTextMessageEnd_returnsTextMessageEndEvent() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TEXT_MESSAGE_END",
+ "messageId": "\(EventTestData.messageId)"
+ }
+ """)
+
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ guard let textMessageEnd = event as? TextMessageEndEvent else {
+ return XCTFail("Expected TextMessageEndEvent, got \(type(of: event))")
+ }
+ XCTAssertEqual(textMessageEnd.eventType, .textMessageEnd)
+ XCTAssertEqual(textMessageEnd.messageId, EventTestData.messageId)
+ XCTAssertNil(textMessageEnd.timestamp)
+ }
+
+ func test_decodeTextMessageEnd_withTimestamp_populatesTimestamp() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TEXT_MESSAGE_END",
+ "messageId": "\(EventTestData.messageId)",
+ "timestamp": \(EventTestData.timestamp)
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let textMessageEnd = try XCTUnwrap(event as? TextMessageEndEvent)
+ XCTAssertEqual(textMessageEnd.timestamp, EventTestData.timestamp)
+ }
+
+ func test_decodeTextMessageEnd_preservesRawEventBytes() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TEXT_MESSAGE_END",
+ "messageId": "\(EventTestData.messageId)",
+ "timestamp": \(EventTestData.timestamp)
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let textMessageEnd = try XCTUnwrap(event as? TextMessageEndEvent)
+ XCTAssertEqual(textMessageEnd.rawEvent, data)
+ }
+
+ func test_decodeTextMessageEnd_ignoresUnknownExtraFields() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TEXT_MESSAGE_END",
+ "messageId": "\(EventTestData.messageId)",
+ "extraField": "ignored",
+ "nested": { "x": 1 }
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let textMessageEnd = try XCTUnwrap(event as? TextMessageEndEvent)
+ XCTAssertEqual(textMessageEnd.messageId, EventTestData.messageId)
+ }
+
+ func test_decodeTextMessageEnd_withUnicodeMessageId_handlesCorrectly() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TEXT_MESSAGE_END",
+ "messageId": "msg-🚀-123"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let textMessageEnd = try XCTUnwrap(event as? TextMessageEndEvent)
+ XCTAssertEqual(textMessageEnd.messageId, "msg-🚀-123")
+ }
+
+ // MARK: - Feature: Error handling (event-specific)
+
+ func test_decodeTextMessageEnd_missingMessageId_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TEXT_MESSAGE_END"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("messageId") || message.contains("Missing key"))
+ }
+ }
+
+ func test_decodeTextMessageEnd_wrongTypeForMessageId_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TEXT_MESSAGE_END",
+ "messageId": 123
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("messageId") || message.contains("Type mismatch"))
+ }
+ }
+
+ func test_decodeTextMessageEnd_wrongTypeForTimestamp_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TEXT_MESSAGE_END",
+ "messageId": "\(EventTestData.messageId)",
+ "timestamp": "invalid"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("timestamp") || message.contains("Type mismatch"))
+ }
+ }
+
+ func test_decodeInvalidJSON_throwsInvalidJSON() {
+ // Given
+ let data = Data("invalid json".utf8)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ XCTAssertEqual(error as? EventDecodingError, .invalidJSON)
+ }
+ }
+
+ // MARK: - Feature: Model behaviors
+
+ func test_textMessageEndEvent_eventTypeIsAlwaysTextMessageEnd() {
+ // Given
+ let event = TextMessageEndEvent(messageId: EventTestData.messageId, timestamp: nil, rawEvent: nil)
+
+ // Then
+ XCTAssertEqual(event.eventType, .textMessageEnd)
+ }
+
+ func test_textMessageEndEvent_equatable_sameFields_areEqual() {
+ // Given
+ let event1 = TextMessageEndEvent(messageId: EventTestData.messageId, timestamp: EventTestData.timestamp, rawEvent: nil)
+ let event2 = TextMessageEndEvent(messageId: EventTestData.messageId, timestamp: EventTestData.timestamp, rawEvent: nil)
+
+ // Then
+ XCTAssertEqual(event1, event2)
+ }
+
+ func test_textMessageEndEvent_equatable_differentMessageIds_areNotEqual() {
+ // Given
+ let event1 = TextMessageEndEvent(messageId: EventTestData.messageId, timestamp: EventTestData.timestamp, rawEvent: nil)
+ let event2 = TextMessageEndEvent(messageId: EventTestData.messageId2, timestamp: EventTestData.timestamp, rawEvent: nil)
+
+ // Then
+ XCTAssertNotEqual(event1, event2)
+ }
+
+ func test_textMessageEndEvent_equatable_differentTimestamps_areNotEqual() {
+ // Given
+ let event1 = TextMessageEndEvent(messageId: EventTestData.messageId, timestamp: EventTestData.timestamp, rawEvent: nil)
+ let event2 = TextMessageEndEvent(messageId: EventTestData.messageId, timestamp: EventTestData.timestamp2, rawEvent: nil)
+
+ // Then
+ XCTAssertNotEqual(event1, event2)
+ }
+
+ func test_textMessageEndEvent_equatable_oneNilTimestamp_areNotEqual() {
+ // Given
+ let event1 = TextMessageEndEvent(messageId: EventTestData.messageId, timestamp: EventTestData.timestamp, rawEvent: nil)
+ let event2 = TextMessageEndEvent(messageId: EventTestData.messageId, timestamp: nil, rawEvent: nil)
+
+ // Then
+ XCTAssertNotEqual(event1, event2)
+ }
+
+ func test_textMessageEndEvent_equatable_bothNilTimestamps_areEqual() {
+ // Given
+ let event1 = TextMessageEndEvent(messageId: EventTestData.messageId, timestamp: nil, rawEvent: nil)
+ let event2 = TextMessageEndEvent(messageId: EventTestData.messageId, timestamp: nil, rawEvent: nil)
+
+ // Then
+ XCTAssertEqual(event1, event2)
+ }
+
+ func test_textMessageEndEvent_withEmptyMessageId_isValid() {
+ // Given
+ let event = TextMessageEndEvent(messageId: "", timestamp: nil, rawEvent: nil)
+
+ // Then
+ XCTAssertEqual(event.messageId, "")
+ XCTAssertEqual(event.eventType, .textMessageEnd)
+ }
+
+ // MARK: - Helpers
+
+ private func makeStrictDecoder(
+ registry: [EventType: AGUIEventDecoder.DecodeHandler]? = nil
+ ) -> AGUIEventDecoder {
+ var config = AGUIEventDecoder.Configuration()
+ config.unknownEventStrategy = .throwError
+ return AGUIEventDecoder(
+ config: config,
+ makeDecoder: { JSONDecoder() },
+ registry: registry ?? AGUIEventDecoder.defaultRegistry()
+ )
+ }
+
+ private func makeTolerantDecoder(
+ registry: [EventType: AGUIEventDecoder.DecodeHandler]? = nil
+ ) -> AGUIEventDecoder {
+ var config = AGUIEventDecoder.Configuration()
+ config.unknownEventStrategy = .returnUnknown
+ return AGUIEventDecoder(
+ config: config,
+ makeDecoder: { JSONDecoder() },
+ registry: registry ?? AGUIEventDecoder.defaultRegistry()
+ )
+ }
+
+ private func jsonData(_ json: String) -> Data {
+ Data(json.utf8)
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/TextMessageEvents/TextMessageStartEventTests.swift b/sdks/community/swift/Tests/AGUICoreTests/TextMessageEvents/TextMessageStartEventTests.swift
new file mode 100644
index 0000000000..8cfc8bda16
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/TextMessageEvents/TextMessageStartEventTests.swift
@@ -0,0 +1,354 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+final class TextMessageStartEventTests: XCTestCase,
+ AGUIEventDecoderTestHelpers,
+ EventDecodingErrorTests {
+
+ // MARK: - EventDecodingErrorTests Protocol Requirements
+
+ var validEventFieldsWithoutType: [String: Any] {
+ [
+ "messageId": EventTestData.messageId,
+ "role": "assistant"
+ ]
+ }
+
+ var eventTypeString: String { "TEXT_MESSAGE_START" }
+ var expectedEventType: EventType { .textMessageStart }
+ var unknownEventTypeString: String { "TEXT_MESSAGE_PAUSED" }
+
+ // MARK: - Feature: Decode TEXT_MESSAGE_START
+
+ func test_decodeValidTextMessageStart_returnsTextMessageStartEvent() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TEXT_MESSAGE_START",
+ "messageId": "\(EventTestData.messageId)",
+ "role": "assistant"
+ }
+ """)
+
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ guard let textMessageStart = event as? TextMessageStartEvent else {
+ return XCTFail("Expected TextMessageStartEvent, got \(type(of: event))")
+ }
+ XCTAssertEqual(textMessageStart.eventType, .textMessageStart)
+ XCTAssertEqual(textMessageStart.messageId, EventTestData.messageId)
+ XCTAssertEqual(textMessageStart.role, "assistant")
+ XCTAssertNil(textMessageStart.timestamp)
+ }
+
+ func test_decodeTextMessageStart_withTimestamp_populatesTimestamp() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TEXT_MESSAGE_START",
+ "messageId": "\(EventTestData.messageId)",
+ "role": "assistant",
+ "timestamp": \(EventTestData.timestamp)
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let textMessageStart = try XCTUnwrap(event as? TextMessageStartEvent)
+ XCTAssertEqual(textMessageStart.timestamp, EventTestData.timestamp)
+ }
+
+ func test_decodeTextMessageStart_preservesRawEventBytes() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TEXT_MESSAGE_START",
+ "messageId": "\(EventTestData.messageId)",
+ "role": "assistant",
+ "timestamp": \(EventTestData.timestamp)
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let textMessageStart = try XCTUnwrap(event as? TextMessageStartEvent)
+ XCTAssertEqual(textMessageStart.rawEvent, data)
+ }
+
+ func test_decodeTextMessageStart_ignoresUnknownExtraFields() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TEXT_MESSAGE_START",
+ "messageId": "\(EventTestData.messageId)",
+ "role": "assistant",
+ "extraField": "ignored",
+ "nested": { "x": 1 }
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let textMessageStart = try XCTUnwrap(event as? TextMessageStartEvent)
+ XCTAssertEqual(textMessageStart.messageId, EventTestData.messageId)
+ XCTAssertEqual(textMessageStart.role, "assistant")
+ }
+
+ func test_decodeTextMessageStart_withUnicodeMessageId_handlesCorrectly() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TEXT_MESSAGE_START",
+ "messageId": "msg-🚀-123",
+ "role": "assistant"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let textMessageStart = try XCTUnwrap(event as? TextMessageStartEvent)
+ XCTAssertEqual(textMessageStart.messageId, "msg-🚀-123")
+ }
+
+ // MARK: - Feature: Error handling (event-specific)
+
+ func test_decodeTextMessageStart_missingMessageId_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TEXT_MESSAGE_START",
+ "role": "assistant"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("messageId") || message.contains("Missing key"))
+ }
+ }
+
+ func test_decodeTextMessageStart_missingRole_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TEXT_MESSAGE_START",
+ "messageId": "msg-123"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("role") || message.contains("Missing key"))
+ }
+ }
+
+ func test_decodeTextMessageStart_wrongTypeForMessageId_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TEXT_MESSAGE_START",
+ "messageId": 123,
+ "role": "assistant"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("messageId") || message.contains("Type mismatch"))
+ }
+ }
+
+ func test_decodeTextMessageStart_wrongTypeForRole_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TEXT_MESSAGE_START",
+ "messageId": "msg-123",
+ "role": 123
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("role") || message.contains("Type mismatch"))
+ }
+ }
+
+ func test_decodeTextMessageStart_wrongTypeForTimestamp_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TEXT_MESSAGE_START",
+ "messageId": "\(EventTestData.messageId)",
+ "role": "assistant",
+ "timestamp": "invalid"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("timestamp") || message.contains("Type mismatch"))
+ }
+ }
+
+ // MARK: - Feature: Model behaviors
+
+ func test_textMessageStartEvent_eventTypeIsAlwaysTextMessageStart() {
+ // Given
+ let event = TextMessageStartEvent(messageId: EventTestData.messageId, role: "assistant", timestamp: nil, rawEvent: nil)
+
+ // Then
+ XCTAssertEqual(event.eventType, .textMessageStart)
+ }
+
+ func test_textMessageStartEvent_equatable_sameFields_areEqual() {
+ // Given
+ let event1 = TextMessageStartEvent(messageId: EventTestData.messageId, role: "assistant", timestamp: EventTestData.timestamp, rawEvent: nil)
+ let event2 = TextMessageStartEvent(messageId: EventTestData.messageId, role: "assistant", timestamp: EventTestData.timestamp, rawEvent: nil)
+
+ // Then
+ XCTAssertEqual(event1, event2)
+ }
+
+ func test_textMessageStartEvent_equatable_differentMessageIds_areNotEqual() {
+ // Given
+ let event1 = TextMessageStartEvent(messageId: EventTestData.messageId, role: "assistant", timestamp: EventTestData.timestamp, rawEvent: nil)
+ let event2 = TextMessageStartEvent(messageId: EventTestData.messageId2, role: "assistant", timestamp: EventTestData.timestamp, rawEvent: nil)
+
+ // Then
+ XCTAssertNotEqual(event1, event2)
+ }
+
+ func test_textMessageStartEvent_equatable_differentRoles_areNotEqual() {
+ // Given
+ let event1 = TextMessageStartEvent(messageId: EventTestData.messageId, role: "assistant", timestamp: EventTestData.timestamp, rawEvent: nil)
+ let event2 = TextMessageStartEvent(messageId: EventTestData.messageId, role: "user", timestamp: EventTestData.timestamp, rawEvent: nil)
+
+ // Then
+ XCTAssertNotEqual(event1, event2)
+ }
+
+ func test_textMessageStartEvent_equatable_differentTimestamps_areNotEqual() {
+ // Given
+ let event1 = TextMessageStartEvent(messageId: EventTestData.messageId, role: "assistant", timestamp: EventTestData.timestamp, rawEvent: nil)
+ let event2 = TextMessageStartEvent(messageId: EventTestData.messageId, role: "assistant", timestamp: EventTestData.timestamp2, rawEvent: nil)
+
+ // Then
+ XCTAssertNotEqual(event1, event2)
+ }
+
+ func test_textMessageStartEvent_withEmptyMessageId_isValid() {
+ // Given
+ let event = TextMessageStartEvent(messageId: "", role: "assistant", timestamp: nil, rawEvent: nil)
+
+ // Then
+ XCTAssertEqual(event.messageId, "")
+ XCTAssertEqual(event.eventType, .textMessageStart)
+ }
+
+ // MARK: - Feature: name field (protocol spec)
+
+ func test_decodeTextMessageStart_withName_populatesName() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TEXT_MESSAGE_START",
+ "messageId": "\(EventTestData.messageId)",
+ "role": "assistant",
+ "name": "Alice"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try XCTUnwrap(try decoder.decode(data) as? TextMessageStartEvent)
+
+ // Then
+ XCTAssertEqual(event.name, "Alice")
+ }
+
+ func test_decodeTextMessageStart_withoutName_nameIsNil() throws {
+ // Given — JSON with no "name" field
+ let data = jsonData("""
+ {
+ "type": "TEXT_MESSAGE_START",
+ "messageId": "\(EventTestData.messageId)",
+ "role": "assistant"
+ }
+ """)
+ let event = try XCTUnwrap(try makeStrictDecoder().decode(data) as? TextMessageStartEvent)
+
+ // Then
+ XCTAssertNil(event.name)
+ }
+
+ func test_textMessageStartEvent_init_acceptsName() {
+ // Given
+ let event = TextMessageStartEvent(
+ messageId: EventTestData.messageId,
+ role: "assistant",
+ name: "Bob",
+ timestamp: nil,
+ rawEvent: nil
+ )
+
+ // Then
+ XCTAssertEqual(event.name, "Bob")
+ }
+
+ func test_textMessageStartEvent_equalityDifferentNames_notEqual() {
+ let e1 = TextMessageStartEvent(
+ messageId: EventTestData.messageId,
+ role: "assistant",
+ name: "Alice",
+ timestamp: nil,
+ rawEvent: nil
+ )
+ let e2 = TextMessageStartEvent(
+ messageId: EventTestData.messageId,
+ role: "assistant",
+ name: "Bob",
+ timestamp: nil,
+ rawEvent: nil
+ )
+ XCTAssertNotEqual(e1, e2)
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/ToolCallEvents/ToolCallArgsEventTests.swift b/sdks/community/swift/Tests/AGUICoreTests/ToolCallEvents/ToolCallArgsEventTests.swift
new file mode 100644
index 0000000000..4c45db94b4
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/ToolCallEvents/ToolCallArgsEventTests.swift
@@ -0,0 +1,327 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+final class ToolCallArgsEventTests: XCTestCase,
+ AGUIEventDecoderTestHelpers,
+ EventDecodingErrorTests {
+
+ // MARK: - EventDecodingErrorTests Protocol Requirements
+
+ var validEventFieldsWithoutType: [String: Any] {
+ [
+ "toolCallId": EventTestData.toolCallId,
+ "delta": "{\"location\": \"San Francisco\"}"
+ ]
+ }
+
+ var eventTypeString: String { "TOOL_CALL_ARGS" }
+ var expectedEventType: EventType { .toolCallArgs }
+ var unknownEventTypeString: String { "TOOL_CALL_PAUSED" }
+
+ // MARK: - Feature: Decode TOOL_CALL_ARGS
+
+ func test_decodeValidToolCallArgs_returnsToolCallArgsEvent() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_ARGS",
+ "toolCallId": "\(EventTestData.toolCallId)",
+ "delta": "{\\"location\\": \\"San Francisco\\"}"
+ }
+ """)
+
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ guard let toolCallArgs = event as? ToolCallArgsEvent else {
+ return XCTFail("Expected ToolCallArgsEvent, got \(type(of: event))")
+ }
+ XCTAssertEqual(toolCallArgs.eventType, .toolCallArgs)
+ XCTAssertEqual(toolCallArgs.toolCallId, EventTestData.toolCallId)
+ XCTAssertEqual(toolCallArgs.delta, "{\"location\": \"San Francisco\"}")
+ XCTAssertNil(toolCallArgs.timestamp)
+ }
+
+ func test_decodeToolCallArgs_withTimestamp_populatesTimestamp() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_ARGS",
+ "toolCallId": "\(EventTestData.toolCallId)",
+ "delta": "{\\"location\\": \\"San Francisco\\"}",
+ "timestamp": \(EventTestData.timestamp)
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let toolCallArgs = try XCTUnwrap(event as? ToolCallArgsEvent)
+ XCTAssertEqual(toolCallArgs.timestamp, EventTestData.timestamp)
+ }
+
+ func test_decodeToolCallArgs_preservesRawEventBytes() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_ARGS",
+ "toolCallId": "\(EventTestData.toolCallId)",
+ "delta": "{\\"location\\": \\"San Francisco\\"}",
+ "timestamp": \(EventTestData.timestamp)
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let toolCallArgs = try XCTUnwrap(event as? ToolCallArgsEvent)
+ XCTAssertEqual(toolCallArgs.rawEvent, data)
+ }
+
+ func test_decodeToolCallArgs_ignoresUnknownExtraFields() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_ARGS",
+ "toolCallId": "\(EventTestData.toolCallId)",
+ "delta": "{\\"location\\": \\"San Francisco\\"}",
+ "extraField": "ignored",
+ "nested": { "x": 1 }
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let toolCallArgs = try XCTUnwrap(event as? ToolCallArgsEvent)
+ XCTAssertEqual(toolCallArgs.toolCallId, EventTestData.toolCallId)
+ XCTAssertEqual(toolCallArgs.delta, "{\"location\": \"San Francisco\"}")
+ }
+
+ func test_decodeToolCallArgs_withUnicodeDelta_handlesCorrectly() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_ARGS",
+ "toolCallId": "\(EventTestData.toolCallId)",
+ "delta": "{\\"city\\": \\"北京\\"}"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let toolCallArgs = try XCTUnwrap(event as? ToolCallArgsEvent)
+ XCTAssertEqual(toolCallArgs.delta, "{\"city\": \"北京\"}")
+ }
+
+ func test_decodeToolCallArgs_withMultilineDelta_handlesCorrectly() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_ARGS",
+ "toolCallId": "\(EventTestData.toolCallId)",
+ "delta": "{\\n \\"key\\": \\"value\\"\\n}"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let toolCallArgs = try XCTUnwrap(event as? ToolCallArgsEvent)
+ XCTAssertEqual(toolCallArgs.delta, "{\n \"key\": \"value\"\n}")
+ }
+
+ func test_decodeToolCallArgs_withEmptyDelta_allowsEmptyString() throws {
+ // Given
+ // Note: According to protocol, delta should be non-empty, but we allow it
+ // for flexibility. Validation can be done at a higher level if needed.
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_ARGS",
+ "toolCallId": "\(EventTestData.toolCallId)",
+ "delta": ""
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let toolCallArgs = try XCTUnwrap(event as? ToolCallArgsEvent)
+ XCTAssertEqual(toolCallArgs.delta, "")
+ }
+
+ // MARK: - Feature: Error handling
+
+ func test_decodeToolCallArgs_missingToolCallId_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_ARGS",
+ "delta": "{\\"location\\": \\"San Francisco\\"}"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("toolCallId") || message.contains("Missing key"))
+ }
+ }
+
+ func test_decodeToolCallArgs_missingDelta_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_ARGS",
+ "toolCallId": "call-123"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("delta") || message.contains("Missing key"))
+ }
+ }
+
+ func test_decodeToolCallArgs_wrongTypeForToolCallId_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_ARGS",
+ "toolCallId": 123,
+ "delta": "{\\"location\\": \\"San Francisco\\"}"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("toolCallId") || message.contains("Type mismatch"))
+ }
+ }
+
+ func test_decodeToolCallArgs_wrongTypeForDelta_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_ARGS",
+ "toolCallId": "\(EventTestData.toolCallId)",
+ "delta": 123
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("delta") || message.contains("Type mismatch"))
+ }
+ }
+
+ func test_decodeToolCallArgs_wrongTypeForTimestamp_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_ARGS",
+ "toolCallId": "\(EventTestData.toolCallId)",
+ "delta": "{\\"location\\": \\"San Francisco\\"}",
+ "timestamp": "invalid"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("timestamp") || message.contains("Type mismatch"))
+ }
+ }
+
+ // MARK: - Feature: Model behaviors
+
+ func test_toolCallArgsEvent_eventTypeIsAlwaysToolCallArgs() {
+ // Given
+ let event = ToolCallArgsEvent(toolCallId: EventTestData.toolCallId, delta: "{\"key\": \"value\"}", timestamp: nil, rawEvent: nil)
+
+ // Then
+ XCTAssertEqual(event.eventType, .toolCallArgs)
+ }
+
+ func test_toolCallArgsEvent_equatable_sameFields_areEqual() {
+ // Given
+ let event1 = ToolCallArgsEvent(toolCallId: EventTestData.toolCallId, delta: "{\"key\": \"value\"}", timestamp: EventTestData.timestamp, rawEvent: nil)
+ let event2 = ToolCallArgsEvent(toolCallId: EventTestData.toolCallId, delta: "{\"key\": \"value\"}", timestamp: EventTestData.timestamp, rawEvent: nil)
+
+ // Then
+ XCTAssertEqual(event1, event2)
+ }
+
+ func test_toolCallArgsEvent_equatable_differentToolCallIds_areNotEqual() {
+ // Given
+ let event1 = ToolCallArgsEvent(toolCallId: EventTestData.toolCallId, delta: "{\"key\": \"value\"}", timestamp: EventTestData.timestamp, rawEvent: nil)
+ let event2 = ToolCallArgsEvent(toolCallId: "call-456", delta: "{\"key\": \"value\"}", timestamp: EventTestData.timestamp, rawEvent: nil)
+
+ // Then
+ XCTAssertNotEqual(event1, event2)
+ }
+
+ func test_toolCallArgsEvent_equatable_differentDeltas_areNotEqual() {
+ // Given
+ let event1 = ToolCallArgsEvent(toolCallId: EventTestData.toolCallId, delta: "{\"key\": \"value1\"}", timestamp: EventTestData.timestamp, rawEvent: nil)
+ let event2 = ToolCallArgsEvent(toolCallId: EventTestData.toolCallId, delta: "{\"key\": \"value2\"}", timestamp: EventTestData.timestamp, rawEvent: nil)
+
+ // Then
+ XCTAssertNotEqual(event1, event2)
+ }
+
+ func test_toolCallArgsEvent_equatable_differentTimestamps_areNotEqual() {
+ // Given
+ let event1 = ToolCallArgsEvent(toolCallId: EventTestData.toolCallId, delta: "{\"key\": \"value\"}", timestamp: EventTestData.timestamp, rawEvent: nil)
+ let event2 = ToolCallArgsEvent(toolCallId: EventTestData.toolCallId, delta: "{\"key\": \"value\"}", timestamp: EventTestData.timestamp2, rawEvent: nil)
+
+ // Then
+ XCTAssertNotEqual(event1, event2)
+ }
+
+ func test_toolCallArgsEvent_withEmptyDelta_isValid() {
+ // Given
+ // Note: Protocol says delta should be non-empty, but we allow it for flexibility
+ let event = ToolCallArgsEvent(toolCallId: EventTestData.toolCallId, delta: "", timestamp: nil, rawEvent: nil)
+
+ // Then
+ XCTAssertEqual(event.delta, "")
+ XCTAssertEqual(event.eventType, .toolCallArgs)
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/ToolCallEvents/ToolCallChunkEventTests.swift b/sdks/community/swift/Tests/AGUICoreTests/ToolCallEvents/ToolCallChunkEventTests.swift
new file mode 100644
index 0000000000..6edaca2dcd
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/ToolCallEvents/ToolCallChunkEventTests.swift
@@ -0,0 +1,233 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+final class ToolCallChunkEventTests: XCTestCase,
+ AGUIEventDecoderTestHelpers,
+ EventDecodingErrorTests {
+
+ // MARK: - EventDecodingErrorTests Protocol Requirements
+
+ var validEventFieldsWithoutType: [String: Any] {
+ [
+ "toolCallId": EventTestData.toolCallId,
+ "toolCallName": "test_tool",
+ "delta": "{\"key\":\"value\"}"
+ ]
+ }
+
+ var eventTypeString: String { "TOOL_CALL_CHUNK" }
+ var expectedEventType: EventType { .toolCallChunk }
+ var unknownEventTypeString: String { "TOOL_CALL_PAUSED" }
+
+ // MARK: - Feature: Decode TOOL_CALL_CHUNK
+
+ func test_decodeValidToolCallChunk_returnsToolCallChunkEvent() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_CHUNK",
+ "toolCallId": "\(EventTestData.toolCallId)",
+ "toolCallName": "test_tool",
+ "delta": "{\\"key\\": \\"value\\"}"
+ }
+ """)
+
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ guard let chunk = event as? ToolCallChunkEvent else {
+ return XCTFail("Expected ToolCallChunkEvent, got \(type(of: event))")
+ }
+ XCTAssertEqual(chunk.eventType, .toolCallChunk)
+ XCTAssertEqual(chunk.toolCallId, EventTestData.toolCallId)
+ XCTAssertEqual(chunk.toolCallName, "test_tool")
+ // JSONDecoder may add spaces, so check that delta contains the key-value pair
+ XCTAssertTrue(chunk.delta?.contains("\"key\"") == true)
+ XCTAssertTrue(chunk.delta?.contains("\"value\"") == true)
+ XCTAssertNil(chunk.parentMessageId)
+ XCTAssertNil(chunk.timestamp)
+ }
+
+ func test_decodeToolCallChunkWithParentMessageId_returnsToolCallChunkEvent() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_CHUNK",
+ "toolCallId": "\(EventTestData.toolCallId)",
+ "toolCallName": "test_tool",
+ "delta": "{\\"key\\": \\"value\\"}",
+ "parentMessageId": "\(EventTestData.messageId)"
+ }
+ """)
+
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ guard let chunk = event as? ToolCallChunkEvent else {
+ return XCTFail("Expected ToolCallChunkEvent, got \(type(of: event))")
+ }
+ XCTAssertEqual(chunk.parentMessageId, EventTestData.messageId)
+ }
+
+ func test_decodeToolCallChunkWithTimestamp_returnsToolCallChunkEvent() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_CHUNK",
+ "toolCallId": "\(EventTestData.toolCallId)",
+ "toolCallName": "test_tool",
+ "delta": "{\\"key\\": \\"value\\"}",
+ "timestamp": \(EventTestData.timestamp)
+ }
+ """)
+
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ guard let chunk = event as? ToolCallChunkEvent else {
+ return XCTFail("Expected ToolCallChunkEvent, got \(type(of: event))")
+ }
+ XCTAssertEqual(chunk.timestamp, EventTestData.timestamp)
+ }
+
+ func test_decodeToolCallChunkWithoutToolCallId_returnsToolCallChunkEvent() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_CHUNK",
+ "delta": "{\\"key\\": \\"value\\"}"
+ }
+ """)
+
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ guard let chunk = event as? ToolCallChunkEvent else {
+ return XCTFail("Expected ToolCallChunkEvent, got \(type(of: event))")
+ }
+ XCTAssertNil(chunk.toolCallId)
+ XCTAssertNil(chunk.toolCallName)
+ // JSONDecoder may add spaces, so check that delta contains the key-value pair
+ XCTAssertTrue(chunk.delta?.contains("\"key\"") == true)
+ XCTAssertTrue(chunk.delta?.contains("\"value\"") == true)
+ }
+
+ func test_toolCallChunkEvent_eventTypeIsAlwaysToolCallChunk() {
+ // Given
+ let event = ToolCallChunkEvent(
+ toolCallId: EventTestData.toolCallId,
+ toolCallName: "test_tool",
+ delta: "{\"key\":\"value\"}"
+ )
+
+ // Then
+ XCTAssertEqual(event.eventType, .toolCallChunk)
+ }
+
+ // MARK: - Equatable Tests
+
+ func test_toolCallChunkEvent_equatable_sameFields_areEqual() {
+ // Given
+ let event1 = ToolCallChunkEvent(
+ toolCallId: EventTestData.toolCallId,
+ toolCallName: "test_tool",
+ delta: "{\"key\":\"value\"}",
+ parentMessageId: EventTestData.messageId,
+ timestamp: EventTestData.timestamp,
+ rawEvent: nil
+ )
+ let event2 = ToolCallChunkEvent(
+ toolCallId: EventTestData.toolCallId,
+ toolCallName: "test_tool",
+ delta: "{\"key\":\"value\"}",
+ parentMessageId: EventTestData.messageId,
+ timestamp: EventTestData.timestamp,
+ rawEvent: nil
+ )
+
+ // Then
+ XCTAssertEqual(event1, event2)
+ }
+
+ func test_toolCallChunkEvent_equatable_differentToolCallIds_areNotEqual() {
+ // Given
+ let event1 = ToolCallChunkEvent(
+ toolCallId: EventTestData.toolCallId,
+ toolCallName: "test_tool",
+ delta: "{\"key\":\"value\"}"
+ )
+ let event2 = ToolCallChunkEvent(
+ toolCallId: "other-id",
+ toolCallName: "test_tool",
+ delta: "{\"key\":\"value\"}"
+ )
+
+ // Then
+ XCTAssertNotEqual(event1, event2)
+ }
+
+ func test_toolCallChunkEvent_equatable_differentDeltas_areNotEqual() {
+ // Given
+ let event1 = ToolCallChunkEvent(
+ toolCallId: EventTestData.toolCallId,
+ toolCallName: "test_tool",
+ delta: "{\"key\":\"value\"}"
+ )
+ let event2 = ToolCallChunkEvent(
+ toolCallId: EventTestData.toolCallId,
+ toolCallName: "test_tool",
+ delta: "{\"key\":\"other\"}"
+ )
+
+ // Then
+ XCTAssertNotEqual(event1, event2)
+ }
+
+ func test_toolCallChunkEvent_equatable_oneNilToolCallId_areNotEqual() {
+ // Given
+ let event1 = ToolCallChunkEvent(
+ toolCallId: EventTestData.toolCallId,
+ toolCallName: "test_tool",
+ delta: "{\"key\":\"value\"}"
+ )
+ let event2 = ToolCallChunkEvent(
+ toolCallId: nil,
+ toolCallName: "test_tool",
+ delta: "{\"key\":\"value\"}"
+ )
+
+ // Then
+ XCTAssertNotEqual(event1, event2)
+ }
+
+ func test_toolCallChunkEvent_equatable_bothNilToolCallIds_areEqual() {
+ // Given
+ let event1 = ToolCallChunkEvent(
+ toolCallId: nil,
+ toolCallName: nil,
+ delta: "{\"key\":\"value\"}"
+ )
+ let event2 = ToolCallChunkEvent(
+ toolCallId: nil,
+ toolCallName: nil,
+ delta: "{\"key\":\"value\"}"
+ )
+
+ // Then
+ XCTAssertEqual(event1, event2)
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/ToolCallEvents/ToolCallEndEventTests.swift b/sdks/community/swift/Tests/AGUICoreTests/ToolCallEvents/ToolCallEndEventTests.swift
new file mode 100644
index 0000000000..4205c32f90
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/ToolCallEvents/ToolCallEndEventTests.swift
@@ -0,0 +1,243 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+final class ToolCallEndEventTests: XCTestCase,
+ AGUIEventDecoderTestHelpers,
+ EventDecodingErrorTests {
+
+ // MARK: - EventDecodingErrorTests Protocol Requirements
+
+ var validEventFieldsWithoutType: [String: Any] {
+ ["toolCallId": EventTestData.toolCallId]
+ }
+
+ var eventTypeString: String { "TOOL_CALL_END" }
+ var expectedEventType: EventType { .toolCallEnd }
+ var unknownEventTypeString: String { "TOOL_CALL_PAUSED" }
+
+ // MARK: - Feature: Decode TOOL_CALL_END
+
+ func test_decodeValidToolCallEnd_returnsToolCallEndEvent() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_END",
+ "toolCallId": "\(EventTestData.toolCallId)"
+ }
+ """)
+
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ guard let toolCallEnd = event as? ToolCallEndEvent else {
+ return XCTFail("Expected ToolCallEndEvent, got \(type(of: event))")
+ }
+ XCTAssertEqual(toolCallEnd.eventType, .toolCallEnd)
+ XCTAssertEqual(toolCallEnd.toolCallId, EventTestData.toolCallId)
+ XCTAssertNil(toolCallEnd.timestamp)
+ }
+
+ func test_decodeToolCallEnd_withTimestamp_populatesTimestamp() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_END",
+ "toolCallId": "\(EventTestData.toolCallId)",
+ "timestamp": \(EventTestData.timestamp)
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let toolCallEnd = try XCTUnwrap(event as? ToolCallEndEvent)
+ XCTAssertEqual(toolCallEnd.timestamp, EventTestData.timestamp)
+ }
+
+ func test_decodeToolCallEnd_preservesRawEventBytes() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_END",
+ "toolCallId": "\(EventTestData.toolCallId)",
+ "timestamp": \(EventTestData.timestamp)
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let toolCallEnd = try XCTUnwrap(event as? ToolCallEndEvent)
+ XCTAssertEqual(toolCallEnd.rawEvent, data)
+ }
+
+ func test_decodeToolCallEnd_ignoresUnknownExtraFields() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_END",
+ "toolCallId": "\(EventTestData.toolCallId)",
+ "extraField": "ignored",
+ "nested": { "x": 1 }
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let toolCallEnd = try XCTUnwrap(event as? ToolCallEndEvent)
+ XCTAssertEqual(toolCallEnd.toolCallId, EventTestData.toolCallId)
+ }
+
+ func test_decodeToolCallEnd_withUnicodeToolCallId_handlesCorrectly() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_END",
+ "toolCallId": "call-🚀-123"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let toolCallEnd = try XCTUnwrap(event as? ToolCallEndEvent)
+ XCTAssertEqual(toolCallEnd.toolCallId, "call-🚀-123")
+ }
+
+ // MARK: - Feature: Error handling
+
+ func test_decodeToolCallEnd_missingToolCallId_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_END"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("toolCallId") || message.contains("Missing key"))
+ }
+ }
+
+ func test_decodeToolCallEnd_wrongTypeForToolCallId_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_END",
+ "toolCallId": 123
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("toolCallId") || message.contains("Type mismatch"))
+ }
+ }
+
+ func test_decodeToolCallEnd_wrongTypeForTimestamp_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_END",
+ "toolCallId": "\(EventTestData.toolCallId)",
+ "timestamp": "invalid"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("timestamp") || message.contains("Type mismatch"))
+ }
+ }
+
+ // MARK: - Feature: Model behaviors
+
+ func test_toolCallEndEvent_eventTypeIsAlwaysToolCallEnd() {
+ // Given
+ let event = ToolCallEndEvent(toolCallId: EventTestData.toolCallId, timestamp: nil, rawEvent: nil)
+
+ // Then
+ XCTAssertEqual(event.eventType, .toolCallEnd)
+ }
+
+ func test_toolCallEndEvent_equatable_sameFields_areEqual() {
+ // Given
+ let event1 = ToolCallEndEvent(toolCallId: EventTestData.toolCallId, timestamp: EventTestData.timestamp, rawEvent: nil)
+ let event2 = ToolCallEndEvent(toolCallId: EventTestData.toolCallId, timestamp: EventTestData.timestamp, rawEvent: nil)
+
+ // Then
+ XCTAssertEqual(event1, event2)
+ }
+
+ func test_toolCallEndEvent_equatable_differentToolCallIds_areNotEqual() {
+ // Given
+ let event1 = ToolCallEndEvent(toolCallId: EventTestData.toolCallId, timestamp: EventTestData.timestamp, rawEvent: nil)
+ let event2 = ToolCallEndEvent(toolCallId: "call-456", timestamp: EventTestData.timestamp, rawEvent: nil)
+
+ // Then
+ XCTAssertNotEqual(event1, event2)
+ }
+
+ func test_toolCallEndEvent_equatable_differentTimestamps_areNotEqual() {
+ // Given
+ let event1 = ToolCallEndEvent(toolCallId: EventTestData.toolCallId, timestamp: EventTestData.timestamp, rawEvent: nil)
+ let event2 = ToolCallEndEvent(toolCallId: EventTestData.toolCallId, timestamp: EventTestData.timestamp2, rawEvent: nil)
+
+ // Then
+ XCTAssertNotEqual(event1, event2)
+ }
+
+ func test_toolCallEndEvent_equatable_oneNilTimestamp_areNotEqual() {
+ // Given
+ let event1 = ToolCallEndEvent(toolCallId: EventTestData.toolCallId, timestamp: EventTestData.timestamp, rawEvent: nil)
+ let event2 = ToolCallEndEvent(toolCallId: EventTestData.toolCallId, timestamp: nil, rawEvent: nil)
+
+ // Then
+ XCTAssertNotEqual(event1, event2)
+ }
+
+ func test_toolCallEndEvent_equatable_bothNilTimestamps_areEqual() {
+ // Given
+ let event1 = ToolCallEndEvent(toolCallId: EventTestData.toolCallId, timestamp: nil, rawEvent: nil)
+ let event2 = ToolCallEndEvent(toolCallId: EventTestData.toolCallId, timestamp: nil, rawEvent: nil)
+
+ // Then
+ XCTAssertEqual(event1, event2)
+ }
+
+ func test_toolCallEndEvent_withEmptyToolCallId_isValid() {
+ // Given
+ let event = ToolCallEndEvent(toolCallId: "", timestamp: nil, rawEvent: nil)
+
+ // Then
+ XCTAssertEqual(event.toolCallId, "")
+ XCTAssertEqual(event.eventType, .toolCallEnd)
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/ToolCallEvents/ToolCallResultEventTests.swift b/sdks/community/swift/Tests/AGUICoreTests/ToolCallEvents/ToolCallResultEventTests.swift
new file mode 100644
index 0000000000..da3b212379
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/ToolCallEvents/ToolCallResultEventTests.swift
@@ -0,0 +1,460 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+final class ToolCallResultEventTests: XCTestCase,
+ AGUIEventDecoderTestHelpers,
+ EventDecodingErrorTests {
+
+ // MARK: - EventDecodingErrorTests Protocol Requirements
+
+ var validEventFieldsWithoutType: [String: Any] {
+ [
+ "messageId": EventTestData.messageId,
+ "toolCallId": "call-456",
+ "content": "Temperature: 72°F"
+ ]
+ }
+
+ var eventTypeString: String { "TOOL_CALL_RESULT" }
+ var expectedEventType: EventType { .toolCallResult }
+ var unknownEventTypeString: String { "TOOL_CALL_PAUSED" }
+
+ // MARK: - Feature: Decode TOOL_CALL_RESULT
+
+ func test_decodeValidToolCallResult_returnsToolCallResultEvent() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_RESULT",
+ "messageId": "\(EventTestData.messageId)",
+ "toolCallId": "call-456",
+ "content": "Temperature: 72°F"
+ }
+ """)
+
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ guard let toolCallResult = event as? ToolCallResultEvent else {
+ return XCTFail("Expected ToolCallResultEvent, got \(type(of: event))")
+ }
+ XCTAssertEqual(toolCallResult.eventType, .toolCallResult)
+ XCTAssertEqual(toolCallResult.messageId, EventTestData.messageId)
+ XCTAssertEqual(toolCallResult.toolCallId, "call-456")
+ XCTAssertEqual(toolCallResult.content, "Temperature: 72°F")
+ XCTAssertNil(toolCallResult.role)
+ XCTAssertNil(toolCallResult.timestamp)
+ }
+
+ func test_decodeToolCallResult_withRole_populatesRole() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_RESULT",
+ "messageId": "\(EventTestData.messageId)",
+ "toolCallId": "call-456",
+ "content": "Temperature: 72°F",
+ "role": "tool"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let toolCallResult = try XCTUnwrap(event as? ToolCallResultEvent)
+ XCTAssertEqual(toolCallResult.role, "tool")
+ }
+
+ func test_decodeToolCallResult_withTimestamp_populatesTimestamp() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_RESULT",
+ "messageId": "\(EventTestData.messageId)",
+ "toolCallId": "call-456",
+ "content": "Temperature: 72°F",
+ "timestamp": \(EventTestData.timestamp)
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let toolCallResult = try XCTUnwrap(event as? ToolCallResultEvent)
+ XCTAssertEqual(toolCallResult.timestamp, EventTestData.timestamp)
+ }
+
+ func test_decodeToolCallResult_preservesRawEventBytes() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_RESULT",
+ "messageId": "\(EventTestData.messageId)",
+ "toolCallId": "call-456",
+ "content": "Temperature: 72°F",
+ "timestamp": \(EventTestData.timestamp)
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let toolCallResult = try XCTUnwrap(event as? ToolCallResultEvent)
+ XCTAssertEqual(toolCallResult.rawEvent, data)
+ }
+
+ func test_decodeToolCallResult_ignoresUnknownExtraFields() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_RESULT",
+ "messageId": "\(EventTestData.messageId)",
+ "toolCallId": "call-456",
+ "content": "Temperature: 72°F",
+ "extraField": "ignored",
+ "nested": { "x": 1 }
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let toolCallResult = try XCTUnwrap(event as? ToolCallResultEvent)
+ XCTAssertEqual(toolCallResult.messageId, EventTestData.messageId)
+ XCTAssertEqual(toolCallResult.toolCallId, "call-456")
+ XCTAssertEqual(toolCallResult.content, "Temperature: 72°F")
+ }
+
+ func test_decodeToolCallResult_withUnicodeContent_handlesCorrectly() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_RESULT",
+ "messageId": "\(EventTestData.messageId)",
+ "toolCallId": "call-456",
+ "content": "温度: 22°C 🌡️"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let toolCallResult = try XCTUnwrap(event as? ToolCallResultEvent)
+ XCTAssertEqual(toolCallResult.content, "温度: 22°C 🌡️")
+ }
+
+ func test_decodeToolCallResult_withMultilineContent_handlesCorrectly() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_RESULT",
+ "messageId": "\(EventTestData.messageId)",
+ "toolCallId": "call-456",
+ "content": "Line 1\\nLine 2\\nLine 3"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let toolCallResult = try XCTUnwrap(event as? ToolCallResultEvent)
+ XCTAssertEqual(toolCallResult.content, "Line 1\nLine 2\nLine 3")
+ }
+
+ func test_decodeToolCallResult_withEmptyContent_allowsEmptyString() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_RESULT",
+ "messageId": "\(EventTestData.messageId)",
+ "toolCallId": "call-456",
+ "content": ""
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let toolCallResult = try XCTUnwrap(event as? ToolCallResultEvent)
+ XCTAssertEqual(toolCallResult.content, "")
+ }
+
+ // MARK: - Feature: Error handling
+
+ func test_decodeToolCallResult_missingMessageId_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_RESULT",
+ "toolCallId": "call-456",
+ "content": "Temperature: 72°F"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("messageId") || message.contains("Missing key"))
+ }
+ }
+
+ func test_decodeToolCallResult_missingToolCallId_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_RESULT",
+ "messageId": "\(EventTestData.messageId)",
+ "content": "Temperature: 72°F"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("toolCallId") || message.contains("Missing key"))
+ }
+ }
+
+ func test_decodeToolCallResult_missingContent_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_RESULT",
+ "messageId": "\(EventTestData.messageId)",
+ "toolCallId": "call-456"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("content") || message.contains("Missing key"))
+ }
+ }
+
+ func test_decodeToolCallResult_wrongTypeForMessageId_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_RESULT",
+ "messageId": 123,
+ "toolCallId": "call-456",
+ "content": "Temperature: 72°F"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("messageId") || message.contains("Type mismatch"))
+ }
+ }
+
+ func test_decodeToolCallResult_wrongTypeForToolCallId_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_RESULT",
+ "messageId": "\(EventTestData.messageId)",
+ "toolCallId": 456,
+ "content": "Temperature: 72°F"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("toolCallId") || message.contains("Type mismatch"))
+ }
+ }
+
+ func test_decodeToolCallResult_wrongTypeForContent_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_RESULT",
+ "messageId": "\(EventTestData.messageId)",
+ "toolCallId": "call-456",
+ "content": 123
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("content") || message.contains("Type mismatch"))
+ }
+ }
+
+ func test_decodeToolCallResult_wrongTypeForRole_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_RESULT",
+ "messageId": "\(EventTestData.messageId)",
+ "toolCallId": "call-456",
+ "content": "Temperature: 72°F",
+ "role": 123
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("role") || message.contains("Type mismatch"))
+ }
+ }
+
+ func test_decodeToolCallResult_wrongTypeForTimestamp_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_RESULT",
+ "messageId": "\(EventTestData.messageId)",
+ "toolCallId": "call-456",
+ "content": "Temperature: 72°F",
+ "timestamp": "invalid"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("timestamp") || message.contains("Type mismatch"))
+ }
+ }
+
+ // MARK: - Feature: Model behaviors
+
+ func test_toolCallResultEvent_eventTypeIsAlwaysToolCallResult() {
+ // Given
+ let event = ToolCallResultEvent(messageId: EventTestData.messageId, toolCallId: "call-456", content: "Result", timestamp: nil, rawEvent: nil)
+
+ // Then
+ XCTAssertEqual(event.eventType, .toolCallResult)
+ }
+
+ func test_toolCallResultEvent_equatable_sameFields_areEqual() {
+ // Given
+ let event1 = ToolCallResultEvent(messageId: EventTestData.messageId, toolCallId: "call-456", content: "Result", role: "tool", timestamp: EventTestData.timestamp, rawEvent: nil)
+ let event2 = ToolCallResultEvent(messageId: EventTestData.messageId, toolCallId: "call-456", content: "Result", role: "tool", timestamp: EventTestData.timestamp, rawEvent: nil)
+
+ // Then
+ XCTAssertEqual(event1, event2)
+ }
+
+ func test_toolCallResultEvent_equatable_differentMessageIds_areNotEqual() {
+ // Given
+ let event1 = ToolCallResultEvent(messageId: EventTestData.messageId, toolCallId: "call-456", content: "Result", timestamp: EventTestData.timestamp, rawEvent: nil)
+ let event2 = ToolCallResultEvent(messageId: "msg-789", toolCallId: "call-456", content: "Result", timestamp: EventTestData.timestamp, rawEvent: nil)
+
+ // Then
+ XCTAssertNotEqual(event1, event2)
+ }
+
+ func test_toolCallResultEvent_equatable_differentToolCallIds_areNotEqual() {
+ // Given
+ let event1 = ToolCallResultEvent(messageId: EventTestData.messageId, toolCallId: "call-456", content: "Result", timestamp: EventTestData.timestamp, rawEvent: nil)
+ let event2 = ToolCallResultEvent(messageId: EventTestData.messageId, toolCallId: "call-789", content: "Result", timestamp: EventTestData.timestamp, rawEvent: nil)
+
+ // Then
+ XCTAssertNotEqual(event1, event2)
+ }
+
+ func test_toolCallResultEvent_equatable_differentContent_areNotEqual() {
+ // Given
+ let event1 = ToolCallResultEvent(messageId: EventTestData.messageId, toolCallId: "call-456", content: "Result1", timestamp: EventTestData.timestamp, rawEvent: nil)
+ let event2 = ToolCallResultEvent(messageId: EventTestData.messageId, toolCallId: "call-456", content: "Result2", timestamp: EventTestData.timestamp, rawEvent: nil)
+
+ // Then
+ XCTAssertNotEqual(event1, event2)
+ }
+
+ func test_toolCallResultEvent_equatable_differentRoles_areNotEqual() {
+ // Given
+ let event1 = ToolCallResultEvent(messageId: EventTestData.messageId, toolCallId: "call-456", content: "Result", role: "tool", timestamp: EventTestData.timestamp, rawEvent: nil)
+ let event2 = ToolCallResultEvent(messageId: EventTestData.messageId, toolCallId: "call-456", content: "Result", role: "assistant", timestamp: EventTestData.timestamp, rawEvent: nil)
+
+ // Then
+ XCTAssertNotEqual(event1, event2)
+ }
+
+ func test_toolCallResultEvent_equatable_oneNilRole_areNotEqual() {
+ // Given
+ let event1 = ToolCallResultEvent(messageId: EventTestData.messageId, toolCallId: "call-456", content: "Result", role: "tool", timestamp: EventTestData.timestamp, rawEvent: nil)
+ let event2 = ToolCallResultEvent(messageId: EventTestData.messageId, toolCallId: "call-456", content: "Result", role: nil, timestamp: EventTestData.timestamp, rawEvent: nil)
+
+ // Then
+ XCTAssertNotEqual(event1, event2)
+ }
+
+ func test_toolCallResultEvent_equatable_bothNilRoles_areEqual() {
+ // Given
+ let event1 = ToolCallResultEvent(messageId: EventTestData.messageId, toolCallId: "call-456", content: "Result", role: nil, timestamp: EventTestData.timestamp, rawEvent: nil)
+ let event2 = ToolCallResultEvent(messageId: EventTestData.messageId, toolCallId: "call-456", content: "Result", role: nil, timestamp: EventTestData.timestamp, rawEvent: nil)
+
+ // Then
+ XCTAssertEqual(event1, event2)
+ }
+
+ func test_toolCallResultEvent_equatable_differentTimestamps_areNotEqual() {
+ // Given
+ let event1 = ToolCallResultEvent(messageId: EventTestData.messageId, toolCallId: "call-456", content: "Result", timestamp: EventTestData.timestamp, rawEvent: nil)
+ let event2 = ToolCallResultEvent(messageId: EventTestData.messageId, toolCallId: "call-456", content: "Result", timestamp: EventTestData.timestamp2, rawEvent: nil)
+
+ // Then
+ XCTAssertNotEqual(event1, event2)
+ }
+
+ func test_toolCallResultEvent_withEmptyContent_isValid() {
+ // Given
+ let event = ToolCallResultEvent(messageId: EventTestData.messageId, toolCallId: "call-456", content: "", timestamp: nil, rawEvent: nil)
+
+ // Then
+ XCTAssertEqual(event.content, "")
+ XCTAssertEqual(event.eventType, .toolCallResult)
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/ToolCallEvents/ToolCallStartEventTests.swift b/sdks/community/swift/Tests/AGUICoreTests/ToolCallEvents/ToolCallStartEventTests.swift
new file mode 100644
index 0000000000..3bdc64b121
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/ToolCallEvents/ToolCallStartEventTests.swift
@@ -0,0 +1,383 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+final class ToolCallStartEventTests: XCTestCase,
+ AGUIEventDecoderTestHelpers,
+ EventDecodingErrorTests {
+
+ // MARK: - EventDecodingErrorTests Protocol Requirements
+
+ var validEventFieldsWithoutType: [String: Any] {
+ [
+ "toolCallId": EventTestData.toolCallId,
+ "toolCallName": "get_weather"
+ ]
+ }
+
+ var eventTypeString: String { "TOOL_CALL_START" }
+ var expectedEventType: EventType { .toolCallStart }
+ var unknownEventTypeString: String { "TOOL_CALL_PAUSED" }
+
+ // MARK: - Feature: Decode TOOL_CALL_START
+
+ func test_decodeValidToolCallStart_returnsToolCallStartEvent() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_START",
+ "toolCallId": "\(EventTestData.toolCallId)",
+ "toolCallName": "get_weather"
+ }
+ """)
+
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ guard let toolCallStart = event as? ToolCallStartEvent else {
+ return XCTFail("Expected ToolCallStartEvent, got \(type(of: event))")
+ }
+ XCTAssertEqual(toolCallStart.eventType, .toolCallStart)
+ XCTAssertEqual(toolCallStart.toolCallId, EventTestData.toolCallId)
+ XCTAssertEqual(toolCallStart.toolCallName, "get_weather")
+ XCTAssertNil(toolCallStart.parentMessageId)
+ XCTAssertNil(toolCallStart.timestamp)
+ }
+
+ func test_decodeToolCallStart_withParentMessageId_populatesParentMessageId() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_START",
+ "toolCallId": "\(EventTestData.toolCallId)",
+ "toolCallName": "get_weather",
+ "parentMessageId": "\(EventTestData.messageId2)"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let toolCallStart = try XCTUnwrap(event as? ToolCallStartEvent)
+ XCTAssertEqual(toolCallStart.parentMessageId, EventTestData.messageId2)
+ }
+
+ func test_decodeToolCallStart_withTimestamp_populatesTimestamp() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_START",
+ "toolCallId": "\(EventTestData.toolCallId)",
+ "toolCallName": "get_weather",
+ "timestamp": \(EventTestData.timestamp)
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let toolCallStart = try XCTUnwrap(event as? ToolCallStartEvent)
+ XCTAssertEqual(toolCallStart.timestamp, EventTestData.timestamp)
+ }
+
+ func test_decodeToolCallStart_preservesRawEventBytes() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_START",
+ "toolCallId": "\(EventTestData.toolCallId)",
+ "toolCallName": "get_weather",
+ "timestamp": \(EventTestData.timestamp)
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let toolCallStart = try XCTUnwrap(event as? ToolCallStartEvent)
+ XCTAssertEqual(toolCallStart.rawEvent, data)
+ }
+
+ func test_decodeToolCallStart_ignoresUnknownExtraFields() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_START",
+ "toolCallId": "\(EventTestData.toolCallId)",
+ "toolCallName": "get_weather",
+ "extraField": "ignored",
+ "nested": { "x": 1 }
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let toolCallStart = try XCTUnwrap(event as? ToolCallStartEvent)
+ XCTAssertEqual(toolCallStart.toolCallId, EventTestData.toolCallId)
+ XCTAssertEqual(toolCallStart.toolCallName, "get_weather")
+ }
+
+ func test_decodeToolCallStart_withUnicodeToolCallId_handlesCorrectly() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_START",
+ "toolCallId": "call-🚀-123",
+ "toolCallName": "get_weather"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let toolCallStart = try XCTUnwrap(event as? ToolCallStartEvent)
+ XCTAssertEqual(toolCallStart.toolCallId, "call-🚀-123")
+ }
+
+ func test_decodeToolCallStart_withUnicodeToolCallName_handlesCorrectly() throws {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_START",
+ "toolCallId": "\(EventTestData.toolCallId)",
+ "toolCallName": "get_天气"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When
+ let event = try decoder.decode(data)
+
+ // Then
+ let toolCallStart = try XCTUnwrap(event as? ToolCallStartEvent)
+ XCTAssertEqual(toolCallStart.toolCallName, "get_天气")
+ }
+
+ // MARK: - Feature: Error handling
+
+ func test_decodeToolCallStart_missingToolCallId_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_START",
+ "toolCallName": "get_weather"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("toolCallId") || message.contains("Missing key"))
+ }
+ }
+
+ func test_decodeToolCallStart_missingToolCallName_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_START",
+ "toolCallId": "\(EventTestData.toolCallId)"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("toolCallName") || message.contains("Missing key"))
+ }
+ }
+
+ func test_decodeToolCallStart_wrongTypeForToolCallId_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_START",
+ "toolCallId": 123,
+ "toolCallName": "get_weather"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("toolCallId") || message.contains("Type mismatch"))
+ }
+ }
+
+ func test_decodeToolCallStart_wrongTypeForToolCallName_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_START",
+ "toolCallId": "\(EventTestData.toolCallId)",
+ "toolCallName": 123
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("toolCallName") || message.contains("Type mismatch"))
+ }
+ }
+
+ func test_decodeToolCallStart_wrongTypeForParentMessageId_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_START",
+ "toolCallId": "\(EventTestData.toolCallId)",
+ "toolCallName": "get_weather",
+ "parentMessageId": 123
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("parentMessageId") || message.contains("Type mismatch"))
+ }
+ }
+
+ func test_decodeToolCallStart_wrongTypeForTimestamp_throwsDecodingFailed() {
+ // Given
+ let data = jsonData("""
+ {
+ "type": "TOOL_CALL_START",
+ "toolCallId": "\(EventTestData.toolCallId)",
+ "toolCallName": "get_weather",
+ "timestamp": "invalid"
+ }
+ """)
+ let decoder = makeStrictDecoder()
+
+ // When / Then
+ XCTAssertThrowsError(try decoder.decode(data)) { error in
+ guard case .decodingFailed(let message) = error as? EventDecodingError else {
+ return XCTFail("Expected decodingFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("timestamp") || message.contains("Type mismatch"))
+ }
+ }
+
+ // MARK: - Feature: Model behaviors
+
+ func test_toolCallStartEvent_eventTypeIsAlwaysToolCallStart() {
+ // Given
+ let event = ToolCallStartEvent(toolCallId: EventTestData.toolCallId, toolCallName: "get_weather", parentMessageId: nil, timestamp: nil, rawEvent: nil)
+
+ // Then
+ XCTAssertEqual(event.eventType, .toolCallStart)
+ }
+
+ func test_toolCallStartEvent_equatable_sameFields_areEqual() {
+ // Given
+ let event1 = ToolCallStartEvent(toolCallId: EventTestData.toolCallId, toolCallName: "get_weather", parentMessageId: EventTestData.messageId2, timestamp: EventTestData.timestamp, rawEvent: nil)
+ let event2 = ToolCallStartEvent(toolCallId: EventTestData.toolCallId, toolCallName: "get_weather", parentMessageId: EventTestData.messageId2, timestamp: EventTestData.timestamp, rawEvent: nil)
+
+ // Then
+ XCTAssertEqual(event1, event2)
+ }
+
+ func test_toolCallStartEvent_equatable_differentToolCallIds_areNotEqual() {
+ // Given
+ let event1 = ToolCallStartEvent(toolCallId: EventTestData.toolCallId, toolCallName: "get_weather", parentMessageId: nil, timestamp: EventTestData.timestamp, rawEvent: nil)
+ let event2 = ToolCallStartEvent(toolCallId: "call-456", toolCallName: "get_weather", parentMessageId: nil, timestamp: EventTestData.timestamp, rawEvent: nil)
+
+ // Then
+ XCTAssertNotEqual(event1, event2)
+ }
+
+ func test_toolCallStartEvent_equatable_differentToolCallNames_areNotEqual() {
+ // Given
+ let event1 = ToolCallStartEvent(toolCallId: EventTestData.toolCallId, toolCallName: "get_weather", parentMessageId: nil, timestamp: EventTestData.timestamp, rawEvent: nil)
+ let event2 = ToolCallStartEvent(toolCallId: EventTestData.toolCallId, toolCallName: "get_time", parentMessageId: nil, timestamp: EventTestData.timestamp, rawEvent: nil)
+
+ // Then
+ XCTAssertNotEqual(event1, event2)
+ }
+
+ func test_toolCallStartEvent_equatable_differentParentMessageIds_areNotEqual() {
+ // Given
+ let event1 = ToolCallStartEvent(toolCallId: EventTestData.toolCallId, toolCallName: "get_weather", parentMessageId: EventTestData.messageId2, timestamp: EventTestData.timestamp, rawEvent: nil)
+ let event2 = ToolCallStartEvent(toolCallId: EventTestData.toolCallId, toolCallName: "get_weather", parentMessageId: "msg-789", timestamp: EventTestData.timestamp, rawEvent: nil)
+
+ // Then
+ XCTAssertNotEqual(event1, event2)
+ }
+
+ func test_toolCallStartEvent_equatable_oneNilParentMessageId_areNotEqual() {
+ // Given
+ let event1 = ToolCallStartEvent(toolCallId: EventTestData.toolCallId, toolCallName: "get_weather", parentMessageId: EventTestData.messageId2, timestamp: EventTestData.timestamp, rawEvent: nil)
+ let event2 = ToolCallStartEvent(toolCallId: EventTestData.toolCallId, toolCallName: "get_weather", parentMessageId: nil, timestamp: EventTestData.timestamp, rawEvent: nil)
+
+ // Then
+ XCTAssertNotEqual(event1, event2)
+ }
+
+ func test_toolCallStartEvent_equatable_bothNilParentMessageIds_areEqual() {
+ // Given
+ let event1 = ToolCallStartEvent(toolCallId: EventTestData.toolCallId, toolCallName: "get_weather", parentMessageId: nil, timestamp: EventTestData.timestamp, rawEvent: nil)
+ let event2 = ToolCallStartEvent(toolCallId: EventTestData.toolCallId, toolCallName: "get_weather", parentMessageId: nil, timestamp: EventTestData.timestamp, rawEvent: nil)
+
+ // Then
+ XCTAssertEqual(event1, event2)
+ }
+
+ func test_toolCallStartEvent_equatable_differentTimestamps_areNotEqual() {
+ // Given
+ let event1 = ToolCallStartEvent(toolCallId: EventTestData.toolCallId, toolCallName: "get_weather", parentMessageId: nil, timestamp: EventTestData.timestamp, rawEvent: nil)
+ let event2 = ToolCallStartEvent(toolCallId: EventTestData.toolCallId, toolCallName: "get_weather", parentMessageId: nil, timestamp: EventTestData.timestamp2, rawEvent: nil)
+
+ // Then
+ XCTAssertNotEqual(event1, event2)
+ }
+
+ func test_toolCallStartEvent_withEmptyToolCallId_isValid() {
+ // Given
+ let event = ToolCallStartEvent(toolCallId: "", toolCallName: "get_weather", parentMessageId: nil, timestamp: nil, rawEvent: nil)
+
+ // Then
+ XCTAssertEqual(event.toolCallId, "")
+ XCTAssertEqual(event.eventType, .toolCallStart)
+ }
+
+ func test_toolCallStartEvent_withEmptyToolCallName_isValid() {
+ // Given
+ let event = ToolCallStartEvent(toolCallId: EventTestData.toolCallId, toolCallName: "", parentMessageId: nil, timestamp: nil, rawEvent: nil)
+
+ // Then
+ XCTAssertEqual(event.toolCallName, "")
+ XCTAssertEqual(event.eventType, .toolCallStart)
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/Types/AgentExecution/ContextTests.swift b/sdks/community/swift/Tests/AGUICoreTests/Types/AgentExecution/ContextTests.swift
new file mode 100644
index 0000000000..d812187098
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/Types/AgentExecution/ContextTests.swift
@@ -0,0 +1,273 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+/// Tests for the Context type
+final class ContextTests: XCTestCase {
+ // MARK: - Initialization Tests
+
+ func testInitWithBasicContext() {
+ let context = Context(
+ description: "User preferences",
+ value: "Dark mode enabled"
+ )
+
+ XCTAssertEqual(context.description, "User preferences")
+ XCTAssertEqual(context.value, "Dark mode enabled")
+ }
+
+ func testInitWithLongDescription() {
+ let context = Context(
+ description: "This is a detailed description of the contextual information",
+ value: "Some value"
+ )
+
+ XCTAssertEqual(context.description, "This is a detailed description of the contextual information")
+ }
+
+ func testInitWithEmptyStrings() {
+ let context = Context(
+ description: "",
+ value: ""
+ )
+
+ XCTAssertEqual(context.description, "")
+ XCTAssertEqual(context.value, "")
+ }
+
+ // MARK: - Encoding Tests
+
+ func testEncodingBasicContext() throws {
+ let context = Context(
+ description: "API Key",
+ value: "sk-test-123"
+ )
+
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys]
+ let encoded = try encoder.encode(context)
+ let json = String(data: encoded, encoding: .utf8)
+
+ XCTAssertNotNil(json)
+ XCTAssertTrue(json?.contains("\"description\"") ?? false)
+ XCTAssertTrue(json?.contains("\"value\"") ?? false)
+ XCTAssertTrue(json?.contains("\"API Key\"") ?? false)
+ }
+
+ func testEncodedStructure() throws {
+ let context = Context(
+ description: "User location",
+ value: "San Francisco, CA"
+ )
+
+ let encoded = try JSONEncoder().encode(context)
+ let json = try JSONSerialization.jsonObject(with: encoded) as? [String: Any]
+
+ XCTAssertEqual(json?["description"] as? String, "User location")
+ XCTAssertEqual(json?["value"] as? String, "San Francisco, CA")
+ }
+
+ // MARK: - Decoding Tests
+
+ func testDecodingBasicContext() throws {
+ let json = """
+ {
+ "description": "Current date",
+ "value": "2024-01-01"
+ }
+ """
+
+ let decoder = JSONDecoder()
+ let context = try decoder.decode(Context.self, from: Data(json.utf8))
+
+ XCTAssertEqual(context.description, "Current date")
+ XCTAssertEqual(context.value, "2024-01-01")
+ }
+
+ func testDecodingWithSpecialCharacters() throws {
+ let json = """
+ {
+ "description": "User's name",
+ "value": "O'Brien"
+ }
+ """
+
+ let decoder = JSONDecoder()
+ let context = try decoder.decode(Context.self, from: Data(json.utf8))
+
+ XCTAssertEqual(context.description, "User's name")
+ XCTAssertEqual(context.value, "O'Brien")
+ }
+
+ func testDecodingFailsWithoutDescription() {
+ let json = """
+ {
+ "value": "test"
+ }
+ """
+
+ let decoder = JSONDecoder()
+ XCTAssertThrowsError(try decoder.decode(Context.self, from: Data(json.utf8))) { error in
+ XCTAssertTrue(error is DecodingError)
+ }
+ }
+
+ func testDecodingFailsWithoutValue() {
+ let json = """
+ {
+ "description": "test"
+ }
+ """
+
+ let decoder = JSONDecoder()
+ XCTAssertThrowsError(try decoder.decode(Context.self, from: Data(json.utf8))) { error in
+ XCTAssertTrue(error is DecodingError)
+ }
+ }
+
+ // MARK: - Round-trip Tests
+
+ func testRoundTrip() throws {
+ let original = Context(
+ description: "Session ID",
+ value: "abc-123-xyz"
+ )
+
+ let encoder = JSONEncoder()
+ let encoded = try encoder.encode(original)
+
+ let decoder = JSONDecoder()
+ let decoded = try decoder.decode(Context.self, from: encoded)
+
+ XCTAssertEqual(decoded.description, original.description)
+ XCTAssertEqual(decoded.value, original.value)
+ }
+
+ func testRoundTripWithUnicode() throws {
+ let original = Context(
+ description: "Greeting",
+ value: "こんにちは"
+ )
+
+ let encoder = JSONEncoder()
+ let encoded = try encoder.encode(original)
+
+ let decoder = JSONDecoder()
+ let decoded = try decoder.decode(Context.self, from: encoded)
+
+ XCTAssertEqual(decoded.value, "こんにちは")
+ }
+
+ // MARK: - Equatable Tests
+
+ func testEquality() {
+ let context1 = Context(description: "Test", value: "Value1")
+ let context2 = Context(description: "Test", value: "Value1")
+ let context3 = Context(description: "Test", value: "Value2")
+ let context4 = Context(description: "Other", value: "Value1")
+
+ XCTAssertEqual(context1, context2)
+ XCTAssertNotEqual(context1, context3)
+ XCTAssertNotEqual(context1, context4)
+ }
+
+ // MARK: - Hashable Tests
+
+ func testHashable() {
+ let context1 = Context(description: "Key1", value: "Val1")
+ let context2 = Context(description: "Key2", value: "Val2")
+
+ let set: Set = [context1, context2]
+ XCTAssertEqual(set.count, 2)
+ XCTAssertTrue(set.contains(context1))
+ XCTAssertTrue(set.contains(context2))
+ }
+
+ func testHashableDuplicates() {
+ let context1 = Context(description: "Key", value: "Val")
+ let context2 = Context(description: "Key", value: "Val")
+
+ let set: Set = [context1, context2]
+ XCTAssertEqual(set.count, 1)
+ }
+
+ // MARK: - Sendable Tests
+
+ func testSendableConformance() {
+ let context = Context(
+ description: "Test",
+ value: "Concurrent"
+ )
+
+ Task {
+ let capturedContext = context
+ XCTAssertEqual(capturedContext.description, "Test")
+ }
+ }
+
+ // MARK: - Real-world Usage Tests
+
+ func testUserPreferencesContext() {
+ let context = Context(
+ description: "Theme preference",
+ value: "dark"
+ )
+
+ XCTAssertEqual(context.description, "Theme preference")
+ XCTAssertEqual(context.value, "dark")
+ }
+
+ func testLocationContext() {
+ let context = Context(
+ description: "User location",
+ value: "New York, NY, USA"
+ )
+
+ XCTAssertEqual(context.value, "New York, NY, USA")
+ }
+
+ func testAPIKeyContext() {
+ let context = Context(
+ description: "API credentials",
+ value: "sk-proj-abc123xyz"
+ )
+
+ XCTAssertEqual(context.description, "API credentials")
+ }
+
+ func testTimestampContext() {
+ let context = Context(
+ description: "Request timestamp",
+ value: "2024-01-01T12:00:00Z"
+ )
+
+ XCTAssertEqual(context.value, "2024-01-01T12:00:00Z")
+ }
+
+ func testMultipleContextsInArray() {
+ let contexts = [
+ Context(description: "user_id", value: "12345"),
+ Context(description: "session_id", value: "abc-xyz"),
+ Context(description: "language", value: "en-US")
+ ]
+
+ XCTAssertEqual(contexts.count, 3)
+ XCTAssertEqual(contexts[0].description, "user_id")
+ XCTAssertEqual(contexts[1].description, "session_id")
+ XCTAssertEqual(contexts[2].description, "language")
+ }
+
+ func testJSONStructuredValue() throws {
+ // Value can be a JSON string itself
+ let context = Context(
+ description: "User profile",
+ value: """
+ {"name": "Alice", "age": 30}
+ """
+ )
+
+ XCTAssertEqual(context.description, "User profile")
+ XCTAssertTrue(context.value.contains("Alice"))
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/Types/AgentExecution/RunAgentInputBuilderTests.swift b/sdks/community/swift/Tests/AGUICoreTests/Types/AgentExecution/RunAgentInputBuilderTests.swift
new file mode 100644
index 0000000000..8de0d771ef
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/Types/AgentExecution/RunAgentInputBuilderTests.swift
@@ -0,0 +1,353 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+/// Tests for RunAgentInputBuilder
+final class RunAgentInputBuilderTests: XCTestCase {
+
+ // MARK: - Basic Builder Tests
+
+ func testBuilderWithMinimalParameters() throws {
+ let input = try RunAgentInput.builder()
+ .threadId("thread-123")
+ .runId("run-456")
+ .build()
+
+ XCTAssertEqual(input.threadId, "thread-123")
+ XCTAssertEqual(input.runId, "run-456")
+ XCTAssertNil(input.parentRunId)
+ XCTAssertTrue(input.messages.isEmpty)
+ XCTAssertTrue(input.tools.isEmpty)
+ XCTAssertTrue(input.context.isEmpty)
+ }
+
+ func testBuilderWithAllParameters() throws {
+ let messages: [any Message] = [
+ UserMessage(id: "msg-1", content: "Hello")
+ ]
+ let tools = [
+ Tool(name: "tool1", description: "Test tool", parameters: Data("{}".utf8))
+ ]
+ let contexts = [
+ Context(description: "key", value: "val")
+ ]
+ let state = Data("{\"x\": 1}".utf8)
+ let props = Data("{\"y\": 2}".utf8)
+
+ let input = try RunAgentInput.builder()
+ .threadId("thread-1")
+ .runId("run-1")
+ .parentRunId("run-0")
+ .state(state)
+ .messages(messages)
+ .tools(tools)
+ .context(contexts)
+ .forwardedProps(props)
+ .build()
+
+ XCTAssertEqual(input.threadId, "thread-1")
+ XCTAssertEqual(input.runId, "run-1")
+ XCTAssertEqual(input.parentRunId, "run-0")
+ XCTAssertEqual(input.messages.count, 1)
+ XCTAssertEqual(input.tools.count, 1)
+ XCTAssertEqual(input.context.count, 1)
+ XCTAssertEqual(input.state, state)
+ XCTAssertEqual(input.forwardedProps, props)
+ }
+
+ // MARK: - Fluent Interface Tests
+
+ func testBuilderMethodChaining() throws {
+ let input = try RunAgentInput.builder()
+ .threadId("t1")
+ .runId("r1")
+ .parentRunId("r0")
+ .build()
+
+ XCTAssertEqual(input.threadId, "t1")
+ XCTAssertEqual(input.runId, "r1")
+ XCTAssertEqual(input.parentRunId, "r0")
+ }
+
+ func testBuilderCanBeReused() throws {
+ let builder = RunAgentInput.builder()
+ .threadId("thread-1")
+ .runId("run-1")
+
+ let input1 = try builder.build()
+ let input2 = try builder.build()
+
+ XCTAssertEqual(input1.threadId, input2.threadId)
+ XCTAssertEqual(input1.runId, input2.runId)
+ }
+
+ // MARK: - Message Building Tests
+
+ func testBuilderWithSingleMessage() throws {
+ let message = UserMessage(id: "msg-1", content: "Hello")
+
+ let input = try RunAgentInput.builder()
+ .threadId("t1")
+ .runId("r1")
+ .message(message)
+ .build()
+
+ XCTAssertEqual(input.messages.count, 1)
+ XCTAssertEqual(input.messages[0].id, "msg-1")
+ }
+
+ func testBuilderWithMultipleMessages() throws {
+ let input = try RunAgentInput.builder()
+ .threadId("t1")
+ .runId("r1")
+ .message(UserMessage(id: "msg-1", content: "Hello"))
+ .message(AssistantMessage(id: "msg-2", content: "Hi"))
+ .build()
+
+ XCTAssertEqual(input.messages.count, 2)
+ XCTAssertEqual(input.messages[0].id, "msg-1")
+ XCTAssertEqual(input.messages[1].id, "msg-2")
+ }
+
+ func testBuilderWithMessagesArray() throws {
+ let messages: [any Message] = [
+ UserMessage(id: "msg-1", content: "Hello"),
+ AssistantMessage(id: "msg-2", content: "Hi")
+ ]
+
+ let input = try RunAgentInput.builder()
+ .threadId("t1")
+ .runId("r1")
+ .messages(messages)
+ .build()
+
+ XCTAssertEqual(input.messages.count, 2)
+ }
+
+ func testBuilderCombiningMessageAndMessages() throws {
+ let messages: [any Message] = [
+ UserMessage(id: "msg-1", content: "Hello")
+ ]
+
+ let input = try RunAgentInput.builder()
+ .threadId("t1")
+ .runId("r1")
+ .messages(messages)
+ .message(AssistantMessage(id: "msg-2", content: "Hi"))
+ .build()
+
+ XCTAssertEqual(input.messages.count, 2)
+ XCTAssertEqual(input.messages[0].id, "msg-1")
+ XCTAssertEqual(input.messages[1].id, "msg-2")
+ }
+
+ // MARK: - Tool Building Tests
+
+ func testBuilderWithSingleTool() throws {
+ let tool = Tool(name: "get_weather", description: "Get weather", parameters: Data("{}".utf8))
+
+ let input = try RunAgentInput.builder()
+ .threadId("t1")
+ .runId("r1")
+ .tool(tool)
+ .build()
+
+ XCTAssertEqual(input.tools.count, 1)
+ XCTAssertEqual(input.tools[0].name, "get_weather")
+ }
+
+ func testBuilderWithMultipleTools() throws {
+ let input = try RunAgentInput.builder()
+ .threadId("t1")
+ .runId("r1")
+ .tool(Tool(name: "tool1", description: "Tool 1", parameters: Data("{}".utf8)))
+ .tool(Tool(name: "tool2", description: "Tool 2", parameters: Data("{}".utf8)))
+ .build()
+
+ XCTAssertEqual(input.tools.count, 2)
+ XCTAssertEqual(input.tools[0].name, "tool1")
+ XCTAssertEqual(input.tools[1].name, "tool2")
+ }
+
+ // MARK: - Context Building Tests
+
+ func testBuilderWithSingleContext() throws {
+ let input = try RunAgentInput.builder()
+ .threadId("t1")
+ .runId("r1")
+ .contextItem(Context(description: "user_id", value: "123"))
+ .build()
+
+ XCTAssertEqual(input.context.count, 1)
+ XCTAssertEqual(input.context[0].description, "user_id")
+ }
+
+ func testBuilderWithMultipleContextItems() throws {
+ let input = try RunAgentInput.builder()
+ .threadId("t1")
+ .runId("r1")
+ .contextItem(Context(description: "user_id", value: "123"))
+ .contextItem(Context(description: "language", value: "en"))
+ .build()
+
+ XCTAssertEqual(input.context.count, 2)
+ XCTAssertEqual(input.context[0].description, "user_id")
+ XCTAssertEqual(input.context[1].description, "language")
+ }
+
+ func testBuilderWithContextArray() throws {
+ let contexts = [
+ Context(description: "user_id", value: "123"),
+ Context(description: "language", value: "en")
+ ]
+
+ let input = try RunAgentInput.builder()
+ .threadId("t1")
+ .runId("r1")
+ .context(contexts)
+ .build()
+
+ XCTAssertEqual(input.context.count, 2)
+ }
+
+ // MARK: - State and Props Tests
+
+ func testBuilderWithJSONState() throws {
+ let stateDict: [String: Any] = ["counter": 42, "active": true]
+ let stateData = try JSONSerialization.data(withJSONObject: stateDict)
+
+ let input = try RunAgentInput.builder()
+ .threadId("t1")
+ .runId("r1")
+ .state(stateData)
+ .build()
+
+ let decoded = try JSONSerialization.jsonObject(with: input.state) as? [String: Any]
+ XCTAssertEqual(decoded?["counter"] as? Int, 42)
+ XCTAssertEqual(decoded?["active"] as? Bool, true)
+ }
+
+ func testBuilderWithForwardedProps() throws {
+ let propsDict: [String: Any] = ["custom_field": "value"]
+ let propsData = try JSONSerialization.data(withJSONObject: propsDict)
+
+ let input = try RunAgentInput.builder()
+ .threadId("t1")
+ .runId("r1")
+ .forwardedProps(propsData)
+ .build()
+
+ let decoded = try JSONSerialization.jsonObject(with: input.forwardedProps) as? [String: Any]
+ XCTAssertEqual(decoded?["custom_field"] as? String, "value")
+ }
+
+ // MARK: - Real-world Usage Tests
+
+ func testBuilderForChatConversation() throws {
+ let input = try RunAgentInput.builder()
+ .threadId("chat-session-123")
+ .runId("run-456")
+ .message(DeveloperMessage(id: "dev-1", content: "You are helpful"))
+ .message(UserMessage(id: "user-1", content: "Hello!"))
+ .build()
+
+ XCTAssertEqual(input.threadId, "chat-session-123")
+ XCTAssertEqual(input.messages.count, 2)
+ XCTAssertEqual(input.messages[0].role, .developer)
+ XCTAssertEqual(input.messages[1].role, .user)
+ }
+
+ func testBuilderForAgentWithTools() throws {
+ let weatherTool = Tool(
+ name: "get_weather",
+ description: "Get current weather",
+ parameters: Data("{\"type\": \"object\"}".utf8)
+ )
+
+ let input = try RunAgentInput.builder()
+ .threadId("agent-thread-1")
+ .runId("run-1")
+ .message(UserMessage(id: "user-1", content: "What's the weather?"))
+ .tool(weatherTool)
+ .contextItem(Context(description: "user_location", value: "San Francisco"))
+ .build()
+
+ XCTAssertEqual(input.tools.count, 1)
+ XCTAssertEqual(input.context.count, 1)
+ XCTAssertEqual(input.messages.count, 1)
+ }
+
+ func testBuilderForNestedRun() throws {
+ let input = try RunAgentInput.builder()
+ .threadId("thread-1")
+ .runId("child-run-1")
+ .parentRunId("parent-run-1")
+ .build()
+
+ XCTAssertEqual(input.parentRunId, "parent-run-1")
+ }
+
+ // MARK: - Default Values Tests
+
+ func testBuilderDefaultsAreCorrect() throws {
+ let input = try RunAgentInput.builder()
+ .threadId("t1")
+ .runId("r1")
+ .build()
+
+ // Verify defaults match direct initialization
+ let direct = RunAgentInput(threadId: "t1", runId: "r1")
+
+ XCTAssertEqual(input.threadId, direct.threadId)
+ XCTAssertEqual(input.runId, direct.runId)
+ XCTAssertEqual(input.parentRunId, direct.parentRunId)
+ XCTAssertEqual(input.state, direct.state)
+ XCTAssertEqual(input.forwardedProps, direct.forwardedProps)
+ XCTAssertEqual(input.messages.count, direct.messages.count)
+ XCTAssertEqual(input.tools.count, direct.tools.count)
+ XCTAssertEqual(input.context.count, direct.context.count)
+ }
+
+ // MARK: - Resume Tests
+
+ func test_builder_withResume_setsResumeField() throws {
+ let entries = [
+ ResumeEntry(interruptId: "int-1", status: .resolved),
+ ResumeEntry(interruptId: "int-2", status: .cancelled)
+ ]
+
+ let input = try RunAgentInput.builder()
+ .threadId("t")
+ .runId("r")
+ .resume(entries)
+ .build()
+
+ XCTAssertEqual(input.resume?.count, 2)
+ XCTAssertEqual(input.resume?[0].interruptId, "int-1")
+ XCTAssertEqual(input.resume?[1].interruptId, "int-2")
+ }
+
+ func test_builder_withoutResume_resumeIsNil() throws {
+ let input = try RunAgentInput.builder()
+ .threadId("t")
+ .runId("r")
+ .build()
+
+ XCTAssertNil(input.resume)
+ }
+
+ // MARK: - Sendable Tests
+
+ func testBuilderIsSendable() {
+ let builder = RunAgentInput.builder()
+ .threadId("t1")
+ .runId("r1")
+
+ Task {
+ let capturedBuilder = builder
+ let input = try capturedBuilder.build()
+ XCTAssertEqual(input.threadId, "t1")
+ }
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/Types/AgentExecution/RunAgentInputTests.swift b/sdks/community/swift/Tests/AGUICoreTests/Types/AgentExecution/RunAgentInputTests.swift
new file mode 100644
index 0000000000..42afe39555
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/Types/AgentExecution/RunAgentInputTests.swift
@@ -0,0 +1,680 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+/// Tests for the RunAgentInput type
+final class RunAgentInputTests: XCTestCase {
+ // MARK: - Initialization Tests
+
+ func testInitWithMinimalParameters() {
+ let input = RunAgentInput(
+ threadId: "thread-123",
+ runId: "run-456"
+ )
+
+ XCTAssertEqual(input.threadId, "thread-123")
+ XCTAssertEqual(input.runId, "run-456")
+ XCTAssertNil(input.parentRunId)
+ XCTAssertTrue(input.messages.isEmpty)
+ XCTAssertTrue(input.tools.isEmpty)
+ XCTAssertTrue(input.context.isEmpty)
+ }
+
+ func testInitWithParentRunId() {
+ let input = RunAgentInput(
+ threadId: "thread-1",
+ runId: "run-2",
+ parentRunId: "run-1"
+ )
+
+ XCTAssertEqual(input.parentRunId, "run-1")
+ }
+
+ func testInitWithMessages() {
+ let messages: [any Message] = [
+ UserMessage(id: "msg-1", content: "Hello"),
+ AssistantMessage(id: "msg-2", content: "Hi there!")
+ ]
+
+ let input = RunAgentInput(
+ threadId: "thread-1",
+ runId: "run-1",
+ messages: messages
+ )
+
+ XCTAssertEqual(input.messages.count, 2)
+ }
+
+ func testInitWithTools() {
+ let tools = [
+ Tool(
+ name: "get_weather",
+ description: "Get weather data",
+ parameters: Data("{\"type\": \"object\"}".utf8)
+ )
+ ]
+
+ let input = RunAgentInput(
+ threadId: "thread-1",
+ runId: "run-1",
+ tools: tools
+ )
+
+ XCTAssertEqual(input.tools.count, 1)
+ XCTAssertEqual(input.tools[0].name, "get_weather")
+ }
+
+ func testInitWithContext() {
+ let contexts = [
+ Context(description: "User ID", value: "12345"),
+ Context(description: "Language", value: "en-US")
+ ]
+
+ let input = RunAgentInput(
+ threadId: "thread-1",
+ runId: "run-1",
+ context: contexts
+ )
+
+ XCTAssertEqual(input.context.count, 2)
+ XCTAssertEqual(input.context[0].description, "User ID")
+ }
+
+ func testInitWithState() {
+ let state = Data("""
+ {"counter": 42, "active": true}
+ """.utf8)
+
+ let input = RunAgentInput(
+ threadId: "thread-1",
+ runId: "run-1",
+ state: state
+ )
+
+ XCTAssertNotNil(input.state)
+ }
+
+ func testInitWithForwardedProps() {
+ let props = Data("""
+ {"custom_field": "value"}
+ """.utf8)
+
+ let input = RunAgentInput(
+ threadId: "thread-1",
+ runId: "run-1",
+ forwardedProps: props
+ )
+
+ XCTAssertNotNil(input.forwardedProps)
+ }
+
+ func testInitWithAllParameters() {
+ let messages: [any Message] = [
+ UserMessage(id: "msg-1", content: "Test")
+ ]
+ let tools = [
+ Tool(name: "tool1", description: "Test tool", parameters: Data("{}".utf8))
+ ]
+ let contexts = [
+ Context(description: "ctx1", value: "val1")
+ ]
+ let state = Data("""
+ {"key": "value"}
+ """.utf8)
+ let props = Data("""
+ {"prop": "data"}
+ """.utf8)
+
+ let input = RunAgentInput(
+ threadId: "thread-1",
+ runId: "run-1",
+ parentRunId: "run-0",
+ state: state,
+ messages: messages,
+ tools: tools,
+ context: contexts,
+ forwardedProps: props
+ )
+
+ XCTAssertEqual(input.threadId, "thread-1")
+ XCTAssertEqual(input.runId, "run-1")
+ XCTAssertEqual(input.parentRunId, "run-0")
+ XCTAssertEqual(input.messages.count, 1)
+ XCTAssertEqual(input.tools.count, 1)
+ XCTAssertEqual(input.context.count, 1)
+ XCTAssertNotNil(input.state)
+ XCTAssertNotNil(input.forwardedProps)
+ }
+
+ // MARK: - Encoding Tests
+
+ func testEncodingMinimal() throws {
+ let input = RunAgentInput(
+ threadId: "thread-123",
+ runId: "run-456"
+ )
+
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys]
+ let encoded = try encoder.encode(input)
+ let json = String(data: encoded, encoding: .utf8)
+
+ XCTAssertNotNil(json)
+ XCTAssertTrue(json?.contains("\"threadId\"") ?? false)
+ XCTAssertTrue(json?.contains("\"runId\"") ?? false)
+ XCTAssertTrue(json?.contains("\"messages\"") ?? false)
+ XCTAssertTrue(json?.contains("\"tools\"") ?? false)
+ XCTAssertTrue(json?.contains("\"context\"") ?? false)
+ }
+
+ func testEncodedStructure() throws {
+ let input = RunAgentInput(
+ threadId: "thread-1",
+ runId: "run-1",
+ parentRunId: "run-0"
+ )
+
+ let encoded = try JSONEncoder().encode(input)
+ let json = try JSONSerialization.jsonObject(with: encoded) as? [String: Any]
+
+ XCTAssertEqual(json?["threadId"] as? String, "thread-1")
+ XCTAssertEqual(json?["runId"] as? String, "run-1")
+ XCTAssertEqual(json?["parentRunId"] as? String, "run-0")
+
+ let messages = json?["messages"] as? [Any]
+ XCTAssertEqual(messages?.count, 0)
+
+ let tools = json?["tools"] as? [Any]
+ XCTAssertEqual(tools?.count, 0)
+
+ let context = json?["context"] as? [Any]
+ XCTAssertEqual(context?.count, 0)
+ }
+
+ func testEncodingWithMessages() throws {
+ let messages: [any Message] = [
+ UserMessage(id: "msg-1", content: "Hello"),
+ AssistantMessage(id: "msg-2", content: "Hi")
+ ]
+
+ let input = RunAgentInput(
+ threadId: "thread-1",
+ runId: "run-1",
+ messages: messages
+ )
+
+ let encoded = try JSONEncoder().encode(input)
+ let json = try JSONSerialization.jsonObject(with: encoded) as? [String: Any]
+
+ let messagesArray = json?["messages"] as? [[String: Any]]
+ XCTAssertEqual(messagesArray?.count, 2)
+ XCTAssertEqual(messagesArray?[0]["role"] as? String, "user")
+ XCTAssertEqual(messagesArray?[1]["role"] as? String, "assistant")
+ }
+
+ // MARK: - Decoding Tests
+
+ func testDecodingMinimal() throws {
+ let json = """
+ {
+ "threadId": "thread-123",
+ "runId": "run-456"
+ }
+ """
+
+ let decoder = JSONDecoder()
+ let input = try decoder.decode(RunAgentInput.self, from: Data(json.utf8))
+
+ XCTAssertEqual(input.threadId, "thread-123")
+ XCTAssertEqual(input.runId, "run-456")
+ XCTAssertNil(input.parentRunId)
+ XCTAssertTrue(input.messages.isEmpty)
+ XCTAssertTrue(input.tools.isEmpty)
+ XCTAssertTrue(input.context.isEmpty)
+ }
+
+ func testDecodingWithParentRunId() throws {
+ let json = """
+ {
+ "threadId": "thread-1",
+ "runId": "run-2",
+ "parentRunId": "run-1"
+ }
+ """
+
+ let decoder = JSONDecoder()
+ let input = try decoder.decode(RunAgentInput.self, from: Data(json.utf8))
+
+ XCTAssertEqual(input.parentRunId, "run-1")
+ }
+
+ func testDecodingWithMessages() throws {
+ let json = """
+ {
+ "threadId": "thread-1",
+ "runId": "run-1",
+ "messages": [
+ {
+ "id": "msg-1",
+ "role": "user",
+ "content": "Hello"
+ },
+ {
+ "id": "msg-2",
+ "role": "assistant",
+ "content": "Hi there!"
+ }
+ ]
+ }
+ """
+
+ let decoder = JSONDecoder()
+ let input = try decoder.decode(RunAgentInput.self, from: Data(json.utf8))
+
+ XCTAssertEqual(input.messages.count, 2)
+ XCTAssertEqual(input.messages[0].role, .user)
+ XCTAssertEqual(input.messages[1].role, .assistant)
+ }
+
+ func testDecodingWithTools() throws {
+ let json = """
+ {
+ "threadId": "thread-1",
+ "runId": "run-1",
+ "tools": [
+ {
+ "name": "get_weather",
+ "description": "Get weather",
+ "parameters": {"type": "object"}
+ }
+ ]
+ }
+ """
+
+ let decoder = JSONDecoder()
+ let input = try decoder.decode(RunAgentInput.self, from: Data(json.utf8))
+
+ XCTAssertEqual(input.tools.count, 1)
+ XCTAssertEqual(input.tools[0].name, "get_weather")
+ }
+
+ func testDecodingWithContext() throws {
+ let json = """
+ {
+ "threadId": "thread-1",
+ "runId": "run-1",
+ "context": [
+ {"description": "User ID", "value": "12345"}
+ ]
+ }
+ """
+
+ let decoder = JSONDecoder()
+ let input = try decoder.decode(RunAgentInput.self, from: Data(json.utf8))
+
+ XCTAssertEqual(input.context.count, 1)
+ XCTAssertEqual(input.context[0].value, "12345")
+ }
+
+ func testDecodingWithState() throws {
+ let json = """
+ {
+ "threadId": "thread-1",
+ "runId": "run-1",
+ "state": {"counter": 42}
+ }
+ """
+
+ let decoder = JSONDecoder()
+ let input = try decoder.decode(RunAgentInput.self, from: Data(json.utf8))
+
+ let state = try JSONSerialization.jsonObject(with: input.state) as? [String: Any]
+ XCTAssertEqual(state?["counter"] as? Int, 42)
+ }
+
+ func testDecodingFailsWithoutThreadId() {
+ let json = """
+ {
+ "runId": "run-1"
+ }
+ """
+
+ let decoder = JSONDecoder()
+ XCTAssertThrowsError(try decoder.decode(RunAgentInput.self, from: Data(json.utf8))) { error in
+ XCTAssertTrue(error is DecodingError)
+ }
+ }
+
+ func testDecodingFailsWithoutRunId() {
+ let json = """
+ {
+ "threadId": "thread-1"
+ }
+ """
+
+ let decoder = JSONDecoder()
+ XCTAssertThrowsError(try decoder.decode(RunAgentInput.self, from: Data(json.utf8))) { error in
+ XCTAssertTrue(error is DecodingError)
+ }
+ }
+
+ // MARK: - Round-trip Tests
+
+ func testRoundTripMinimal() throws {
+ let original = RunAgentInput(
+ threadId: "thread-rt-1",
+ runId: "run-rt-1"
+ )
+
+ let encoder = JSONEncoder()
+ let encoded = try encoder.encode(original)
+
+ let decoder = JSONDecoder()
+ let decoded = try decoder.decode(RunAgentInput.self, from: encoded)
+
+ XCTAssertEqual(decoded.threadId, original.threadId)
+ XCTAssertEqual(decoded.runId, original.runId)
+ XCTAssertEqual(decoded.parentRunId, original.parentRunId)
+ }
+
+ func testRoundTripWithAllFields() throws {
+ let messages: [any Message] = [
+ UserMessage(id: "msg-1", content: "Test")
+ ]
+ let tools = [
+ Tool(name: "tool1", description: "Test", parameters: Data("{}".utf8))
+ ]
+ let contexts = [
+ Context(description: "key", value: "val")
+ ]
+
+ let original = RunAgentInput(
+ threadId: "thread-1",
+ runId: "run-1",
+ parentRunId: "run-0",
+ state: Data("{\"x\": 1}".utf8),
+ messages: messages,
+ tools: tools,
+ context: contexts,
+ forwardedProps: Data("{\"y\": 2}".utf8)
+ )
+
+ let encoder = JSONEncoder()
+ let encoded = try encoder.encode(original)
+
+ let decoder = JSONDecoder()
+ let decoded = try decoder.decode(RunAgentInput.self, from: encoded)
+
+ XCTAssertEqual(decoded.threadId, original.threadId)
+ XCTAssertEqual(decoded.runId, original.runId)
+ XCTAssertEqual(decoded.parentRunId, original.parentRunId)
+ XCTAssertEqual(decoded.messages.count, 1)
+ XCTAssertEqual(decoded.tools.count, 1)
+ XCTAssertEqual(decoded.context.count, 1)
+ }
+
+ // MARK: - Equatable Tests
+
+ func testEquality() {
+ let input1 = RunAgentInput(threadId: "t1", runId: "r1")
+ let input2 = RunAgentInput(threadId: "t1", runId: "r1")
+ let input3 = RunAgentInput(threadId: "t2", runId: "r1")
+ let input4 = RunAgentInput(threadId: "t1", runId: "r2")
+
+ XCTAssertEqual(input1, input2)
+ XCTAssertNotEqual(input1, input3)
+ XCTAssertNotEqual(input1, input4)
+ }
+
+ func testEqualityWithDifferentMessages() {
+ let messages1: [any Message] = [
+ UserMessage(id: "msg-1", content: "Hello"),
+ AssistantMessage(id: "msg-2", content: "Hi there!")
+ ]
+
+ let messages2: [any Message] = [
+ UserMessage(id: "msg-3", content: "Goodbye"),
+ AssistantMessage(id: "msg-4", content: "See you!")
+ ]
+
+ let input1 = RunAgentInput(
+ threadId: "t1",
+ runId: "r1",
+ messages: messages1
+ )
+
+ let input2 = RunAgentInput(
+ threadId: "t1",
+ runId: "r1",
+ messages: messages2
+ )
+
+ // Should be different because message IDs differ
+ XCTAssertNotEqual(input1, input2)
+ }
+
+ func testEqualityWithIdenticalMessages() {
+ let messages1: [any Message] = [
+ UserMessage(id: "msg-1", content: "Hello"),
+ AssistantMessage(id: "msg-2", content: "Hi there!")
+ ]
+
+ let messages2: [any Message] = [
+ UserMessage(id: "msg-1", content: "Hello"),
+ AssistantMessage(id: "msg-2", content: "Hi there!")
+ ]
+
+ let input1 = RunAgentInput(
+ threadId: "t1",
+ runId: "r1",
+ messages: messages1
+ )
+
+ let input2 = RunAgentInput(
+ threadId: "t1",
+ runId: "r1",
+ messages: messages2
+ )
+
+ // Should be equal when messages have same id, role, content, name
+ XCTAssertEqual(input1, input2)
+ }
+
+ // MARK: - Hashable Tests
+
+ func testHashable() {
+ let input1 = RunAgentInput(threadId: "t1", runId: "r1")
+ let input2 = RunAgentInput(threadId: "t2", runId: "r2")
+
+ let set: Set = [input1, input2]
+ XCTAssertEqual(set.count, 2)
+ XCTAssertTrue(set.contains(input1))
+ XCTAssertTrue(set.contains(input2))
+ }
+
+ func testHashableWithDifferentMessages() {
+ let messages1: [any Message] = [
+ UserMessage(id: "msg-1", content: "Hello"),
+ AssistantMessage(id: "msg-2", content: "Hi!")
+ ]
+
+ let messages2: [any Message] = [
+ UserMessage(id: "msg-3", content: "Goodbye"),
+ AssistantMessage(id: "msg-4", content: "Bye!")
+ ]
+
+ let input1 = RunAgentInput(
+ threadId: "t1",
+ runId: "r1",
+ messages: messages1
+ )
+
+ let input2 = RunAgentInput(
+ threadId: "t1",
+ runId: "r1",
+ messages: messages2
+ )
+
+ // Different messages should produce different hashes (or at least be distinguishable in Set)
+ let set: Set = [input1, input2]
+ XCTAssertEqual(set.count, 2, "Set should contain both inputs with different messages")
+ XCTAssertTrue(set.contains(input1))
+ XCTAssertTrue(set.contains(input2))
+ }
+
+ func testHashConsistency() {
+ let messages: [any Message] = [
+ UserMessage(id: "msg-1", content: "Test")
+ ]
+
+ let input1 = RunAgentInput(
+ threadId: "t1",
+ runId: "r1",
+ messages: messages
+ )
+
+ let input2 = RunAgentInput(
+ threadId: "t1",
+ runId: "r1",
+ messages: messages
+ )
+
+ // Equal objects must have equal hashes
+ XCTAssertEqual(input1, input2)
+ XCTAssertEqual(input1.hashValue, input2.hashValue)
+ }
+
+ // MARK: - Sendable Tests
+
+ func testSendableConformance() {
+ let input = RunAgentInput(
+ threadId: "thread-concurrent",
+ runId: "run-concurrent"
+ )
+
+ Task {
+ let capturedInput = input
+ XCTAssertEqual(capturedInput.threadId, "thread-concurrent")
+ }
+ }
+
+ // MARK: - Real-world Usage Tests
+
+ func testHTTPPostRequest() {
+ let messages: [any Message] = [
+ DeveloperMessage(id: "dev-1", content: "You are a helpful assistant"),
+ UserMessage(id: "user-1", content: "What's the weather?")
+ ]
+
+ let tools = [
+ Tool(
+ name: "get_weather",
+ description: "Get current weather",
+ parameters: Data("""
+ {
+ "type": "object",
+ "properties": {
+ "location": {"type": "string"}
+ }
+ }
+ """.utf8)
+ )
+ ]
+
+ let contexts = [
+ Context(description: "user_location", value: "San Francisco, CA"),
+ Context(description: "timezone", value: "America/Los_Angeles")
+ ]
+
+ let input = RunAgentInput(
+ threadId: "thread-weather-1",
+ runId: "run-weather-1",
+ messages: messages,
+ tools: tools,
+ context: contexts
+ )
+
+ XCTAssertEqual(input.messages.count, 2)
+ XCTAssertEqual(input.tools.count, 1)
+ XCTAssertEqual(input.context.count, 2)
+ }
+
+ func testNestedRunWithParentId() {
+ let input = RunAgentInput(
+ threadId: "thread-nested",
+ runId: "run-child-1",
+ parentRunId: "run-parent-1"
+ )
+
+ XCTAssertEqual(input.parentRunId, "run-parent-1")
+ }
+
+ func testEmptyDefaults() {
+ let input = RunAgentInput(
+ threadId: "thread-1",
+ runId: "run-1"
+ )
+
+ XCTAssertTrue(input.messages.isEmpty)
+ XCTAssertTrue(input.tools.isEmpty)
+ XCTAssertTrue(input.context.isEmpty)
+ XCTAssertNil(input.resume)
+ }
+
+ // MARK: - Resume field
+
+ func test_init_withResume_setsResumeField() {
+ let entries = [ResumeEntry(interruptId: "int-1", status: .resolved)]
+ let input = RunAgentInput(threadId: "t", runId: "r", resume: entries)
+ XCTAssertEqual(input.resume?.count, 1)
+ XCTAssertEqual(input.resume?[0].interruptId, "int-1")
+ }
+
+ func test_init_withoutResume_resumeIsNil() {
+ let input = RunAgentInput(threadId: "t", runId: "r")
+ XCTAssertNil(input.resume)
+ }
+
+ func test_encode_withResume_includesResumeKey() throws {
+ let entries = [ResumeEntry(interruptId: "int-1", status: .resolved)]
+ let input = RunAgentInput(threadId: "t", runId: "r", resume: entries)
+ let encoded = try JSONEncoder().encode(input)
+ let json = try XCTUnwrap(try JSONSerialization.jsonObject(with: encoded) as? [String: Any])
+ XCTAssertNotNil(json["resume"])
+ }
+
+ func test_encode_withNilResume_omitsResumeKey() throws {
+ let input = RunAgentInput(threadId: "t", runId: "r")
+ let encoded = try JSONEncoder().encode(input)
+ let json = try XCTUnwrap(try JSONSerialization.jsonObject(with: encoded) as? [String: Any])
+ // Key must be absent entirely, not present as null
+ XCTAssertNil(json["resume"])
+ }
+
+ func test_decode_withResume_roundTrips() throws {
+ let entries = [
+ ResumeEntry(interruptId: "int-1", status: .resolved),
+ ResumeEntry(interruptId: "int-2", status: .cancelled)
+ ]
+ let original = RunAgentInput(threadId: "t", runId: "r", resume: entries)
+ let encoded = try JSONEncoder().encode(original)
+ let decoded = try JSONDecoder().decode(RunAgentInput.self, from: encoded)
+
+ XCTAssertEqual(decoded.resume?.count, 2)
+ XCTAssertEqual(decoded.resume?[0].interruptId, "int-1")
+ XCTAssertEqual(decoded.resume?[0].status, .resolved)
+ XCTAssertEqual(decoded.resume?[1].interruptId, "int-2")
+ XCTAssertEqual(decoded.resume?[1].status, .cancelled)
+ }
+
+ func test_equality_withDifferentResume_notEqual() {
+ let a = RunAgentInput(threadId: "t", runId: "r", resume: nil)
+ let b = RunAgentInput(
+ threadId: "t",
+ runId: "r",
+ resume: [ResumeEntry(interruptId: "int-1", status: .resolved)]
+ )
+ XCTAssertNotEqual(a, b)
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/Types/AgentExecution/StateTests.swift b/sdks/community/swift/Tests/AGUICoreTests/Types/AgentExecution/StateTests.swift
new file mode 100644
index 0000000000..31db912976
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/Types/AgentExecution/StateTests.swift
@@ -0,0 +1,206 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+/// Tests for the State type alias
+final class StateTests: XCTestCase {
+ // MARK: - Type Alias Tests
+
+ func testStateIsData() {
+ // Verify State is a type alias for Data
+ let state: State = Data()
+ XCTAssertTrue(type(of: state) == Data.self)
+ }
+
+ // MARK: - JSON State Creation Tests
+
+ func testCreateEmptyState() throws {
+ let emptyObject = "{}"
+ let state: State = Data(emptyObject.utf8)
+
+ let json = try JSONSerialization.jsonObject(with: state) as? [String: Any]
+ XCTAssertNotNil(json)
+ XCTAssertEqual(json?.count, 0)
+ }
+
+ func testCreateStateWithProperties() throws {
+ let jsonString = """
+ {
+ "counter": 42,
+ "name": "test",
+ "enabled": true
+ }
+ """
+ let state: State = Data(jsonString.utf8)
+
+ let json = try JSONSerialization.jsonObject(with: state) as? [String: Any]
+ XCTAssertNotNil(json)
+ XCTAssertEqual(json?["counter"] as? Int, 42)
+ XCTAssertEqual(json?["name"] as? String, "test")
+ XCTAssertEqual(json?["enabled"] as? Bool, true)
+ }
+
+ func testCreateStateWithNestedObjects() throws {
+ let jsonString = """
+ {
+ "user": {
+ "id": "123",
+ "preferences": {
+ "theme": "dark"
+ }
+ }
+ }
+ """
+ let state: State = Data(jsonString.utf8)
+
+ let json = try JSONSerialization.jsonObject(with: state) as? [String: Any]
+ let user = json?["user"] as? [String: Any]
+ let preferences = user?["preferences"] as? [String: Any]
+
+ XCTAssertEqual(preferences?["theme"] as? String, "dark")
+ }
+
+ func testCreateStateWithArray() throws {
+ let jsonString = """
+ {
+ "items": [1, 2, 3],
+ "tags": ["swift", "testing"]
+ }
+ """
+ let state: State = Data(jsonString.utf8)
+
+ let json = try JSONSerialization.jsonObject(with: state) as? [String: Any]
+ let items = json?["items"] as? [Int]
+ let tags = json?["tags"] as? [String]
+
+ XCTAssertEqual(items, [1, 2, 3])
+ XCTAssertEqual(tags, ["swift", "testing"])
+ }
+
+ // MARK: - State Encoding/Decoding Tests
+
+ func testEncodeCodableToState() throws {
+ struct AppState: Codable {
+ let counter: Int
+ let message: String
+ }
+
+ let appState = AppState(counter: 100, message: "Hello")
+ let state: State = try JSONEncoder().encode(appState)
+
+ // Verify we can decode it back
+ let decoded = try JSONDecoder().decode(AppState.self, from: state)
+ XCTAssertEqual(decoded.counter, 100)
+ XCTAssertEqual(decoded.message, "Hello")
+ }
+
+ func testDecodeStateIntoCodableType() throws {
+ let jsonString = """
+ {
+ "value": 42,
+ "label": "Answer"
+ }
+ """
+ let state: State = Data(jsonString.utf8)
+
+ struct StateModel: Codable {
+ let value: Int
+ let label: String
+ }
+
+ let decoded = try JSONDecoder().decode(StateModel.self, from: state)
+ XCTAssertEqual(decoded.value, 42)
+ XCTAssertEqual(decoded.label, "Answer")
+ }
+
+ // MARK: - State Manipulation Tests
+
+ func testMutateStateData() throws {
+ var state: State = Data("{}".utf8)
+
+ // Replace with new state
+ let newJsonString = """
+ {
+ "updated": true
+ }
+ """
+ state = Data(newJsonString.utf8)
+
+ let json = try JSONSerialization.jsonObject(with: state) as? [String: Any]
+ XCTAssertEqual(json?["updated"] as? Bool, true)
+ }
+
+ func testStateEquality() {
+ let state1: State = Data("{\"key\":\"value\"}".utf8)
+ let state2: State = Data("{\"key\":\"value\"}".utf8)
+ let state3: State = Data("{\"key\":\"other\"}".utf8)
+
+ XCTAssertEqual(state1, state2)
+ XCTAssertNotEqual(state1, state3)
+ }
+
+ // MARK: - Edge Cases
+
+ func testEmptyState() {
+ let state: State = Data()
+ XCTAssertEqual(state.count, 0)
+ }
+
+ func testStateWithNullValue() throws {
+ let jsonString = """
+ {
+ "nullable": null
+ }
+ """
+ let state: State = Data(jsonString.utf8)
+
+ let json = try JSONSerialization.jsonObject(with: state) as? [String: Any]
+ XCTAssertTrue(json?["nullable"] is NSNull)
+ }
+
+ // MARK: - Sendable Conformance Tests
+
+ func testStateIsSendable() {
+ let state: State = Data("{}".utf8)
+
+ Task {
+ // If State (Data) is Sendable, this should compile without warnings
+ let capturedState = state
+ XCTAssertNotNil(capturedState)
+ }
+ }
+
+ // MARK: - Real-world Usage Tests
+
+ func testStateAsRunAgentInputParameter() throws {
+ // Simulate how State would be used in RunAgentInput
+ struct RunAgentInput: Codable {
+ let threadId: String
+ let state: State?
+
+ init(threadId: String, state: State? = nil) {
+ self.threadId = threadId
+ self.state = state
+ }
+ }
+
+ let appState = Data("""
+ {
+ "session": "abc123",
+ "user_preferences": {
+ "language": "en"
+ }
+ }
+ """.utf8)
+
+ let input = RunAgentInput(threadId: "thread-1", state: appState)
+
+ // Verify encoding/decoding works
+ let encoded = try JSONEncoder().encode(input)
+ let decoded = try JSONDecoder().decode(RunAgentInput.self, from: encoded)
+
+ XCTAssertEqual(decoded.threadId, "thread-1")
+ XCTAssertNotNil(decoded.state)
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/Types/HumanInTheLoop/InterruptTests.swift b/sdks/community/swift/Tests/AGUICoreTests/Types/HumanInTheLoop/InterruptTests.swift
new file mode 100644
index 0000000000..d9ae501340
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/Types/HumanInTheLoop/InterruptTests.swift
@@ -0,0 +1,194 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+final class InterruptTests: XCTestCase {
+
+ // MARK: - Initialization
+
+ func test_init_withRequiredFields_storesValues() {
+ let interrupt = Interrupt(id: "int-1", reason: "Approval required")
+
+ XCTAssertEqual(interrupt.id, "int-1")
+ XCTAssertEqual(interrupt.reason, "Approval required")
+ XCTAssertNil(interrupt.message)
+ XCTAssertNil(interrupt.toolCallId)
+ XCTAssertNil(interrupt.responseSchema)
+ XCTAssertNil(interrupt.expiresAt)
+ XCTAssertNil(interrupt.metadata)
+ }
+
+ func test_init_withAllFields_storesValues() {
+ let schema = Data(#"{"approved":{"type":"boolean"}}"#.utf8)
+ let meta = Data(#"{"source":"tool-executor"}"#.utf8)
+
+ let interrupt = Interrupt(
+ id: "int-2",
+ reason: "Confirmation needed",
+ message: "Please confirm the action",
+ toolCallId: "tool-call-abc",
+ responseSchema: schema,
+ expiresAt: "2025-12-31T23:59:59Z",
+ metadata: meta
+ )
+
+ XCTAssertEqual(interrupt.id, "int-2")
+ XCTAssertEqual(interrupt.reason, "Confirmation needed")
+ XCTAssertEqual(interrupt.message, "Please confirm the action")
+ XCTAssertEqual(interrupt.toolCallId, "tool-call-abc")
+ XCTAssertEqual(interrupt.responseSchema, schema)
+ XCTAssertEqual(interrupt.expiresAt, "2025-12-31T23:59:59Z")
+ XCTAssertEqual(interrupt.metadata, meta)
+ }
+
+ // MARK: - Codable round-trip
+
+ func test_codable_roundTrip_withRequiredFieldsOnly() throws {
+ let original = Interrupt(id: "int-1", reason: "Needs review")
+ let encoded = try JSONEncoder().encode(original)
+ let decoded = try JSONDecoder().decode(Interrupt.self, from: encoded)
+
+ XCTAssertEqual(decoded.id, original.id)
+ XCTAssertEqual(decoded.reason, original.reason)
+ XCTAssertNil(decoded.message)
+ XCTAssertNil(decoded.toolCallId)
+ XCTAssertNil(decoded.responseSchema)
+ XCTAssertNil(decoded.expiresAt)
+ XCTAssertNil(decoded.metadata)
+ }
+
+ func test_codable_roundTrip_withAllFields() throws {
+ let schema = Data(#"{"type":"object","properties":{"value":{"type":"string"}}}"#.utf8)
+ let meta = Data(#"{"priority":"high"}"#.utf8)
+
+ let original = Interrupt(
+ id: "int-full",
+ reason: "Full interrupt",
+ message: "User action needed",
+ toolCallId: "tool-xyz",
+ responseSchema: schema,
+ expiresAt: "2025-06-30T00:00:00Z",
+ metadata: meta
+ )
+
+ let encoded = try JSONEncoder().encode(original)
+ let decoded = try JSONDecoder().decode(Interrupt.self, from: encoded)
+
+ XCTAssertEqual(decoded.id, original.id)
+ XCTAssertEqual(decoded.reason, original.reason)
+ XCTAssertEqual(decoded.message, original.message)
+ XCTAssertEqual(decoded.toolCallId, original.toolCallId)
+ XCTAssertEqual(decoded.expiresAt, original.expiresAt)
+
+ // Verify responseSchema survives the round-trip as equivalent JSON
+ let originalSchema = try JSONSerialization.jsonObject(with: schema) as? [String: Any]
+ let decodedSchema = try XCTUnwrap(decoded.responseSchema)
+ let roundTrippedSchema = try JSONSerialization.jsonObject(with: decodedSchema) as? [String: Any]
+ XCTAssertEqual(originalSchema?.keys.sorted(), roundTrippedSchema?.keys.sorted())
+
+ // Verify metadata survives the round-trip as equivalent JSON
+ let originalMeta = try JSONSerialization.jsonObject(with: meta) as? [String: Any]
+ let decodedMeta = try XCTUnwrap(decoded.metadata)
+ let roundTrippedMeta = try JSONSerialization.jsonObject(with: decodedMeta) as? [String: Any]
+ XCTAssertEqual(originalMeta?.keys.sorted(), roundTrippedMeta?.keys.sorted())
+ }
+
+ func test_codable_omitsNilFields_fromJSON() throws {
+ let interrupt = Interrupt(id: "int-1", reason: "Simple")
+ let encoded = try JSONEncoder().encode(interrupt)
+ let json = try XCTUnwrap(try JSONSerialization.jsonObject(with: encoded) as? [String: Any])
+
+ XCTAssertNotNil(json["id"])
+ XCTAssertNotNil(json["reason"])
+ XCTAssertNil(json["message"])
+ XCTAssertNil(json["toolCallId"])
+ XCTAssertNil(json["responseSchema"])
+ XCTAssertNil(json["expiresAt"])
+ XCTAssertNil(json["metadata"])
+ }
+
+ // MARK: - Manual decode (from [String: Any])
+
+ func test_decode_fromDict_withRequiredFields_succeeds() throws {
+ let dict: [String: Any] = ["id": "int-1", "reason": "Check needed"]
+ let interrupt = try Interrupt.decode(from: dict)
+
+ XCTAssertEqual(interrupt.id, "int-1")
+ XCTAssertEqual(interrupt.reason, "Check needed")
+ XCTAssertNil(interrupt.message)
+ XCTAssertNil(interrupt.toolCallId)
+ }
+
+ func test_decode_fromDict_withAllFields_succeeds() throws {
+ let dict: [String: Any] = [
+ "id": "int-full",
+ "reason": "Tool approval",
+ "message": "Approve the database write",
+ "toolCallId": "tc-123",
+ "responseSchema": ["approved": ["type": "boolean"]],
+ "expiresAt": "2025-12-01T00:00:00Z",
+ "metadata": ["source": "executor"]
+ ]
+ let interrupt = try Interrupt.decode(from: dict)
+
+ XCTAssertEqual(interrupt.id, "int-full")
+ XCTAssertEqual(interrupt.reason, "Tool approval")
+ XCTAssertEqual(interrupt.message, "Approve the database write")
+ XCTAssertEqual(interrupt.toolCallId, "tc-123")
+ XCTAssertNotNil(interrupt.responseSchema)
+ XCTAssertEqual(interrupt.expiresAt, "2025-12-01T00:00:00Z")
+ XCTAssertNotNil(interrupt.metadata)
+ }
+
+ func test_decode_fromDict_missingId_throws() {
+ let dict: [String: Any] = ["reason": "Missing id"]
+ XCTAssertThrowsError(try Interrupt.decode(from: dict))
+ }
+
+ func test_decode_fromDict_missingReason_throws() {
+ let dict: [String: Any] = ["id": "int-1"]
+ XCTAssertThrowsError(try Interrupt.decode(from: dict))
+ }
+
+ func test_decode_fromDict_nullResponseSchema_yieldsNil() throws {
+ let dict: [String: Any] = ["id": "int-1", "reason": "R", "responseSchema": NSNull()]
+ let interrupt = try Interrupt.decode(from: dict)
+ XCTAssertNil(interrupt.responseSchema)
+ }
+
+ // MARK: - Equatable
+
+ func test_equatable_sameValues_areEqual() {
+ let a = Interrupt(id: "int-1", reason: "R")
+ let b = Interrupt(id: "int-1", reason: "R")
+ XCTAssertEqual(a, b)
+ }
+
+ func test_equatable_differentId_notEqual() {
+ let a = Interrupt(id: "int-1", reason: "R")
+ let b = Interrupt(id: "int-2", reason: "R")
+ XCTAssertNotEqual(a, b)
+ }
+
+ func test_equatable_differentReason_notEqual() {
+ let a = Interrupt(id: "int-1", reason: "R1")
+ let b = Interrupt(id: "int-1", reason: "R2")
+ XCTAssertNotEqual(a, b)
+ }
+
+ // MARK: - Hashable
+
+ func test_hashable_sameValues_sameHash() {
+ let a = Interrupt(id: "int-1", reason: "R")
+ let b = Interrupt(id: "int-1", reason: "R")
+ XCTAssertEqual(a.hashValue, b.hashValue)
+ }
+
+ func test_hashable_usableInSet() {
+ let a = Interrupt(id: "int-1", reason: "R")
+ let b = Interrupt(id: "int-2", reason: "R")
+ let set: Set = [a, b, a]
+ XCTAssertEqual(set.count, 2)
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/Types/HumanInTheLoop/ResumeEntryTests.swift b/sdks/community/swift/Tests/AGUICoreTests/Types/HumanInTheLoop/ResumeEntryTests.swift
new file mode 100644
index 0000000000..e86d593c04
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/Types/HumanInTheLoop/ResumeEntryTests.swift
@@ -0,0 +1,183 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+final class ResumeEntryTests: XCTestCase {
+
+ // MARK: - Initialization
+
+ func test_init_withRequiredFields_storesValues() {
+ let entry = ResumeEntry(interruptId: "int-1", status: .resolved)
+
+ XCTAssertEqual(entry.interruptId, "int-1")
+ XCTAssertEqual(entry.status, .resolved)
+ XCTAssertNil(entry.payload)
+ }
+
+ func test_init_withPayload_storesPayload() {
+ let payload = Data(#"{"approved": true}"#.utf8)
+ let entry = ResumeEntry(interruptId: "int-2", status: .cancelled, payload: payload)
+
+ XCTAssertEqual(entry.interruptId, "int-2")
+ XCTAssertEqual(entry.status, .cancelled)
+ XCTAssertEqual(entry.payload, payload)
+ }
+
+ // MARK: - ResumeStatus
+
+ func test_resumeStatus_resolvedRawValue() {
+ XCTAssertEqual(ResumeEntry.ResumeStatus.resolved.rawValue, "resolved")
+ }
+
+ func test_resumeStatus_cancelledRawValue() {
+ XCTAssertEqual(ResumeEntry.ResumeStatus.cancelled.rawValue, "cancelled")
+ }
+
+ // MARK: - Codable round-trip
+
+ func test_codable_roundTrip_withoutPayload() throws {
+ let original = ResumeEntry(interruptId: "int-1", status: .resolved)
+ let encoded = try JSONEncoder().encode(original)
+ let decoded = try JSONDecoder().decode(ResumeEntry.self, from: encoded)
+
+ XCTAssertEqual(decoded.interruptId, original.interruptId)
+ XCTAssertEqual(decoded.status, original.status)
+ XCTAssertNil(decoded.payload)
+ }
+
+ func test_codable_roundTrip_withObjectPayload() throws {
+ let payload = Data(#"{"approved":true,"comment":"LGTM"}"#.utf8)
+ let original = ResumeEntry(interruptId: "int-full", status: .resolved, payload: payload)
+
+ let encoded = try JSONEncoder().encode(original)
+ let decoded = try JSONDecoder().decode(ResumeEntry.self, from: encoded)
+
+ XCTAssertEqual(decoded.interruptId, original.interruptId)
+ XCTAssertEqual(decoded.status, original.status)
+
+ // Verify payload survives as equivalent JSON
+ let originalPayload = try JSONSerialization.jsonObject(with: payload) as? [String: Any]
+ let decodedPayloadData = try XCTUnwrap(decoded.payload)
+ let decodedPayload = try JSONSerialization.jsonObject(with: decodedPayloadData) as? [String: Any]
+ XCTAssertEqual(originalPayload?.keys.sorted(), decodedPayload?.keys.sorted())
+ }
+
+ func test_codable_roundTrip_withBooleanPayload() throws {
+ // payload: z.any() — boolean is a valid JSON value.
+ // Store as raw JSON bytes (JSONSerialization.data() rejects scalar top-level values).
+ let payload = Data("true".utf8)
+ let original = ResumeEntry(interruptId: "int-bool", status: .resolved, payload: payload)
+
+ let encoded = try JSONEncoder().encode(original)
+ let decoded = try JSONDecoder().decode(ResumeEntry.self, from: encoded)
+
+ let decodedPayloadData = try XCTUnwrap(decoded.payload)
+ let decodedValue = try JSONSerialization.jsonObject(with: decodedPayloadData, options: .fragmentsAllowed)
+ XCTAssertEqual(decodedValue as? Bool, true)
+ }
+
+ func test_codable_roundTrip_withIntegerPayload() throws {
+ // payload: z.any() — integer is a valid JSON value.
+ let payload = Data("42".utf8)
+ let original = ResumeEntry(interruptId: "int-num", status: .resolved, payload: payload)
+
+ let encoded = try JSONEncoder().encode(original)
+ let decoded = try JSONDecoder().decode(ResumeEntry.self, from: encoded)
+
+ let decodedPayloadData = try XCTUnwrap(decoded.payload)
+ let decodedValue = try JSONSerialization.jsonObject(with: decodedPayloadData, options: .fragmentsAllowed)
+ XCTAssertEqual(decodedValue as? Int, 42)
+ }
+
+ func test_codable_roundTrip_withStringPayload() throws {
+ // payload: z.any() — string is a valid JSON value.
+ let payload = Data("\"user_response\"".utf8)
+ let original = ResumeEntry(interruptId: "int-str", status: .resolved, payload: payload)
+
+ let encoded = try JSONEncoder().encode(original)
+ let decoded = try JSONDecoder().decode(ResumeEntry.self, from: encoded)
+
+ let decodedPayloadData = try XCTUnwrap(decoded.payload)
+ let decodedValue = try JSONSerialization.jsonObject(with: decodedPayloadData, options: .fragmentsAllowed)
+ XCTAssertEqual(decodedValue as? String, "user_response")
+ }
+
+ func test_codable_roundTrip_withArrayPayload() throws {
+ // payload: z.any() — array is a valid JSON value.
+ let payload = try JSONSerialization.data(withJSONObject: ["a", "b"])
+ let original = ResumeEntry(interruptId: "int-arr", status: .resolved, payload: payload)
+
+ let encoded = try JSONEncoder().encode(original)
+ let decoded = try JSONDecoder().decode(ResumeEntry.self, from: encoded)
+
+ let decodedPayloadData = try XCTUnwrap(decoded.payload)
+ let decodedValue = try JSONSerialization.jsonObject(with: decodedPayloadData) as? [String]
+ XCTAssertEqual(decodedValue, ["a", "b"])
+ }
+
+ func test_codable_omitsPayloadKey_whenNil() throws {
+ let entry = ResumeEntry(interruptId: "int-1", status: .resolved)
+ let encoded = try JSONEncoder().encode(entry)
+ let json = try XCTUnwrap(try JSONSerialization.jsonObject(with: encoded) as? [String: Any])
+
+ XCTAssertNotNil(json["interruptId"])
+ XCTAssertNotNil(json["status"])
+ XCTAssertNil(json["payload"])
+ }
+
+ func test_codable_statusEncodesAsString() throws {
+ let entry = ResumeEntry(interruptId: "int-1", status: .cancelled)
+ let encoded = try JSONEncoder().encode(entry)
+ let json = try XCTUnwrap(try JSONSerialization.jsonObject(with: encoded) as? [String: Any])
+
+ XCTAssertEqual(json["status"] as? String, "cancelled")
+ }
+
+ func test_codable_decodesResolvedStatus() throws {
+ let json = Data(#"{"interruptId":"int-1","status":"resolved"}"#.utf8)
+ let entry = try JSONDecoder().decode(ResumeEntry.self, from: json)
+ XCTAssertEqual(entry.status, .resolved)
+ }
+
+ func test_codable_decodesCancelledStatus() throws {
+ let json = Data(#"{"interruptId":"int-1","status":"cancelled"}"#.utf8)
+ let entry = try JSONDecoder().decode(ResumeEntry.self, from: json)
+ XCTAssertEqual(entry.status, .cancelled)
+ }
+
+ // MARK: - Equatable
+
+ func test_equatable_sameValues_areEqual() {
+ let a = ResumeEntry(interruptId: "int-1", status: .resolved)
+ let b = ResumeEntry(interruptId: "int-1", status: .resolved)
+ XCTAssertEqual(a, b)
+ }
+
+ func test_equatable_differentInterruptId_notEqual() {
+ let a = ResumeEntry(interruptId: "int-1", status: .resolved)
+ let b = ResumeEntry(interruptId: "int-2", status: .resolved)
+ XCTAssertNotEqual(a, b)
+ }
+
+ func test_equatable_differentStatus_notEqual() {
+ let a = ResumeEntry(interruptId: "int-1", status: .resolved)
+ let b = ResumeEntry(interruptId: "int-1", status: .cancelled)
+ XCTAssertNotEqual(a, b)
+ }
+
+ func test_equatable_differentPayload_notEqual() {
+ let a = ResumeEntry(interruptId: "int-1", status: .resolved, payload: Data("a".utf8))
+ let b = ResumeEntry(interruptId: "int-1", status: .resolved, payload: Data("b".utf8))
+ XCTAssertNotEqual(a, b)
+ }
+
+ // MARK: - Hashable
+
+ func test_hashable_usableInSet() {
+ let a = ResumeEntry(interruptId: "int-1", status: .resolved)
+ let b = ResumeEntry(interruptId: "int-2", status: .resolved)
+ let set: Set = [a, b, a]
+ XCTAssertEqual(set.count, 2)
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/Types/InputContent/AudioInputContentTests.swift b/sdks/community/swift/Tests/AGUICoreTests/Types/InputContent/AudioInputContentTests.swift
new file mode 100644
index 0000000000..d761865385
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/Types/InputContent/AudioInputContentTests.swift
@@ -0,0 +1,188 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+final class AudioInputContentTests: XCTestCase {
+
+ // MARK: - Initialization
+
+ func test_initWithURL_setsFields() {
+ let content = AudioInputContent(url: "https://example.com/audio.mp3")
+ XCTAssertEqual(content.type, "audio")
+ XCTAssertEqual(content.url, "https://example.com/audio.mp3")
+ XCTAssertNil(content.data)
+ XCTAssertNil(content.format)
+ }
+
+ func test_initWithData_setsFields() {
+ let content = AudioInputContent(data: "base64audio", format: "mp3")
+ XCTAssertEqual(content.type, "audio")
+ XCTAssertEqual(content.data, "base64audio")
+ XCTAssertEqual(content.format, "mp3")
+ XCTAssertNil(content.url)
+ }
+
+ func test_initWithURLAndFormat_setsFormat() {
+ let content = AudioInputContent(url: "https://example.com/audio.wav", format: "wav")
+ XCTAssertEqual(content.format, "wav")
+ }
+
+ // MARK: - Protocol conformance
+
+ func test_conformsToInputContent() {
+ let content: any InputContent = AudioInputContent(url: "https://example.com/audio.mp3")
+ XCTAssertEqual(content.type, "audio")
+ }
+
+ // MARK: - DTO decoding
+
+ func test_dtoDecodeWithURL_succeeds() throws {
+ let json = Data("""
+ {"type":"audio","url":"https://example.com/clip.mp3","format":"mp3"}
+ """.utf8)
+ let dto = try AudioInputContentDTO.decode(from: json)
+ let content = dto.toDomain()
+ XCTAssertEqual(content.url, "https://example.com/clip.mp3")
+ XCTAssertEqual(content.format, "mp3")
+ }
+
+ func test_dtoDecodeWithData_succeeds() throws {
+ let json = Data("""
+ {"type":"audio","data":"base64audiodata"}
+ """.utf8)
+ let dto = try AudioInputContentDTO.decode(from: json)
+ let content = dto.toDomain()
+ XCTAssertEqual(content.data, "base64audiodata")
+ }
+
+ func test_dtoDecodeWithNoSource_throws() {
+ let json = Data("""
+ {"type":"audio","format":"wav"}
+ """.utf8)
+ XCTAssertThrowsError(try AudioInputContentDTO.decode(from: json))
+ }
+
+ func test_dtoDecodeWithWrongType_throws() {
+ let json = Data("""
+ {"type":"image","url":"https://example.com/img.png"}
+ """.utf8)
+ XCTAssertThrowsError(try AudioInputContentDTO.decode(from: json))
+ }
+
+ // MARK: - Integration: decode via UserMessage
+
+ func test_userMessage_withAudioContent_decodesFromJSON() throws {
+ let json = """
+ {
+ "id": "msg-2",
+ "role": "user",
+ "content": [
+ {"type": "audio", "url": "https://example.com/voice.mp3", "format": "mp3"}
+ ]
+ }
+ """
+ let decoder = MessageDecoder()
+ let message = try decoder.decode(Data(json.utf8))
+ guard let userMsg = message as? UserMessage,
+ let parts = userMsg.contentParts else {
+ return XCTFail("Expected multimodal UserMessage")
+ }
+ XCTAssertEqual(parts.count, 1)
+ guard let audioPart = parts[0] as? AudioInputContent else {
+ return XCTFail("Expected AudioInputContent, got \(type(of: parts[0]))")
+ }
+ XCTAssertEqual(audioPart.url, "https://example.com/voice.mp3")
+ XCTAssertEqual(audioPart.format, "mp3")
+ }
+
+ func test_userMessage_withAudioContent_encodesCorrectly() throws {
+ let audio = AudioInputContent(url: "https://example.com/clip.wav", format: "wav")
+ let userMsg = UserMessage.multimodal(id: "msg-2", parts: [audio])
+
+ let encoder = MessageEncoder()
+ let data = try encoder.encode(userMsg)
+ let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
+ let contentArray = json?["content"] as? [[String: Any]]
+
+ XCTAssertEqual(contentArray?[0]["type"] as? String, "audio")
+ XCTAssertEqual(contentArray?[0]["url"] as? String, "https://example.com/clip.wav")
+ XCTAssertEqual(contentArray?[0]["format"] as? String, "wav")
+ }
+
+ // MARK: - Equatable / Hashable / Sendable
+
+ func test_equalContentAreEqual() {
+ let c1 = AudioInputContent(url: "https://example.com/a.mp3", format: "mp3")
+ let c2 = AudioInputContent(url: "https://example.com/a.mp3", format: "mp3")
+ XCTAssertEqual(c1, c2)
+ }
+
+ func test_differentURLNotEqual() {
+ XCTAssertNotEqual(
+ AudioInputContent(url: "https://example.com/a.mp3"),
+ AudioInputContent(url: "https://example.com/b.mp3")
+ )
+ }
+
+ func test_hashable() {
+ let set: Set = [
+ AudioInputContent(url: "https://example.com/a.mp3"),
+ AudioInputContent(url: "https://example.com/b.mp3")
+ ]
+ XCTAssertEqual(set.count, 2)
+ }
+
+ func test_sendable() {
+ let content = AudioInputContent(url: "https://example.com/clip.mp3")
+ Task { XCTAssertEqual(content.type, "audio") }
+ }
+
+ // MARK: - mimeType
+
+ func test_mimeType_isNilByDefault_url() {
+ XCTAssertNil(AudioInputContent(url: "https://example.com/clip.mp3").mimeType)
+ }
+
+ func test_mimeType_isNilByDefault_data() {
+ XCTAssertNil(AudioInputContent(data: "base64audio").mimeType)
+ }
+
+ func test_mimeType_roundTripsViaURLInit() {
+ let content = AudioInputContent(url: "https://example.com/clip.mp3", mimeType: "audio/mpeg")
+ XCTAssertEqual(content.mimeType, "audio/mpeg")
+ }
+
+ func test_mimeType_roundTripsViaDataInit() {
+ let content = AudioInputContent(data: "base64audio", mimeType: "audio/wav")
+ XCTAssertEqual(content.mimeType, "audio/wav")
+ }
+
+ func test_mimeType_decodesFromJSON() throws {
+ let json = Data("""
+ {"type":"audio","url":"https://example.com/clip.mp3","mimeType":"audio/mpeg"}
+ """.utf8)
+ let dto = try AudioInputContentDTO.decode(from: json)
+ let content = dto.toDomain()
+ XCTAssertEqual(content.mimeType, "audio/mpeg")
+ }
+
+ func test_mimeType_isNilWhenAbsentInJSON() throws {
+ let json = Data("""
+ {"type":"audio","url":"https://example.com/clip.mp3"}
+ """.utf8)
+ let dto = try AudioInputContentDTO.decode(from: json)
+ let content = dto.toDomain()
+ XCTAssertNil(content.mimeType)
+ }
+
+ func test_encodeUserMessage_withAudioMimeType_includesMimeTypeInJSON() throws {
+ let audio = AudioInputContent(url: "https://example.com/clip.mp3", mimeType: "audio/mpeg")
+ let userMsg = UserMessage.multimodal(id: "msg-1", parts: [audio])
+ let encoder = MessageEncoder()
+ let data = try encoder.encode(userMsg)
+ let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
+ let contentArray = json?["content"] as? [[String: Any]]
+ XCTAssertEqual(contentArray?[0]["mimeType"] as? String, "audio/mpeg")
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/Types/InputContent/BinaryInputContentTests.swift b/sdks/community/swift/Tests/AGUICoreTests/Types/InputContent/BinaryInputContentTests.swift
new file mode 100644
index 0000000000..5557f5e476
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/Types/InputContent/BinaryInputContentTests.swift
@@ -0,0 +1,345 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+/// Tests for the BinaryInputContent type
+final class BinaryInputContentTests: XCTestCase {
+ // MARK: - Initialization Tests
+
+ func testInitWithURL() {
+ let content = BinaryInputContent(
+ mimeType: "image/png",
+ url: "https://example.com/image.png"
+ )
+
+ XCTAssertEqual(content.mimeType, "image/png")
+ XCTAssertEqual(content.url, "https://example.com/image.png")
+ XCTAssertNil(content.id)
+ XCTAssertNil(content.data)
+ XCTAssertNil(content.filename)
+ XCTAssertEqual(content.type, "binary")
+ }
+
+ func testInitWithID() {
+ let content = BinaryInputContent(
+ mimeType: "application/pdf",
+ id: "file-123"
+ )
+
+ XCTAssertEqual(content.mimeType, "application/pdf")
+ XCTAssertEqual(content.id, "file-123")
+ XCTAssertNil(content.url)
+ XCTAssertNil(content.data)
+ }
+
+ func testInitWithData() {
+ let base64Data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg=="
+
+ let content = BinaryInputContent(
+ mimeType: "image/png",
+ data: base64Data
+ )
+
+ XCTAssertEqual(content.mimeType, "image/png")
+ XCTAssertEqual(content.data, base64Data)
+ XCTAssertNil(content.id)
+ XCTAssertNil(content.url)
+ }
+
+ func testInitWithAllFields() throws {
+ let content = try BinaryInputContent(
+ mimeType: "image/jpeg",
+ id: "img-456",
+ url: "https://example.com/photo.jpg",
+ data: "base64data",
+ filename: "vacation.jpg"
+ )
+
+ XCTAssertEqual(content.mimeType, "image/jpeg")
+ XCTAssertEqual(content.id, "img-456")
+ XCTAssertEqual(content.url, "https://example.com/photo.jpg")
+ XCTAssertEqual(content.data, "base64data")
+ XCTAssertEqual(content.filename, "vacation.jpg")
+ }
+
+ func testInitWithFilename() {
+ let content = BinaryInputContent(
+ mimeType: "application/pdf",
+ url: "https://docs.example.com/report.pdf",
+ filename: "quarterly-report.pdf"
+ )
+
+ XCTAssertEqual(content.filename, "quarterly-report.pdf")
+ }
+
+ // MARK: - Validation Tests
+
+ func testInitFailsWithoutSourceFields() {
+ XCTAssertThrowsError(
+ try BinaryInputContent.validate(
+ mimeType: "image/png",
+ id: nil,
+ url: nil,
+ data: nil
+ )
+ ) { error in
+ guard case BinaryInputContent.ValidationError.noSourceProvided = error else {
+ XCTFail("Expected ValidationError.noSourceProvided but got \(error)")
+ return
+ }
+ }
+ }
+
+ // MARK: - InputContent Protocol Conformance Tests
+
+ func testConformsToInputContent() {
+ let content: any InputContent = BinaryInputContent(
+ mimeType: "image/png",
+ url: "https://example.com/test.png"
+ )
+
+ XCTAssertEqual(content.type, "binary")
+ }
+
+ func testMixedInputContentArray() {
+ let contents: [any InputContent] = [
+ TextInputContent(text: "Check this image:"),
+ BinaryInputContent(mimeType: "image/png", url: "https://example.com/photo.png"),
+ TextInputContent(text: "What do you see?")
+ ]
+
+ XCTAssertEqual(contents.count, 3)
+ XCTAssertEqual(contents[0].type, "text")
+ XCTAssertEqual(contents[1].type, "binary")
+ XCTAssertEqual(contents[2].type, "text")
+ }
+
+ // MARK: - Encoding Tests
+ // Note: Direct encoding of BinaryInputContent is not supported.
+ // BinaryInputContent is encoded via DTOs when part of larger structures
+ // (e.g., UserMessageDTO when encoding UserMessage with multimodal content).
+ // These tests are intentionally omitted as they would not reflect real-world usage.
+
+ // MARK: - Decoding Tests
+
+ func testDecodingWithURL() throws {
+ let json = """
+ {
+ "type": "binary",
+ "mimeType": "image/png",
+ "url": "https://example.com/image.png"
+ }
+ """
+
+ let dto = try BinaryInputContentDTO.decode(from: Data(json.utf8))
+ let content = try dto.toDomain()
+
+ XCTAssertEqual(content.type, "binary")
+ XCTAssertEqual(content.mimeType, "image/png")
+ XCTAssertEqual(content.url, "https://example.com/image.png")
+ XCTAssertNil(content.id)
+ XCTAssertNil(content.data)
+ }
+
+ func testDecodingWithID() throws {
+ let json = """
+ {
+ "type": "binary",
+ "mimeType": "application/pdf",
+ "id": "doc-456"
+ }
+ """
+
+ let dto = try BinaryInputContentDTO.decode(from: Data(json.utf8))
+ let content = try dto.toDomain()
+
+ XCTAssertEqual(content.id, "doc-456")
+ }
+
+ func testDecodingWithData() throws {
+ let json = """
+ {
+ "type": "binary",
+ "mimeType": "image/png",
+ "data": "iVBORw0KGgo="
+ }
+ """
+
+ let dto = try BinaryInputContentDTO.decode(from: Data(json.utf8))
+ let content = try dto.toDomain()
+
+ XCTAssertEqual(content.data, "iVBORw0KGgo=")
+ }
+
+ func testDecodingWithAllFields() throws {
+ let json = """
+ {
+ "type": "binary",
+ "mimeType": "image/jpeg",
+ "id": "img-789",
+ "url": "https://example.com/photo.jpg",
+ "data": "base64data",
+ "filename": "photo.jpg"
+ }
+ """
+
+ let dto = try BinaryInputContentDTO.decode(from: Data(json.utf8))
+ let content = try dto.toDomain()
+
+ XCTAssertEqual(content.mimeType, "image/jpeg")
+ XCTAssertEqual(content.id, "img-789")
+ XCTAssertEqual(content.url, "https://example.com/photo.jpg")
+ XCTAssertEqual(content.data, "base64data")
+ XCTAssertEqual(content.filename, "photo.jpg")
+ }
+
+ func testDecodingFailsWithoutMimeType() {
+ let json = """
+ {
+ "type": "binary",
+ "url": "https://example.com/file"
+ }
+ """
+
+ XCTAssertThrowsError(try BinaryInputContentDTO.decode(from: Data(json.utf8))) { error in
+ XCTAssertTrue(error is DecodingError)
+ }
+ }
+
+ func testDecodingFailsWithoutAnySource() {
+ let json = """
+ {
+ "type": "binary",
+ "mimeType": "image/png"
+ }
+ """
+
+ XCTAssertThrowsError(try BinaryInputContentDTO.decode(from: Data(json.utf8)))
+ }
+
+ // MARK: - Round-trip Tests
+
+ func testRoundTripWithURL() throws {
+ let original = BinaryInputContent(
+ mimeType: "video/mp4",
+ url: "https://example.com/video.mp4",
+ filename: "demo.mp4"
+ )
+
+ // Encode manually (simulating what UserMessageDTO does)
+ var dict: [String: Any] = [
+ "type": "binary",
+ "mimeType": original.mimeType
+ ]
+ if let url = original.url {
+ dict["url"] = url
+ }
+ if let filename = original.filename {
+ dict["filename"] = filename
+ }
+ let encoded = try JSONSerialization.data(withJSONObject: dict)
+
+ let dto = try BinaryInputContentDTO.decode(from: encoded)
+ let decoded = try dto.toDomain()
+
+ XCTAssertEqual(decoded.type, original.type)
+ XCTAssertEqual(decoded.mimeType, original.mimeType)
+ XCTAssertEqual(decoded.url, original.url)
+ XCTAssertEqual(decoded.filename, original.filename)
+ }
+
+ // MARK: - Equatable Tests
+
+ func testEquality() {
+ let content1 = BinaryInputContent(mimeType: "image/png", url: "https://test.com/1.png")
+ let content2 = BinaryInputContent(mimeType: "image/png", url: "https://test.com/1.png")
+ let content3 = BinaryInputContent(mimeType: "image/jpeg", url: "https://test.com/1.png")
+ let content4 = BinaryInputContent(mimeType: "image/png", id: "img-1")
+
+ XCTAssertEqual(content1, content2)
+ XCTAssertNotEqual(content1, content3)
+ XCTAssertNotEqual(content1, content4)
+ }
+
+ // MARK: - Hashable Tests
+
+ func testHashable() {
+ let content1 = BinaryInputContent(mimeType: "image/png", id: "img-1")
+ let content2 = BinaryInputContent(mimeType: "image/png", id: "img-2")
+
+ let set: Set = [content1, content2]
+ XCTAssertEqual(set.count, 2)
+ XCTAssertTrue(set.contains(content1))
+ XCTAssertTrue(set.contains(content2))
+ }
+
+ // MARK: - Sendable Tests
+
+ func testSendableConformance() {
+ let content = BinaryInputContent(mimeType: "image/png", url: "https://test.com/img.png")
+
+ Task {
+ let capturedContent = content
+ XCTAssertEqual(capturedContent.mimeType, "image/png")
+ }
+ }
+
+ // MARK: - Real-world Usage Tests
+
+ func testImageFromURL() {
+ let imageContent = BinaryInputContent(
+ mimeType: "image/jpeg",
+ url: "https://photos.example.com/sunset.jpg",
+ filename: "sunset.jpg"
+ )
+
+ XCTAssertEqual(imageContent.mimeType, "image/jpeg")
+ XCTAssertNotNil(imageContent.url)
+ XCTAssertEqual(imageContent.filename, "sunset.jpg")
+ }
+
+ func testPDFDocument() {
+ let pdfContent = BinaryInputContent(
+ mimeType: "application/pdf",
+ id: "doc-report-2024",
+ filename: "annual-report.pdf"
+ )
+
+ XCTAssertEqual(pdfContent.mimeType, "application/pdf")
+ XCTAssertEqual(pdfContent.id, "doc-report-2024")
+ }
+
+ func testBase64EmbeddedImage() {
+ let smallPNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg=="
+
+ let imageContent = BinaryInputContent(
+ mimeType: "image/png",
+ data: smallPNG,
+ filename: "pixel.png"
+ )
+
+ XCTAssertEqual(imageContent.data, smallPNG)
+ XCTAssertTrue(imageContent.data?.hasSuffix("==") ?? false)
+ }
+
+ func testAudioFile() {
+ let audioContent = BinaryInputContent(
+ mimeType: "audio/mpeg",
+ url: "https://cdn.example.com/audio/track01.mp3",
+ filename: "track01.mp3"
+ )
+
+ XCTAssertEqual(audioContent.mimeType, "audio/mpeg")
+ }
+
+ func testTypeAlwaysBinary() {
+ let content1 = BinaryInputContent(mimeType: "image/png", url: "https://test.com/1.png")
+ let content2 = BinaryInputContent(mimeType: "application/pdf", id: "doc-1")
+ let content3 = BinaryInputContent(mimeType: "audio/wav", data: "base64data")
+
+ XCTAssertEqual(content1.type, "binary")
+ XCTAssertEqual(content2.type, "binary")
+ XCTAssertEqual(content3.type, "binary")
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/Types/InputContent/DocumentInputContentTests.swift b/sdks/community/swift/Tests/AGUICoreTests/Types/InputContent/DocumentInputContentTests.swift
new file mode 100644
index 0000000000..6ac304d3b6
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/Types/InputContent/DocumentInputContentTests.swift
@@ -0,0 +1,143 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+final class DocumentInputContentTests: XCTestCase {
+
+ // MARK: - Initialization
+
+ func test_initWithURL_setsFields() {
+ let content = DocumentInputContent(url: "https://example.com/doc.pdf", mimeType: "application/pdf", title: "Annual Report")
+ XCTAssertEqual(content.type, "document")
+ XCTAssertEqual(content.url, "https://example.com/doc.pdf")
+ XCTAssertEqual(content.mimeType, "application/pdf")
+ XCTAssertEqual(content.title, "Annual Report")
+ XCTAssertNil(content.data)
+ }
+
+ func test_initWithData_setsFields() {
+ let content = DocumentInputContent(data: "base64pdf", mimeType: "application/pdf")
+ XCTAssertEqual(content.type, "document")
+ XCTAssertEqual(content.data, "base64pdf")
+ XCTAssertEqual(content.mimeType, "application/pdf")
+ XCTAssertNil(content.url)
+ }
+
+ func test_initWithMinimalFields_onlyURL() {
+ let content = DocumentInputContent(url: "https://example.com/doc.txt")
+ XCTAssertEqual(content.type, "document")
+ XCTAssertNil(content.mimeType)
+ XCTAssertNil(content.title)
+ }
+
+ // MARK: - Protocol conformance
+
+ func test_conformsToInputContent() {
+ let content: any InputContent = DocumentInputContent(url: "https://example.com/doc.pdf")
+ XCTAssertEqual(content.type, "document")
+ }
+
+ // MARK: - DTO decoding
+
+ func test_dtoDecodeWithURL_succeeds() throws {
+ let json = Data("""
+ {"type":"document","url":"https://example.com/report.pdf","mimeType":"application/pdf","title":"Report"}
+ """.utf8)
+ let dto = try DocumentInputContentDTO.decode(from: json)
+ let content = dto.toDomain()
+ XCTAssertEqual(content.url, "https://example.com/report.pdf")
+ XCTAssertEqual(content.mimeType, "application/pdf")
+ XCTAssertEqual(content.title, "Report")
+ }
+
+ func test_dtoDecodeWithData_succeeds() throws {
+ let json = Data("""
+ {"type":"document","data":"base64docdata","mimeType":"text/plain"}
+ """.utf8)
+ let dto = try DocumentInputContentDTO.decode(from: json)
+ XCTAssertEqual(dto.toDomain().data, "base64docdata")
+ }
+
+ func test_dtoDecodeWithNoSource_throws() {
+ let json = Data("""
+ {"type":"document","mimeType":"application/pdf"}
+ """.utf8)
+ XCTAssertThrowsError(try DocumentInputContentDTO.decode(from: json))
+ }
+
+ func test_dtoDecodeWithWrongType_throws() {
+ let json = Data("""
+ {"type":"image","url":"https://example.com/img.png"}
+ """.utf8)
+ XCTAssertThrowsError(try DocumentInputContentDTO.decode(from: json))
+ }
+
+ // MARK: - Integration: decode via UserMessage
+
+ func test_userMessage_withDocumentContent_decodesFromJSON() throws {
+ let json = """
+ {
+ "id": "msg-4",
+ "role": "user",
+ "content": [
+ {"type": "document", "url": "https://example.com/report.pdf", "mimeType": "application/pdf", "title": "Q4 Report"}
+ ]
+ }
+ """
+ let decoder = MessageDecoder()
+ let message = try decoder.decode(Data(json.utf8))
+ guard let userMsg = message as? UserMessage,
+ let parts = userMsg.contentParts else {
+ return XCTFail("Expected multimodal UserMessage")
+ }
+ guard let docPart = parts[0] as? DocumentInputContent else {
+ return XCTFail("Expected DocumentInputContent")
+ }
+ XCTAssertEqual(docPart.mimeType, "application/pdf")
+ XCTAssertEqual(docPart.title, "Q4 Report")
+ }
+
+ func test_userMessage_withDocumentContent_encodesCorrectly() throws {
+ let doc = DocumentInputContent(url: "https://example.com/doc.pdf", mimeType: "application/pdf", title: "Report")
+ let userMsg = UserMessage.multimodal(id: "msg-4", parts: [doc])
+
+ let encoder = MessageEncoder()
+ let data = try encoder.encode(userMsg)
+ let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
+ let contentArray = json?["content"] as? [[String: Any]]
+
+ XCTAssertEqual(contentArray?[0]["type"] as? String, "document")
+ XCTAssertEqual(contentArray?[0]["mimeType"] as? String, "application/pdf")
+ XCTAssertEqual(contentArray?[0]["title"] as? String, "Report")
+ }
+
+ // MARK: - Equatable / Hashable / Sendable
+
+ func test_equalContentAreEqual() {
+ XCTAssertEqual(
+ DocumentInputContent(url: "https://example.com/d.pdf", mimeType: "application/pdf"),
+ DocumentInputContent(url: "https://example.com/d.pdf", mimeType: "application/pdf")
+ )
+ }
+
+ func test_differentURLNotEqual() {
+ XCTAssertNotEqual(
+ DocumentInputContent(url: "https://example.com/a.pdf"),
+ DocumentInputContent(url: "https://example.com/b.pdf")
+ )
+ }
+
+ func test_hashable() {
+ let set: Set = [
+ DocumentInputContent(url: "https://example.com/a.pdf"),
+ DocumentInputContent(url: "https://example.com/b.pdf")
+ ]
+ XCTAssertEqual(set.count, 2)
+ }
+
+ func test_sendable() {
+ let content = DocumentInputContent(url: "https://example.com/doc.pdf")
+ Task { XCTAssertEqual(content.type, "document") }
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/Types/InputContent/ImageInputContentTests.swift b/sdks/community/swift/Tests/AGUICoreTests/Types/InputContent/ImageInputContentTests.swift
new file mode 100644
index 0000000000..1d9518e75e
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/Types/InputContent/ImageInputContentTests.swift
@@ -0,0 +1,211 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+final class ImageInputContentTests: XCTestCase {
+
+ // MARK: - Initialization
+
+ func test_initWithURL_setsFields() {
+ let content = ImageInputContent(url: "https://example.com/photo.jpg")
+ XCTAssertEqual(content.type, "image")
+ XCTAssertEqual(content.url, "https://example.com/photo.jpg")
+ XCTAssertNil(content.data)
+ XCTAssertNil(content.detail)
+ }
+
+ func test_initWithData_setsFields() {
+ let content = ImageInputContent(data: "base64abc", detail: "high")
+ XCTAssertEqual(content.type, "image")
+ XCTAssertEqual(content.data, "base64abc")
+ XCTAssertEqual(content.detail, "high")
+ XCTAssertNil(content.url)
+ }
+
+ func test_initWithURLAndDetail_setsDetail() {
+ let content = ImageInputContent(url: "https://example.com/img.png", detail: "low")
+ XCTAssertEqual(content.detail, "low")
+ }
+
+ func test_initWithNoSourceProvided_createsWithNilFields() {
+ // Convenience inits require at least one of url/data; default init not available
+ // This test documents the available init forms
+ let contentFromURL = ImageInputContent(url: "https://example.com/img.png")
+ XCTAssertNotNil(contentFromURL.url)
+ }
+
+ // MARK: - Protocol conformance
+
+ func test_conformsToInputContent() {
+ let content: any InputContent = ImageInputContent(url: "https://example.com/img.png")
+ XCTAssertEqual(content.type, "image")
+ }
+
+ // MARK: - DTO decoding
+
+ func test_dtoDecodeWithURL_succeeds() throws {
+ let json = Data("""
+ {"type":"image","url":"https://example.com/img.png"}
+ """.utf8)
+ let dto = try ImageInputContentDTO.decode(from: json)
+ let content = dto.toDomain()
+ XCTAssertEqual(content.url, "https://example.com/img.png")
+ XCTAssertEqual(content.type, "image")
+ }
+
+ func test_dtoDecodeWithData_succeeds() throws {
+ let json = Data("""
+ {"type":"image","data":"base64xyz","detail":"auto"}
+ """.utf8)
+ let dto = try ImageInputContentDTO.decode(from: json)
+ let content = dto.toDomain()
+ XCTAssertEqual(content.data, "base64xyz")
+ XCTAssertEqual(content.detail, "auto")
+ }
+
+ func test_dtoDecodeWithNoSource_throws() {
+ let json = Data("""
+ {"type":"image","detail":"high"}
+ """.utf8)
+ XCTAssertThrowsError(try ImageInputContentDTO.decode(from: json))
+ }
+
+ func test_dtoDecodeWithWrongType_throws() {
+ let json = Data("""
+ {"type":"video","url":"https://example.com/vid.mp4"}
+ """.utf8)
+ XCTAssertThrowsError(try ImageInputContentDTO.decode(from: json))
+ }
+
+ // MARK: - Integration: decode via UserMessage
+
+ func test_userMessage_withImageContent_decodesFromJSON() throws {
+ let json = """
+ {
+ "id": "msg-1",
+ "role": "user",
+ "content": [
+ {"type": "image", "url": "https://example.com/photo.jpg", "detail": "high"}
+ ]
+ }
+ """
+ let decoder = MessageDecoder()
+ let message = try decoder.decode(Data(json.utf8))
+ guard let userMsg = message as? UserMessage,
+ let parts = userMsg.contentParts else {
+ return XCTFail("Expected multimodal UserMessage")
+ }
+ XCTAssertEqual(parts.count, 1)
+ guard let imagePart = parts[0] as? ImageInputContent else {
+ return XCTFail("Expected ImageInputContent, got \(type(of: parts[0]))")
+ }
+ XCTAssertEqual(imagePart.url, "https://example.com/photo.jpg")
+ XCTAssertEqual(imagePart.detail, "high")
+ }
+
+ func test_userMessage_withImageContent_encodesCorrectly() throws {
+ let image = ImageInputContent(url: "https://example.com/photo.jpg", detail: "low")
+ let userMsg = UserMessage.multimodal(id: "msg-1", parts: [image])
+
+ let encoder = MessageEncoder()
+ let data = try encoder.encode(userMsg)
+ let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
+ let contentArray = json?["content"] as? [[String: Any]]
+
+ XCTAssertEqual(contentArray?.count, 1)
+ XCTAssertEqual(contentArray?[0]["type"] as? String, "image")
+ XCTAssertEqual(contentArray?[0]["url"] as? String, "https://example.com/photo.jpg")
+ XCTAssertEqual(contentArray?[0]["detail"] as? String, "low")
+ }
+
+ // MARK: - Equatable / Hashable
+
+ func test_equalContentAreEqual() {
+ let c1 = ImageInputContent(url: "https://example.com/img.png", detail: "high")
+ let c2 = ImageInputContent(url: "https://example.com/img.png", detail: "high")
+ XCTAssertEqual(c1, c2)
+ }
+
+ func test_differentURLNotEqual() {
+ let c1 = ImageInputContent(url: "https://example.com/a.png")
+ let c2 = ImageInputContent(url: "https://example.com/b.png")
+ XCTAssertNotEqual(c1, c2)
+ }
+
+ func test_hashable() {
+ let c1 = ImageInputContent(url: "https://example.com/a.png")
+ let c2 = ImageInputContent(url: "https://example.com/b.png")
+ let set: Set = [c1, c2]
+ XCTAssertEqual(set.count, 2)
+ }
+
+ // MARK: - mimeType
+
+ func test_mimeType_isNilByDefault_url() {
+ let content = ImageInputContent(url: "https://example.com/img.png")
+ XCTAssertNil(content.mimeType)
+ }
+
+ func test_mimeType_isNilByDefault_data() {
+ let content = ImageInputContent(data: "base64abc")
+ XCTAssertNil(content.mimeType)
+ }
+
+ func test_mimeType_roundTripsViaURLInit() {
+ let content = ImageInputContent(url: "https://example.com/img.png", mimeType: "image/png")
+ XCTAssertEqual(content.mimeType, "image/png")
+ }
+
+ func test_mimeType_roundTripsViaDataInit() {
+ let content = ImageInputContent(data: "base64abc", mimeType: "image/jpeg")
+ XCTAssertEqual(content.mimeType, "image/jpeg")
+ }
+
+ func test_mimeType_decodesFromJSON() throws {
+ let json = Data("""
+ {"type":"image","url":"https://example.com/img.png","mimeType":"image/png"}
+ """.utf8)
+ let dto = try ImageInputContentDTO.decode(from: json)
+ let content = dto.toDomain()
+ XCTAssertEqual(content.mimeType, "image/png")
+ }
+
+ func test_mimeType_isNilWhenAbsentInJSON() throws {
+ let json = Data("""
+ {"type":"image","url":"https://example.com/img.png"}
+ """.utf8)
+ let dto = try ImageInputContentDTO.decode(from: json)
+ let content = dto.toDomain()
+ XCTAssertNil(content.mimeType)
+ }
+
+ func test_encodeUserMessage_withImageMimeType_includesMimeTypeInJSON() throws {
+ let image = ImageInputContent(url: "https://example.com/img.png", mimeType: "image/png")
+ let userMsg = UserMessage.multimodal(id: "msg-1", parts: [image])
+ let encoder = MessageEncoder()
+ let data = try encoder.encode(userMsg)
+ let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
+ let contentArray = json?["content"] as? [[String: Any]]
+ XCTAssertEqual(contentArray?[0]["mimeType"] as? String, "image/png")
+ }
+
+ func test_encodeUserMessage_withNilImageMimeType_omitsMimeTypeFromJSON() throws {
+ let image = ImageInputContent(url: "https://example.com/img.png")
+ let userMsg = UserMessage.multimodal(id: "msg-1", parts: [image])
+ let encoder = MessageEncoder()
+ let data = try encoder.encode(userMsg)
+ let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
+ let contentArray = json?["content"] as? [[String: Any]]
+ XCTAssertNil(contentArray?[0]["mimeType"])
+ }
+
+ // MARK: - Sendable
+
+ func test_sendable() {
+ let content = ImageInputContent(url: "https://example.com/photo.jpg")
+ Task {
+ XCTAssertEqual(content.type, "image")
+ }
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/Types/InputContent/InputContentTests.swift b/sdks/community/swift/Tests/AGUICoreTests/Types/InputContent/InputContentTests.swift
new file mode 100644
index 0000000000..5136b77647
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/Types/InputContent/InputContentTests.swift
@@ -0,0 +1,221 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+/// Tests for the InputContent protocol and TextInputContent type
+final class InputContentTests: XCTestCase {
+ // MARK: - TextInputContent Initialization Tests
+
+ func testTextInputContentInit() {
+ let content = TextInputContent(text: "Hello, world!")
+
+ XCTAssertEqual(content.text, "Hello, world!")
+ XCTAssertEqual(content.type, "text")
+ }
+
+ func testTextInputContentWithEmptyString() {
+ let content = TextInputContent(text: "")
+
+ XCTAssertEqual(content.text, "")
+ XCTAssertEqual(content.type, "text")
+ }
+
+ func testTextInputContentWithMultilineText() {
+ let multilineText = """
+ This is line 1
+ This is line 2
+ This is line 3
+ """
+
+ let content = TextInputContent(text: multilineText)
+
+ XCTAssertTrue(content.text.contains("line 1"))
+ XCTAssertTrue(content.text.contains("line 2"))
+ }
+
+ // MARK: - TextInputContent Encoding Tests
+ // Note: Direct encoding of TextInputContent is not supported.
+ // TextInputContent is encoded via DTOs when part of larger structures
+ // (e.g., UserMessageDTO when encoding UserMessage with multimodal content).
+ // These tests are intentionally omitted as they would not reflect real-world usage.
+
+ // MARK: - TextInputContent Decoding Tests
+
+ func testTextInputContentDecoding() throws {
+ let json = """
+ {
+ "type": "text",
+ "text": "Hello from JSON"
+ }
+ """
+
+ let dto = try TextInputContentDTO.decode(from: Data(json.utf8))
+ let content = dto.toDomain()
+
+ XCTAssertEqual(content.type, "text")
+ XCTAssertEqual(content.text, "Hello from JSON")
+ }
+
+ func testTextInputContentDecodingWithoutType() throws {
+ // Type field is optional in the JSON, but the domain model always has type "text"
+ let json = """
+ {
+ "text": "Text without explicit type"
+ }
+ """
+
+ let dto = try TextInputContentDTO.decode(from: Data(json.utf8))
+ let content = dto.toDomain()
+
+ XCTAssertEqual(content.type, "text")
+ XCTAssertEqual(content.text, "Text without explicit type")
+ }
+
+ func testTextInputContentDecodingFailsWithoutText() {
+ let json = """
+ {
+ "type": "text"
+ }
+ """
+
+ XCTAssertThrowsError(try TextInputContentDTO.decode(from: Data(json.utf8))) { error in
+ XCTAssertTrue(error is DecodingError)
+ }
+ }
+
+ // MARK: - TextInputContent Round-trip Tests
+
+ func testTextInputContentRoundTrip() throws {
+ let original = TextInputContent(text: "Round-trip test message")
+
+ // Encode manually (simulating what UserMessageDTO does)
+ let dict: [String: Any] = [
+ "type": "text",
+ "text": original.text
+ ]
+ let encoded = try JSONSerialization.data(withJSONObject: dict)
+
+ let dto = try TextInputContentDTO.decode(from: encoded)
+ let decoded = dto.toDomain()
+
+ XCTAssertEqual(decoded.type, original.type)
+ XCTAssertEqual(decoded.text, original.text)
+ }
+
+ // MARK: - InputContent Protocol Conformance Tests
+
+ func testTextInputContentConformsToInputContent() {
+ let content: any InputContent = TextInputContent(text: "Protocol test")
+
+ XCTAssertEqual(content.type, "text")
+ }
+
+ func testInputContentArrayWithTextContent() {
+ let contents: [any InputContent] = [
+ TextInputContent(text: "First message"),
+ TextInputContent(text: "Second message"),
+ TextInputContent(text: "Third message")
+ ]
+
+ XCTAssertEqual(contents.count, 3)
+ XCTAssertEqual(contents[0].type, "text")
+ }
+
+ // MARK: - Equatable Tests
+
+ func testTextInputContentEquality() {
+ let content1 = TextInputContent(text: "Same text")
+ let content2 = TextInputContent(text: "Same text")
+ let content3 = TextInputContent(text: "Different text")
+
+ XCTAssertEqual(content1, content2)
+ XCTAssertNotEqual(content1, content3)
+ }
+
+ // MARK: - Hashable Tests
+
+ func testTextInputContentHashable() {
+ let content1 = TextInputContent(text: "Text 1")
+ let content2 = TextInputContent(text: "Text 2")
+
+ let set: Set = [content1, content2]
+ XCTAssertEqual(set.count, 2)
+ XCTAssertTrue(set.contains(content1))
+ XCTAssertTrue(set.contains(content2))
+ }
+
+ // MARK: - Sendable Tests
+
+ func testTextInputContentSendable() {
+ let content = TextInputContent(text: "Concurrent test")
+
+ Task {
+ let capturedContent = content
+ XCTAssertEqual(capturedContent.text, "Concurrent test")
+ }
+ }
+
+ // MARK: - Real-world Usage Tests
+
+ func testSimpleTextMessage() {
+ let content = TextInputContent(text: "What is the weather like today?")
+
+ XCTAssertEqual(content.type, "text")
+ XCTAssertTrue(content.text.contains("weather"))
+ }
+
+ func testLongTextMessage() {
+ let longText = """
+ This is a longer message that spans multiple lines and contains
+ various information. It might be used in a complex user query that
+ requires detailed explanation or context.
+
+ The message can include:
+ - Multiple paragraphs
+ - Lists
+ - Technical details
+ """
+
+ let content = TextInputContent(text: longText)
+
+ XCTAssertTrue(content.text.contains("multiple lines"))
+ XCTAssertTrue(content.text.contains("Lists"))
+ }
+
+ func testCodeSnippetInText() {
+ let codeText = """
+ Can you help me with this Swift code?
+
+ ```swift
+ func greet(name: String) {
+ print("Hello, \\(name)!")
+ }
+ ```
+ """
+
+ let content = TextInputContent(text: codeText)
+
+ XCTAssertTrue(content.text.contains("Swift code"))
+ XCTAssertTrue(content.text.contains("func greet"))
+ }
+
+ func testUnicodeInText() {
+ let unicodeText = "Hello 👋 World 🌍 with émojis and spëcial çharacters"
+
+ let content = TextInputContent(text: unicodeText)
+
+ XCTAssertEqual(content.text, unicodeText)
+ XCTAssertTrue(content.text.contains("👋"))
+ }
+
+ func testTextTypeAlwaysText() {
+ let content1 = TextInputContent(text: "Message 1")
+ let content2 = TextInputContent(text: "Message 2")
+ let content3 = TextInputContent(text: "")
+
+ XCTAssertEqual(content1.type, "text")
+ XCTAssertEqual(content2.type, "text")
+ XCTAssertEqual(content3.type, "text")
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/Types/InputContent/VideoInputContentTests.swift b/sdks/community/swift/Tests/AGUICoreTests/Types/InputContent/VideoInputContentTests.swift
new file mode 100644
index 0000000000..8f92277bba
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/Types/InputContent/VideoInputContentTests.swift
@@ -0,0 +1,178 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+final class VideoInputContentTests: XCTestCase {
+
+ // MARK: - Initialization
+
+ func test_initWithURL_setsFields() {
+ let content = VideoInputContent(url: "https://example.com/video.mp4")
+ XCTAssertEqual(content.type, "video")
+ XCTAssertEqual(content.url, "https://example.com/video.mp4")
+ XCTAssertNil(content.data)
+ }
+
+ func test_initWithData_setsFields() {
+ let content = VideoInputContent(data: "base64video")
+ XCTAssertEqual(content.type, "video")
+ XCTAssertEqual(content.data, "base64video")
+ XCTAssertNil(content.url)
+ }
+
+ // MARK: - Protocol conformance
+
+ func test_conformsToInputContent() {
+ let content: any InputContent = VideoInputContent(url: "https://example.com/video.mp4")
+ XCTAssertEqual(content.type, "video")
+ }
+
+ // MARK: - DTO decoding
+
+ func test_dtoDecodeWithURL_succeeds() throws {
+ let json = Data("""
+ {"type":"video","url":"https://example.com/clip.mp4"}
+ """.utf8)
+ let dto = try VideoInputContentDTO.decode(from: json)
+ let content = dto.toDomain()
+ XCTAssertEqual(content.url, "https://example.com/clip.mp4")
+ XCTAssertEqual(content.type, "video")
+ }
+
+ func test_dtoDecodeWithData_succeeds() throws {
+ let json = Data("""
+ {"type":"video","data":"base64videodata"}
+ """.utf8)
+ let dto = try VideoInputContentDTO.decode(from: json)
+ XCTAssertEqual(dto.toDomain().data, "base64videodata")
+ }
+
+ func test_dtoDecodeWithNoSource_throws() {
+ let json = Data("""
+ {"type":"video"}
+ """.utf8)
+ XCTAssertThrowsError(try VideoInputContentDTO.decode(from: json))
+ }
+
+ func test_dtoDecodeWithWrongType_throws() {
+ let json = Data("""
+ {"type":"audio","url":"https://example.com/audio.mp3"}
+ """.utf8)
+ XCTAssertThrowsError(try VideoInputContentDTO.decode(from: json))
+ }
+
+ // MARK: - Integration: decode via UserMessage
+
+ func test_userMessage_withVideoContent_decodesFromJSON() throws {
+ let json = """
+ {
+ "id": "msg-3",
+ "role": "user",
+ "content": [
+ {"type": "video", "url": "https://example.com/clip.mp4"}
+ ]
+ }
+ """
+ let decoder = MessageDecoder()
+ let message = try decoder.decode(Data(json.utf8))
+ guard let userMsg = message as? UserMessage,
+ let parts = userMsg.contentParts else {
+ return XCTFail("Expected multimodal UserMessage")
+ }
+ guard let videoPart = parts[0] as? VideoInputContent else {
+ return XCTFail("Expected VideoInputContent")
+ }
+ XCTAssertEqual(videoPart.url, "https://example.com/clip.mp4")
+ }
+
+ func test_userMessage_withVideoContent_encodesCorrectly() throws {
+ let video = VideoInputContent(url: "https://example.com/clip.mp4")
+ let userMsg = UserMessage.multimodal(id: "msg-3", parts: [video])
+
+ let encoder = MessageEncoder()
+ let data = try encoder.encode(userMsg)
+ let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
+ let contentArray = json?["content"] as? [[String: Any]]
+
+ XCTAssertEqual(contentArray?[0]["type"] as? String, "video")
+ XCTAssertEqual(contentArray?[0]["url"] as? String, "https://example.com/clip.mp4")
+ }
+
+ // MARK: - Equatable / Hashable / Sendable
+
+ func test_equalContentAreEqual() {
+ XCTAssertEqual(
+ VideoInputContent(url: "https://example.com/v.mp4"),
+ VideoInputContent(url: "https://example.com/v.mp4")
+ )
+ }
+
+ func test_differentURLNotEqual() {
+ XCTAssertNotEqual(
+ VideoInputContent(url: "https://example.com/a.mp4"),
+ VideoInputContent(url: "https://example.com/b.mp4")
+ )
+ }
+
+ func test_hashable() {
+ let set: Set = [
+ VideoInputContent(url: "https://example.com/a.mp4"),
+ VideoInputContent(url: "https://example.com/b.mp4")
+ ]
+ XCTAssertEqual(set.count, 2)
+ }
+
+ func test_sendable() {
+ let content = VideoInputContent(url: "https://example.com/video.mp4")
+ Task { XCTAssertEqual(content.type, "video") }
+ }
+
+ // MARK: - mimeType
+
+ func test_mimeType_isNilByDefault_url() {
+ XCTAssertNil(VideoInputContent(url: "https://example.com/video.mp4").mimeType)
+ }
+
+ func test_mimeType_isNilByDefault_data() {
+ XCTAssertNil(VideoInputContent(data: "base64video").mimeType)
+ }
+
+ func test_mimeType_roundTripsViaURLInit() {
+ let content = VideoInputContent(url: "https://example.com/video.mp4", mimeType: "video/mp4")
+ XCTAssertEqual(content.mimeType, "video/mp4")
+ }
+
+ func test_mimeType_roundTripsViaDataInit() {
+ let content = VideoInputContent(data: "base64video", mimeType: "video/webm")
+ XCTAssertEqual(content.mimeType, "video/webm")
+ }
+
+ func test_mimeType_decodesFromJSON() throws {
+ let json = Data("""
+ {"type":"video","url":"https://example.com/video.mp4","mimeType":"video/mp4"}
+ """.utf8)
+ let dto = try VideoInputContentDTO.decode(from: json)
+ let content = dto.toDomain()
+ XCTAssertEqual(content.mimeType, "video/mp4")
+ }
+
+ func test_mimeType_isNilWhenAbsentInJSON() throws {
+ let json = Data("""
+ {"type":"video","url":"https://example.com/video.mp4"}
+ """.utf8)
+ let dto = try VideoInputContentDTO.decode(from: json)
+ let content = dto.toDomain()
+ XCTAssertNil(content.mimeType)
+ }
+
+ func test_encodeUserMessage_withVideoMimeType_includesMimeTypeInJSON() throws {
+ let video = VideoInputContent(url: "https://example.com/video.mp4", mimeType: "video/mp4")
+ let userMsg = UserMessage.multimodal(id: "msg-1", parts: [video])
+ let encoder = MessageEncoder()
+ let data = try encoder.encode(userMsg)
+ let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
+ let contentArray = json?["content"] as? [[String: Any]]
+ XCTAssertEqual(contentArray?[0]["mimeType"] as? String, "video/mp4")
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/Types/Messages/ActivityMessageTests.swift b/sdks/community/swift/Tests/AGUICoreTests/Types/Messages/ActivityMessageTests.swift
new file mode 100644
index 0000000000..526f6fd454
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/Types/Messages/ActivityMessageTests.swift
@@ -0,0 +1,434 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+/// Tests for the ActivityMessage type
+final class ActivityMessageTests: XCTestCase {
+ // MARK: - Initialization Tests
+
+ func testInitWithBasicActivity() {
+ let content = Data("""
+ {
+ "text": "Processing request..."
+ }
+ """.utf8)
+
+ let message = ActivityMessage(
+ id: "activity-1",
+ activityType: "progress",
+ content: content
+ )
+
+ XCTAssertEqual(message.id, "activity-1")
+ XCTAssertEqual(message.activityType, "progress")
+ XCTAssertEqual(message.role, .activity)
+ XCTAssertFalse(message.content.isEmpty)
+ XCTAssertNil(message.name)
+ }
+
+ func testInitWithComplexActivity() {
+ let content = Data("""
+ {
+ "type": "chart",
+ "data": {
+ "labels": ["Jan", "Feb", "Mar"],
+ "values": [10, 20, 30]
+ }
+ }
+ """.utf8)
+
+ let message = ActivityMessage(
+ id: "activity-2",
+ activityType: "visualization",
+ content: content
+ )
+
+ XCTAssertEqual(message.activityType, "visualization")
+ XCTAssertNotNil(message.content)
+ }
+
+ // MARK: - Message Protocol Conformance Tests
+
+ func testConformsToMessageProtocol() {
+ let content = Data("{}".utf8)
+ let message: any Message = ActivityMessage(
+ id: "activity-3",
+ activityType: "status",
+ content: content
+ )
+
+ XCTAssertEqual(message.id, "activity-3")
+ XCTAssertEqual(message.role, .activity)
+ XCTAssertNil(message.name)
+ }
+
+ func testRoleIsAlwaysActivity() {
+ let content = Data("{}".utf8)
+ let message = ActivityMessage(
+ id: "1",
+ activityType: "test",
+ content: content
+ )
+
+ XCTAssertEqual(message.role, .activity)
+ }
+
+ // MARK: - Serialization Tests (via DTO)
+
+ // Note: ActivityMessage no longer directly supports Codable.
+ // Serialization is handled through ActivityMessageDTO and MessageDecoder.
+ // These tests verify that the DTO layer works correctly.
+
+ // MARK: - Decoding Tests (via MessageDecoder)
+
+ func testDecodingBasicActivity() throws {
+ let json = """
+ {
+ "id": "activity-decode-1",
+ "role": "activity",
+ "activityType": "progress",
+ "content": {
+ "percent": 75
+ }
+ }
+ """
+
+ let decoder = MessageDecoder()
+ let message = try decoder.decode(Data(json.utf8))
+
+ XCTAssertTrue(message is ActivityMessage)
+ let activityMessage = message as! ActivityMessage
+ XCTAssertEqual(activityMessage.id, "activity-decode-1")
+ XCTAssertEqual(activityMessage.role, .activity)
+ XCTAssertEqual(activityMessage.activityType, "progress")
+ XCTAssertFalse(activityMessage.content.isEmpty)
+ XCTAssertNil(activityMessage.name)
+
+ let activityContent = try JSONSerialization.jsonObject(with: activityMessage.content) as? [String: Any]
+ XCTAssertEqual(activityContent?["percent"] as? Int, 75)
+ }
+
+ func testDecodingComplexActivity() throws {
+ let json = """
+ {
+ "id": "activity-decode-2",
+ "role": "activity",
+ "activityType": "visualization",
+ "content": {
+ "type": "chart",
+ "data": {
+ "labels": ["A", "B", "C"],
+ "values": [1, 2, 3]
+ }
+ }
+ }
+ """
+
+ let decoder = MessageDecoder()
+ let message = try decoder.decode(Data(json.utf8))
+
+ XCTAssertTrue(message is ActivityMessage)
+ let activityMessage = message as! ActivityMessage
+ XCTAssertEqual(activityMessage.activityType, "visualization")
+
+ let content = try JSONSerialization.jsonObject(with: activityMessage.content) as? [String: Any]
+ XCTAssertEqual(content?["type"] as? String, "chart")
+
+ let data = content?["data"] as? [String: Any]
+ let labels = data?["labels"] as? [String]
+ XCTAssertEqual(labels, ["A", "B", "C"])
+ }
+
+ func testDecodingFailsWithoutId() {
+ let json = """
+ {
+ "role": "activity",
+ "activityType": "test",
+ "content": {}
+ }
+ """
+
+ let decoder = MessageDecoder()
+ XCTAssertThrowsError(try decoder.decode(Data(json.utf8))) { error in
+ XCTAssertTrue(error is MessageDecodingError || error is DecodingError)
+ }
+ }
+
+ func testDecodingFailsWithoutActivityType() {
+ let json = """
+ {
+ "id": "activity-1",
+ "role": "activity",
+ "content": {}
+ }
+ """
+
+ let decoder = MessageDecoder()
+ XCTAssertThrowsError(try decoder.decode(Data(json.utf8))) { error in
+ XCTAssertTrue(error is MessageDecodingError || error is DecodingError)
+ }
+ }
+
+ func testDecodingFailsWithoutActivityContent() {
+ let json = """
+ {
+ "id": "activity-1",
+ "role": "activity",
+ "activityType": "test"
+ }
+ """
+
+ let decoder = MessageDecoder()
+ XCTAssertThrowsError(try decoder.decode(Data(json.utf8))) { error in
+ XCTAssertTrue(error is MessageDecodingError || error is DecodingError)
+ }
+ }
+
+ func testDecodingFailsWithWrongRole() {
+ let json = """
+ {
+ "id": "activity-1",
+ "role": "user",
+ "content": "Test message"
+ }
+ """
+
+ // With polymorphic MessageDecoder, wrong role returns different message type
+ let decoder = MessageDecoder()
+ let message = try? decoder.decode(Data(json.utf8))
+
+ // Should decode as UserMessage, not ActivityMessage
+ XCTAssertNotNil(message)
+ XCTAssertFalse(message is ActivityMessage)
+ XCTAssertTrue(message is UserMessage)
+ }
+
+ // MARK: - Round-trip Tests (via DTO layer)
+
+ func testRoundTripBasicActivity() throws {
+ // Create original message
+ let content = Data("""
+ {"status": "complete"}
+ """.utf8)
+
+ let original = ActivityMessage(
+ id: "activity-rt-1",
+ activityType: "status",
+ content: content
+ )
+
+ // Encode via DTO (simulating what RunAgentInput does)
+ let activityContentDict = try JSONSerialization.jsonObject(with: content) as? [String: Any]
+ let dict: [String: Any] = [
+ "id": original.id,
+ "role": original.role.rawValue,
+ "activityType": original.activityType,
+ "content": activityContentDict as Any
+ ]
+ let encoded = try JSONSerialization.data(withJSONObject: dict)
+
+ // Decode via MessageDecoder
+ let decoder = MessageDecoder()
+ let decoded = try decoder.decode(encoded)
+
+ XCTAssertTrue(decoded is ActivityMessage)
+ let activityMessage = decoded as! ActivityMessage
+ XCTAssertEqual(activityMessage.id, original.id)
+ XCTAssertEqual(activityMessage.activityType, original.activityType)
+ XCTAssertEqual(activityMessage.role, original.role)
+
+ let originalContent = try JSONSerialization.jsonObject(with: original.content) as? [String: Any]
+ let decodedContent = try JSONSerialization.jsonObject(with: activityMessage.content) as? [String: Any]
+ XCTAssertEqual(originalContent?["status"] as? String, decodedContent?["status"] as? String)
+ }
+
+ // MARK: - Equatable Tests
+
+ func testEquality() {
+ let content1 = Data("""
+ {"value": 1}
+ """.utf8)
+
+ let content2 = Data("""
+ {"value": 1}
+ """.utf8)
+
+ let content3 = Data("""
+ {"value": 2}
+ """.utf8)
+
+ let message1 = ActivityMessage(id: "1", activityType: "test", content: content1)
+ let message2 = ActivityMessage(id: "1", activityType: "test", content: content2)
+ let message3 = ActivityMessage(id: "2", activityType: "test", content: content1)
+ let message4 = ActivityMessage(id: "1", activityType: "other", content: content1)
+ let message5 = ActivityMessage(id: "1", activityType: "test", content: content3)
+
+ XCTAssertEqual(message1, message2)
+ XCTAssertNotEqual(message1, message3)
+ XCTAssertNotEqual(message1, message4)
+ XCTAssertNotEqual(message1, message5)
+ }
+
+ // MARK: - Hashable Tests
+
+ func testHashable() {
+ let content1 = Data("""
+ {"id": 1}
+ """.utf8)
+
+ let content2 = Data("""
+ {"id": 2}
+ """.utf8)
+
+ let message1 = ActivityMessage(id: "1", activityType: "test", content: content1)
+ let message2 = ActivityMessage(id: "2", activityType: "test", content: content2)
+
+ let set: Set = [message1, message2]
+ XCTAssertEqual(set.count, 2)
+ XCTAssertTrue(set.contains(message1))
+ XCTAssertTrue(set.contains(message2))
+ }
+
+ // MARK: - Sendable Tests
+
+ func testSendableConformance() {
+ let content = Data("{}".utf8)
+ let message = ActivityMessage(
+ id: "activity-concurrent",
+ activityType: "test",
+ content: content
+ )
+
+ Task {
+ let capturedMessage = message
+ XCTAssertEqual(capturedMessage.id, "activity-concurrent")
+ }
+ }
+
+ // MARK: - Real-world Usage Tests
+
+ func testProgressIndicator() {
+ let content = Data("""
+ {
+ "percent": 50,
+ "message": "Processing files...",
+ "current": 5,
+ "total": 10
+ }
+ """.utf8)
+
+ let progress = ActivityMessage(
+ id: "progress-1",
+ activityType: "progress",
+ content: content
+ )
+
+ XCTAssertEqual(progress.activityType, "progress")
+ XCTAssertEqual(progress.role, .activity)
+ }
+
+ func testA2UISurface() {
+ let content = Data("""
+ {
+ "surfaceType": "form",
+ "fields": [
+ {"name": "email", "type": "text"},
+ {"name": "submit", "type": "button"}
+ ]
+ }
+ """.utf8)
+
+ let surface = ActivityMessage(
+ id: "surface-1",
+ activityType: "a2ui-form",
+ content: content
+ )
+
+ XCTAssertEqual(surface.activityType, "a2ui-form")
+ }
+
+ func testVisualization() {
+ let content = Data("""
+ {
+ "chartType": "bar",
+ "data": {
+ "labels": ["Q1", "Q2", "Q3", "Q4"],
+ "datasets": [
+ {"label": "Sales", "values": [100, 150, 120, 180]}
+ ]
+ }
+ }
+ """.utf8)
+
+ let chart = ActivityMessage(
+ id: "viz-1",
+ activityType: "chart",
+ content: content
+ )
+
+ XCTAssertEqual(chart.activityType, "chart")
+ }
+
+ func testStatusUpdate() {
+ let content = Data("""
+ {
+ "status": "running",
+ "step": "validation",
+ "timestamp": "2024-01-01T12:00:00Z"
+ }
+ """.utf8)
+
+ let status = ActivityMessage(
+ id: "status-1",
+ activityType: "status",
+ content: content
+ )
+
+ XCTAssertEqual(status.activityType, "status")
+ XCTAssertFalse(status.content.isEmpty)
+ }
+
+ // MARK: - Wire Format Tests (Protocol Compliance)
+
+ func test_decodingUsesWireKey_content_notActivityContent() throws {
+ // The AG-UI spec sends "content", not "activityContent"
+ let json = """
+ {
+ "id": "wire-format-1",
+ "role": "activity",
+ "activityType": "progress",
+ "content": {
+ "percent": 80
+ }
+ }
+ """
+
+ let decoder = MessageDecoder()
+ let message = try decoder.decode(Data(json.utf8))
+
+ XCTAssertTrue(message is ActivityMessage)
+ let activityMessage = message as! ActivityMessage
+ XCTAssertEqual(activityMessage.id, "wire-format-1")
+ XCTAssertEqual(activityMessage.activityType, "progress")
+ let parsedContent = try JSONSerialization.jsonObject(with: activityMessage.content) as? [String: Any]
+ XCTAssertEqual(parsedContent?["percent"] as? Int, 80)
+ }
+
+ func test_encodingWritesWireKey_content() throws {
+ // The AG-UI spec expects "content" in the encoded output
+ let rawContent = Data("{\"percent\":90}".utf8)
+ let message = ActivityMessage(
+ id: "wire-encode-1",
+ activityType: "progress",
+ content: rawContent
+ )
+
+ let encoder = MessageEncoder()
+ let encoded = try encoder.encode(message)
+ let json = try JSONSerialization.jsonObject(with: encoded) as? [String: Any]
+
+ XCTAssertNotNil(json?["content"], "Encoded JSON must contain 'content' key (wire format)")
+ XCTAssertNil(json?["activityContent"], "Encoded JSON must NOT contain old 'activityContent' key")
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/Types/Messages/AssistantMessageTests.swift b/sdks/community/swift/Tests/AGUICoreTests/Types/Messages/AssistantMessageTests.swift
new file mode 100644
index 0000000000..a877469772
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/Types/Messages/AssistantMessageTests.swift
@@ -0,0 +1,414 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+/// Tests for the AssistantMessage type
+final class AssistantMessageTests: XCTestCase {
+ // MARK: - Initialization Tests
+
+ func testInitWithContentOnly() {
+ let message = AssistantMessage(
+ id: "asst-1",
+ content: "Hello! How can I help you?"
+ )
+
+ XCTAssertEqual(message.id, "asst-1")
+ XCTAssertEqual(message.content, "Hello! How can I help you?")
+ XCTAssertNil(message.name)
+ XCTAssertNil(message.toolCalls)
+ XCTAssertEqual(message.role, .assistant)
+ }
+
+ func testInitWithToolCallsOnly() {
+ let toolCall = ToolCall(
+ id: "call_123",
+ function: FunctionCall(name: "get_weather", arguments: "{}")
+ )
+
+ let message = AssistantMessage(
+ id: "asst-2",
+ content: nil,
+ toolCalls: [toolCall]
+ )
+
+ XCTAssertEqual(message.id, "asst-2")
+ XCTAssertNil(message.content)
+ XCTAssertEqual(message.toolCalls?.count, 1)
+ XCTAssertEqual(message.toolCalls?.first?.id, "call_123")
+ }
+
+ func testInitWithContentAndToolCalls() {
+ let toolCall = ToolCall(
+ id: "call_456",
+ function: FunctionCall(name: "search", arguments: "{\"query\":\"test\"}")
+ )
+
+ let message = AssistantMessage(
+ id: "asst-3",
+ content: "Let me search for that information.",
+ toolCalls: [toolCall]
+ )
+
+ XCTAssertEqual(message.id, "asst-3")
+ XCTAssertEqual(message.content, "Let me search for that information.")
+ XCTAssertEqual(message.toolCalls?.count, 1)
+ }
+
+ func testInitWithAllFields() {
+ let toolCalls = [
+ ToolCall(id: "call_1", function: FunctionCall(name: "func1", arguments: "{}")),
+ ToolCall(id: "call_2", function: FunctionCall(name: "func2", arguments: "{}"))
+ ]
+
+ let message = AssistantMessage(
+ id: "asst-4",
+ content: "Processing your request",
+ name: "HelperBot",
+ toolCalls: toolCalls
+ )
+
+ XCTAssertEqual(message.id, "asst-4")
+ XCTAssertEqual(message.content, "Processing your request")
+ XCTAssertEqual(message.name, "HelperBot")
+ XCTAssertEqual(message.toolCalls?.count, 2)
+ XCTAssertEqual(message.role, .assistant)
+ }
+
+ // MARK: - Message Protocol Conformance Tests
+
+ func testConformsToMessageProtocol() {
+ let message: any Message = AssistantMessage(
+ id: "asst-5",
+ content: "Test"
+ )
+
+ XCTAssertEqual(message.id, "asst-5")
+ XCTAssertEqual(message.role, .assistant)
+ XCTAssertEqual((message as? AssistantMessage)?.content, "Test")
+ }
+
+ func testRoleIsAlwaysAssistant() {
+ let message1 = AssistantMessage(id: "1", content: "Message 1")
+ let message2 = AssistantMessage(id: "2", content: nil, toolCalls: [])
+
+ XCTAssertEqual(message1.role, .assistant)
+ XCTAssertEqual(message2.role, .assistant)
+ }
+
+ // MARK: - Serialization Tests (via DTO)
+
+ // Note: AssistantMessage no longer directly supports Codable.
+ // Serialization is handled through AssistantMessageDTO and MessageDecoder.
+ // These tests verify that the DTO layer works correctly.
+
+ // MARK: - Decoding Tests (via MessageDecoder)
+
+ func testDecodingWithContentOnly() throws {
+ let json = """
+ {
+ "id": "asst-decode-1",
+ "role": "assistant",
+ "content": "Hello there!"
+ }
+ """
+
+ let decoder = MessageDecoder()
+ let message = try decoder.decode(Data(json.utf8))
+
+ XCTAssertTrue(message is AssistantMessage)
+ let asstMessage = message as! AssistantMessage
+ XCTAssertEqual(asstMessage.id, "asst-decode-1")
+ XCTAssertEqual(asstMessage.role, .assistant)
+ XCTAssertEqual(asstMessage.content, "Hello there!")
+ XCTAssertNil(asstMessage.toolCalls)
+ }
+
+ func testDecodingWithToolCalls() throws {
+ let json = """
+ {
+ "id": "asst-decode-2",
+ "role": "assistant",
+ "content": "Let me check that",
+ "toolCalls": [
+ {
+ "id": "call_abc",
+ "type": "function",
+ "function": {
+ "name": "get_data",
+ "arguments": "{\\"key\\":\\"value\\"}"
+ }
+ }
+ ]
+ }
+ """
+
+ let decoder = MessageDecoder()
+ let message = try decoder.decode(Data(json.utf8))
+
+ XCTAssertTrue(message is AssistantMessage)
+ let asstMessage = message as! AssistantMessage
+ XCTAssertEqual(asstMessage.id, "asst-decode-2")
+ XCTAssertEqual(asstMessage.content, "Let me check that")
+ XCTAssertEqual(asstMessage.toolCalls?.count, 1)
+ XCTAssertEqual(asstMessage.toolCalls?.first?.id, "call_abc")
+ XCTAssertEqual(asstMessage.toolCalls?.first?.function.name, "get_data")
+ }
+
+ func testDecodingWithNilContent() throws {
+ let json = """
+ {
+ "id": "asst-decode-3",
+ "role": "assistant"
+ }
+ """
+
+ let decoder = MessageDecoder()
+ let message = try decoder.decode(Data(json.utf8))
+
+ XCTAssertTrue(message is AssistantMessage)
+ let asstMessage = message as! AssistantMessage
+ XCTAssertEqual(asstMessage.id, "asst-decode-3")
+ XCTAssertNil(asstMessage.content)
+ }
+
+ func testDecodingWithMultipleToolCalls() throws {
+ let json = """
+ {
+ "id": "asst-decode-4",
+ "role": "assistant",
+ "toolCalls": [
+ {
+ "id": "call_1",
+ "type": "function",
+ "function": {"name": "func1", "arguments": "{}"}
+ },
+ {
+ "id": "call_2",
+ "type": "function",
+ "function": {"name": "func2", "arguments": "{}"}
+ }
+ ]
+ }
+ """
+
+ let decoder = MessageDecoder()
+ let message = try decoder.decode(Data(json.utf8))
+
+ XCTAssertTrue(message is AssistantMessage)
+ let asstMessage = message as! AssistantMessage
+ XCTAssertEqual(asstMessage.toolCalls?.count, 2)
+ XCTAssertEqual(asstMessage.toolCalls?[0].id, "call_1")
+ XCTAssertEqual(asstMessage.toolCalls?[1].id, "call_2")
+ }
+
+ func testDecodingFailsWithoutId() {
+ let json = """
+ {
+ "role": "assistant",
+ "content": "Test"
+ }
+ """
+
+ let decoder = MessageDecoder()
+ XCTAssertThrowsError(try decoder.decode(Data(json.utf8))) { error in
+ XCTAssertTrue(error is MessageDecodingError || error is DecodingError)
+ }
+ }
+
+ func testDecodingFailsWithWrongRole() {
+ let json = """
+ {
+ "id": "asst-1",
+ "role": "user",
+ "content": "Test"
+ }
+ """
+
+ // With polymorphic MessageDecoder, wrong role returns different message type
+ let decoder = MessageDecoder()
+ let message = try? decoder.decode(Data(json.utf8))
+
+ // Should decode as UserMessage, not AssistantMessage
+ XCTAssertNotNil(message)
+ XCTAssertFalse(message is AssistantMessage)
+ XCTAssertTrue(message is UserMessage)
+ }
+
+ // MARK: - Round-trip Tests (via DTO layer)
+
+ func testRoundTripWithContent() throws {
+ // Create original message
+ let original = AssistantMessage(
+ id: "asst-roundtrip-1",
+ content: "This is a test response",
+ name: "Assistant"
+ )
+
+ // Encode via DTO (simulating what RunAgentInput does)
+ let dict: [String: Any] = [
+ "id": original.id,
+ "role": original.role.rawValue,
+ "content": original.content as Any,
+ "name": original.name as Any
+ ]
+ let encoded = try JSONSerialization.data(withJSONObject: dict)
+
+ // Decode via MessageDecoder
+ let decoder = MessageDecoder()
+ let decoded = try decoder.decode(encoded)
+
+ XCTAssertTrue(decoded is AssistantMessage)
+ let asstMessage = decoded as! AssistantMessage
+ XCTAssertEqual(asstMessage.id, original.id)
+ XCTAssertEqual(asstMessage.content, original.content)
+ XCTAssertEqual(asstMessage.name, original.name)
+ }
+
+ func testRoundTripWithToolCalls() throws {
+ // Create original message
+ let toolCalls = [
+ ToolCall(id: "call_rt1", function: FunctionCall(name: "func1", arguments: "{\"a\":1}")),
+ ToolCall(id: "call_rt2", function: FunctionCall(name: "func2", arguments: "{\"b\":2}"))
+ ]
+
+ let original = AssistantMessage(
+ id: "asst-roundtrip-2",
+ content: "Executing functions",
+ toolCalls: toolCalls
+ )
+
+ // Encode via DTO (simulating what RunAgentInput does)
+ let toolCallsArray = original.toolCalls?.map { toolCall in
+ [
+ "id": toolCall.id,
+ "type": "function",
+ "function": [
+ "name": toolCall.function.name,
+ "arguments": toolCall.function.arguments
+ ]
+ ] as [String: Any]
+ }
+
+ let dict: [String: Any] = [
+ "id": original.id,
+ "role": original.role.rawValue,
+ "content": original.content as Any,
+ "toolCalls": toolCallsArray as Any
+ ]
+ let encoded = try JSONSerialization.data(withJSONObject: dict)
+
+ // Decode via MessageDecoder
+ let decoder = MessageDecoder()
+ let decoded = try decoder.decode(encoded)
+
+ XCTAssertTrue(decoded is AssistantMessage)
+ let asstMessage = decoded as! AssistantMessage
+ XCTAssertEqual(asstMessage.id, original.id)
+ XCTAssertEqual(asstMessage.toolCalls?.count, original.toolCalls?.count)
+ XCTAssertEqual(asstMessage.toolCalls?[0].id, original.toolCalls?[0].id)
+ }
+
+ // MARK: - Equatable Tests
+
+ func testEquality() {
+ let toolCall = ToolCall(id: "call_1", function: FunctionCall(name: "test", arguments: "{}"))
+
+ let message1 = AssistantMessage(id: "1", content: "Test", toolCalls: [toolCall])
+ let message2 = AssistantMessage(id: "1", content: "Test", toolCalls: [toolCall])
+ let message3 = AssistantMessage(id: "2", content: "Test", toolCalls: [toolCall])
+ let message4 = AssistantMessage(id: "1", content: "Different")
+
+ XCTAssertEqual(message1, message2)
+ XCTAssertNotEqual(message1, message3)
+ XCTAssertNotEqual(message1, message4)
+ }
+
+ // MARK: - Hashable Tests
+
+ func testHashable() {
+ let message1 = AssistantMessage(id: "1", content: "Test")
+ let message2 = AssistantMessage(id: "2", content: "Test")
+
+ let set: Set = [message1, message2]
+ XCTAssertEqual(set.count, 2)
+ XCTAssertTrue(set.contains(message1))
+ XCTAssertTrue(set.contains(message2))
+ }
+
+ // MARK: - Sendable Tests
+
+ func testSendableConformance() {
+ let message = AssistantMessage(id: "asst-concurrent", content: "Test")
+
+ Task {
+ let capturedMessage = message
+ XCTAssertEqual(capturedMessage.id, "asst-concurrent")
+ }
+ }
+
+ // MARK: - Real-world Usage Tests
+
+ func testTextOnlyResponse() {
+ let response = AssistantMessage(
+ id: "asst-text-1",
+ content: "I understand your question. Let me explain..."
+ )
+
+ XCTAssertEqual(response.role, .assistant)
+ XCTAssertNotNil(response.content)
+ XCTAssertNil(response.toolCalls)
+ }
+
+ func testToolCallWithExplanation() {
+ let weatherCall = ToolCall(
+ id: "call_weather",
+ function: FunctionCall(
+ name: "get_weather",
+ arguments: "{\"location\":\"San Francisco\"}"
+ )
+ )
+
+ let response = AssistantMessage(
+ id: "asst-tool-1",
+ content: "Let me check the weather for you.",
+ toolCalls: [weatherCall]
+ )
+
+ XCTAssertNotNil(response.content)
+ XCTAssertEqual(response.toolCalls?.count, 1)
+ XCTAssertTrue(response.content?.contains("weather") ?? false)
+ }
+
+ func testMultipleToolCallsAtOnce() {
+ let toolCalls = [
+ ToolCall(id: "call_1", function: FunctionCall(name: "get_user", arguments: "{\"id\":\"123\"}")),
+ ToolCall(id: "call_2", function: FunctionCall(name: "get_orders", arguments: "{\"userId\":\"123\"}")),
+ ToolCall(id: "call_3", function: FunctionCall(name: "get_preferences", arguments: "{\"userId\":\"123\"}"))
+ ]
+
+ let response = AssistantMessage(
+ id: "asst-multi-1",
+ content: "Gathering information from multiple sources...",
+ toolCalls: toolCalls
+ )
+
+ XCTAssertEqual(response.toolCalls?.count, 3)
+ }
+
+ func testToolCallOnlyNoText() {
+ let toolCall = ToolCall(
+ id: "call_silent",
+ function: FunctionCall(name: "background_task", arguments: "{}")
+ )
+
+ let response = AssistantMessage(
+ id: "asst-silent-1",
+ content: nil,
+ toolCalls: [toolCall]
+ )
+
+ XCTAssertNil(response.content)
+ XCTAssertNotNil(response.toolCalls)
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/Types/Messages/DeveloperMessageTests.swift b/sdks/community/swift/Tests/AGUICoreTests/Types/Messages/DeveloperMessageTests.swift
new file mode 100644
index 0000000000..4a4869a774
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/Types/Messages/DeveloperMessageTests.swift
@@ -0,0 +1,246 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+/// Tests for the DeveloperMessage type
+final class DeveloperMessageTests: XCTestCase {
+ // MARK: - Initialization Tests
+
+ func testInitWithRequiredFields() {
+ let message = DeveloperMessage(
+ id: "dev-1",
+ content: "System configuration message"
+ )
+
+ XCTAssertEqual(message.id, "dev-1")
+ XCTAssertEqual(message.content, "System configuration message")
+ XCTAssertNil(message.name)
+ XCTAssertEqual(message.role, .developer)
+ }
+
+ func testInitWithAllFields() {
+ let message = DeveloperMessage(
+ id: "dev-2",
+ content: "Admin instructions",
+ name: "SystemAdmin"
+ )
+
+ XCTAssertEqual(message.id, "dev-2")
+ XCTAssertEqual(message.content, "Admin instructions")
+ XCTAssertEqual(message.name, "SystemAdmin")
+ XCTAssertEqual(message.role, .developer)
+ }
+
+ // MARK: - Message Protocol Conformance Tests
+
+ func testConformsToMessageProtocol() {
+ let message: any Message = DeveloperMessage(
+ id: "dev-3",
+ content: "Test message"
+ )
+
+ XCTAssertEqual(message.id, "dev-3")
+ XCTAssertEqual(message.role, .developer)
+ XCTAssertEqual((message as? DeveloperMessage)?.content, "Test message")
+ }
+
+ func testRoleIsAlwaysDeveloper() {
+ let message1 = DeveloperMessage(id: "1", content: "Message 1")
+ let message2 = DeveloperMessage(id: "2", content: "Message 2", name: "Admin")
+
+ XCTAssertEqual(message1.role, .developer)
+ XCTAssertEqual(message2.role, .developer)
+ }
+
+ // MARK: - Serialization Tests (via DTO)
+
+ // Note: DeveloperMessage no longer directly supports Codable.
+ // Serialization is handled through DeveloperMessageDTO and MessageDecoder.
+ // These tests verify that the DTO layer works correctly.
+
+ // MARK: - Decoding Tests (via MessageDecoder)
+
+ func testDecodingWithRequiredFields() throws {
+ let json = """
+ {
+ "id": "dev-decode-1",
+ "role": "developer",
+ "content": "System setup"
+ }
+ """
+
+ let decoder = MessageDecoder()
+ let message = try decoder.decode(Data(json.utf8))
+
+ XCTAssertTrue(message is DeveloperMessage)
+ let devMessage = message as! DeveloperMessage
+ XCTAssertEqual(devMessage.id, "dev-decode-1")
+ XCTAssertEqual(devMessage.role, .developer)
+ XCTAssertEqual(devMessage.content, "System setup")
+ XCTAssertNil(devMessage.name)
+ }
+
+ func testDecodingWithAllFields() throws {
+ let json = """
+ {
+ "id": "dev-decode-2",
+ "role": "developer",
+ "content": "Configuration",
+ "name": "SysAdmin"
+ }
+ """
+
+ let decoder = MessageDecoder()
+ let message = try decoder.decode(Data(json.utf8))
+
+ XCTAssertTrue(message is DeveloperMessage)
+ let devMessage = message as! DeveloperMessage
+ XCTAssertEqual(devMessage.id, "dev-decode-2")
+ XCTAssertEqual(devMessage.role, .developer)
+ XCTAssertEqual(devMessage.content, "Configuration")
+ XCTAssertEqual(devMessage.name, "SysAdmin")
+ }
+
+ func testDecodingFailsWithoutId() {
+ let json = """
+ {
+ "role": "developer",
+ "content": "Test"
+ }
+ """
+
+ let decoder = MessageDecoder()
+ XCTAssertThrowsError(try decoder.decode(Data(json.utf8))) { error in
+ XCTAssertTrue(error is MessageDecodingError || error is DecodingError)
+ }
+ }
+
+ func testDecodingFailsWithoutContent() {
+ let json = """
+ {
+ "id": "dev-1",
+ "role": "developer"
+ }
+ """
+
+ let decoder = MessageDecoder()
+ XCTAssertThrowsError(try decoder.decode(Data(json.utf8))) { error in
+ XCTAssertTrue(error is MessageDecodingError || error is DecodingError)
+ }
+ }
+
+ func testDecodingFailsWithWrongRole() {
+ let json = """
+ {
+ "id": "dev-1",
+ "role": "user",
+ "content": "Test"
+ }
+ """
+
+ // With polymorphic MessageDecoder, wrong role returns different message type
+ let decoder = MessageDecoder()
+ let message = try? decoder.decode(Data(json.utf8))
+
+ // Should decode as UserMessage, not DeveloperMessage
+ XCTAssertNotNil(message)
+ XCTAssertFalse(message is DeveloperMessage)
+ XCTAssertTrue(message is UserMessage)
+ }
+
+ // MARK: - Round-trip Tests (via DTO layer)
+
+ func testRoundTripEncodingDecoding() throws {
+ // Create original message
+ let original = DeveloperMessage(
+ id: "dev-roundtrip",
+ content: "System configuration for agent behavior",
+ name: "ConfigManager"
+ )
+
+ // Encode via DTO (simulating what RunAgentInput does)
+ let dict: [String: Any] = [
+ "id": original.id,
+ "role": original.role.rawValue,
+ "content": original.content ?? "",
+ "name": original.name as Any
+ ]
+ let encoded = try JSONSerialization.data(withJSONObject: dict)
+
+ // Decode via MessageDecoder
+ let decoder = MessageDecoder()
+ let decoded = try decoder.decode(encoded)
+
+ XCTAssertTrue(decoded is DeveloperMessage)
+ let devMessage = decoded as! DeveloperMessage
+ XCTAssertEqual(devMessage.id, original.id)
+ XCTAssertEqual(devMessage.role, original.role)
+ XCTAssertEqual(devMessage.content, original.content)
+ XCTAssertEqual(devMessage.name, original.name)
+ }
+
+ // MARK: - Equatable Tests
+
+ func testEquality() {
+ let message1 = DeveloperMessage(id: "1", content: "Config", name: "Admin")
+ let message2 = DeveloperMessage(id: "1", content: "Config", name: "Admin")
+ let message3 = DeveloperMessage(id: "2", content: "Config", name: "Admin")
+ let message4 = DeveloperMessage(id: "1", content: "Different", name: "Admin")
+
+ XCTAssertEqual(message1, message2)
+ XCTAssertNotEqual(message1, message3)
+ XCTAssertNotEqual(message1, message4)
+ }
+
+ // MARK: - Hashable Tests
+
+ func testHashable() {
+ let message1 = DeveloperMessage(id: "1", content: "Test")
+ let message2 = DeveloperMessage(id: "2", content: "Test")
+
+ let set: Set = [message1, message2]
+ XCTAssertEqual(set.count, 2)
+ XCTAssertTrue(set.contains(message1))
+ XCTAssertTrue(set.contains(message2))
+ }
+
+ // MARK: - Sendable Tests
+
+ func testSendableConformance() {
+ let message = DeveloperMessage(id: "dev-concurrent", content: "Test")
+
+ Task {
+ let capturedMessage = message
+ XCTAssertEqual(capturedMessage.id, "dev-concurrent")
+ }
+ }
+
+ // MARK: - Real-world Usage Tests
+
+ func testSystemLevelConfiguration() {
+ let configMessage = DeveloperMessage(
+ id: "config-1",
+ content: """
+ System configuration:
+ - Enable debug logging
+ - Set max tokens to 4096
+ - Use temperature 0.7
+ """,
+ name: "SystemConfigurator"
+ )
+
+ XCTAssertEqual(configMessage.role, .developer)
+ XCTAssertTrue(configMessage.content?.contains("System configuration") ?? false)
+ }
+
+ func testMetaInstructions() {
+ let metaMessage = DeveloperMessage(
+ id: "meta-1",
+ content: "Agent should prioritize code quality over speed in all responses"
+ )
+
+ XCTAssertEqual(metaMessage.role, .developer)
+ XCTAssertNotNil(metaMessage.content)
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/Types/Messages/MessageTests.swift b/sdks/community/swift/Tests/AGUICoreTests/Types/Messages/MessageTests.swift
new file mode 100644
index 0000000000..6091bf3bee
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/Types/Messages/MessageTests.swift
@@ -0,0 +1,108 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+/// Tests for the Message protocol
+final class MessageTests: XCTestCase {
+ // MARK: - Mock Message Implementation
+
+ /// Mock message implementation for testing protocol conformance
+ struct MockMessage: Message {
+ let id: String
+ let role: Role
+ let content: String?
+ let name: String?
+ let encryptedValue: String?
+
+ init(id: String = "test-id", role: Role = .user, content: String? = "Test content", name: String? = nil, encryptedValue: String? = nil) {
+ self.id = id
+ self.role = role
+ self.content = content
+ self.name = name
+ self.encryptedValue = encryptedValue
+ }
+ }
+
+ // MARK: - Protocol Conformance Tests
+
+ func testMessageProtocolRequiresId() {
+ let message = MockMessage(id: "msg-123")
+ XCTAssertEqual(message.id, "msg-123")
+ }
+
+ func testMessageProtocolRequiresRole() {
+ let message = MockMessage(role: .assistant)
+ XCTAssertEqual(message.role, .assistant)
+ }
+
+ func testMessageProtocolRequiresContent() {
+ let message = MockMessage(content: "Hello, world!")
+ XCTAssertEqual(message.content, "Hello, world!")
+ }
+
+ func testMessageProtocolRequiresName() {
+ let message = MockMessage(name: "TestBot")
+ XCTAssertEqual(message.name, "TestBot")
+ }
+
+ func testMessageContentCanBeNil() {
+ let message = MockMessage(content: nil)
+ XCTAssertNil(message.content)
+ }
+
+ func testMessageNameCanBeNil() {
+ let message = MockMessage(name: nil)
+ XCTAssertNil(message.name)
+ }
+
+ // MARK: - Sendable Conformance Tests
+
+ func testMessageIsSendable() {
+ // This test verifies that Message conforming types can be sent across isolation boundaries
+ let message = MockMessage()
+
+ Task {
+ // If MockMessage is Sendable, this should compile without warnings
+ let capturedMessage = message
+ XCTAssertEqual(capturedMessage.id, message.id)
+ }
+ }
+
+ // MARK: - Protocol Behavior Tests
+
+ // Note: Message protocol no longer requires Codable conformance.
+ // Serialization is handled through message-specific DTOs and MessageDecoder.
+ // The following tests verify basic protocol requirements.
+
+ func testMessageSupportsAllRoleTypes() {
+ let roles: [Role] = [.developer, .system, .assistant, .user, .tool, .activity]
+
+ for role in roles {
+ let message = MockMessage(id: "msg-\(role)", role: role)
+ XCTAssertEqual(message.role, role, "Message should support role: \(role)")
+ }
+ }
+
+ // MARK: - Equatable Tests (if implemented)
+
+ func testMockMessageEquality() {
+ let message1 = MockMessage(id: "msg-1", role: .user, content: "Hello", name: "User1")
+ let message2 = MockMessage(id: "msg-1", role: .user, content: "Hello", name: "User1")
+ let message3 = MockMessage(id: "msg-2", role: .user, content: "Hello", name: "User1")
+
+ XCTAssertEqual(message1, message2)
+ XCTAssertNotEqual(message1, message3)
+ }
+}
+
+// MARK: - MockMessage Equatable Conformance
+
+extension MessageTests.MockMessage: Equatable {
+ static func == (lhs: MessageTests.MockMessage, rhs: MessageTests.MockMessage) -> Bool {
+ lhs.id == rhs.id &&
+ lhs.role == rhs.role &&
+ lhs.content == rhs.content &&
+ lhs.name == rhs.name
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/Types/Messages/ReasoningMessageTests.swift b/sdks/community/swift/Tests/AGUICoreTests/Types/Messages/ReasoningMessageTests.swift
new file mode 100644
index 0000000000..9908b5410e
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/Types/Messages/ReasoningMessageTests.swift
@@ -0,0 +1,76 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+final class ReasoningMessageTests: XCTestCase {
+
+ private let messageId = "msg-reasoning-1"
+ private let content = "Let me think step by step..."
+
+ // MARK: - Role
+
+ func test_roleIsAlwaysReasoning() {
+ let message = ReasoningMessage(id: messageId, content: content)
+ XCTAssertEqual(message.role, .reasoning)
+ }
+
+ // MARK: - Initialization
+
+ func test_initWithContent_storesContent() {
+ let message = ReasoningMessage(id: messageId, content: content)
+ XCTAssertEqual(message.id, messageId)
+ XCTAssertEqual(message.content, content)
+ XCTAssertNil(message.encryptedValue)
+ }
+
+ func test_initWithEncryptedValue_storesEncryptedValue() {
+ let token = "enc-abc-token"
+ let message = ReasoningMessage(id: messageId, content: content, encryptedValue: token)
+ XCTAssertEqual(message.encryptedValue, token)
+ }
+
+ func test_nameIsAlwaysNil() {
+ let message = ReasoningMessage(id: messageId, content: content)
+ XCTAssertNil(message.name)
+ }
+
+ // MARK: - Equatable / Hashable
+
+ func test_equatable_sameFields_areEqual() {
+ let m1 = ReasoningMessage(id: messageId, content: content)
+ let m2 = ReasoningMessage(id: messageId, content: content)
+ XCTAssertEqual(m1, m2)
+ }
+
+ func test_equatable_differentContent_notEqual() {
+ let m1 = ReasoningMessage(id: messageId, content: "a")
+ let m2 = ReasoningMessage(id: messageId, content: "b")
+ XCTAssertNotEqual(m1, m2)
+ }
+
+ func test_equatable_differentId_notEqual() {
+ let m1 = ReasoningMessage(id: "id-1", content: content)
+ let m2 = ReasoningMessage(id: "id-2", content: content)
+ XCTAssertNotEqual(m1, m2)
+ }
+
+ func test_equatable_differentEncryptedValue_notEqual() {
+ let m1 = ReasoningMessage(id: messageId, content: content, encryptedValue: "token-a")
+ let m2 = ReasoningMessage(id: messageId, content: content, encryptedValue: "token-b")
+ XCTAssertNotEqual(m1, m2)
+ }
+
+ func test_hashable_equalMessagesHaveSameHash() {
+ let m1 = ReasoningMessage(id: messageId, content: content)
+ let m2 = ReasoningMessage(id: messageId, content: content)
+ XCTAssertEqual(m1.hashValue, m2.hashValue)
+ }
+
+ func test_hashable_canBeUsedInSet() {
+ let m1 = ReasoningMessage(id: messageId, content: content)
+ let m2 = ReasoningMessage(id: messageId, content: content)
+ let set: Set = [m1, m2]
+ XCTAssertEqual(set.count, 1)
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/Types/Messages/RoleTests.swift b/sdks/community/swift/Tests/AGUICoreTests/Types/Messages/RoleTests.swift
new file mode 100644
index 0000000000..e0727e32cd
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/Types/Messages/RoleTests.swift
@@ -0,0 +1,140 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+/// Tests for the Role enum
+final class RoleTests: XCTestCase {
+ // MARK: - Encoding Tests
+
+ func testDeveloperRoleEncoding() throws {
+ let role = Role.developer
+ let encoded = try JSONEncoder().encode(role)
+ let json = String(data: encoded, encoding: .utf8)
+ XCTAssertEqual(json, "\"developer\"")
+ }
+
+ func testSystemRoleEncoding() throws {
+ let role = Role.system
+ let encoded = try JSONEncoder().encode(role)
+ let json = String(data: encoded, encoding: .utf8)
+ XCTAssertEqual(json, "\"system\"")
+ }
+
+ func testAssistantRoleEncoding() throws {
+ let role = Role.assistant
+ let encoded = try JSONEncoder().encode(role)
+ let json = String(data: encoded, encoding: .utf8)
+ XCTAssertEqual(json, "\"assistant\"")
+ }
+
+ func testUserRoleEncoding() throws {
+ let role = Role.user
+ let encoded = try JSONEncoder().encode(role)
+ let json = String(data: encoded, encoding: .utf8)
+ XCTAssertEqual(json, "\"user\"")
+ }
+
+ func testToolRoleEncoding() throws {
+ let role = Role.tool
+ let encoded = try JSONEncoder().encode(role)
+ let json = String(data: encoded, encoding: .utf8)
+ XCTAssertEqual(json, "\"tool\"")
+ }
+
+ func testActivityRoleEncoding() throws {
+ let role = Role.activity
+ let encoded = try JSONEncoder().encode(role)
+ let json = String(data: encoded, encoding: .utf8)
+ XCTAssertEqual(json, "\"activity\"")
+ }
+
+ // MARK: - Decoding Tests
+
+ func testDeveloperRoleDecoding() throws {
+ let json = Data("\"developer\"".utf8)
+ let role = try JSONDecoder().decode(Role.self, from: json)
+ XCTAssertEqual(role, .developer)
+ }
+
+ func testSystemRoleDecoding() throws {
+ let json = Data("\"system\"".utf8)
+ let role = try JSONDecoder().decode(Role.self, from: json)
+ XCTAssertEqual(role, .system)
+ }
+
+ func testAssistantRoleDecoding() throws {
+ let json = Data("\"assistant\"".utf8)
+ let role = try JSONDecoder().decode(Role.self, from: json)
+ XCTAssertEqual(role, .assistant)
+ }
+
+ func testUserRoleDecoding() throws {
+ let json = Data("\"user\"".utf8)
+ let role = try JSONDecoder().decode(Role.self, from: json)
+ XCTAssertEqual(role, .user)
+ }
+
+ func testToolRoleDecoding() throws {
+ let json = Data("\"tool\"".utf8)
+ let role = try JSONDecoder().decode(Role.self, from: json)
+ XCTAssertEqual(role, .tool)
+ }
+
+ func testActivityRoleDecoding() throws {
+ let json = Data("\"activity\"".utf8)
+ let role = try JSONDecoder().decode(Role.self, from: json)
+ XCTAssertEqual(role, .activity)
+ }
+
+ // MARK: - Round-trip Tests
+
+ func testRoleRoundTrip() throws {
+ let roles: [Role] = [.developer, .system, .assistant, .user, .tool, .activity]
+
+ for role in roles {
+ let encoded = try JSONEncoder().encode(role)
+ let decoded = try JSONDecoder().decode(Role.self, from: encoded)
+ XCTAssertEqual(decoded, role, "Round-trip failed for role: \(role)")
+ }
+ }
+
+ // MARK: - Invalid Input Tests
+
+ func testInvalidRoleDecoding() {
+ let json = Data("\"invalid_role\"".utf8)
+ XCTAssertThrowsError(try JSONDecoder().decode(Role.self, from: json)) { error in
+ XCTAssertTrue(error is DecodingError, "Expected DecodingError but got \(type(of: error))")
+ }
+ }
+
+ // MARK: - Hashable & Equatable Tests
+
+ func testRoleEquality() {
+ XCTAssertEqual(Role.developer, Role.developer)
+ XCTAssertNotEqual(Role.developer, Role.system)
+ }
+
+ func testRoleHashable() {
+ let roleSet: Set = [.developer, .system, .assistant]
+ XCTAssertTrue(roleSet.contains(.developer))
+ XCTAssertTrue(roleSet.contains(.system))
+ XCTAssertFalse(roleSet.contains(.user))
+ }
+
+ // MARK: - CaseIterable Tests
+
+ func testAllRolesCount() {
+ XCTAssertEqual(Role.allCases.count, 7, "Role enum should have exactly 6 cases")
+ }
+
+ func testAllRolesContainsAllValues() {
+ let allRoles = Role.allCases
+ XCTAssertTrue(allRoles.contains(.developer))
+ XCTAssertTrue(allRoles.contains(.system))
+ XCTAssertTrue(allRoles.contains(.assistant))
+ XCTAssertTrue(allRoles.contains(.user))
+ XCTAssertTrue(allRoles.contains(.tool))
+ XCTAssertTrue(allRoles.contains(.activity))
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/Types/Messages/SystemMessageTests.swift b/sdks/community/swift/Tests/AGUICoreTests/Types/Messages/SystemMessageTests.swift
new file mode 100644
index 0000000000..faf339a915
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/Types/Messages/SystemMessageTests.swift
@@ -0,0 +1,317 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+/// Tests for the SystemMessage type
+final class SystemMessageTests: XCTestCase {
+ // MARK: - Initialization Tests
+
+ func testInitWithRequiredFields() {
+ let message = SystemMessage(
+ id: "sys-1",
+ content: "You are a helpful assistant."
+ )
+
+ XCTAssertEqual(message.id, "sys-1")
+ XCTAssertEqual(message.content, "You are a helpful assistant.")
+ XCTAssertNil(message.name)
+ XCTAssertEqual(message.role, .system)
+ }
+
+ func testInitWithAllFields() {
+ let message = SystemMessage(
+ id: "sys-2",
+ content: "Be concise and professional.",
+ name: "ProfessionalAssistant"
+ )
+
+ XCTAssertEqual(message.id, "sys-2")
+ XCTAssertEqual(message.content, "Be concise and professional.")
+ XCTAssertEqual(message.name, "ProfessionalAssistant")
+ XCTAssertEqual(message.role, .system)
+ }
+
+ func testInitWithDefaultContent() {
+ // content defaults to "" when omitted
+ let message = SystemMessage(id: "sys-3")
+
+ XCTAssertEqual(message.id, "sys-3")
+ XCTAssertEqual(message.content, "")
+ XCTAssertNil(message.name)
+ XCTAssertEqual(message.role, .system)
+ }
+
+ // MARK: - Message Protocol Conformance Tests
+
+ func testConformsToMessageProtocol() {
+ let message: any Message = SystemMessage(
+ id: "sys-4",
+ content: "Test message"
+ )
+
+ XCTAssertEqual(message.id, "sys-4")
+ XCTAssertEqual(message.role, .system)
+ XCTAssertEqual((message as? SystemMessage)?.content, "Test message")
+ }
+
+ func testRoleIsAlwaysSystem() {
+ let message1 = SystemMessage(id: "1", content: "Message 1")
+ let message2 = SystemMessage(id: "2", content: "", name: "System")
+
+ XCTAssertEqual(message1.role, .system)
+ XCTAssertEqual(message2.role, .system)
+ }
+
+ // MARK: - Serialization Tests (via DTO)
+
+ // Note: SystemMessage no longer directly supports Codable.
+ // Serialization is handled through SystemMessageDTO and MessageDecoder.
+ // These tests verify that the DTO layer works correctly.
+
+ // MARK: - Decoding Tests (via MessageDecoder)
+
+ func testDecodingWithContent() throws {
+ let json = """
+ {
+ "id": "sys-decode-1",
+ "role": "system",
+ "content": "You are a coding assistant."
+ }
+ """
+
+ let decoder = MessageDecoder()
+ let message = try decoder.decode(Data(json.utf8))
+
+ XCTAssertTrue(message is SystemMessage)
+ let sysMessage = message as! SystemMessage
+ XCTAssertEqual(sysMessage.id, "sys-decode-1")
+ XCTAssertEqual(sysMessage.role, .system)
+ XCTAssertEqual(sysMessage.content, "You are a coding assistant.")
+ XCTAssertNil(sysMessage.name)
+ }
+
+ func testDecodingWithAllFields() throws {
+ let json = """
+ {
+ "id": "sys-decode-2",
+ "role": "system",
+ "content": "Be professional.",
+ "name": "ProfessionalMode"
+ }
+ """
+
+ let decoder = MessageDecoder()
+ let message = try decoder.decode(Data(json.utf8))
+
+ XCTAssertTrue(message is SystemMessage)
+ let sysMessage = message as! SystemMessage
+ XCTAssertEqual(sysMessage.id, "sys-decode-2")
+ XCTAssertEqual(sysMessage.role, .system)
+ XCTAssertEqual(sysMessage.content, "Be professional.")
+ XCTAssertEqual(sysMessage.name, "ProfessionalMode")
+ }
+
+ func testDecodingWithMissingContent() throws {
+ // Missing content key decodes as "" (non-optional String with fallback)
+ let json = """
+ {
+ "id": "sys-decode-3",
+ "role": "system"
+ }
+ """
+
+ let decoder = MessageDecoder()
+ let message = try decoder.decode(Data(json.utf8))
+
+ XCTAssertTrue(message is SystemMessage)
+ let sysMessage = message as! SystemMessage
+ XCTAssertEqual(sysMessage.id, "sys-decode-3")
+ XCTAssertEqual(sysMessage.role, .system)
+ XCTAssertEqual(sysMessage.content, "")
+ XCTAssertNil(sysMessage.name)
+ }
+
+ func testDecodingWithNullContent() throws {
+ // Null content decodes as "" (non-optional String with fallback)
+ let json = """
+ {
+ "id": "sys-decode-4",
+ "role": "system",
+ "content": null
+ }
+ """
+
+ let decoder = MessageDecoder()
+ let message = try decoder.decode(Data(json.utf8))
+
+ XCTAssertTrue(message is SystemMessage)
+ let sysMessage = message as! SystemMessage
+ XCTAssertEqual(sysMessage.id, "sys-decode-4")
+ XCTAssertEqual(sysMessage.content, "")
+ }
+
+ func testDecodingFailsWithoutId() {
+ let json = """
+ {
+ "role": "system",
+ "content": "Test"
+ }
+ """
+
+ let decoder = MessageDecoder()
+ XCTAssertThrowsError(try decoder.decode(Data(json.utf8))) { error in
+ XCTAssertTrue(error is MessageDecodingError || error is DecodingError)
+ }
+ }
+
+ func testDecodingFailsWithWrongRole() {
+ let json = """
+ {
+ "id": "sys-1",
+ "role": "user",
+ "content": "Test"
+ }
+ """
+
+ // With polymorphic MessageDecoder, wrong role returns different message type
+ let decoder = MessageDecoder()
+ let message = try? decoder.decode(Data(json.utf8))
+
+ // Should decode as UserMessage, not SystemMessage
+ XCTAssertNotNil(message)
+ XCTAssertFalse(message is SystemMessage)
+ XCTAssertTrue(message is UserMessage)
+ }
+
+ // MARK: - Round-trip Tests (via DTO layer)
+
+ func testRoundTripWithContent() throws {
+ // Create original message
+ let original = SystemMessage(
+ id: "sys-roundtrip-1",
+ content: "You are an expert Swift developer.",
+ name: "SwiftExpert"
+ )
+
+ // Encode via DTO (simulating what RunAgentInput does)
+ let dict: [String: Any] = [
+ "id": original.id,
+ "role": original.role.rawValue,
+ "content": original.content as Any,
+ "name": original.name as Any
+ ]
+ let encoded = try JSONSerialization.data(withJSONObject: dict)
+
+ // Decode via MessageDecoder
+ let decoder = MessageDecoder()
+ let decoded = try decoder.decode(encoded)
+
+ XCTAssertTrue(decoded is SystemMessage)
+ let sysMessage = decoded as! SystemMessage
+ XCTAssertEqual(sysMessage.id, original.id)
+ XCTAssertEqual(sysMessage.role, original.role)
+ XCTAssertEqual(sysMessage.content, original.content)
+ XCTAssertEqual(sysMessage.name, original.name)
+ }
+
+ func testRoundTripWithEmptyContent() throws {
+ // Create original message with default empty content
+ let original = SystemMessage(id: "sys-roundtrip-2")
+
+ // Encode via DTO
+ let dict: [String: Any] = [
+ "id": original.id,
+ "role": original.role.rawValue,
+ "content": original.content
+ ]
+ let encoded = try JSONSerialization.data(withJSONObject: dict)
+
+ // Decode via MessageDecoder
+ let decoder = MessageDecoder()
+ let decoded = try decoder.decode(encoded)
+
+ XCTAssertTrue(decoded is SystemMessage)
+ let sysMessage = decoded as! SystemMessage
+ XCTAssertEqual(sysMessage.id, original.id)
+ XCTAssertEqual(sysMessage.role, original.role)
+ XCTAssertEqual(sysMessage.content, "")
+ }
+
+ // MARK: - Equatable Tests
+
+ func testEquality() {
+ let message1 = SystemMessage(id: "1", content: "Test", name: "Sys")
+ let message2 = SystemMessage(id: "1", content: "Test", name: "Sys")
+ let message3 = SystemMessage(id: "2", content: "Test", name: "Sys")
+ let message4 = SystemMessage(id: "1", content: "Different", name: "Sys")
+ let message5 = SystemMessage(id: "1", content: "", name: "Sys")
+
+ XCTAssertEqual(message1, message2)
+ XCTAssertNotEqual(message1, message3)
+ XCTAssertNotEqual(message1, message4)
+ XCTAssertNotEqual(message1, message5)
+ }
+
+ // MARK: - Hashable Tests
+
+ func testHashable() {
+ let message1 = SystemMessage(id: "1", content: "Test")
+ let message2 = SystemMessage(id: "2", content: "Test")
+
+ let set: Set = [message1, message2]
+ XCTAssertEqual(set.count, 2)
+ XCTAssertTrue(set.contains(message1))
+ XCTAssertTrue(set.contains(message2))
+ }
+
+ // MARK: - Sendable Tests
+
+ func testSendableConformance() {
+ let message = SystemMessage(id: "sys-concurrent", content: "Test")
+
+ Task {
+ let capturedMessage = message
+ XCTAssertEqual(capturedMessage.id, "sys-concurrent")
+ }
+ }
+
+ // MARK: - Real-world Usage Tests
+
+ func testBehavioralGuidelines() {
+ let guidelines = SystemMessage(
+ id: "sys-behavioral-1",
+ content: """
+ You are a professional coding assistant. Guidelines:
+ - Always explain your reasoning
+ - Write clean, well-documented code
+ - Follow best practices
+ - Be concise but thorough
+ """
+ )
+
+ XCTAssertEqual(guidelines.role, .system)
+ XCTAssertTrue(guidelines.content.contains("professional"))
+ }
+
+ func testPersonalityTraits() {
+ let personality = SystemMessage(
+ id: "sys-personality-1",
+ content: "You are friendly, patient, and encouraging in all interactions.",
+ name: "FriendlyAssistant"
+ )
+
+ XCTAssertEqual(personality.role, .system)
+ XCTAssertEqual(personality.name, "FriendlyAssistant")
+ }
+
+ func testContextSetting() {
+ let context = SystemMessage(
+ id: "sys-context-1",
+ content: "You are helping a beginner learn Swift programming. Be patient and explain concepts clearly."
+ )
+
+ XCTAssertFalse(context.content.isEmpty)
+ XCTAssertTrue(context.content.contains("beginner"))
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/Types/Messages/ToolMessageTests.swift b/sdks/community/swift/Tests/AGUICoreTests/Types/Messages/ToolMessageTests.swift
new file mode 100644
index 0000000000..ed298bf3c1
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/Types/Messages/ToolMessageTests.swift
@@ -0,0 +1,382 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+/// Tests for the ToolMessage type
+final class ToolMessageTests: XCTestCase {
+ // MARK: - Initialization Tests
+
+ func testInitWithRequiredFields() {
+ let message = ToolMessage(
+ id: "tool-msg-1",
+ content: "Result: 42",
+ toolCallId: "call-123"
+ )
+
+ XCTAssertEqual(message.id, "tool-msg-1")
+ XCTAssertEqual(message.content, "Result: 42")
+ XCTAssertEqual(message.toolCallId, "call-123")
+ XCTAssertNil(message.name)
+ XCTAssertNil(message.error)
+ XCTAssertEqual(message.role, .tool)
+ }
+
+ func testInitWithAllFields() {
+ let message = ToolMessage(
+ id: "tool-msg-2",
+ content: "File saved successfully",
+ toolCallId: "call-456",
+ name: "save_file",
+ error: nil
+ )
+
+ XCTAssertEqual(message.id, "tool-msg-2")
+ XCTAssertEqual(message.content, "File saved successfully")
+ XCTAssertEqual(message.toolCallId, "call-456")
+ XCTAssertEqual(message.name, "save_file")
+ XCTAssertNil(message.error)
+ XCTAssertEqual(message.role, .tool)
+ }
+
+ func testInitWithError() {
+ let message = ToolMessage(
+ id: "tool-msg-3",
+ content: "Failed to execute",
+ toolCallId: "call-789",
+ name: "broken_tool",
+ error: "File not found"
+ )
+
+ XCTAssertEqual(message.id, "tool-msg-3")
+ XCTAssertEqual(message.error, "File not found")
+ }
+
+ // MARK: - Message Protocol Conformance Tests
+
+ func testConformsToMessageProtocol() {
+ let message: any Message = ToolMessage(
+ id: "tool-msg-4",
+ content: "Success",
+ toolCallId: "call-test"
+ )
+
+ XCTAssertEqual(message.id, "tool-msg-4")
+ XCTAssertEqual(message.role, .tool)
+ XCTAssertEqual((message as? ToolMessage)?.content, "Success")
+ }
+
+ func testRoleIsAlwaysTool() {
+ let message1 = ToolMessage(id: "1", content: "Result 1", toolCallId: "call-1")
+ let message2 = ToolMessage(id: "2", content: "Result 2", toolCallId: "call-2", name: "tool")
+
+ XCTAssertEqual(message1.role, .tool)
+ XCTAssertEqual(message2.role, .tool)
+ }
+
+ // MARK: - Serialization Tests (via DTO)
+
+ // Note: ToolMessage no longer directly supports Codable.
+ // Serialization is handled through ToolMessageDTO and MessageDecoder.
+ // These tests verify that the DTO layer works correctly.
+
+ // MARK: - Decoding Tests (via MessageDecoder)
+
+ func testDecodingWithRequiredFields() throws {
+ let json = """
+ {
+ "id": "tool-decode-1",
+ "role": "tool",
+ "content": "Operation successful",
+ "toolCallId": "call-dec-1"
+ }
+ """
+
+ let decoder = MessageDecoder()
+ let message = try decoder.decode(Data(json.utf8))
+
+ XCTAssertTrue(message is ToolMessage)
+ let toolMessage = message as! ToolMessage
+ XCTAssertEqual(toolMessage.id, "tool-decode-1")
+ XCTAssertEqual(toolMessage.role, .tool)
+ XCTAssertEqual(toolMessage.content, "Operation successful")
+ XCTAssertEqual(toolMessage.toolCallId, "call-dec-1")
+ XCTAssertNil(toolMessage.name)
+ XCTAssertNil(toolMessage.error)
+ }
+
+ func testDecodingWithAllFields() throws {
+ let json = """
+ {
+ "id": "tool-decode-2",
+ "role": "tool",
+ "content": "File written",
+ "toolCallId": "call-dec-2",
+ "name": "write_file",
+ "error": null
+ }
+ """
+
+ let decoder = MessageDecoder()
+ let message = try decoder.decode(Data(json.utf8))
+
+ XCTAssertTrue(message is ToolMessage)
+ let toolMessage = message as! ToolMessage
+ XCTAssertEqual(toolMessage.id, "tool-decode-2")
+ XCTAssertEqual(toolMessage.content, "File written")
+ XCTAssertEqual(toolMessage.toolCallId, "call-dec-2")
+ XCTAssertEqual(toolMessage.name, "write_file")
+ XCTAssertNil(toolMessage.error)
+ }
+
+ func testDecodingWithError() throws {
+ let json = """
+ {
+ "id": "tool-decode-3",
+ "role": "tool",
+ "content": "Failed",
+ "toolCallId": "call-dec-3",
+ "error": "Network timeout"
+ }
+ """
+
+ let decoder = MessageDecoder()
+ let message = try decoder.decode(Data(json.utf8))
+
+ XCTAssertTrue(message is ToolMessage)
+ let toolMessage = message as! ToolMessage
+ XCTAssertEqual(toolMessage.error, "Network timeout")
+ }
+
+ func testDecodingFailsWithoutId() {
+ let json = """
+ {
+ "role": "tool",
+ "content": "Test",
+ "toolCallId": "call-1"
+ }
+ """
+
+ let decoder = MessageDecoder()
+ XCTAssertThrowsError(try decoder.decode(Data(json.utf8))) { error in
+ XCTAssertTrue(error is MessageDecodingError || error is DecodingError)
+ }
+ }
+
+ func testDecodingWithoutContent() throws {
+ // Content is optional for ToolMessage, defaults to empty string when missing
+ let json = """
+ {
+ "id": "tool-1",
+ "role": "tool",
+ "toolCallId": "call-1"
+ }
+ """
+
+ let decoder = MessageDecoder()
+ let message = try decoder.decode(Data(json.utf8))
+
+ XCTAssertTrue(message is ToolMessage)
+ let toolMessage = message as! ToolMessage
+ XCTAssertEqual(toolMessage.id, "tool-1")
+ XCTAssertEqual(toolMessage.toolCallId, "call-1")
+ // When content is missing, it defaults to empty string
+ XCTAssertEqual(toolMessage.content, "")
+ }
+
+ func testDecodingFailsWithoutToolCallId() {
+ let json = """
+ {
+ "id": "tool-1",
+ "role": "tool",
+ "content": "Result"
+ }
+ """
+
+ let decoder = MessageDecoder()
+ XCTAssertThrowsError(try decoder.decode(Data(json.utf8))) { error in
+ XCTAssertTrue(error is MessageDecodingError || error is DecodingError)
+ }
+ }
+
+ func testDecodingFailsWithWrongRole() {
+ let json = """
+ {
+ "id": "tool-1",
+ "role": "user",
+ "content": "Test",
+ "toolCallId": "call-1"
+ }
+ """
+
+ // With polymorphic MessageDecoder, wrong role returns different message type
+ let decoder = MessageDecoder()
+ let message = try? decoder.decode(Data(json.utf8))
+
+ // Should decode as UserMessage, not ToolMessage
+ XCTAssertNotNil(message)
+ XCTAssertFalse(message is ToolMessage)
+ XCTAssertTrue(message is UserMessage)
+ }
+
+ // MARK: - Round-trip Tests (via DTO layer)
+
+ func testRoundTripWithRequiredFields() throws {
+ // Create original message
+ let original = ToolMessage(
+ id: "tool-roundtrip-1",
+ content: "Database query result: 100 rows",
+ toolCallId: "call-rt-1"
+ )
+
+ // Encode via DTO (simulating what RunAgentInput does)
+ let dict: [String: Any] = [
+ "id": original.id,
+ "role": original.role.rawValue,
+ "content": original.content,
+ "toolCallId": original.toolCallId
+ ]
+ let encoded = try JSONSerialization.data(withJSONObject: dict)
+
+ // Decode via MessageDecoder
+ let decoder = MessageDecoder()
+ let decoded = try decoder.decode(encoded)
+
+ XCTAssertTrue(decoded is ToolMessage)
+ let toolMessage = decoded as! ToolMessage
+ XCTAssertEqual(toolMessage.id, original.id)
+ XCTAssertEqual(toolMessage.role, original.role)
+ XCTAssertEqual(toolMessage.content, original.content)
+ XCTAssertEqual(toolMessage.toolCallId, original.toolCallId)
+ }
+
+ func testRoundTripWithAllFields() throws {
+ // Create original message
+ let original = ToolMessage(
+ id: "tool-roundtrip-2",
+ content: "API call failed",
+ toolCallId: "call-rt-2",
+ name: "api_request",
+ error: "HTTP 500 Internal Server Error"
+ )
+
+ // Encode via DTO (simulating what RunAgentInput does)
+ let dict: [String: Any] = [
+ "id": original.id,
+ "role": original.role.rawValue,
+ "content": original.content,
+ "toolCallId": original.toolCallId,
+ "name": original.name as Any,
+ "error": original.error as Any
+ ]
+ let encoded = try JSONSerialization.data(withJSONObject: dict)
+
+ // Decode via MessageDecoder
+ let decoder = MessageDecoder()
+ let decoded = try decoder.decode(encoded)
+
+ XCTAssertTrue(decoded is ToolMessage)
+ let toolMessage = decoded as! ToolMessage
+ XCTAssertEqual(toolMessage.id, original.id)
+ XCTAssertEqual(toolMessage.content, original.content)
+ XCTAssertEqual(toolMessage.toolCallId, original.toolCallId)
+ XCTAssertEqual(toolMessage.name, original.name)
+ XCTAssertEqual(toolMessage.error, original.error)
+ }
+
+ // MARK: - Equatable Tests
+
+ func testEquality() {
+ let message1 = ToolMessage(id: "1", content: "Result", toolCallId: "call-1", name: "tool")
+ let message2 = ToolMessage(id: "1", content: "Result", toolCallId: "call-1", name: "tool")
+ let message3 = ToolMessage(id: "2", content: "Result", toolCallId: "call-1", name: "tool")
+ let message4 = ToolMessage(id: "1", content: "Different", toolCallId: "call-1", name: "tool")
+ let message5 = ToolMessage(id: "1", content: "Result", toolCallId: "call-2", name: "tool")
+
+ XCTAssertEqual(message1, message2)
+ XCTAssertNotEqual(message1, message3)
+ XCTAssertNotEqual(message1, message4)
+ XCTAssertNotEqual(message1, message5)
+ }
+
+ // MARK: - Hashable Tests
+
+ func testHashable() {
+ let message1 = ToolMessage(id: "1", content: "Test", toolCallId: "call-1")
+ let message2 = ToolMessage(id: "2", content: "Test", toolCallId: "call-2")
+
+ let set: Set = [message1, message2]
+ XCTAssertEqual(set.count, 2)
+ XCTAssertTrue(set.contains(message1))
+ XCTAssertTrue(set.contains(message2))
+ }
+
+ // MARK: - Sendable Tests
+
+ func testSendableConformance() {
+ let message = ToolMessage(id: "tool-concurrent", content: "Result", toolCallId: "call-123")
+
+ Task {
+ let capturedMessage = message
+ XCTAssertEqual(capturedMessage.toolCallId, "call-123")
+ }
+ }
+
+ // MARK: - Real-world Usage Tests
+
+ func testSuccessfulToolExecution() {
+ let result = ToolMessage(
+ id: "tool-success-1",
+ content: "Successfully saved 5 files to /documents",
+ toolCallId: "call-save-files-1",
+ name: "save_files"
+ )
+
+ XCTAssertEqual(result.role, .tool)
+ XCTAssertNil(result.error)
+ XCTAssertTrue(result.content.contains("Successfully"))
+ }
+
+ func testFailedToolExecution() {
+ let result = ToolMessage(
+ id: "tool-error-1",
+ content: "Operation failed",
+ toolCallId: "call-delete-1",
+ name: "delete_file",
+ error: "Permission denied: Cannot delete system file"
+ )
+
+ XCTAssertNotNil(result.error)
+ XCTAssertTrue(result.error?.contains("Permission denied") ?? false)
+ }
+
+ func testToolCallIdLinkage() {
+ // Verify that toolCallId properly links request and response
+ let toolCallId = "call-calc-123"
+
+ let toolMessage = ToolMessage(
+ id: "tool-response-1",
+ content: "Result: 3.14159",
+ toolCallId: toolCallId,
+ name: "calculate_pi"
+ )
+
+ // The toolCallId should match the original tool call request
+ XCTAssertEqual(toolMessage.toolCallId, toolCallId)
+ }
+
+ func testDatabaseQueryResult() {
+ let queryResult = ToolMessage(
+ id: "tool-db-1",
+ content: """
+ Query executed successfully.
+ Rows affected: 42
+ Execution time: 127ms
+ """,
+ toolCallId: "call-query-users",
+ name: "execute_sql"
+ )
+
+ XCTAssertTrue(queryResult.content.contains("42"))
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/Types/Messages/UserMessageTests.swift b/sdks/community/swift/Tests/AGUICoreTests/Types/Messages/UserMessageTests.swift
new file mode 100644
index 0000000000..2fb81cf756
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/Types/Messages/UserMessageTests.swift
@@ -0,0 +1,438 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+/// Tests for the UserMessage type
+final class UserMessageTests: XCTestCase {
+ // MARK: - Text-only Initialization Tests
+
+ func testInitWithTextContent() {
+ let message = UserMessage(
+ id: "user-1",
+ content: "What is the weather like today?"
+ )
+
+ XCTAssertEqual(message.id, "user-1")
+ XCTAssertEqual(message.content, "What is the weather like today?")
+ XCTAssertNil(message.name)
+ XCTAssertNil(message.contentParts)
+ XCTAssertFalse(message.isMultimodal)
+ XCTAssertEqual(message.role, .user)
+ }
+
+ func testInitWithTextAndName() {
+ let message = UserMessage(
+ id: "user-2",
+ content: "Hello!",
+ name: "Alice"
+ )
+
+ XCTAssertEqual(message.id, "user-2")
+ XCTAssertEqual(message.content, "Hello!")
+ XCTAssertEqual(message.name, "Alice")
+ XCTAssertFalse(message.isMultimodal)
+ }
+
+ // MARK: - Multimodal Initialization Tests
+
+ func testInitMultimodalWithTextAndImage() {
+ let parts: [any InputContent] = [
+ TextInputContent(text: "What's in this image?"),
+ BinaryInputContent(mimeType: "image/jpeg", url: "https://example.com/photo.jpg")
+ ]
+
+ let message = UserMessage.multimodal(
+ id: "user-3",
+ parts: parts
+ )
+
+ XCTAssertEqual(message.id, "user-3")
+ XCTAssertEqual(message.content, "") // Empty for multimodal
+ XCTAssertTrue(message.isMultimodal)
+ XCTAssertEqual(message.contentParts?.count, 2)
+ }
+
+ func testInitMultimodalWithName() {
+ let parts: [any InputContent] = [
+ TextInputContent(text: "Analyze this")
+ ]
+
+ let message = UserMessage.multimodal(
+ id: "user-4",
+ parts: parts,
+ name: "Bob"
+ )
+
+ XCTAssertEqual(message.name, "Bob")
+ XCTAssertTrue(message.isMultimodal)
+ }
+
+ func testInitMultimodalWithMultipleParts() {
+ let parts: [any InputContent] = [
+ TextInputContent(text: "Compare these images:"),
+ BinaryInputContent(mimeType: "image/png", url: "https://example.com/img1.png"),
+ BinaryInputContent(mimeType: "image/png", url: "https://example.com/img2.png"),
+ TextInputContent(text: "What are the differences?")
+ ]
+
+ let message = UserMessage.multimodal(id: "user-5", parts: parts)
+
+ XCTAssertEqual(message.contentParts?.count, 4)
+ XCTAssertTrue(message.isMultimodal)
+ }
+
+ // MARK: - Message Protocol Conformance Tests
+
+ func testConformsToMessageProtocol() {
+ let message: any Message = UserMessage(
+ id: "user-6",
+ content: "Test message"
+ )
+
+ XCTAssertEqual(message.id, "user-6")
+ XCTAssertEqual(message.role, .user)
+ XCTAssertEqual((message as? UserMessage)?.content, "Test message")
+ }
+
+ func testRoleIsAlwaysUser() {
+ let message1 = UserMessage(id: "1", content: "Text")
+ let message2 = UserMessage.multimodal(id: "2", parts: [TextInputContent(text: "Multimodal")])
+
+ XCTAssertEqual(message1.role, .user)
+ XCTAssertEqual(message2.role, .user)
+ }
+
+ // MARK: - Serialization Tests (via DTO)
+
+ // Note: UserMessage no longer directly supports Codable.
+ // Serialization is handled through UserMessageDTO and MessageDecoder.
+ // These tests verify that the DTO layer works correctly.
+
+ // MARK: - Decoding Tests (via MessageDecoder)
+
+ func testDecodingTextOnly() throws {
+ let json = """
+ {
+ "id": "user-decode-1",
+ "role": "user",
+ "content": "Hello, how are you?"
+ }
+ """
+
+ let decoder = MessageDecoder()
+ let message = try decoder.decode(Data(json.utf8))
+
+ XCTAssertTrue(message is UserMessage)
+ let userMessage = message as! UserMessage
+ XCTAssertEqual(userMessage.id, "user-decode-1")
+ XCTAssertEqual(userMessage.role, .user)
+ XCTAssertEqual(userMessage.content, "Hello, how are you?")
+ XCTAssertNil(userMessage.contentParts)
+ XCTAssertFalse(userMessage.isMultimodal)
+ }
+
+ func testDecodingTextWithName() throws {
+ let json = """
+ {
+ "id": "user-decode-2",
+ "role": "user",
+ "content": "Test message",
+ "name": "Charlie"
+ }
+ """
+
+ let decoder = MessageDecoder()
+ let message = try decoder.decode(Data(json.utf8))
+
+ XCTAssertTrue(message is UserMessage)
+ let userMessage = message as! UserMessage
+ XCTAssertEqual(userMessage.name, "Charlie")
+ XCTAssertFalse(userMessage.isMultimodal)
+ }
+
+ // MARK: - Multimodal Decoding Tests
+
+ func testDecodingMultimodal() throws {
+ let json = """
+ {
+ "id": "user-decode-3",
+ "role": "user",
+ "content": [
+ {
+ "type": "text",
+ "text": "What is this?"
+ },
+ {
+ "type": "binary",
+ "mimeType": "image/jpeg",
+ "url": "https://example.com/photo.jpg"
+ }
+ ]
+ }
+ """
+
+ let decoder = MessageDecoder()
+ let message = try decoder.decode(Data(json.utf8))
+
+ XCTAssertTrue(message is UserMessage)
+ let userMessage = message as! UserMessage
+ XCTAssertEqual(userMessage.id, "user-decode-3")
+ XCTAssertTrue(userMessage.isMultimodal)
+ XCTAssertEqual(userMessage.contentParts?.count, 2)
+
+ // Verify first part is text
+ if let textPart = userMessage.contentParts?[0] as? TextInputContent {
+ XCTAssertEqual(textPart.text, "What is this?")
+ } else {
+ XCTFail("First part should be TextInputContent")
+ }
+
+ // Verify second part is binary
+ if let binaryPart = userMessage.contentParts?[1] as? BinaryInputContent {
+ XCTAssertEqual(binaryPart.mimeType, "image/jpeg")
+ } else {
+ XCTFail("Second part should be BinaryInputContent")
+ }
+ }
+
+ func testDecodingMultimodalWithMultipleParts() throws {
+ let json = """
+ {
+ "id": "user-decode-4",
+ "role": "user",
+ "content": [
+ {"type": "text", "text": "Part 1"},
+ {"type": "text", "text": "Part 2"},
+ {"type": "binary", "mimeType": "image/png", "url": "https://test.com/img.png"}
+ ]
+ }
+ """
+
+ let decoder = MessageDecoder()
+ let message = try decoder.decode(Data(json.utf8))
+
+ XCTAssertTrue(message is UserMessage)
+ let userMessage = message as! UserMessage
+ XCTAssertEqual(userMessage.contentParts?.count, 3)
+ XCTAssertTrue(userMessage.isMultimodal)
+ }
+
+ func testDecodingFailsWithoutId() {
+ let json = """
+ {
+ "role": "user",
+ "content": "Test"
+ }
+ """
+
+ let decoder = MessageDecoder()
+ XCTAssertThrowsError(try decoder.decode(Data(json.utf8))) { error in
+ XCTAssertTrue(error is MessageDecodingError || error is DecodingError)
+ }
+ }
+
+ func testDecodingFailsWithoutContent() {
+ let json = """
+ {
+ "id": "user-1",
+ "role": "user"
+ }
+ """
+
+ let decoder = MessageDecoder()
+ XCTAssertThrowsError(try decoder.decode(Data(json.utf8))) { error in
+ XCTAssertTrue(error is MessageDecodingError || error is DecodingError)
+ }
+ }
+
+ func testDecodingFailsWithWrongRole() {
+ let json = """
+ {
+ "id": "user-1",
+ "role": "assistant",
+ "content": "Test"
+ }
+ """
+
+ // With polymorphic MessageDecoder, wrong role returns different message type
+ let decoder = MessageDecoder()
+ let message = try? decoder.decode(Data(json.utf8))
+
+ // Should decode as AssistantMessage, not UserMessage
+ XCTAssertNotNil(message)
+ XCTAssertFalse(message is UserMessage)
+ XCTAssertTrue(message is AssistantMessage)
+ }
+
+ // MARK: - Round-trip Tests (via DTO layer)
+
+ func testRoundTripTextOnly() throws {
+ // Create original message
+ let original = UserMessage(
+ id: "user-rt-1",
+ content: "This is a round-trip test",
+ name: "Tester"
+ )
+
+ // Encode via DTO (simulating what RunAgentInput does)
+ let dict: [String: Any] = [
+ "id": original.id,
+ "role": original.role.rawValue,
+ "content": original.content ?? "",
+ "name": original.name as Any
+ ]
+ let encoded = try JSONSerialization.data(withJSONObject: dict)
+
+ // Decode via MessageDecoder
+ let decoder = MessageDecoder()
+ let decoded = try decoder.decode(encoded)
+
+ XCTAssertTrue(decoded is UserMessage)
+ let userMessage = decoded as! UserMessage
+ XCTAssertEqual(userMessage.id, original.id)
+ XCTAssertEqual(userMessage.content, original.content)
+ XCTAssertEqual(userMessage.name, original.name)
+ XCTAssertEqual(userMessage.isMultimodal, original.isMultimodal)
+ }
+
+ func testRoundTripMultimodal() throws {
+ // Create original message
+ let parts: [any InputContent] = [
+ TextInputContent(text: "Analyze:"),
+ BinaryInputContent(mimeType: "image/png", url: "https://test.com/img.png")
+ ]
+
+ let original = UserMessage.multimodal(id: "user-rt-2", parts: parts, name: "User")
+
+ // Encode via DTO (simulating what RunAgentInput does)
+ let contentArray: [[String: Any]] = [
+ ["type": "text", "text": "Analyze:"],
+ ["type": "binary", "mimeType": "image/png", "url": "https://test.com/img.png"]
+ ]
+
+ let dict: [String: Any] = [
+ "id": original.id,
+ "role": original.role.rawValue,
+ "content": contentArray,
+ "name": original.name as Any
+ ]
+ let encoded = try JSONSerialization.data(withJSONObject: dict)
+
+ // Decode via MessageDecoder
+ let decoder = MessageDecoder()
+ let decoded = try decoder.decode(encoded)
+
+ XCTAssertTrue(decoded is UserMessage)
+ let userMessage = decoded as! UserMessage
+ XCTAssertEqual(userMessage.id, original.id)
+ XCTAssertEqual(userMessage.name, original.name)
+ XCTAssertTrue(userMessage.isMultimodal)
+ XCTAssertEqual(userMessage.contentParts?.count, 2)
+ }
+
+ // MARK: - Equatable Tests
+
+ func testEquality() {
+ let message1 = UserMessage(id: "1", content: "Test")
+ let message2 = UserMessage(id: "1", content: "Test")
+ let message3 = UserMessage(id: "2", content: "Test")
+ let message4 = UserMessage(id: "1", content: "Different")
+
+ XCTAssertEqual(message1, message2)
+ XCTAssertNotEqual(message1, message3)
+ XCTAssertNotEqual(message1, message4)
+ }
+
+ // MARK: - Hashable Tests
+
+ func testHashable() {
+ let message1 = UserMessage(id: "1", content: "Test")
+ let message2 = UserMessage(id: "2", content: "Test")
+
+ let set: Set = [message1, message2]
+ XCTAssertEqual(set.count, 2)
+ XCTAssertTrue(set.contains(message1))
+ XCTAssertTrue(set.contains(message2))
+ }
+
+ // MARK: - Sendable Tests
+
+ func testSendableConformance() {
+ let message = UserMessage(id: "user-concurrent", content: "Test")
+
+ Task {
+ let capturedMessage = message
+ XCTAssertEqual(capturedMessage.id, "user-concurrent")
+ }
+ }
+
+ // MARK: - Real-world Usage Tests
+
+ func testSimpleQuestion() {
+ let question = UserMessage(
+ id: "q-1",
+ content: "What is the capital of France?"
+ )
+
+ XCTAssertFalse(question.isMultimodal)
+ XCTAssertEqual(question.role, .user)
+ }
+
+ func testImageAnalysisRequest() {
+ let parts: [any InputContent] = [
+ TextInputContent(text: "What objects are in this image?"),
+ BinaryInputContent(
+ mimeType: "image/jpeg",
+ url: "https://photos.example.com/vacation.jpg",
+ filename: "vacation.jpg"
+ )
+ ]
+
+ let request = UserMessage.multimodal(id: "img-1", parts: parts)
+
+ XCTAssertTrue(request.isMultimodal)
+ XCTAssertEqual(request.contentParts?.count, 2)
+ }
+
+ func testMultiImageComparison() {
+ let parts: [any InputContent] = [
+ TextInputContent(text: "Compare these two images:"),
+ BinaryInputContent(mimeType: "image/png", url: "https://test.com/before.png"),
+ TextInputContent(text: "versus"),
+ BinaryInputContent(mimeType: "image/png", url: "https://test.com/after.png"),
+ TextInputContent(text: "What changed?")
+ ]
+
+ let comparison = UserMessage.multimodal(id: "comp-1", parts: parts)
+
+ XCTAssertEqual(comparison.contentParts?.count, 5)
+ XCTAssertTrue(comparison.isMultimodal)
+ }
+
+ func testDocumentAnalysis() {
+ let parts: [any InputContent] = [
+ TextInputContent(text: "Summarize this document:"),
+ BinaryInputContent(
+ mimeType: "application/pdf",
+ id: "doc-annual-report",
+ filename: "annual-report.pdf"
+ )
+ ]
+
+ let request = UserMessage.multimodal(id: "doc-1", parts: parts)
+
+ XCTAssertTrue(request.isMultimodal)
+ }
+
+ func testIsMultimodalProperty() {
+ let textMessage = UserMessage(id: "1", content: "Text")
+ let multimodalMessage = UserMessage.multimodal(
+ id: "2",
+ parts: [TextInputContent(text: "Part")]
+ )
+
+ XCTAssertFalse(textMessage.isMultimodal)
+ XCTAssertTrue(multimodalMessage.isMultimodal)
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/Types/Tools/FunctionCallTests.swift b/sdks/community/swift/Tests/AGUICoreTests/Types/Tools/FunctionCallTests.swift
new file mode 100644
index 0000000000..bd0ba071c5
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/Types/Tools/FunctionCallTests.swift
@@ -0,0 +1,294 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+/// Tests for the FunctionCall type
+final class FunctionCallTests: XCTestCase {
+ // MARK: - Initialization Tests
+
+ func testInitWithRequiredFields() {
+ let functionCall = FunctionCall(
+ name: "get_weather",
+ arguments: "{\"location\":\"San Francisco\"}"
+ )
+
+ XCTAssertEqual(functionCall.name, "get_weather")
+ XCTAssertEqual(functionCall.arguments, "{\"location\":\"San Francisco\"}")
+ }
+
+ func testInitWithEmptyArguments() {
+ let functionCall = FunctionCall(
+ name: "no_params_function",
+ arguments: "{}"
+ )
+
+ XCTAssertEqual(functionCall.name, "no_params_function")
+ XCTAssertEqual(functionCall.arguments, "{}")
+ }
+
+ func testInitWithComplexArguments() {
+ let complexArgs = """
+ {
+ "user_id": "123",
+ "filters": {
+ "date_range": ["2024-01-01", "2024-12-31"],
+ "categories": ["tech", "science"]
+ },
+ "limit": 100
+ }
+ """
+
+ let functionCall = FunctionCall(
+ name: "fetch_articles",
+ arguments: complexArgs
+ )
+
+ XCTAssertEqual(functionCall.name, "fetch_articles")
+ XCTAssertTrue(functionCall.arguments.contains("user_id"))
+ }
+
+ // MARK: - Encoding Tests
+
+ func testEncodingBasic() throws {
+ let functionCall = FunctionCall(
+ name: "calculate",
+ arguments: "{\"x\":10,\"y\":20}"
+ )
+
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys]
+ let encoded = try encoder.encode(functionCall)
+ let json = String(data: encoded, encoding: .utf8)
+
+ XCTAssertNotNil(json)
+ XCTAssertTrue(json?.contains("\"name\"") ?? false)
+ XCTAssertTrue(json?.contains("\"arguments\"") ?? false)
+ XCTAssertTrue(json?.contains("\"calculate\"") ?? false)
+ }
+
+ func testEncodedStructure() throws {
+ let functionCall = FunctionCall(
+ name: "test_function",
+ arguments: "{\"param\":\"value\"}"
+ )
+
+ let encoded = try JSONEncoder().encode(functionCall)
+ let json = try JSONSerialization.jsonObject(with: encoded) as? [String: Any]
+
+ XCTAssertEqual(json?["name"] as? String, "test_function")
+ XCTAssertEqual(json?["arguments"] as? String, "{\"param\":\"value\"}")
+ }
+
+ // MARK: - Decoding Tests
+
+ func testDecodingBasic() throws {
+ let json = """
+ {
+ "name": "send_email",
+ "arguments": "{\\"to\\":\\"user@example.com\\",\\"subject\\":\\"Hello\\"}"
+ }
+ """
+
+ let decoder = JSONDecoder()
+ let functionCall = try decoder.decode(FunctionCall.self, from: Data(json.utf8))
+
+ XCTAssertEqual(functionCall.name, "send_email")
+ XCTAssertTrue(functionCall.arguments.contains("user@example.com"))
+ }
+
+ func testDecodingWithEmptyArguments() throws {
+ let json = """
+ {
+ "name": "ping",
+ "arguments": "{}"
+ }
+ """
+
+ let decoder = JSONDecoder()
+ let functionCall = try decoder.decode(FunctionCall.self, from: Data(json.utf8))
+
+ XCTAssertEqual(functionCall.name, "ping")
+ XCTAssertEqual(functionCall.arguments, "{}")
+ }
+
+ func testDecodingFailsWithoutName() {
+ let json = """
+ {
+ "arguments": "{}"
+ }
+ """
+
+ let decoder = JSONDecoder()
+ XCTAssertThrowsError(try decoder.decode(FunctionCall.self, from: Data(json.utf8))) { error in
+ XCTAssertTrue(error is DecodingError)
+ }
+ }
+
+ func testDecodingFailsWithoutArguments() {
+ let json = """
+ {
+ "name": "test"
+ }
+ """
+
+ let decoder = JSONDecoder()
+ XCTAssertThrowsError(try decoder.decode(FunctionCall.self, from: Data(json.utf8))) { error in
+ XCTAssertTrue(error is DecodingError)
+ }
+ }
+
+ // MARK: - Round-trip Tests
+
+ func testRoundTrip() throws {
+ let original = FunctionCall(
+ name: "get_user_info",
+ arguments: "{\"user_id\":\"abc123\",\"include_profile\":true}"
+ )
+
+ let encoder = JSONEncoder()
+ let encoded = try encoder.encode(original)
+
+ let decoder = JSONDecoder()
+ let decoded = try decoder.decode(FunctionCall.self, from: encoded)
+
+ XCTAssertEqual(decoded.name, original.name)
+ XCTAssertEqual(decoded.arguments, original.arguments)
+ }
+
+ // MARK: - Equatable Tests
+
+ func testEquality() {
+ let call1 = FunctionCall(name: "func", arguments: "{\"x\":1}")
+ let call2 = FunctionCall(name: "func", arguments: "{\"x\":1}")
+ let call3 = FunctionCall(name: "other", arguments: "{\"x\":1}")
+ let call4 = FunctionCall(name: "func", arguments: "{\"x\":2}")
+
+ XCTAssertEqual(call1, call2)
+ XCTAssertNotEqual(call1, call3)
+ XCTAssertNotEqual(call1, call4)
+ }
+
+ // MARK: - Hashable Tests
+
+ func testHashable() {
+ let call1 = FunctionCall(name: "func1", arguments: "{}")
+ let call2 = FunctionCall(name: "func2", arguments: "{}")
+
+ let set: Set = [call1, call2]
+ XCTAssertEqual(set.count, 2)
+ XCTAssertTrue(set.contains(call1))
+ XCTAssertTrue(set.contains(call2))
+ }
+
+ // MARK: - Sendable Tests
+
+ func testSendableConformance() {
+ let functionCall = FunctionCall(name: "test", arguments: "{}")
+
+ Task {
+ let capturedCall = functionCall
+ XCTAssertEqual(capturedCall.name, "test")
+ }
+ }
+
+ // MARK: - Arguments Parsing Tests
+
+ func testArgumentsAsValidJSON() throws {
+ let functionCall = FunctionCall(
+ name: "test",
+ arguments: "{\"key\":\"value\",\"number\":42}"
+ )
+
+ // Verify arguments can be parsed as JSON
+ let argsData = Data(functionCall.arguments.utf8)
+ let parsed = try JSONSerialization.jsonObject(with: argsData) as? [String: Any]
+
+ XCTAssertNotNil(parsed)
+ XCTAssertEqual(parsed?["key"] as? String, "value")
+ XCTAssertEqual(parsed?["number"] as? Int, 42)
+ }
+
+ func testDecodableArgumentsStruct() throws {
+ struct WeatherArgs: Codable {
+ let location: String
+ let units: String
+ }
+
+ let functionCall = FunctionCall(
+ name: "get_weather",
+ arguments: "{\"location\":\"Paris\",\"units\":\"celsius\"}"
+ )
+
+ // Verify arguments can be decoded into a struct
+ let argsData = Data(functionCall.arguments.utf8)
+ let weatherArgs = try JSONDecoder().decode(WeatherArgs.self, from: argsData)
+
+ XCTAssertEqual(weatherArgs.location, "Paris")
+ XCTAssertEqual(weatherArgs.units, "celsius")
+ }
+
+ // MARK: - Real-world Usage Tests
+
+ func testWeatherFunctionCall() {
+ let weatherCall = FunctionCall(
+ name: "get_current_weather",
+ arguments: """
+ {
+ "location": "Boston, MA",
+ "unit": "fahrenheit"
+ }
+ """
+ )
+
+ XCTAssertEqual(weatherCall.name, "get_current_weather")
+ XCTAssertTrue(weatherCall.arguments.contains("Boston"))
+ }
+
+ func testDatabaseQueryFunctionCall() {
+ let queryCall = FunctionCall(
+ name: "execute_sql_query",
+ arguments: """
+ {
+ "database": "users_db",
+ "query": "SELECT * FROM users WHERE active = true",
+ "max_results": 100
+ }
+ """
+ )
+
+ XCTAssertEqual(queryCall.name, "execute_sql_query")
+ XCTAssertTrue(queryCall.arguments.contains("users_db"))
+ }
+
+ func testAPIRequestFunctionCall() {
+ let apiCall = FunctionCall(
+ name: "http_request",
+ arguments: """
+ {
+ "method": "POST",
+ "url": "https://api.example.com/v1/data",
+ "headers": {
+ "Authorization": "Bearer token123"
+ },
+ "body": {
+ "data": "example"
+ }
+ }
+ """
+ )
+
+ XCTAssertEqual(apiCall.name, "http_request")
+ XCTAssertTrue(apiCall.arguments.contains("POST"))
+ }
+
+ func testNoArgumentsFunction() {
+ let noArgsCall = FunctionCall(
+ name: "get_current_time",
+ arguments: "{}"
+ )
+
+ XCTAssertEqual(noArgsCall.name, "get_current_time")
+ XCTAssertEqual(noArgsCall.arguments, "{}")
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/Types/Tools/ToolCallTests.swift b/sdks/community/swift/Tests/AGUICoreTests/Types/Tools/ToolCallTests.swift
new file mode 100644
index 0000000000..cfcc0f6dab
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/Types/Tools/ToolCallTests.swift
@@ -0,0 +1,286 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+/// Tests for the ToolCall type
+final class ToolCallTests: XCTestCase {
+ // MARK: - Initialization Tests
+
+ func testInitWithRequiredFields() {
+ let functionCall = FunctionCall(name: "get_weather", arguments: "{\"location\":\"NYC\"}")
+ let toolCall = ToolCall(
+ id: "call_123",
+ function: functionCall
+ )
+
+ XCTAssertEqual(toolCall.id, "call_123")
+ XCTAssertEqual(toolCall.function.name, "get_weather")
+ XCTAssertEqual(toolCall.type, "function")
+ }
+
+ func testTypeIsAlwaysFunction() {
+ let functionCall1 = FunctionCall(name: "func1", arguments: "{}")
+ let functionCall2 = FunctionCall(name: "func2", arguments: "{}")
+
+ let toolCall1 = ToolCall(id: "call_1", function: functionCall1)
+ let toolCall2 = ToolCall(id: "call_2", function: functionCall2)
+
+ XCTAssertEqual(toolCall1.type, "function")
+ XCTAssertEqual(toolCall2.type, "function")
+ }
+
+ // MARK: - Encoding Tests
+
+ func testEncodingBasic() throws {
+ let functionCall = FunctionCall(name: "test_func", arguments: "{\"x\":1}")
+ let toolCall = ToolCall(id: "call_abc", function: functionCall)
+
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys]
+ let encoded = try encoder.encode(toolCall)
+ let json = String(data: encoded, encoding: .utf8)
+
+ XCTAssertNotNil(json)
+ XCTAssertTrue(json?.contains("\"id\"") ?? false)
+ XCTAssertTrue(json?.contains("\"type\"") ?? false)
+ XCTAssertTrue(json?.contains("\"function\"") ?? false)
+ XCTAssertTrue(json?.contains("\"call_abc\"") ?? false)
+ XCTAssertTrue(json?.contains("\"function\"") ?? false)
+ }
+
+ func testEncodedTypeField() throws {
+ let functionCall = FunctionCall(name: "test", arguments: "{}")
+ let toolCall = ToolCall(id: "call_1", function: functionCall)
+
+ let encoded = try JSONEncoder().encode(toolCall)
+ let json = try JSONSerialization.jsonObject(with: encoded) as? [String: Any]
+
+ XCTAssertEqual(json?["type"] as? String, "function")
+ XCTAssertEqual(json?["id"] as? String, "call_1")
+ XCTAssertNotNil(json?["function"])
+ }
+
+ func testEncodedFunctionNested() throws {
+ let functionCall = FunctionCall(name: "get_data", arguments: "{\"id\":42}")
+ let toolCall = ToolCall(id: "call_nested", function: functionCall)
+
+ let encoded = try JSONEncoder().encode(toolCall)
+ let json = try JSONSerialization.jsonObject(with: encoded) as? [String: Any]
+ let functionJson = json?["function"] as? [String: Any]
+
+ XCTAssertNotNil(functionJson)
+ XCTAssertEqual(functionJson?["name"] as? String, "get_data")
+ XCTAssertEqual(functionJson?["arguments"] as? String, "{\"id\":42}")
+ }
+
+ // MARK: - Decoding Tests
+
+ func testDecodingBasic() throws {
+ let json = """
+ {
+ "id": "call_xyz",
+ "type": "function",
+ "function": {
+ "name": "calculate",
+ "arguments": "{\\"x\\":10,\\"y\\":20}"
+ }
+ }
+ """
+
+ let decoder = JSONDecoder()
+ let toolCall = try decoder.decode(ToolCall.self, from: Data(json.utf8))
+
+ XCTAssertEqual(toolCall.id, "call_xyz")
+ XCTAssertEqual(toolCall.type, "function")
+ XCTAssertEqual(toolCall.function.name, "calculate")
+ XCTAssertTrue(toolCall.function.arguments.contains("\"x\":10"))
+ }
+
+ func testDecodingWithoutTypeField() throws {
+ // Type field should default to "function" if not present
+ let json = """
+ {
+ "id": "call_no_type",
+ "function": {
+ "name": "test",
+ "arguments": "{}"
+ }
+ }
+ """
+
+ let decoder = JSONDecoder()
+ let toolCall = try decoder.decode(ToolCall.self, from: Data(json.utf8))
+
+ XCTAssertEqual(toolCall.id, "call_no_type")
+ XCTAssertEqual(toolCall.type, "function")
+ }
+
+ func testDecodingFailsWithoutId() {
+ let json = """
+ {
+ "type": "function",
+ "function": {
+ "name": "test",
+ "arguments": "{}"
+ }
+ }
+ """
+
+ let decoder = JSONDecoder()
+ XCTAssertThrowsError(try decoder.decode(ToolCall.self, from: Data(json.utf8))) { error in
+ XCTAssertTrue(error is DecodingError)
+ }
+ }
+
+ func testDecodingFailsWithoutFunction() {
+ let json = """
+ {
+ "id": "call_1",
+ "type": "function"
+ }
+ """
+
+ let decoder = JSONDecoder()
+ XCTAssertThrowsError(try decoder.decode(ToolCall.self, from: Data(json.utf8))) { error in
+ XCTAssertTrue(error is DecodingError)
+ }
+ }
+
+ // MARK: - Round-trip Tests
+
+ func testRoundTrip() throws {
+ let original = ToolCall(
+ id: "call_roundtrip",
+ function: FunctionCall(
+ name: "send_message",
+ arguments: "{\"to\":\"user@example.com\",\"message\":\"Hello\"}"
+ )
+ )
+
+ let encoder = JSONEncoder()
+ let encoded = try encoder.encode(original)
+
+ let decoder = JSONDecoder()
+ let decoded = try decoder.decode(ToolCall.self, from: encoded)
+
+ XCTAssertEqual(decoded.id, original.id)
+ XCTAssertEqual(decoded.type, original.type)
+ XCTAssertEqual(decoded.function.name, original.function.name)
+ XCTAssertEqual(decoded.function.arguments, original.function.arguments)
+ }
+
+ // MARK: - Equatable Tests
+
+ func testEquality() {
+ let func1 = FunctionCall(name: "test", arguments: "{}")
+ let func2 = FunctionCall(name: "test", arguments: "{}")
+ let func3 = FunctionCall(name: "other", arguments: "{}")
+
+ let call1 = ToolCall(id: "call_1", function: func1)
+ let call2 = ToolCall(id: "call_1", function: func2)
+ let call3 = ToolCall(id: "call_2", function: func1)
+ let call4 = ToolCall(id: "call_1", function: func3)
+
+ XCTAssertEqual(call1, call2)
+ XCTAssertNotEqual(call1, call3)
+ XCTAssertNotEqual(call1, call4)
+ }
+
+ // MARK: - Hashable Tests
+
+ func testHashable() {
+ let call1 = ToolCall(id: "call_1", function: FunctionCall(name: "f1", arguments: "{}"))
+ let call2 = ToolCall(id: "call_2", function: FunctionCall(name: "f2", arguments: "{}"))
+
+ let set: Set = [call1, call2]
+ XCTAssertEqual(set.count, 2)
+ XCTAssertTrue(set.contains(call1))
+ XCTAssertTrue(set.contains(call2))
+ }
+
+ // MARK: - Sendable Tests
+
+ func testSendableConformance() {
+ let toolCall = ToolCall(
+ id: "call_concurrent",
+ function: FunctionCall(name: "test", arguments: "{}")
+ )
+
+ Task {
+ let capturedCall = toolCall
+ XCTAssertEqual(capturedCall.id, "call_concurrent")
+ }
+ }
+
+ // MARK: - Real-world Usage Tests
+
+ func testWeatherToolCall() {
+ let weatherCall = ToolCall(
+ id: "call_weather_123",
+ function: FunctionCall(
+ name: "get_current_weather",
+ arguments: """
+ {
+ "location": "San Francisco, CA",
+ "unit": "fahrenheit"
+ }
+ """
+ )
+ )
+
+ XCTAssertEqual(weatherCall.id, "call_weather_123")
+ XCTAssertEqual(weatherCall.function.name, "get_current_weather")
+ XCTAssertTrue(weatherCall.function.arguments.contains("San Francisco"))
+ }
+
+ func testDatabaseQueryToolCall() {
+ let queryCall = ToolCall(
+ id: "call_db_query_456",
+ function: FunctionCall(
+ name: "execute_query",
+ arguments: """
+ {
+ "sql": "SELECT * FROM users WHERE active = true",
+ "database": "production"
+ }
+ """
+ )
+ )
+
+ XCTAssertTrue(queryCall.function.arguments.contains("SELECT"))
+ }
+
+ func testMultipleToolCallsInArray() {
+ let calls: [ToolCall] = [
+ ToolCall(id: "call_1", function: FunctionCall(name: "func1", arguments: "{}")),
+ ToolCall(id: "call_2", function: FunctionCall(name: "func2", arguments: "{}")),
+ ToolCall(id: "call_3", function: FunctionCall(name: "func3", arguments: "{}"))
+ ]
+
+ XCTAssertEqual(calls.count, 3)
+ XCTAssertEqual(calls[0].id, "call_1")
+ XCTAssertEqual(calls[1].id, "call_2")
+ XCTAssertEqual(calls[2].id, "call_3")
+ }
+
+ func testToolCallLinkageWithToolMessage() {
+ // Verify that the tool call ID can be used to link with ToolMessage
+ let callId = "call_link_123"
+
+ let toolCall = ToolCall(
+ id: callId,
+ function: FunctionCall(name: "test", arguments: "{}")
+ )
+
+ // This would later be matched with a ToolMessage
+ let toolMessage = ToolMessage(
+ id: "msg_1",
+ content: "Result",
+ toolCallId: callId
+ )
+
+ // Verify the linkage works
+ XCTAssertEqual(toolCall.id, toolMessage.toolCallId)
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUICoreTests/Types/Tools/ToolTests.swift b/sdks/community/swift/Tests/AGUICoreTests/Types/Tools/ToolTests.swift
new file mode 100644
index 0000000000..1000c81631
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUICoreTests/Types/Tools/ToolTests.swift
@@ -0,0 +1,497 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUICore
+
+/// Tests for the Tool type
+final class ToolTests: XCTestCase {
+ // MARK: - Initialization Tests
+
+ func testInitWithBasicSchema() throws {
+ let schema = Data("""
+ {
+ "type": "object",
+ "properties": {
+ "location": {"type": "string"}
+ },
+ "required": ["location"]
+ }
+ """.utf8)
+
+ let tool = Tool(
+ name: "get_weather",
+ description: "Get the current weather for a location",
+ parameters: schema
+ )
+
+ XCTAssertEqual(tool.name, "get_weather")
+ XCTAssertEqual(tool.description, "Get the current weather for a location")
+ XCTAssertEqual(tool.parameters, schema)
+ }
+
+ func testInitWithEmptySchema() {
+ let emptySchema = Data("{}".utf8)
+
+ let tool = Tool(
+ name: "ping",
+ description: "Simple ping tool with no parameters",
+ parameters: emptySchema
+ )
+
+ XCTAssertEqual(tool.name, "ping")
+ XCTAssertEqual(tool.parameters, emptySchema)
+ }
+
+ func testInitWithComplexSchema() throws {
+ let complexSchema = Data("""
+ {
+ "type": "object",
+ "properties": {
+ "query": {
+ "type": "string",
+ "description": "The SQL query to execute"
+ },
+ "database": {
+ "type": "string",
+ "enum": ["production", "staging", "development"]
+ },
+ "limit": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 1000,
+ "default": 100
+ }
+ },
+ "required": ["query", "database"]
+ }
+ """.utf8)
+
+ let tool = Tool(
+ name: "execute_sql",
+ description: "Execute a SQL query on a specified database",
+ parameters: complexSchema
+ )
+
+ XCTAssertEqual(tool.name, "execute_sql")
+ XCTAssertTrue(tool.description.contains("SQL"))
+ }
+
+ // MARK: - Encoding Tests
+
+ func testEncodingBasic() throws {
+ let schema = Data("{\"type\":\"object\"}".utf8)
+ let tool = Tool(
+ name: "test_tool",
+ description: "A test tool",
+ parameters: schema
+ )
+
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys]
+ let encoded = try encoder.encode(tool)
+ let json = String(data: encoded, encoding: .utf8)
+
+ XCTAssertNotNil(json)
+ XCTAssertTrue(json?.contains("\"name\"") ?? false)
+ XCTAssertTrue(json?.contains("\"description\"") ?? false)
+ XCTAssertTrue(json?.contains("\"parameters\"") ?? false)
+ XCTAssertTrue(json?.contains("\"test_tool\"") ?? false)
+ }
+
+ func testEncodedStructure() throws {
+ let schema = Data("{\"type\":\"object\"}".utf8)
+ let tool = Tool(
+ name: "sample",
+ description: "Sample tool",
+ parameters: schema
+ )
+
+ let encoded = try JSONEncoder().encode(tool)
+ let json = try JSONSerialization.jsonObject(with: encoded) as? [String: Any]
+
+ XCTAssertEqual(json?["name"] as? String, "sample")
+ XCTAssertEqual(json?["description"] as? String, "Sample tool")
+ XCTAssertNotNil(json?["parameters"])
+ }
+
+ // MARK: - Decoding Tests
+
+ func testDecodingBasic() throws {
+ let json = """
+ {
+ "name": "send_email",
+ "description": "Send an email to a recipient",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "to": {"type": "string"},
+ "subject": {"type": "string"}
+ }
+ }
+ }
+ """
+
+ let decoder = JSONDecoder()
+ let tool = try decoder.decode(Tool.self, from: Data(json.utf8))
+
+ XCTAssertEqual(tool.name, "send_email")
+ XCTAssertEqual(tool.description, "Send an email to a recipient")
+ XCTAssertFalse(tool.parameters.isEmpty)
+ }
+
+ func testDecodingWithEmptyParameters() throws {
+ let json = """
+ {
+ "name": "no_params",
+ "description": "Tool with no parameters",
+ "parameters": {}
+ }
+ """
+
+ let decoder = JSONDecoder()
+ let tool = try decoder.decode(Tool.self, from: Data(json.utf8))
+
+ XCTAssertEqual(tool.name, "no_params")
+ XCTAssertFalse(tool.parameters.isEmpty)
+ }
+
+ func testDecodingFailsWithoutName() {
+ let json = """
+ {
+ "description": "Missing name",
+ "parameters": {}
+ }
+ """
+
+ let decoder = JSONDecoder()
+ XCTAssertThrowsError(try decoder.decode(Tool.self, from: Data(json.utf8))) { error in
+ XCTAssertTrue(error is DecodingError)
+ }
+ }
+
+ func testDecodingFailsWithoutDescription() {
+ let json = """
+ {
+ "name": "test",
+ "parameters": {}
+ }
+ """
+
+ let decoder = JSONDecoder()
+ XCTAssertThrowsError(try decoder.decode(Tool.self, from: Data(json.utf8))) { error in
+ XCTAssertTrue(error is DecodingError)
+ }
+ }
+
+ func testDecodingFailsWithoutParameters() {
+ let json = """
+ {
+ "name": "test",
+ "description": "Test tool"
+ }
+ """
+
+ let decoder = JSONDecoder()
+ XCTAssertThrowsError(try decoder.decode(Tool.self, from: Data(json.utf8))) { error in
+ XCTAssertTrue(error is DecodingError)
+ }
+ }
+
+ // MARK: - Round-trip Tests
+
+ func testRoundTrip() throws {
+ let schema = Data("""
+ {
+ "type": "object",
+ "properties": {
+ "query": {"type": "string"}
+ }
+ }
+ """.utf8)
+
+ let original = Tool(
+ name: "search",
+ description: "Search for information",
+ parameters: schema
+ )
+
+ let encoder = JSONEncoder()
+ let encoded = try encoder.encode(original)
+
+ let decoder = JSONDecoder()
+ let decoded = try decoder.decode(Tool.self, from: encoded)
+
+ XCTAssertEqual(decoded.name, original.name)
+ XCTAssertEqual(decoded.description, original.description)
+
+ // Compare parameters semantically (as JSON) rather than byte-for-byte
+ let originalJSON = try JSONSerialization.jsonObject(with: original.parameters) as? [String: Any]
+ let decodedJSON = try JSONSerialization.jsonObject(with: decoded.parameters) as? [String: Any]
+ XCTAssertEqual(originalJSON?["type"] as? String, decodedJSON?["type"] as? String)
+ XCTAssertNotNil(originalJSON?["properties"])
+ XCTAssertNotNil(decodedJSON?["properties"])
+ }
+
+ // MARK: - Equatable Tests
+
+ func testEquality() {
+ let schema1 = Data("{\"type\":\"object\"}".utf8)
+ let schema2 = Data("{\"type\":\"object\"}".utf8)
+ let schema3 = Data("{\"type\":\"string\"}".utf8)
+
+ let tool1 = Tool(name: "tool", description: "desc", parameters: schema1)
+ let tool2 = Tool(name: "tool", description: "desc", parameters: schema2)
+ let tool3 = Tool(name: "other", description: "desc", parameters: schema1)
+ let tool4 = Tool(name: "tool", description: "different", parameters: schema1)
+ let tool5 = Tool(name: "tool", description: "desc", parameters: schema3)
+
+ XCTAssertEqual(tool1, tool2)
+ XCTAssertNotEqual(tool1, tool3)
+ XCTAssertNotEqual(tool1, tool4)
+ XCTAssertNotEqual(tool1, tool5)
+ }
+
+ // MARK: - Hashable Tests
+
+ func testHashable() {
+ let schema = Data("{}".utf8)
+ let tool1 = Tool(name: "tool1", description: "First", parameters: schema)
+ let tool2 = Tool(name: "tool2", description: "Second", parameters: schema)
+
+ let set: Set = [tool1, tool2]
+ XCTAssertEqual(set.count, 2)
+ XCTAssertTrue(set.contains(tool1))
+ XCTAssertTrue(set.contains(tool2))
+ }
+
+ // MARK: - Sendable Tests
+
+ func testSendableConformance() {
+ let schema = Data("{}".utf8)
+ let tool = Tool(name: "test", description: "Test", parameters: schema)
+
+ Task {
+ let capturedTool = tool
+ XCTAssertEqual(capturedTool.name, "test")
+ }
+ }
+
+ // MARK: - Parameter Schema Tests
+
+ func testParametersAsValidJSONSchema() throws {
+ let schema = Data("""
+ {
+ "type": "object",
+ "properties": {
+ "name": {"type": "string"},
+ "age": {"type": "integer"}
+ },
+ "required": ["name"]
+ }
+ """.utf8)
+
+ let tool = Tool(
+ name: "create_user",
+ description: "Create a new user",
+ parameters: schema
+ )
+
+ // Verify parameters can be parsed as JSON
+ let parsed = try JSONSerialization.jsonObject(with: tool.parameters) as? [String: Any]
+ XCTAssertNotNil(parsed)
+ XCTAssertEqual(parsed?["type"] as? String, "object")
+ XCTAssertNotNil(parsed?["properties"])
+ }
+
+ // MARK: - Real-world Usage Tests
+
+ func testWeatherTool() throws {
+ let weatherSchema = Data("""
+ {
+ "type": "object",
+ "properties": {
+ "location": {
+ "type": "string",
+ "description": "City and state, e.g., San Francisco, CA"
+ },
+ "unit": {
+ "type": "string",
+ "enum": ["celsius", "fahrenheit"],
+ "default": "fahrenheit"
+ }
+ },
+ "required": ["location"]
+ }
+ """.utf8)
+
+ let weatherTool = Tool(
+ name: "get_current_weather",
+ description: "Get the current weather in a given location",
+ parameters: weatherSchema
+ )
+
+ XCTAssertEqual(weatherTool.name, "get_current_weather")
+ XCTAssertTrue(weatherTool.description.contains("weather"))
+
+ // Verify schema is valid JSON
+ let schema = try JSONSerialization.jsonObject(with: weatherTool.parameters) as? [String: Any]
+ let properties = schema?["properties"] as? [String: Any]
+ XCTAssertNotNil(properties?["location"])
+ }
+
+ func testDatabaseTool() throws {
+ let dbSchema = Data("""
+ {
+ "type": "object",
+ "properties": {
+ "query": {
+ "type": "string",
+ "description": "SQL query to execute"
+ },
+ "database": {
+ "type": "string",
+ "description": "Target database name"
+ }
+ },
+ "required": ["query", "database"]
+ }
+ """.utf8)
+
+ let dbTool = Tool(
+ name: "execute_sql_query",
+ description: "Execute a SQL query on the specified database",
+ parameters: dbSchema
+ )
+
+ XCTAssertEqual(dbTool.name, "execute_sql_query")
+ }
+
+ func testToolArray() {
+ let schema = Data("{}".utf8)
+
+ let tools: [Tool] = [
+ Tool(name: "tool1", description: "First tool", parameters: schema),
+ Tool(name: "tool2", description: "Second tool", parameters: schema),
+ Tool(name: "tool3", description: "Third tool", parameters: schema)
+ ]
+
+ XCTAssertEqual(tools.count, 3)
+ XCTAssertEqual(tools[0].name, "tool1")
+ XCTAssertEqual(tools[1].name, "tool2")
+ XCTAssertEqual(tools[2].name, "tool3")
+ }
+
+ func testNoParametersTool() {
+ let emptySchema = Data("{}".utf8)
+
+ let pingTool = Tool(
+ name: "ping",
+ description: "Check if the service is alive",
+ parameters: emptySchema
+ )
+
+ XCTAssertEqual(pingTool.name, "ping")
+ XCTAssertFalse(pingTool.parameters.isEmpty)
+ }
+
+ // MARK: - metadata: Data? Tests
+
+ func test_toolWithMetadata_init() {
+ let schema = Data("{}".utf8)
+ let metadata = Data("{\"category\":\"weather\"}".utf8)
+
+ let tool = Tool(
+ name: "get_weather",
+ description: "Get weather",
+ parameters: schema,
+ metadata: metadata
+ )
+
+ XCTAssertEqual(tool.metadata, metadata)
+ }
+
+ func test_toolWithoutMetadata_metadataIsNil() {
+ let tool = Tool(name: "ping", description: "Ping", parameters: Data("{}".utf8))
+ XCTAssertNil(tool.metadata)
+ }
+
+ func test_encodingWithMetadata_includesMetadataKey() throws {
+ let schema = Data("{}".utf8)
+ let metadata = Data("{\"category\":\"search\"}".utf8)
+ let tool = Tool(name: "search", description: "Search", parameters: schema, metadata: metadata)
+
+ let encoded = try JSONEncoder().encode(tool)
+ let json = try JSONSerialization.jsonObject(with: encoded) as? [String: Any]
+
+ XCTAssertNotNil(json?["metadata"], "Encoded JSON must contain 'metadata' key")
+ let meta = json?["metadata"] as? [String: Any]
+ XCTAssertEqual(meta?["category"] as? String, "search")
+ }
+
+ func test_encodingWithoutMetadata_omitsMetadataKey() throws {
+ let tool = Tool(name: "ping", description: "Ping", parameters: Data("{}".utf8))
+
+ let encoded = try JSONEncoder().encode(tool)
+ let json = try JSONSerialization.jsonObject(with: encoded) as? [String: Any]
+
+ XCTAssertNil(json?["metadata"], "Encoded JSON must NOT contain 'metadata' when nil")
+ }
+
+ func test_decodingWithMetadata_populatesMetadata() throws {
+ let json = """
+ {
+ "name": "search",
+ "description": "Search tool",
+ "parameters": {},
+ "metadata": {"category": "search", "version": 2}
+ }
+ """
+
+ let tool = try JSONDecoder().decode(Tool.self, from: Data(json.utf8))
+
+ XCTAssertNotNil(tool.metadata)
+ let meta = try JSONSerialization.jsonObject(with: tool.metadata!) as? [String: Any]
+ XCTAssertEqual(meta?["category"] as? String, "search")
+ XCTAssertEqual(meta?["version"] as? Int, 2)
+ }
+
+ func test_decodingWithoutMetadata_metadataIsNil() throws {
+ // Existing format without metadata — must remain backward compatible
+ let json = """
+ {
+ "name": "legacy_tool",
+ "description": "Legacy tool",
+ "parameters": {}
+ }
+ """
+
+ let tool = try JSONDecoder().decode(Tool.self, from: Data(json.utf8))
+ XCTAssertNil(tool.metadata)
+ }
+
+ func test_roundTripWithMetadata() throws {
+ let schema = Data("{}".utf8)
+ let metadata = Data("{\"category\":\"weather\",\"priority\":1}".utf8)
+ let original = Tool(name: "get_weather", description: "Weather", parameters: schema, metadata: metadata)
+
+ let encoded = try JSONEncoder().encode(original)
+ let decoded = try JSONDecoder().decode(Tool.self, from: encoded)
+
+ XCTAssertNotNil(decoded.metadata)
+ let origMeta = try JSONSerialization.jsonObject(with: original.metadata!) as? [String: Any]
+ let decodedMeta = try JSONSerialization.jsonObject(with: decoded.metadata!) as? [String: Any]
+ XCTAssertEqual(origMeta?["category"] as? String, decodedMeta?["category"] as? String)
+ XCTAssertEqual(origMeta?["priority"] as? Int, decodedMeta?["priority"] as? Int)
+ }
+
+ func test_equalityWithDifferentMetadata_notEqual() {
+ let schema = Data("{}".utf8)
+ let meta1 = Data("{\"v\":1}".utf8)
+ let meta2 = Data("{\"v\":2}".utf8)
+
+ let tool1 = Tool(name: "t", description: "d", parameters: schema, metadata: meta1)
+ let tool2 = Tool(name: "t", description: "d", parameters: schema, metadata: meta2)
+
+ XCTAssertNotEqual(tool1, tool2)
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUIToolsTests/AGUIToolsTests.swift b/sdks/community/swift/Tests/AGUIToolsTests/AGUIToolsTests.swift
new file mode 100644
index 0000000000..5b829a07e4
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUIToolsTests/AGUIToolsTests.swift
@@ -0,0 +1,5 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+// AGUITools module tests live in the subdirectories:
+// Core/ — ToolExecutor, ToolExecutionResult, ToolExecutionContext, ToolExecutionManager
+// Registry/ — ToolRegistry, ToolExecutionStats
diff --git a/sdks/community/swift/Tests/AGUIToolsTests/Core/ToolExecutionContextTests.swift b/sdks/community/swift/Tests/AGUIToolsTests/Core/ToolExecutionContextTests.swift
new file mode 100644
index 0000000000..76891a0446
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUIToolsTests/Core/ToolExecutionContextTests.swift
@@ -0,0 +1,232 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+import AGUICore
+@testable import AGUITools
+
+final class ToolExecutionContextTests: XCTestCase {
+
+ // MARK: - Initialization Tests
+
+ func testContextWithToolCallOnly() {
+ // Given: A tool call
+ let toolCall = ToolCall(
+ id: "call_123",
+ function: FunctionCall(
+ name: "get_weather",
+ arguments: #"{"location": "SF"}"#
+ )
+ )
+
+ // When: Creating a context with just the tool call
+ let context = ToolExecutionContext(toolCall: toolCall)
+
+ // Then: Context should have the tool call and nil optional fields
+ XCTAssertEqual(context.toolCall.id, "call_123")
+ XCTAssertEqual(context.toolCall.function.name, "get_weather")
+ XCTAssertNil(context.threadId)
+ XCTAssertNil(context.runId)
+ XCTAssertTrue(context.metadata.isEmpty)
+ }
+
+ func testContextWithThreadId() {
+ // Given: A tool call and thread ID
+ let toolCall = ToolCall(
+ id: "call_456",
+ function: FunctionCall(
+ name: "send_email",
+ arguments: "{}"
+ )
+ )
+ let threadId = "thread_abc"
+
+ // When: Creating a context with thread ID
+ let context = ToolExecutionContext(
+ toolCall: toolCall,
+ threadId: threadId
+ )
+
+ // Then: Context should have the thread ID
+ XCTAssertEqual(context.threadId, threadId)
+ XCTAssertNil(context.runId)
+ XCTAssertTrue(context.metadata.isEmpty)
+ }
+
+ func testContextWithRunId() {
+ // Given: A tool call and run ID
+ let toolCall = ToolCall(
+ id: "call_789",
+ function: FunctionCall(
+ name: "calculate",
+ arguments: #"{"value": 42}"#
+ )
+ )
+ let runId = "run_xyz"
+
+ // When: Creating a context with run ID
+ let context = ToolExecutionContext(
+ toolCall: toolCall,
+ runId: runId
+ )
+
+ // Then: Context should have the run ID
+ XCTAssertNil(context.threadId)
+ XCTAssertEqual(context.runId, runId)
+ XCTAssertTrue(context.metadata.isEmpty)
+ }
+
+ func testContextWithMetadata() {
+ // Given: A tool call and metadata
+ let toolCall = ToolCall(
+ id: "call_meta",
+ function: FunctionCall(
+ name: "process",
+ arguments: "{}"
+ )
+ )
+ let metadata = [
+ "userId": "user_123",
+ "sessionId": "session_456",
+ "timestamp": "2025-01-01T12:00:00Z"
+ ]
+
+ // When: Creating a context with metadata
+ let context = ToolExecutionContext(
+ toolCall: toolCall,
+ metadata: metadata
+ )
+
+ // Then: Context should have the metadata
+ XCTAssertEqual(context.metadata, metadata)
+ XCTAssertEqual(context.metadata["userId"], "user_123")
+ XCTAssertEqual(context.metadata["sessionId"], "session_456")
+ }
+
+ func testContextWithAllFields() {
+ // Given: All context fields
+ let toolCall = ToolCall(
+ id: "call_full",
+ function: FunctionCall(
+ name: "full_context_test",
+ arguments: #"{"param": "value"}"#
+ )
+ )
+ let threadId = "thread_full"
+ let runId = "run_full"
+ let metadata = ["key": "value", "another": "data"]
+
+ // When: Creating a context with all fields
+ let context = ToolExecutionContext(
+ toolCall: toolCall,
+ threadId: threadId,
+ runId: runId,
+ metadata: metadata
+ )
+
+ // Then: All fields should be set
+ XCTAssertEqual(context.toolCall.id, "call_full")
+ XCTAssertEqual(context.threadId, threadId)
+ XCTAssertEqual(context.runId, runId)
+ XCTAssertEqual(context.metadata, metadata)
+ }
+
+ // MARK: - Sendable Conformance
+
+ func testSendableAcrossActors() async {
+ // Given: A context
+ let toolCall = ToolCall(
+ id: "call_sendable",
+ function: FunctionCall(name: "test", arguments: "{}")
+ )
+ let context = ToolExecutionContext(
+ toolCall: toolCall,
+ threadId: "thread_1",
+ runId: "run_1",
+ metadata: ["test": "value"]
+ )
+
+ // When: Passing it to an actor
+ actor ContextHolder {
+ var context: ToolExecutionContext?
+
+ func store(_ context: ToolExecutionContext) {
+ self.context = context
+ }
+ }
+
+ let holder = ContextHolder()
+ await holder.store(context)
+
+ // Then: No compiler errors (Sendable conformance)
+ // This test verifies that ToolExecutionContext is Sendable
+ }
+
+ // MARK: - Edge Cases
+
+ func testEmptyMetadata() {
+ // Given: A tool call with explicitly empty metadata
+ let toolCall = ToolCall(
+ id: "call_empty",
+ function: FunctionCall(name: "test", arguments: "{}")
+ )
+
+ // When: Creating context with empty metadata
+ let context = ToolExecutionContext(
+ toolCall: toolCall,
+ metadata: [:]
+ )
+
+ // Then: Metadata should be empty
+ XCTAssertTrue(context.metadata.isEmpty)
+ }
+
+ func testLargeMetadata() {
+ // Given: A tool call with large metadata
+ let toolCall = ToolCall(
+ id: "call_large_meta",
+ function: FunctionCall(name: "test", arguments: "{}")
+ )
+ var metadata: [String: String] = [:]
+ for i in 0 ..< 100 {
+ metadata["key\(i)"] = "value\(i)"
+ }
+
+ // When: Creating context with large metadata
+ let context = ToolExecutionContext(
+ toolCall: toolCall,
+ metadata: metadata
+ )
+
+ // Then: All metadata should be stored
+ XCTAssertEqual(context.metadata.count, 100)
+ XCTAssertEqual(context.metadata["key50"], "value50")
+ }
+
+ func testComplexToolCallInContext() {
+ // Given: A complex tool call with nested JSON arguments
+ let complexArgs = """
+ {
+ "config": {
+ "nested": {
+ "value": 123,
+ "array": [1, 2, 3]
+ }
+ },
+ "options": ["a", "b", "c"]
+ }
+ """
+ let toolCall = ToolCall(
+ id: "call_complex",
+ function: FunctionCall(name: "complex_tool", arguments: complexArgs)
+ )
+
+ // When: Creating context with complex tool call
+ let context = ToolExecutionContext(
+ toolCall: toolCall,
+ threadId: "thread_complex"
+ )
+
+ // Then: Tool call should be preserved correctly
+ XCTAssertEqual(context.toolCall.function.arguments, complexArgs)
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUIToolsTests/Core/ToolExecutionManagerTests.swift b/sdks/community/swift/Tests/AGUIToolsTests/Core/ToolExecutionManagerTests.swift
new file mode 100644
index 0000000000..f7963ef103
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUIToolsTests/Core/ToolExecutionManagerTests.swift
@@ -0,0 +1,814 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+import AGUICore
+@testable import AGUITools
+
+// MARK: - Mock types
+
+/// Always throws on sendToolResponse — used to test Issue 26 delivery-failure path.
+private actor FailingResponseHandler: ToolResponseHandler {
+ struct DeliveryError: Error {}
+ func sendToolResponse(_ message: ToolMessage, threadId: String?, runId: String?) async throws {
+ throw DeliveryError()
+ }
+}
+
+/// Records execute calls and returns a configurable result or error.
+private actor CapturingToolExecutor: ToolExecutor {
+ let tool: Tool
+ private(set) var executionCount = 0
+ var resultToReturn: ToolExecutionResult?
+ var errorToThrow: Error?
+
+ init(name: String) {
+ tool = Tool(name: name, description: "Test tool", parameters: Data("{}".utf8))
+ }
+
+ func execute(context: ToolExecutionContext) async throws -> ToolExecutionResult {
+ executionCount += 1
+ if let error = errorToThrow { throw error }
+ return resultToReturn ?? .success()
+ }
+
+ nonisolated func validate(toolCall: ToolCall) -> ToolValidationResult { .valid }
+ nonisolated func maximumExecutionTime() -> Duration? { nil }
+
+ func setResult(_ result: ToolExecutionResult) { resultToReturn = result }
+ func setError(_ error: Error?) { errorToThrow = error }
+}
+
+/// Captures every tool response message sent by the manager.
+private actor CapturingResponseHandler: ToolResponseHandler {
+ private(set) var sentMessages: [ToolMessage] = []
+ private(set) var sentThreadIds: [String?] = []
+ private(set) var sentRunIds: [String?] = []
+
+ func sendToolResponse(_ message: ToolMessage, threadId: String?, runId: String?) async throws {
+ sentMessages.append(message)
+ sentThreadIds.append(threadId)
+ sentRunIds.append(runId)
+ }
+}
+
+// MARK: - ToolExecutionManagerTests
+
+final class ToolExecutionManagerTests: XCTestCase {
+
+ // MARK: - Helpers
+
+ /// Builds a manager with 0 max-retry attempts so tests never wait on back-off delays.
+ private func makeManager(
+ registry: any ToolRegistry = DefaultToolRegistry(),
+ responseHandler: any ToolResponseHandler = NullToolResponseHandler()
+ ) -> ToolExecutionManager {
+ ToolExecutionManager(
+ toolRegistry: registry,
+ responseHandler: responseHandler,
+ errorHandlerConfig: ToolErrorConfig(maxRetryAttempts: 0)
+ )
+ }
+
+ private func makeStream(_ events: [any AGUIEvent]) -> AsyncThrowingStream {
+ AsyncThrowingStream { continuation in
+ for event in events { continuation.yield(event) }
+ continuation.finish()
+ }
+ }
+
+ /// Produces the canonical three-event sequence for a single tool call.
+ private func toolCallEvents(id: String, name: String, args: String = "{}") -> [any AGUIEvent] {
+ [
+ ToolCallStartEvent(toolCallId: id, toolCallName: name),
+ ToolCallArgsEvent(toolCallId: id, delta: args),
+ ToolCallEndEvent(toolCallId: id),
+ ]
+ }
+
+ @discardableResult
+ private func drain(
+ _ stream: AsyncThrowingStream
+ ) async throws -> [any AGUIEvent] {
+ var collected: [any AGUIEvent] = []
+ for try await event in stream { collected.append(event) }
+ return collected
+ }
+
+ // MARK: - Feature: Initialization
+
+ func test_init_activeExecutionCount_isZero() async {
+ // Given / When
+ let manager = makeManager()
+
+ // Then
+ let count = await manager.activeExecutionCount()
+ XCTAssertEqual(count, 0)
+ }
+
+ func test_init_isExecuting_unknownId_returnsFalse() async {
+ // Given / When
+ let manager = makeManager()
+
+ // Then
+ let result = await manager.isExecuting(toolCallId: "anything")
+ XCTAssertFalse(result)
+ }
+
+ // MARK: - Feature: Event passthrough
+
+ func test_processEventStream_nonToolCallEvents_areForwardedUnchanged() async throws {
+ // Given
+ let manager = makeManager()
+ let input: [any AGUIEvent] = [
+ RunStartedEvent(threadId: "t1", runId: "r1"),
+ RunFinishedEvent(threadId: "t1", runId: "r1"),
+ ]
+
+ // When
+ let events = try await drain(
+ await manager.processEventStream(makeStream(input), threadId: nil, runId: nil)
+ )
+
+ // Then
+ XCTAssertEqual(events.count, 2)
+ XCTAssertTrue(events[0] is RunStartedEvent)
+ XCTAssertTrue(events[1] is RunFinishedEvent)
+ }
+
+ func test_processEventStream_toolCallEvents_areForwardedToConsumer() async throws {
+ // Given
+ let registry = DefaultToolRegistry()
+ let executor = CapturingToolExecutor(name: "echo")
+ try await registry.register(executor: executor)
+ let manager = makeManager(registry: registry)
+
+ // When
+ let events = try await drain(
+ await manager.processEventStream(
+ makeStream(toolCallEvents(id: "c1", name: "echo")),
+ threadId: nil, runId: nil
+ )
+ )
+
+ // Then: all 3 events forwarded in order
+ XCTAssertEqual(events.count, 3)
+ XCTAssertTrue(events[0] is ToolCallStartEvent)
+ XCTAssertTrue(events[1] is ToolCallArgsEvent)
+ XCTAssertTrue(events[2] is ToolCallEndEvent)
+ }
+
+ func test_processEventStream_mixedEvents_allForwardedInOrder() async throws {
+ // Given
+ let registry = DefaultToolRegistry()
+ let executor = CapturingToolExecutor(name: "tool")
+ try await registry.register(executor: executor)
+ let manager = makeManager(registry: registry)
+
+ let input: [any AGUIEvent] = [RunStartedEvent(threadId: "t", runId: "r")]
+ + toolCallEvents(id: "c1", name: "tool")
+ + [RunFinishedEvent(threadId: "t", runId: "r")]
+
+ // When
+ let events = try await drain(
+ await manager.processEventStream(makeStream(input), threadId: nil, runId: nil)
+ )
+
+ // Then
+ XCTAssertEqual(events.count, 5)
+ XCTAssertTrue(events[0] is RunStartedEvent)
+ XCTAssertTrue(events[4] is RunFinishedEvent)
+ }
+
+ func test_processEventStream_emptyStream_completesNormally() async throws {
+ // Given
+ let manager = makeManager()
+
+ // When / Then: no throw, empty result
+ let events = try await drain(
+ await manager.processEventStream(makeStream([]), threadId: nil, runId: nil)
+ )
+ XCTAssertTrue(events.isEmpty)
+ }
+
+ // MARK: - Feature: Tool execution
+
+ func test_processEventStream_toolCall_executesRegisteredToolOnce() async throws {
+ // Given
+ let registry = DefaultToolRegistry()
+ let executor = CapturingToolExecutor(name: "calc")
+ try await registry.register(executor: executor)
+ let manager = makeManager(registry: registry)
+
+ // When
+ try await drain(
+ await manager.processEventStream(
+ makeStream(toolCallEvents(id: "c1", name: "calc")),
+ threadId: nil, runId: nil
+ )
+ )
+
+ // Then
+ let count = await executor.executionCount
+ XCTAssertEqual(count, 1)
+ }
+
+ func test_processEventStream_multipleToolCalls_eachToolExecutedOnce() async throws {
+ // Given
+ let registry = DefaultToolRegistry()
+ let execA = CapturingToolExecutor(name: "tool_a")
+ let execB = CapturingToolExecutor(name: "tool_b")
+ try await registry.register(executor: execA)
+ try await registry.register(executor: execB)
+ let manager = makeManager(registry: registry)
+
+ let events: [any AGUIEvent] = toolCallEvents(id: "c1", name: "tool_a")
+ + toolCallEvents(id: "c2", name: "tool_b")
+
+ // When
+ try await drain(
+ await manager.processEventStream(makeStream(events), threadId: nil, runId: nil)
+ )
+
+ // Then: each executor called exactly once
+ let countA = await execA.executionCount
+ let countB = await execB.executionCount
+ XCTAssertEqual(countA, 1)
+ XCTAssertEqual(countB, 1)
+ }
+
+ func test_processEventStream_argDeltas_areConcatenatedBeforeExecution() async throws {
+ // Given: executor that captures the raw arguments string
+ actor ArgCapturingExecutor: ToolExecutor {
+ let tool = Tool(name: "args_tool", description: "", parameters: Data("{}".utf8))
+ private(set) var capturedArguments = ""
+
+ func execute(context: ToolExecutionContext) async throws -> ToolExecutionResult {
+ capturedArguments = context.toolCall.function.arguments
+ return .success()
+ }
+
+ nonisolated func validate(toolCall: ToolCall) -> ToolValidationResult { .valid }
+ nonisolated func maximumExecutionTime() -> Duration? { nil }
+ }
+
+ let registry = DefaultToolRegistry()
+ let executor = ArgCapturingExecutor()
+ try await registry.register(executor: executor)
+ let manager = makeManager(registry: registry)
+
+ let events: [any AGUIEvent] = [
+ ToolCallStartEvent(toolCallId: "c1", toolCallName: "args_tool"),
+ ToolCallArgsEvent(toolCallId: "c1", delta: "{\"key\":"),
+ ToolCallArgsEvent(toolCallId: "c1", delta: "\"value\"}"),
+ ToolCallEndEvent(toolCallId: "c1"),
+ ]
+
+ // When
+ try await drain(
+ await manager.processEventStream(makeStream(events), threadId: nil, runId: nil)
+ )
+
+ // Then: two deltas are concatenated into the full argument string
+ let args = await executor.capturedArguments
+ XCTAssertEqual(args, "{\"key\":\"value\"}")
+ }
+
+ // MARK: - Feature: Response handler
+
+ func test_processEventStream_successResult_withJsonData_sendsDataAsContent() async throws {
+ // Given
+ let registry = DefaultToolRegistry()
+ let executor = CapturingToolExecutor(name: "weather")
+ await executor.setResult(.success(result: Data("{\"temp\":72}".utf8)))
+ try await registry.register(executor: executor)
+ let handler = CapturingResponseHandler()
+ let manager = makeManager(registry: registry, responseHandler: handler)
+
+ // When
+ try await drain(
+ await manager.processEventStream(
+ makeStream(toolCallEvents(id: "c1", name: "weather")),
+ threadId: nil, runId: nil
+ )
+ )
+
+ // Then: JSON bytes are decoded to a string and sent as the message content
+ let messages = await handler.sentMessages
+ XCTAssertEqual(messages.count, 1)
+ XCTAssertEqual(messages[0].toolCallId, "c1")
+ XCTAssertEqual(messages[0].content, "{\"temp\":72}")
+ }
+
+ func test_processEventStream_successResult_withMessageOnly_usesMessage() async throws {
+ // Given: result has a message but no raw data
+ let registry = DefaultToolRegistry()
+ let executor = CapturingToolExecutor(name: "tool")
+ await executor.setResult(.success(message: "done"))
+ try await registry.register(executor: executor)
+ let handler = CapturingResponseHandler()
+ let manager = makeManager(registry: registry, responseHandler: handler)
+
+ // When
+ try await drain(
+ await manager.processEventStream(
+ makeStream(toolCallEvents(id: "c1", name: "tool")),
+ threadId: nil, runId: nil
+ )
+ )
+
+ // Then
+ let content = await handler.sentMessages.first?.content
+ XCTAssertEqual(content, "done")
+ }
+
+ func test_processEventStream_successResult_withNeitherDataNorMessage_sendsTrueFallback() async throws {
+ // Given: result has neither data nor message
+ let registry = DefaultToolRegistry()
+ let executor = CapturingToolExecutor(name: "tool")
+ await executor.setResult(.success())
+ try await registry.register(executor: executor)
+ let handler = CapturingResponseHandler()
+ let manager = makeManager(registry: registry, responseHandler: handler)
+
+ // When
+ try await drain(
+ await manager.processEventStream(
+ makeStream(toolCallEvents(id: "c1", name: "tool")),
+ threadId: nil, runId: nil
+ )
+ )
+
+ // Then: falls back to "true" (success flag as string)
+ let content = await handler.sentMessages.first?.content
+ XCTAssertEqual(content, "true")
+ }
+
+ func test_processEventStream_passesThreadIdAndRunIdToResponseHandler() async throws {
+ // Given
+ let registry = DefaultToolRegistry()
+ let executor = CapturingToolExecutor(name: "tool")
+ try await registry.register(executor: executor)
+ let handler = CapturingResponseHandler()
+ let manager = makeManager(registry: registry, responseHandler: handler)
+
+ // When
+ try await drain(
+ await manager.processEventStream(
+ makeStream(toolCallEvents(id: "c1", name: "tool")),
+ threadId: "my-thread", runId: "my-run"
+ )
+ )
+
+ // Then
+ let threadIds = await handler.sentThreadIds
+ let runIds = await handler.sentRunIds
+ XCTAssertEqual(threadIds.first, Optional("my-thread"))
+ XCTAssertEqual(runIds.first, Optional("my-run"))
+ }
+
+ func test_processEventStream_multipleToolCalls_responsesRoutedByToolCallId() async throws {
+ // Given
+ let registry = DefaultToolRegistry()
+ let execA = CapturingToolExecutor(name: "tool_a")
+ await execA.setResult(.success(message: "result-a"))
+ let execB = CapturingToolExecutor(name: "tool_b")
+ await execB.setResult(.success(message: "result-b"))
+ try await registry.register(executor: execA)
+ try await registry.register(executor: execB)
+ let handler = CapturingResponseHandler()
+ let manager = makeManager(registry: registry, responseHandler: handler)
+
+ let events: [any AGUIEvent] = toolCallEvents(id: "c1", name: "tool_a")
+ + toolCallEvents(id: "c2", name: "tool_b")
+
+ // When
+ try await drain(
+ await manager.processEventStream(makeStream(events), threadId: nil, runId: nil)
+ )
+
+ // Then: one response per tool call, each linked by toolCallId
+ let messages = await handler.sentMessages
+ XCTAssertEqual(messages.count, 2)
+ let ids = Set(messages.map(\.toolCallId))
+ XCTAssertTrue(ids.contains("c1"))
+ XCTAssertTrue(ids.contains("c2"))
+ }
+
+ // MARK: - Feature: Failure path
+
+ func test_processEventStream_toolNotFound_sendsErrorResponse() async throws {
+ // Given: no tools registered
+ let registry = DefaultToolRegistry()
+ let handler = CapturingResponseHandler()
+ let manager = makeManager(registry: registry, responseHandler: handler)
+
+ // When
+ try await drain(
+ await manager.processEventStream(
+ makeStream(toolCallEvents(id: "c1", name: "missing")),
+ threadId: nil, runId: nil
+ )
+ )
+
+ // Then: one error response sent for the missing tool
+ let messages = await handler.sentMessages
+ XCTAssertEqual(messages.count, 1)
+ XCTAssertEqual(messages[0].toolCallId, "c1")
+ XCTAssertTrue(
+ messages[0].content.hasPrefix("Error:"),
+ "Expected error message, got: \(messages[0].content)"
+ )
+ }
+
+ func test_processEventStream_toolNotFound_streamCompletesNormally() async throws {
+ // Given: no tools registered
+ let registry = DefaultToolRegistry()
+ let manager = makeManager(registry: registry)
+
+ // When / Then: missing tool is an execution error, not a stream error
+ let events = try await drain(
+ await manager.processEventStream(
+ makeStream(toolCallEvents(id: "c1", name: "missing")),
+ threadId: nil, runId: nil
+ )
+ )
+ XCTAssertEqual(events.count, 3) // all upstream events still forwarded
+ }
+
+ func test_processEventStream_nonRetryableExecutionError_sendsErrorResponse() async throws {
+ // Given: executor throws a validation error (not retryable)
+ let registry = DefaultToolRegistry()
+ let executor = CapturingToolExecutor(name: "broken")
+ await executor.setError(ToolExecutionError.validationFailed(message: "bad args"))
+ try await registry.register(executor: executor)
+ let handler = CapturingResponseHandler()
+ let manager = makeManager(registry: registry, responseHandler: handler)
+
+ // When
+ try await drain(
+ await manager.processEventStream(
+ makeStream(toolCallEvents(id: "c1", name: "broken")),
+ threadId: nil, runId: nil
+ )
+ )
+
+ // Then
+ let messages = await handler.sentMessages
+ XCTAssertEqual(messages.count, 1)
+ XCTAssertTrue(
+ messages[0].content.hasPrefix("Error:"),
+ "Expected error message, got: \(messages[0].content)"
+ )
+ }
+
+ // MARK: - Feature: Execution events stream
+
+ /// A single successful tool call should emit .started → .executing → .succeeded in order.
+ func test_executionEvents_successfulTool_emitsStartedExecutingSucceeded() async throws {
+ // Given
+ let registry = DefaultToolRegistry()
+ let executor = CapturingToolExecutor(name: "tool")
+ try await registry.register(executor: executor)
+ let manager = makeManager(registry: registry)
+
+ // Collect exactly 3 execution events in a background task
+ let execEventsStream = await manager.executionEvents
+ let eventsTask = Task<[ToolExecutionEvent], Never> {
+ var collected: [ToolExecutionEvent] = []
+ for await event in execEventsStream {
+ collected.append(event)
+ if collected.count == 3 { break }
+ }
+ return collected
+ }
+
+ // When: drain the process stream (awaits all executions internally)
+ try await drain(
+ await manager.processEventStream(
+ makeStream(toolCallEvents(id: "c1", name: "tool")),
+ threadId: nil, runId: nil
+ )
+ )
+
+ // Then: background task collects all 3 events from the buffered stream
+ let received = await eventsTask.value
+ XCTAssertEqual(received.count, 3)
+
+ if case .started(let id, _) = received[0] {
+ XCTAssertEqual(id, "c1")
+ } else {
+ XCTFail("Expected .started at index 0, got \(received[0])")
+ }
+
+ if case .executing(let id, _) = received[1] {
+ XCTAssertEqual(id, "c1")
+ } else {
+ XCTFail("Expected .executing at index 1, got \(received[1])")
+ }
+
+ if case .succeeded(let id, _, _) = received[2] {
+ XCTAssertEqual(id, "c1")
+ } else {
+ XCTFail("Expected .succeeded at index 2, got \(received[2])")
+ }
+ }
+
+ /// A failed tool call (tool not found = immediate fail) emits .started → .executing → .failed.
+ func test_executionEvents_failedTool_emitsStartedExecutingFailed() async throws {
+ // Given: no tools registered → ToolRegistryError.toolNotFound → immediate fail (not retryable)
+ let registry = DefaultToolRegistry()
+ let manager = makeManager(registry: registry)
+
+ let execEventsStream = await manager.executionEvents
+ let eventsTask = Task<[ToolExecutionEvent], Never> {
+ var collected: [ToolExecutionEvent] = []
+ for await event in execEventsStream {
+ collected.append(event)
+ if collected.count == 3 { break }
+ }
+ return collected
+ }
+
+ // When
+ try await drain(
+ await manager.processEventStream(
+ makeStream(toolCallEvents(id: "c1", name: "missing")),
+ threadId: nil, runId: nil
+ )
+ )
+
+ // Then
+ let received = await eventsTask.value
+ XCTAssertEqual(received.count, 3)
+
+ if case .started = received[0] {} else {
+ XCTFail("Expected .started at index 0, got \(received[0])")
+ }
+ if case .executing = received[1] {} else {
+ XCTFail("Expected .executing at index 1, got \(received[1])")
+ }
+ if case .failed(let id, _, _) = received[2] {
+ XCTAssertEqual(id, "c1")
+ } else {
+ XCTFail("Expected .failed at index 2, got \(received[2])")
+ }
+ }
+
+ // MARK: - Feature: cancelAllExecutions
+
+ func test_cancelAllExecutions_onEmptyState_doesNotCrash() async {
+ // Given
+ let manager = makeManager()
+
+ // When / Then: no crash, count remains zero
+ await manager.cancelAllExecutions()
+ let count = await manager.activeExecutionCount()
+ XCTAssertEqual(count, 0)
+ }
+
+ // MARK: - Feature: Error propagation from upstream
+
+ func test_processEventStream_upstreamThrows_propagatesError() async {
+ // Given
+ let manager = makeManager()
+
+ struct UpstreamError: Error {}
+
+ let throwingStream = AsyncThrowingStream { continuation in
+ continuation.finish(throwing: UpstreamError())
+ }
+
+ // When / Then
+ do {
+ try await drain(await manager.processEventStream(throwingStream, threadId: nil, runId: nil))
+ XCTFail("Expected error to be thrown")
+ } catch {
+ XCTAssertTrue(error is UpstreamError, "Expected UpstreamError, got \(type(of: error))")
+ }
+ }
+
+ // MARK: - Feature: Edge cases
+
+ /// A ToolCallEndEvent with no matching buffer entry should be silently ignored
+ /// but the event itself still passes through to consumers.
+ func test_processEventStream_toolCallEndWithoutMatchingStart_isDroppedGracefully() async throws {
+ // Given: orphan ToolCallEnd with no prior ToolCallStart
+ let manager = makeManager()
+ let events: [any AGUIEvent] = [ToolCallEndEvent(toolCallId: "orphan")]
+
+ // When
+ let result = try await drain(
+ await manager.processEventStream(makeStream(events), threadId: nil, runId: nil)
+ )
+
+ // Then: event forwarded, no execution launched, no crash
+ XCTAssertEqual(result.count, 1)
+ XCTAssertTrue(result[0] is ToolCallEndEvent)
+ }
+
+ // MARK: - Feature: Delivery-failure propagation (Issue 26)
+
+ /// On the `.fail` path (tool execution failed, non-retryable), when `sendToolResponse`
+ /// also throws, the `.failed` event error string must include a delivery-failure note —
+ /// not just the original tool error. This distinguishes "agent was notified" from
+ /// "agent was NOT notified and may stall".
+ func test_failPath_responseDeliveryFailure_errorReflectsDeliveryFailure() async throws {
+ // Given: tool throws a non-retryable error AND delivery always fails
+ let registry = DefaultToolRegistry()
+ let executor = CapturingToolExecutor(name: "broken")
+ await executor.setError(ToolExecutionError.validationFailed(message: "bad args"))
+ try await registry.register(executor: executor)
+ let manager = ToolExecutionManager(
+ toolRegistry: registry,
+ responseHandler: FailingResponseHandler(),
+ errorHandlerConfig: ToolErrorConfig(maxRetryAttempts: 0)
+ )
+
+ let execEventsStream = await manager.executionEvents
+ let eventsTask = Task<[ToolExecutionEvent], Never> {
+ var collected: [ToolExecutionEvent] = []
+ for await event in execEventsStream {
+ collected.append(event)
+ if collected.count == 3 { break }
+ }
+ return collected
+ }
+
+ try await drain(
+ await manager.processEventStream(
+ makeStream(toolCallEvents(id: "c1", name: "broken")),
+ threadId: nil, runId: nil
+ )
+ )
+
+ let received = await eventsTask.value
+ XCTAssertEqual(received.count, 3)
+
+ // The .failed error must mention delivery failure — not just the original tool error.
+ // Before fix: error = "bad args" only (delivery failure silently dropped via try?).
+ // After fix: error = "bad args; response delivery failed: ..."
+ if case .failed(_, _, let errorStr) = received[2] {
+ XCTAssertTrue(
+ errorStr.contains("response delivery failed"),
+ "Expected delivery failure in .failed error, got: '\(errorStr)'"
+ )
+ } else {
+ XCTFail("Expected .failed at index 2, got \(received[2])")
+ }
+ }
+
+ /// On the `.circuitOpen` path, when `sendToolResponse` also throws, the `.failed`
+ /// event error string must include a delivery-failure note — not just "Circuit breaker open".
+ func test_circuitOpenPath_responseDeliveryFailure_errorReflectsDeliveryFailure() async throws {
+ // Given: tool fails enough to open circuit AND delivery always fails
+ let registry = DefaultToolRegistry()
+ let executor = CapturingToolExecutor(name: "tool")
+ await executor.setError(NSError(domain: "TestError", code: 1, userInfo: nil))
+ try await registry.register(executor: executor)
+
+ // Circuit opens after 1 failure; no recovery window
+ let config = ToolErrorConfig(
+ maxRetryAttempts: 0,
+ circuitBreaker: CircuitBreakerConfig(failureThreshold: 1, recoveryTimeoutSeconds: 3600)
+ )
+ let manager = ToolExecutionManager(
+ toolRegistry: registry,
+ responseHandler: FailingResponseHandler(),
+ errorHandlerConfig: config
+ )
+
+ // Collect all 6 events (3 per run) in a single background pass over the shared stream
+ let execEventsStream = await manager.executionEvents
+ let eventsTask = Task<[ToolExecutionEvent], Never> {
+ var collected: [ToolExecutionEvent] = []
+ for await event in execEventsStream {
+ collected.append(event)
+ if collected.count == 6 { break }
+ }
+ return collected
+ }
+
+ // Run 1: tool fails → .fail decision → circuit opens (threshold=1)
+ try await drain(
+ await manager.processEventStream(
+ makeStream(toolCallEvents(id: "c1", name: "tool")),
+ threadId: nil, runId: nil
+ )
+ )
+
+ // Run 2: circuit already open → .circuitOpen decision → delivery also fails
+ try await drain(
+ await manager.processEventStream(
+ makeStream(toolCallEvents(id: "c2", name: "tool")),
+ threadId: nil, runId: nil
+ )
+ )
+
+ let received = await eventsTask.value
+ XCTAssertEqual(received.count, 6)
+
+ // Events [3..5] are from run 2; [5] is the final .failed for the circuit-open case
+ if case .failed(let id, _, let errorStr) = received[5] {
+ XCTAssertEqual(id, "c2")
+ XCTAssertTrue(
+ errorStr.contains("response delivery failed"),
+ "Expected delivery failure in circuit-open .failed error, got: '\(errorStr)'"
+ )
+ } else {
+ XCTFail("Expected .failed at index 5 (run-2 terminal event), got \(received[5])")
+ }
+ }
+
+ /// When `sendToolResponse` throws, the execution lifecycle must still emit `.failed`
+ /// rather than silently swallowing the error and emitting `.succeeded`.
+ func test_processEventStream_responseDeliveryFailure_yieldsFailedEvent() async throws {
+ // Given: tool executes successfully but response delivery always fails
+ let registry = DefaultToolRegistry()
+ let executor = CapturingToolExecutor(name: "tool")
+ try await registry.register(executor: executor)
+ let manager = ToolExecutionManager(
+ toolRegistry: registry,
+ responseHandler: FailingResponseHandler(),
+ errorHandlerConfig: ToolErrorConfig(maxRetryAttempts: 0)
+ )
+
+ let execEventsStream = await manager.executionEvents
+ let eventsTask = Task<[ToolExecutionEvent], Never> {
+ var collected: [ToolExecutionEvent] = []
+ for await event in execEventsStream {
+ collected.append(event)
+ if collected.count == 3 { break }
+ }
+ return collected
+ }
+
+ try await drain(
+ await manager.processEventStream(
+ makeStream(toolCallEvents(id: "c1", name: "tool")),
+ threadId: nil, runId: nil
+ )
+ )
+
+ let received = await eventsTask.value
+ XCTAssertEqual(received.count, 3)
+
+ // Must be .failed — not .succeeded — when response delivery throws
+ if case .failed(let id, _, _) = received[2] {
+ XCTAssertEqual(id, "c1")
+ } else {
+ XCTFail("Expected .failed when response delivery fails, got \(received[2])")
+ }
+ }
+
+ // MARK: - Feature: Per-tool circuit breaker isolation (Issue 27)
+
+ /// Circuit breakers must be scoped per-tool: one tool's failures must not block other tools.
+ func test_perToolCircuitBreaker_oneToolFailingDoesNotBlockOtherTools() async throws {
+ // Given: two tools; 'fragile' consistently errors, 'healthy' always succeeds
+ let registry = DefaultToolRegistry()
+
+ let fragileExec = CapturingToolExecutor(name: "fragile")
+ await fragileExec.setError(NSError(domain: "TestError", code: 1, userInfo: nil))
+
+ let healthyExec = CapturingToolExecutor(name: "healthy")
+ await healthyExec.setResult(.success(message: "ok"))
+
+ try await registry.register(executor: fragileExec)
+ try await registry.register(executor: healthyExec)
+
+ let handler = CapturingResponseHandler()
+ // Circuit opens after 1 failure; recovery window is impossibly long during the test
+ let config = ToolErrorConfig(
+ maxRetryAttempts: 0,
+ circuitBreaker: CircuitBreakerConfig(failureThreshold: 1, recoveryTimeoutSeconds: 3600)
+ )
+ let manager = ToolExecutionManager(
+ toolRegistry: registry,
+ responseHandler: handler,
+ errorHandlerConfig: config
+ )
+
+ // First run: fail 'fragile' once — this opens its per-tool circuit breaker
+ try await drain(
+ await manager.processEventStream(
+ makeStream(toolCallEvents(id: "c1", name: "fragile")),
+ threadId: nil, runId: nil
+ )
+ )
+
+ // Second run: 'healthy' must succeed — its circuit breaker is independent
+ try await drain(
+ await manager.processEventStream(
+ makeStream(toolCallEvents(id: "c2", name: "healthy")),
+ threadId: nil, runId: nil
+ )
+ )
+
+ let messages = await handler.sentMessages
+ XCTAssertEqual(messages.count, 2)
+
+ let healthyMsg = messages.first(where: { $0.toolCallId == "c2" })
+ XCTAssertEqual(healthyMsg?.content, "ok",
+ "Healthy tool was blocked by fragile tool's circuit breaker (shared state)")
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUIToolsTests/Core/ToolExecutionResultTests.swift b/sdks/community/swift/Tests/AGUIToolsTests/Core/ToolExecutionResultTests.swift
new file mode 100644
index 0000000000..a380e2f409
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUIToolsTests/Core/ToolExecutionResultTests.swift
@@ -0,0 +1,168 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUITools
+
+final class ToolExecutionResultTests: XCTestCase {
+
+ // MARK: - Success Cases
+
+ func testSuccessResultWithNoData() {
+ // Given: A successful result with no data
+ let result = ToolExecutionResult.success()
+
+ // Then: Result should be marked as successful
+ XCTAssertTrue(result.success)
+ XCTAssertNil(result.result)
+ XCTAssertNil(result.message)
+ }
+
+ func testSuccessResultWithData() {
+ // Given: JSON data for the result
+ let jsonData = Data(#"{"temperature": 72, "conditions": "sunny"}"#.utf8)
+
+ // When: Creating a successful result with data
+ let result = ToolExecutionResult.success(result: jsonData)
+
+ // Then: Result should contain the data
+ XCTAssertTrue(result.success)
+ XCTAssertEqual(result.result, jsonData)
+ XCTAssertNil(result.message)
+ }
+
+ func testSuccessResultWithMessage() {
+ // Given: A success message
+ let message = "Weather data retrieved successfully"
+
+ // When: Creating a successful result with message
+ let result = ToolExecutionResult.success(message: message)
+
+ // Then: Result should contain the message
+ XCTAssertTrue(result.success)
+ XCTAssertNil(result.result)
+ XCTAssertEqual(result.message, message)
+ }
+
+ func testSuccessResultWithDataAndMessage() {
+ // Given: JSON data and a message
+ let jsonData = Data(#"{"value": 42}"#.utf8)
+ let message = "Calculation complete"
+
+ // When: Creating a successful result with both
+ let result = ToolExecutionResult.success(result: jsonData, message: message)
+
+ // Then: Result should contain both
+ XCTAssertTrue(result.success)
+ XCTAssertEqual(result.result, jsonData)
+ XCTAssertEqual(result.message, message)
+ }
+
+ // MARK: - Failure Cases
+
+ func testFailureResultWithMessage() {
+ // Given: An error message
+ let errorMessage = "Failed to connect to weather service"
+
+ // When: Creating a failure result
+ let result = ToolExecutionResult.failure(message: errorMessage)
+
+ // Then: Result should be marked as failed
+ XCTAssertFalse(result.success)
+ XCTAssertNil(result.result)
+ XCTAssertEqual(result.message, errorMessage)
+ }
+
+ func testFailureResultWithDataAndMessage() {
+ // Given: Error data and message
+ let errorData = Data(#"{"error_code": 404}"#.utf8)
+ let errorMessage = "Resource not found"
+
+ // When: Creating a failure result with both
+ let result = ToolExecutionResult.failure(message: errorMessage, result: errorData)
+
+ // Then: Result should contain both
+ XCTAssertFalse(result.success)
+ XCTAssertEqual(result.result, errorData)
+ XCTAssertEqual(result.message, errorMessage)
+ }
+
+ // MARK: - Equatable Conformance
+
+ func testEquatableSuccess() {
+ // Given: Two identical successful results
+ let jsonData = Data(#"{"value": 1}"#.utf8)
+ let result1 = ToolExecutionResult.success(result: jsonData, message: "OK")
+ let result2 = ToolExecutionResult.success(result: jsonData, message: "OK")
+
+ // Then: They should be equal
+ XCTAssertEqual(result1, result2)
+ }
+
+ func testEquatableFailure() {
+ // Given: Two identical failure results
+ let errorData = Data(#"{"error": "timeout"}"#.utf8)
+ let result1 = ToolExecutionResult.failure(message: "Timeout", result: errorData)
+ let result2 = ToolExecutionResult.failure(message: "Timeout", result: errorData)
+
+ // Then: They should be equal
+ XCTAssertEqual(result1, result2)
+ }
+
+ func testNotEqualDifferentSuccess() {
+ // Given: Two different results
+ let result1 = ToolExecutionResult.success(message: "OK")
+ let result2 = ToolExecutionResult.failure(message: "Error")
+
+ // Then: They should not be equal
+ XCTAssertNotEqual(result1, result2)
+ }
+
+ // MARK: - Sendable Conformance
+
+ func testSendableAcrossActors() async {
+ // Given: A result
+ let result = ToolExecutionResult.success(message: "Test")
+
+ // When: Passing it to an actor
+ actor ResultHolder {
+ var result: ToolExecutionResult?
+
+ func store(_ result: ToolExecutionResult) {
+ self.result = result
+ }
+ }
+
+ let holder = ResultHolder()
+ await holder.store(result)
+
+ // Then: No compiler errors (Sendable conformance)
+ // This test verifies that ToolExecutionResult is Sendable
+ }
+
+ // MARK: - Edge Cases
+
+ func testEmptyDataResult() {
+ // Given: Empty data
+ let emptyData = Data()
+
+ // When: Creating a result with empty data
+ let result = ToolExecutionResult.success(result: emptyData)
+
+ // Then: Result should contain empty data
+ XCTAssertTrue(result.success)
+ XCTAssertEqual(result.result, emptyData)
+ }
+
+ func testLargeDataResult() {
+ // Given: Large JSON data
+ let largeJSON = String(repeating: #"{"key":"value"},"#, count: 1000)
+ let largeData = Data(("[\(largeJSON)]").utf8)
+
+ // When: Creating a result with large data
+ let result = ToolExecutionResult.success(result: largeData)
+
+ // Then: Result should handle large data
+ XCTAssertTrue(result.success)
+ XCTAssertEqual(result.result, largeData)
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUIToolsTests/Core/ToolExecutorTests.swift b/sdks/community/swift/Tests/AGUIToolsTests/Core/ToolExecutorTests.swift
new file mode 100644
index 0000000000..5238b2b56e
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUIToolsTests/Core/ToolExecutorTests.swift
@@ -0,0 +1,390 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+import AGUICore
+@testable import AGUITools
+
+// MARK: - Mock Tool Executor
+
+/// Mock tool executor for testing
+actor MockToolExecutor: ToolExecutor {
+ let tool: Tool
+ var executeCallCount: Int = 0
+ var resultToReturn: ToolExecutionResult?
+ var errorToThrow: Error?
+
+ init(tool: Tool) {
+ self.tool = tool
+ }
+
+ func execute(context: ToolExecutionContext) async throws -> ToolExecutionResult {
+ executeCallCount += 1
+ if let error = errorToThrow {
+ throw error
+ }
+ return resultToReturn ?? ToolExecutionResult.success()
+ }
+
+ nonisolated func validate(toolCall: ToolCall) -> ToolValidationResult {
+ .valid
+ }
+
+ nonisolated func maximumExecutionTime() -> Duration? {
+ nil
+ }
+
+ func getExecuteCallCount() -> Int {
+ executeCallCount
+ }
+}
+
+// MARK: - Tests
+
+final class ToolExecutorTests: XCTestCase {
+
+ // MARK: - ToolValidationResult Tests
+
+ func testValidationResultSuccess() {
+ // Given/When: A valid result
+ let result = ToolValidationResult.valid
+
+ // Then: Result should be valid with no errors
+ XCTAssertTrue(result.isValid)
+ XCTAssertTrue(result.errors.isEmpty)
+ }
+
+ func testValidationResultFailureWithSingleError() {
+ // Given: An error message
+ let errorMessage = "Missing required parameter: location"
+
+ // When: Creating a failure result
+ let result = ToolValidationResult.invalid(errors: [errorMessage])
+
+ // Then: Result should be invalid with the error
+ XCTAssertFalse(result.isValid)
+ XCTAssertEqual(result.errors.count, 1)
+ XCTAssertEqual(result.errors.first, errorMessage)
+ }
+
+ func testValidationResultFailureWithMultipleErrors() {
+ // Given: Multiple error messages
+ let errors = [
+ "Missing required parameter: location",
+ "Invalid parameter type: temperature should be a number",
+ "Unknown parameter: extra_field"
+ ]
+
+ // When: Creating a failure result
+ let result = ToolValidationResult.invalid(errors: errors)
+
+ // Then: Result should be invalid with all errors
+ XCTAssertFalse(result.isValid)
+ XCTAssertEqual(result.errors.count, 3)
+ XCTAssertEqual(result.errors, errors)
+ }
+
+ // MARK: - ToolExecutor Protocol Tests
+
+ func testToolExecutorHasToolProperty() async {
+ // Given: A tool definition
+ let tool = Tool(
+ name: "test_tool",
+ description: "A test tool",
+ parameters: Data("{}".utf8)
+ )
+
+ // When: Creating an executor
+ let executor = MockToolExecutor(tool: tool)
+
+ // Then: Executor should have the tool
+ let toolName = await executor.tool.name
+ XCTAssertEqual(toolName, "test_tool")
+ }
+
+ func testToolExecutorExecutesSuccessfully() async throws {
+ // Given: An executor that returns success
+ let tool = Tool(
+ name: "weather_tool",
+ description: "Get weather",
+ parameters: Data("{}".utf8)
+ )
+ let executor = MockToolExecutor(tool: tool)
+ let successResult = ToolExecutionResult.success(message: "Success")
+ await executor.setResult(successResult)
+
+ // When: Executing a tool call
+ let toolCall = ToolCall(
+ id: "call_1",
+ function: FunctionCall(name: "weather_tool", arguments: "{}")
+ )
+ let context = ToolExecutionContext(toolCall: toolCall)
+ let result = try await executor.execute(context: context)
+
+ // Then: Result should be successful
+ XCTAssertTrue(result.success)
+ XCTAssertEqual(result.message, "Success")
+ let callCount = await executor.getExecuteCallCount()
+ XCTAssertEqual(callCount, 1)
+ }
+
+ func testToolExecutorExecutesWithError() async throws {
+ // Given: An executor that throws an error
+ let tool = Tool(
+ name: "failing_tool",
+ description: "A tool that fails",
+ parameters: Data("{}".utf8)
+ )
+ let executor = MockToolExecutor(tool: tool)
+ let testError = ToolExecutionError.validationFailed(message: "Invalid input")
+ await executor.setError(testError)
+
+ // When/Then: Executing should throw the error
+ let toolCall = ToolCall(
+ id: "call_error",
+ function: FunctionCall(name: "failing_tool", arguments: "{}")
+ )
+ let context = ToolExecutionContext(toolCall: toolCall)
+
+ do {
+ _ = try await executor.execute(context: context)
+ XCTFail("Expected error to be thrown")
+ } catch let error as ToolExecutionError {
+ switch error {
+ case .validationFailed(let message):
+ XCTAssertEqual(message, "Invalid input")
+ default:
+ XCTFail("Wrong error type")
+ }
+ }
+ }
+
+ func testToolExecutorValidateDefault() async {
+ // Given: An executor with default validation
+ let tool = Tool(
+ name: "default_validation_tool",
+ description: "Tool with default validation",
+ parameters: Data("{}".utf8)
+ )
+ let executor = MockToolExecutor(tool: tool)
+
+ // When: Validating a tool call
+ let toolCall = ToolCall(
+ id: "call_validate",
+ function: FunctionCall(name: "default_validation_tool", arguments: "{}")
+ )
+ let result = executor.validate(toolCall: toolCall)
+
+ // Then: Default validation should be valid
+ XCTAssertTrue(result.isValid)
+ }
+
+ func testToolExecutorMaximumExecutionTimeNil() async {
+ // Given: An executor with no timeout
+ let tool = Tool(
+ name: "no_timeout_tool",
+ description: "Tool without timeout",
+ parameters: Data("{}".utf8)
+ )
+ let executor = MockToolExecutor(tool: tool)
+
+ // When: Getting maximum execution time
+ let maxTime = executor.maximumExecutionTime()
+
+ // Then: Should be nil (no timeout)
+ XCTAssertNil(maxTime)
+ }
+
+ // MARK: - ToolExecutionError Tests
+
+ func testToolExecutionErrorValidationFailed() {
+ // Given: A validation error
+ let error = ToolExecutionError.validationFailed(message: "Invalid params")
+
+ // Then: Error should have correct message
+ switch error {
+ case .validationFailed(let message):
+ XCTAssertEqual(message, "Invalid params")
+ default:
+ XCTFail("Wrong error case")
+ }
+ }
+
+ func testToolExecutionErrorTimeout() {
+ // Given: A timeout error
+ let error = ToolExecutionError.timeout(
+ toolName: "slow_tool",
+ duration: .seconds(30)
+ )
+
+ // Then: Error should have correct details
+ switch error {
+ case .timeout(let toolName, let duration):
+ XCTAssertEqual(toolName, "slow_tool")
+ XCTAssertEqual(duration, .seconds(30))
+ default:
+ XCTFail("Wrong error case")
+ }
+ }
+
+ func testToolExecutionErrorExecutionFailed() {
+ // Given: A general execution failure
+ struct UnderlyingError: Error {}
+ let underlyingError = UnderlyingError()
+ let error = ToolExecutionError.executionFailed(
+ toolName: "broken_tool",
+ underlyingError: underlyingError
+ )
+
+ // Then: Error should have correct details
+ switch error {
+ case .executionFailed(let toolName, let underlying):
+ XCTAssertEqual(toolName, "broken_tool")
+ XCTAssertNotNil(underlying)
+ default:
+ XCTFail("Wrong error case")
+ }
+ }
+
+ func testToolExecutionErrorNotFound() {
+ // Given: A tool not found error
+ let error = ToolExecutionError.toolNotFound(toolName: "missing_tool")
+
+ // Then: Error should have correct tool name
+ switch error {
+ case .toolNotFound(let toolName):
+ XCTAssertEqual(toolName, "missing_tool")
+ default:
+ XCTFail("Wrong error case")
+ }
+ }
+
+ // MARK: - Sendable Conformance
+
+ func test_toolExecutionError_executionFailed_isSendable() async {
+ // ToolExecutionError is Sendable. executionFailed's underlyingError must also
+ // be Sendable; otherwise passing the error across a Task boundary is a Swift 6
+ // compile error. This test verifies the associated value crosses a Task boundary.
+ struct SendableFailure: Error, Sendable { let code: Int }
+ let error = ToolExecutionError.executionFailed(
+ toolName: "my_tool",
+ underlyingError: SendableFailure(code: 42)
+ )
+ let result = await Task.detached { error }.value
+ if case .executionFailed(_, let underlying) = result,
+ let failure = underlying as? SendableFailure {
+ XCTAssertEqual(failure.code, 42)
+ } else {
+ XCTFail("Expected .executionFailed with SendableFailure")
+ }
+ }
+
+ func testToolExecutorSendable() async {
+ // Given: A tool executor
+ let tool = Tool(
+ name: "sendable_test",
+ description: "Test sendable",
+ parameters: Data("{}".utf8)
+ )
+ let executor = MockToolExecutor(tool: tool)
+
+ // When: Passing to another actor
+ actor ExecutorHolder {
+ var executor: MockToolExecutor?
+
+ func store(_ executor: MockToolExecutor) {
+ self.executor = executor
+ }
+ }
+
+ let holder = ExecutorHolder()
+ await holder.store(executor)
+
+ // Then: No compiler errors (Sendable conformance)
+ // This test verifies that tool executors can be sent across actors
+ }
+
+ // MARK: - Integration Tests
+
+ func testCompleteExecutionFlow() async throws {
+ // Given: A complete tool execution setup
+ let tool = Tool(
+ name: "complete_tool",
+ description: "Complete tool for integration test",
+ parameters: Data(#"{"type": "object", "properties": {"value": {"type": "number"}}}"#.utf8)
+ )
+ let executor = MockToolExecutor(tool: tool)
+ let resultData = Data(#"{"result": 42}"#.utf8)
+ let successResult = ToolExecutionResult.success(
+ result: resultData,
+ message: "Calculation complete"
+ )
+ await executor.setResult(successResult)
+
+ // When: Executing with full context
+ let toolCall = ToolCall(
+ id: "call_integration",
+ function: FunctionCall(
+ name: "complete_tool",
+ arguments: #"{"value": 21}"#
+ )
+ )
+ let context = ToolExecutionContext(
+ toolCall: toolCall,
+ threadId: "thread_123",
+ runId: "run_456",
+ metadata: ["user": "test_user"]
+ )
+ let result = try await executor.execute(context: context)
+
+ // Then: Everything should work together
+ XCTAssertTrue(result.success)
+ XCTAssertEqual(result.result, resultData)
+ XCTAssertEqual(result.message, "Calculation complete")
+ let callCount = await executor.getExecuteCallCount()
+ XCTAssertEqual(callCount, 1)
+ }
+
+ // MARK: - Default nonisolated implementations (Issue 43)
+
+ func test_defaultValidate_isCallableFromNonisolatedContextOnActorConformer() {
+ // Verifies that actor conformers can call the default `validate` and
+ // `maximumExecutionTime` without `await`, as required by the `nonisolated`
+ // protocol declaration. This is a compile-time contract: without `nonisolated`
+ // on the extension defaults, Swift 6 would require `await` here (or error).
+ let executor = MinimalActorExecutor()
+ let toolCall = ToolCall(id: "x", function: FunctionCall(name: "minimal", arguments: "{}"))
+ // These must be callable synchronously from a nonisolated context.
+ let validation = executor.validate(toolCall: toolCall)
+ let timeout = executor.maximumExecutionTime()
+ XCTAssertTrue(validation.isValid)
+ XCTAssertNil(timeout)
+ }
+}
+
+// MARK: - Minimal actor conformer (no override of default implementations)
+
+/// An actor that satisfies `ToolExecutor` with only the required `execute` method.
+/// `validate` and `maximumExecutionTime` deliberately rely on extension defaults.
+/// If the extension defaults are missing `nonisolated`, this actor cannot satisfy
+/// the `nonisolated` protocol requirements in Swift 6.
+private actor MinimalActorExecutor: ToolExecutor {
+ let tool = Tool(name: "minimal", description: "Minimal", parameters: Data("{}".utf8))
+
+ func execute(context: ToolExecutionContext) async throws -> ToolExecutionResult {
+ .success()
+ }
+
+ // No override of validate or maximumExecutionTime — uses extension defaults.
+}
+
+// MARK: - Test Helper Extensions
+
+extension MockToolExecutor {
+ func setResult(_ result: ToolExecutionResult) {
+ self.resultToReturn = result
+ }
+
+ func setError(_ error: Error) {
+ self.errorToThrow = error
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUIToolsTests/Registry/ToolExecutionStatsTests.swift b/sdks/community/swift/Tests/AGUIToolsTests/Registry/ToolExecutionStatsTests.swift
new file mode 100644
index 0000000000..e2c26ba29c
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUIToolsTests/Registry/ToolExecutionStatsTests.swift
@@ -0,0 +1,229 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+@testable import AGUITools
+
+final class ToolExecutionStatsTests: XCTestCase {
+
+ // MARK: - Initialization Tests
+
+ func testDefaultInitialization() {
+ // When: Creating stats with default values
+ let stats = ToolExecutionStats()
+
+ // Then: All counters should be zero
+ XCTAssertEqual(stats.executionCount, 0)
+ XCTAssertEqual(stats.successCount, 0)
+ XCTAssertEqual(stats.failureCount, 0)
+ XCTAssertEqual(stats.totalExecutionTime, .zero)
+ XCTAssertEqual(stats.averageExecutionTime, .zero)
+ }
+
+ func testCustomInitialization() {
+ // When: Creating stats with custom values
+ let stats = ToolExecutionStats(
+ executionCount: 10,
+ successCount: 8,
+ failureCount: 2,
+ totalExecutionTime: .seconds(5),
+ averageExecutionTime: .milliseconds(500)
+ )
+
+ // Then: Values should match
+ XCTAssertEqual(stats.executionCount, 10)
+ XCTAssertEqual(stats.successCount, 8)
+ XCTAssertEqual(stats.failureCount, 2)
+ XCTAssertEqual(stats.totalExecutionTime, .seconds(5))
+ XCTAssertEqual(stats.averageExecutionTime, .milliseconds(500))
+ }
+
+ // MARK: - Success Rate Tests
+
+ func testSuccessRateWithNoExecutions() {
+ // Given: Stats with no executions
+ let stats = ToolExecutionStats()
+
+ // When: Calculating success rate
+ let successRate = stats.successRate
+
+ // Then: Should be 0.0
+ XCTAssertEqual(successRate, 0.0)
+ }
+
+ func testSuccessRateAllSuccessful() {
+ // Given: Stats with all successful executions
+ let stats = ToolExecutionStats(
+ executionCount: 10,
+ successCount: 10,
+ failureCount: 0
+ )
+
+ // When: Calculating success rate
+ let successRate = stats.successRate
+
+ // Then: Should be 1.0 (100%)
+ XCTAssertEqual(successRate, 1.0)
+ }
+
+ func testSuccessRateAllFailed() {
+ // Given: Stats with all failed executions
+ let stats = ToolExecutionStats(
+ executionCount: 5,
+ successCount: 0,
+ failureCount: 5
+ )
+
+ // When: Calculating success rate
+ let successRate = stats.successRate
+
+ // Then: Should be 0.0 (0%)
+ XCTAssertEqual(successRate, 0.0)
+ }
+
+ func testSuccessRateMixed() {
+ // Given: Stats with mixed results
+ let stats = ToolExecutionStats(
+ executionCount: 10,
+ successCount: 7,
+ failureCount: 3
+ )
+
+ // When: Calculating success rate
+ let successRate = stats.successRate
+
+ // Then: Should be 0.7 (70%)
+ XCTAssertEqual(successRate, 0.7, accuracy: 0.01)
+ }
+
+ // MARK: - Sendable Conformance
+
+ func testSendableAcrossActors() async {
+ // Given: Stats instance
+ let stats = ToolExecutionStats(
+ executionCount: 5,
+ successCount: 4,
+ failureCount: 1
+ )
+
+ // When: Passing to an actor
+ actor StatsHolder {
+ var stats: ToolExecutionStats?
+
+ func store(_ stats: ToolExecutionStats) {
+ self.stats = stats
+ }
+ }
+
+ let holder = StatsHolder()
+ await holder.store(stats)
+
+ // Then: No compiler errors (Sendable conformance)
+ // This test verifies that ToolExecutionStats is Sendable
+ }
+
+ // MARK: - Equatable Conformance
+
+ func testEquatableIdenticalStats() {
+ // Given: Two identical stats
+ let stats1 = ToolExecutionStats(
+ executionCount: 5,
+ successCount: 3,
+ failureCount: 2,
+ totalExecutionTime: .seconds(1),
+ averageExecutionTime: .milliseconds(200)
+ )
+ let stats2 = ToolExecutionStats(
+ executionCount: 5,
+ successCount: 3,
+ failureCount: 2,
+ totalExecutionTime: .seconds(1),
+ averageExecutionTime: .milliseconds(200)
+ )
+
+ // Then: They should be equal
+ XCTAssertEqual(stats1, stats2)
+ }
+
+ func testEquatableDifferentStats() {
+ // Given: Two different stats
+ let stats1 = ToolExecutionStats(executionCount: 5)
+ let stats2 = ToolExecutionStats(executionCount: 10)
+
+ // Then: They should not be equal
+ XCTAssertNotEqual(stats1, stats2)
+ }
+
+ // MARK: - Edge Cases
+
+ func testLargeExecutionCount() {
+ // Given: Stats with very large execution count
+ let stats = ToolExecutionStats(
+ executionCount: Int.max / 2,
+ successCount: Int.max / 4,
+ failureCount: Int.max / 4
+ )
+
+ // Then: Success rate should still calculate correctly
+ let successRate = stats.successRate
+ XCTAssertEqual(successRate, 0.5, accuracy: 0.01)
+ }
+
+ func testLongDuration() {
+ // Given: Stats with very long duration
+ let stats = ToolExecutionStats(
+ executionCount: 1,
+ successCount: 1,
+ failureCount: 0,
+ totalExecutionTime: .seconds(3600), // 1 hour
+ averageExecutionTime: .seconds(3600)
+ )
+
+ // Then: Duration values should be preserved
+ XCTAssertEqual(stats.totalExecutionTime, .seconds(3600))
+ XCTAssertEqual(stats.averageExecutionTime, .seconds(3600))
+ }
+
+ func testZeroDuration() {
+ // Given: Stats with zero duration
+ let stats = ToolExecutionStats(
+ executionCount: 5,
+ totalExecutionTime: .zero,
+ averageExecutionTime: .zero
+ )
+
+ // Then: Zero durations should be handled correctly
+ XCTAssertEqual(stats.totalExecutionTime, .zero)
+ XCTAssertEqual(stats.averageExecutionTime, .zero)
+ }
+
+ // MARK: - Consistency Tests
+
+ func testExecutionCountConsistency() {
+ // Given: Stats where success + failure equals total
+ let stats = ToolExecutionStats(
+ executionCount: 10,
+ successCount: 6,
+ failureCount: 4
+ )
+
+ // Then: Success + failure should equal execution count
+ XCTAssertEqual(stats.successCount + stats.failureCount, stats.executionCount)
+ }
+
+ func testAverageExecutionTimeConsistency() {
+ // Given: Stats with consistent average
+ let totalTime: Duration = .seconds(10)
+ let execCount = 5
+ let avgTime: Duration = .seconds(2) // 10 / 5 = 2
+
+ let stats = ToolExecutionStats(
+ executionCount: execCount,
+ totalExecutionTime: totalTime,
+ averageExecutionTime: avgTime
+ )
+
+ // Then: Average should match total / count
+ let calculatedAverage = stats.totalExecutionTime / execCount
+ XCTAssertEqual(stats.averageExecutionTime, calculatedAverage)
+ }
+}
diff --git a/sdks/community/swift/Tests/AGUIToolsTests/Registry/ToolRegistryConcurrencyTests.swift b/sdks/community/swift/Tests/AGUIToolsTests/Registry/ToolRegistryConcurrencyTests.swift
new file mode 100644
index 0000000000..adc07d4518
--- /dev/null
+++ b/sdks/community/swift/Tests/AGUIToolsTests/Registry/ToolRegistryConcurrencyTests.swift
@@ -0,0 +1,379 @@
+// Copyright (c) 2025 Perfect Aduh. MIT License. See LICENSE for details.
+
+import XCTest
+import AGUICore
+@testable import AGUITools
+
+final class ToolRegistryConcurrencyTests: XCTestCase {
+
+ // MARK: - Concurrent Registration Tests
+
+ func testConcurrentRegistration() async throws {
+ // Given: A registry and multiple tools
+ let registry = DefaultToolRegistry()
+ let toolCount = 10
+
+ // When: Registering multiple tools concurrently
+ try await withThrowingTaskGroup(of: Void.self) { group in
+ for i in 0.. Int {
+ value
+ }
+ }
+
+ let counter = Counter()
+ let iterations = 30
+
+ // When: Multiple actors execute tools
+ try await withThrowingTaskGroup(of: Void.self) { group in
+ for i in 0.. ToolExecutionResult {
+ executeCallCount += 1
+
+ if let delay = executionDelay {
+ try await Task.sleep(for: delay)
+ }
+
+ if let error = errorToThrow {
+ throw error
+ }
+
+ return resultToReturn ?? ToolExecutionResult.success(message: "Test execution")
+ }
+
+ nonisolated func validate(toolCall: ToolCall) -> ToolValidationResult {
+ .valid
+ }
+
+ nonisolated func maximumExecutionTime() -> Duration? {
+ nil
+ }
+
+ func reset() {
+ executeCallCount = 0
+ resultToReturn = nil
+ errorToThrow = nil
+ executionDelay = nil
+ }
+
+ func getCallCount() -> Int {
+ executeCallCount
+ }
+
+ func setResult(_ result: ToolExecutionResult?) {
+ self.resultToReturn = result
+ }
+
+ func setError(_ error: Error?) {
+ self.errorToThrow = error
+ }
+
+ func setDelay(_ delay: Duration?) {
+ self.executionDelay = delay
+ }
+}
+
+// MARK: - Tests
+
+final class ToolRegistryTests: XCTestCase {
+
+ // MARK: - Registration Tests
+
+ func testRegisterTool() async throws {
+ // Given: A registry and a tool executor
+ let registry = DefaultToolRegistry()
+ let tool = Tool(name: "test_tool", description: "Test", parameters: Data("{}".utf8))
+ let executor = TestToolExecutor(tool: tool)
+
+ // When: Registering the tool
+ try await registry.register(executor: executor)
+
+ // Then: Tool should be registered
+ let retrieved = await registry.executor(for: "test_tool")
+ XCTAssertNotNil(retrieved)
+ let retrievedTool = await retrieved?.tool
+ XCTAssertEqual(retrievedTool?.name, "test_tool")
+ }
+
+ func testRegisterMultipleTools() async throws {
+ // Given: A registry and multiple tool executors
+ let registry = DefaultToolRegistry()
+ let tool1 = Tool(name: "tool_1", description: "Tool 1", parameters: Data("{}".utf8))
+ let tool2 = Tool(name: "tool_2", description: "Tool 2", parameters: Data("{}".utf8))
+ let executor1 = TestToolExecutor(tool: tool1)
+ let executor2 = TestToolExecutor(tool: tool2)
+
+ // When: Registering multiple tools
+ try await registry.register(executor: executor1)
+ try await registry.register(executor: executor2)
+
+ // Then: All tools should be registered
+ let allTools = await registry.allTools()
+ XCTAssertEqual(allTools.count, 2)
+ XCTAssertTrue(allTools.contains { $0.name == "tool_1" })
+ XCTAssertTrue(allTools.contains { $0.name == "tool_2" })
+ }
+
+ func testRegisterDuplicateToolThrowsError() async throws {
+ // Given: A registry with a registered tool
+ let registry = DefaultToolRegistry()
+ let tool = Tool(name: "duplicate", description: "Test", parameters: Data("{}".utf8))
+ let executor1 = TestToolExecutor(tool: tool)
+ let executor2 = TestToolExecutor(tool: tool)
+ try await registry.register(executor: executor1)
+
+ // When/Then: Registering a duplicate should throw
+ do {
+ try await registry.register(executor: executor2)
+ XCTFail("Expected error for duplicate tool registration")
+ } catch let error as ToolRegistryError {
+ switch error {
+ case .alreadyRegistered(let toolName):
+ XCTAssertEqual(toolName, "duplicate")
+ default:
+ XCTFail("Wrong error type")
+ }
+ }
+ }
+
+ func testRegisterToolWithEmptyNameThrowsError() async throws {
+ // Given: A tool with empty name
+ let registry = DefaultToolRegistry()
+ let tool = Tool(name: "", description: "Test", parameters: Data("{}".utf8))
+ let executor = TestToolExecutor(tool: tool)
+
+ // When/Then: Registering should throw
+ do {
+ try await registry.register(executor: executor)
+ XCTFail("Expected error for empty tool name")
+ } catch let error as ToolRegistryError {
+ switch error {
+ case .emptyToolName:
+ break // Expected
+ default:
+ XCTFail("Wrong error type")
+ }
+ }
+ }
+
+ // MARK: - Unregistration Tests
+
+ func testUnregisterTool() async throws {
+ // Given: A registry with a registered tool
+ let registry = DefaultToolRegistry()
+ let tool = Tool(name: "removable", description: "Test", parameters: Data("{}".utf8))
+ let executor = TestToolExecutor(tool: tool)
+ try await registry.register(executor: executor)
+
+ // When: Unregistering the tool
+ let wasRemoved = await registry.unregister(toolName: "removable")
+
+ // Then: Tool should be removed
+ XCTAssertTrue(wasRemoved)
+ let retrieved = await registry.executor(for: "removable")
+ XCTAssertNil(retrieved)
+ }
+
+ func testUnregisterNonExistentTool() async {
+ // Given: An empty registry
+ let registry = DefaultToolRegistry()
+
+ // When: Unregistering a non-existent tool
+ let wasRemoved = await registry.unregister(toolName: "nonexistent")
+
+ // Then: Should return false
+ XCTAssertFalse(wasRemoved)
+ }
+
+ // MARK: - Lookup Tests
+
+ func testGetExecutorForRegisteredTool() async throws {
+ // Given: A registry with a registered tool
+ let registry = DefaultToolRegistry()
+ let tool = Tool(name: "lookup_test", description: "Test", parameters: Data("{}".utf8))
+ let executor = TestToolExecutor(tool: tool)
+ try await registry.register(executor: executor)
+
+ // When: Looking up the executor
+ let retrieved = await registry.executor(for: "lookup_test")
+
+ // Then: Should return the executor
+ XCTAssertNotNil(retrieved)
+ }
+
+ func testGetExecutorForNonExistentTool() async {
+ // Given: An empty registry
+ let registry = DefaultToolRegistry()
+
+ // When: Looking up a non-existent tool
+ let retrieved = await registry.executor(for: "missing")
+
+ // Then: Should return nil
+ XCTAssertNil(retrieved)
+ }
+
+ func testIsToolRegistered() async throws {
+ // Given: A registry with a registered tool
+ let registry = DefaultToolRegistry()
+ let tool = Tool(name: "check_tool", description: "Test", parameters: Data("{}".utf8))
+ let executor = TestToolExecutor(tool: tool)
+ try await registry.register(executor: executor)
+
+ // When/Then: Checking registration status
+ let isRegistered = await registry.isToolRegistered(toolName: "check_tool")
+ let isNotRegistered = await registry.isToolRegistered(toolName: "other_tool")
+ XCTAssertTrue(isRegistered)
+ XCTAssertFalse(isNotRegistered)
+ }
+
+ func testGetAllTools() async throws {
+ // Given: A registry with multiple tools
+ let registry = DefaultToolRegistry()
+ let tools = [
+ Tool(name: "tool_a", description: "A", parameters: Data("{}".utf8)),
+ Tool(name: "tool_b", description: "B", parameters: Data("{}".utf8)),
+ Tool(name: "tool_c", description: "C", parameters: Data("{}".utf8))
+ ]
+
+ for tool in tools {
+ try await registry.register(executor: TestToolExecutor(tool: tool))
+ }
+
+ // When: Getting all tools
+ let allTools = await registry.allTools()
+
+ // Then: Should return all registered tools
+ XCTAssertEqual(allTools.count, 3)
+ let toolNames = Set(allTools.map { $0.name })
+ XCTAssertEqual(toolNames, Set(["tool_a", "tool_b", "tool_c"]))
+ }
+
+ // MARK: - Execution Tests
+
+ func testExecuteToolSuccessfully() async throws {
+ // Given: A registry with a registered tool
+ let registry = DefaultToolRegistry()
+ let tool = Tool(name: "exec_tool", description: "Test", parameters: Data("{}".utf8))
+ let executor = TestToolExecutor(tool: tool)
+ let expectedResult = ToolExecutionResult.success(message: "Success!")
+ await executor.setResult(expectedResult)
+ try await registry.register(executor: executor)
+
+ // When: Executing the tool
+ let toolCall = ToolCall(
+ id: "call_1",
+ function: FunctionCall(name: "exec_tool", arguments: "{}")
+ )
+ let context = ToolExecutionContext(toolCall: toolCall)
+ let result = try await registry.execute(context: context)
+
+ // Then: Should return the expected result
+ XCTAssertTrue(result.success)
+ XCTAssertEqual(result.message, "Success!")
+ }
+
+ func testExecuteNonExistentToolThrowsError() async throws {
+ // Given: An empty registry
+ let registry = DefaultToolRegistry()
+
+ // When/Then: Executing a non-existent tool should throw
+ let toolCall = ToolCall(
+ id: "call_missing",
+ function: FunctionCall(name: "missing_tool", arguments: "{}")
+ )
+ let context = ToolExecutionContext(toolCall: toolCall)
+
+ do {
+ _ = try await registry.execute(context: context)
+ XCTFail("Expected toolNotFound error")
+ } catch let error as ToolRegistryError {
+ switch error {
+ case .toolNotFound(let toolName):
+ XCTAssertEqual(toolName, "missing_tool")
+ default:
+ XCTFail("Wrong error type")
+ }
+ }
+ }
+
+ func testExecuteToolWithError() async throws {
+ // Given: A registry with a tool that throws an error
+ let registry = DefaultToolRegistry()
+ let tool = Tool(name: "failing_tool", description: "Test", parameters: Data("{}".utf8))
+ let executor = TestToolExecutor(tool: tool)
+ let expectedError = ToolExecutionError.validationFailed(message: "Invalid input")
+ await executor.setError(expectedError)
+ try await registry.register(executor: executor)
+
+ // When/Then: Executing should propagate the error
+ let toolCall = ToolCall(
+ id: "call_fail",
+ function: FunctionCall(name: "failing_tool", arguments: "{}")
+ )
+ let context = ToolExecutionContext(toolCall: toolCall)
+
+ do {
+ _ = try await registry.execute(context: context)
+ XCTFail("Expected error to be thrown")
+ } catch let error as ToolExecutionError {
+ switch error {
+ case .validationFailed(let message):
+ XCTAssertEqual(message, "Invalid input")
+ default:
+ XCTFail("Wrong error type")
+ }
+ }
+ }
+
+ // MARK: - Statistics Tests
+
+ func testStatsInitiallyEmpty() async throws {
+ // Given: A registry with a newly registered tool
+ let registry = DefaultToolRegistry()
+ let tool = Tool(name: "stats_tool", description: "Test", parameters: Data("{}".utf8))
+ let executor = TestToolExecutor(tool: tool)
+ try await registry.register(executor: executor)
+
+ // When: Getting stats
+ let stats = await registry.stats(for: "stats_tool")
+
+ // Then: Stats should be initialized with zeros
+ XCTAssertNotNil(stats)
+ XCTAssertEqual(stats?.executionCount, 0)
+ XCTAssertEqual(stats?.successCount, 0)
+ XCTAssertEqual(stats?.failureCount, 0)
+ }
+
+ func testStatsAfterSuccessfulExecution() async throws {
+ // Given: A registry with a tool
+ let registry = DefaultToolRegistry()
+ let tool = Tool(name: "success_stats", description: "Test", parameters: Data("{}".utf8))
+ let executor = TestToolExecutor(tool: tool)
+ try await registry.register(executor: executor)
+
+ // When: Executing the tool successfully
+ let toolCall = ToolCall(
+ id: "call_stats",
+ function: FunctionCall(name: "success_stats", arguments: "{}")
+ )
+ let context = ToolExecutionContext(toolCall: toolCall)
+ _ = try await registry.execute(context: context)
+
+ // Then: Stats should reflect the successful execution
+ let stats = await registry.stats(for: "success_stats")
+ XCTAssertEqual(stats?.executionCount, 1)
+ XCTAssertEqual(stats?.successCount, 1)
+ XCTAssertEqual(stats?.failureCount, 0)
+ XCTAssertEqual(stats?.successRate, 1.0)
+ }
+
+ func testStatsAfterFailedExecution() async throws {
+ // Given: A registry with a tool that fails
+ let registry = DefaultToolRegistry()
+ let tool = Tool(name: "fail_stats", description: "Test", parameters: Data("{}".utf8))
+ let executor = TestToolExecutor(tool: tool)
+ await executor.setError(ToolExecutionError.validationFailed(message: "Fail"))
+ try await registry.register(executor: executor)
+
+ // When: Executing the tool (fails)
+ let toolCall = ToolCall(
+ id: "call_fail_stats",
+ function: FunctionCall(name: "fail_stats", arguments: "{}")
+ )
+ let context = ToolExecutionContext(toolCall: toolCall)
+ _ = try? await registry.execute(context: context)
+
+ // Then: Stats should reflect the failure
+ let stats = await registry.stats(for: "fail_stats")
+ XCTAssertEqual(stats?.executionCount, 1)
+ XCTAssertEqual(stats?.successCount, 0)
+ XCTAssertEqual(stats?.failureCount, 1)
+ XCTAssertEqual(stats?.successRate, 0.0)
+ }
+
+ func testStatsAverageExecutionTime() async throws {
+ // Given: A registry with a tool
+ let registry = DefaultToolRegistry()
+ let tool = Tool(name: "timing_tool", description: "Test", parameters: Data("{}".utf8))
+ let executor = TestToolExecutor(tool: tool)
+ await executor.setDelay(.milliseconds(10))
+ try await registry.register(executor: executor)
+
+ // When: Executing the tool multiple times
+ for i in 1...3 {
+ let toolCall = ToolCall(
+ id: "call_\(i)",
+ function: FunctionCall(name: "timing_tool", arguments: "{}")
+ )
+ let context = ToolExecutionContext(toolCall: toolCall)
+ _ = try await registry.execute(context: context)
+ }
+
+ // Then: Stats should track average execution time
+ let stats = await registry.stats(for: "timing_tool")
+ XCTAssertEqual(stats?.executionCount, 3)
+ XCTAssertGreaterThan(stats?.averageExecutionTime ?? .zero, .zero)
+ }
+
+ func testClearStats() async throws {
+ // Given: A registry with execution history
+ let registry = DefaultToolRegistry()
+ let tool = Tool(name: "clear_stats", description: "Test", parameters: Data("{}".utf8))
+ let executor = TestToolExecutor(tool: tool)
+ try await registry.register(executor: executor)
+
+ let toolCall = ToolCall(
+ id: "call_clear",
+ function: FunctionCall(name: "clear_stats", arguments: "{}")
+ )
+ let context = ToolExecutionContext(toolCall: toolCall)
+ _ = try await registry.execute(context: context)
+
+ // When: Clearing stats
+ await registry.clearStats()
+
+ // Then: Stats should be reset
+ let stats = await registry.stats(for: "clear_stats")
+ XCTAssertEqual(stats?.executionCount, 0)
+ XCTAssertEqual(stats?.successCount, 0)
+ }
+
+ func testGetStatsForNonExistentTool() async {
+ // Given: An empty registry
+ let registry = DefaultToolRegistry()
+
+ // When: Getting stats for non-existent tool
+ let stats = await registry.stats(for: "nonexistent")
+
+ // Then: Should return nil
+ XCTAssertNil(stats)
+ }
+
+ func testGetAllStats() async throws {
+ // Given: A registry with multiple tools
+ let registry = DefaultToolRegistry()
+ let tools = [
+ Tool(name: "tool_stats_1", description: "1", parameters: Data("{}".utf8)),
+ Tool(name: "tool_stats_2", description: "2", parameters: Data("{}".utf8))
+ ]
+
+ for tool in tools {
+ try await registry.register(executor: TestToolExecutor(tool: tool))
+ }
+
+ // Execute one tool
+ let toolCall = ToolCall(
+ id: "call_all_stats",
+ function: FunctionCall(name: "tool_stats_1", arguments: "{}")
+ )
+ let context = ToolExecutionContext(toolCall: toolCall)
+ _ = try await registry.execute(context: context)
+
+ // When: Getting all stats
+ let allStats = await registry.getAllStats()
+
+ // Then: Should return stats for all registered tools
+ XCTAssertEqual(allStats.count, 2)
+ XCTAssertNotNil(allStats["tool_stats_1"])
+ XCTAssertNotNil(allStats["tool_stats_2"])
+ XCTAssertEqual(allStats["tool_stats_1"]?.executionCount, 1)
+ XCTAssertEqual(allStats["tool_stats_2"]?.executionCount, 0)
+ }
+
+ // MARK: - validate() pipeline tests
+
+ func test_registry_execute_callsValidateBeforeExecute() async throws {
+ // validate(toolCall:) must be called and must precede execute(context:).
+ let registry = DefaultToolRegistry()
+ let tool = Tool(name: "ordered_tool", description: "Test", parameters: Data("{}".utf8))
+ let executor = OrderTrackingExecutor(tool: tool, validationResult: .valid)
+ try await registry.register(executor: executor)
+
+ let toolCall = ToolCall(id: "c1", function: FunctionCall(name: "ordered_tool", arguments: "{}"))
+ _ = try await registry.execute(context: ToolExecutionContext(toolCall: toolCall))
+
+ let order = executor.callOrder
+ XCTAssertEqual(order, ["validate", "execute"], "validate must be called before execute")
+ }
+
+ func test_registry_execute_validationFailure_preventsExecution() async throws {
+ // When validate() returns .invalid, execute() must not be called and the registry
+ // must throw ToolExecutionError.validationFailed.
+ let registry = DefaultToolRegistry()
+ let tool = Tool(name: "strict_tool", description: "Test", parameters: Data("{}".utf8))
+ let executor = OrderTrackingExecutor(
+ tool: tool,
+ validationResult: .invalid(errors: ["bad arg"])
+ )
+ try await registry.register(executor: executor)
+
+ let toolCall = ToolCall(id: "c2", function: FunctionCall(name: "strict_tool", arguments: "{}"))
+ do {
+ _ = try await registry.execute(context: ToolExecutionContext(toolCall: toolCall))
+ XCTFail("Expected validationFailed to be thrown")
+ } catch let error as ToolExecutionError {
+ guard case .validationFailed(let message) = error else {
+ return XCTFail("Expected .validationFailed, got \(error)")
+ }
+ XCTAssertTrue(message.contains("bad arg"))
+ }
+
+ let order = executor.callOrder
+ XCTAssertEqual(order, ["validate"], "execute must not be called when validation fails")
+ }
+}
+
+/// Executor that records whether validate and execute were called and in what order.
+///
+/// Uses a class-based recorder with a lock so that `nonisolated func validate()`
+/// can write to shared state without crossing an actor boundary asynchronously.
+final class OrderTrackingExecutor: ToolExecutor, Sendable {
+ let tool: Tool
+
+ /// Thread-safe recorder. `nonisolated` validate() and actor-like execute() both
+ /// write to this synchronously, so the order is deterministic.
+ private let recorder = CallOrderRecorder()
+ private let validationResult: ToolValidationResult
+
+ var callOrder: [String] { recorder.callOrder }
+
+ init(tool: Tool, validationResult: ToolValidationResult) {
+ self.tool = tool
+ self.validationResult = validationResult
+ }
+
+ nonisolated func validate(toolCall: ToolCall) -> ToolValidationResult {
+ recorder.record("validate")
+ return validationResult
+ }
+
+ func execute(context: ToolExecutionContext) async throws -> ToolExecutionResult {
+ recorder.record("execute")
+ return .success()
+ }
+
+ nonisolated func maximumExecutionTime() -> Duration? { nil }
+}
+
+/// Lock-protected call-order log used by OrderTrackingExecutor.
+final class CallOrderRecorder: @unchecked Sendable {
+ private var _callOrder: [String] = []
+ private let lock = NSLock()
+
+ func record(_ step: String) {
+ lock.withLock { _callOrder.append(step) }
+ }
+
+ var callOrder: [String] {
+ lock.withLock { _callOrder }
+ }
+}
+
+// MARK: - Helper Extensions
+// TestToolExecutor methods are defined in the actor above