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 + +[![CI](https://github.com/paduh/ag-ui/actions/workflows/ci.yml/badge.svg)](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