Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,4 @@ Swift SDK for the A2A (Agent-to-Agent) protocol v1.0.
- Run specific suite: `swift test --filter EventQueueManagerTests`

## Current Version
- Latest release: `0.4.0` (SSE reconnection, connection health monitoring, streaming session API)
- Latest release: `0.5.0` (A2ATesting library with mock client, executor, fixtures, and stream helpers)
14 changes: 14 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ let package = Package(
name: "A2AVapor",
targets: ["A2AVapor"]
),
.library(
name: "A2ATesting",
targets: ["A2ATesting"]
),
],
dependencies: [
.package(url: "https://github.com/vapor/vapor.git", from: "4.100.0"),
Expand All @@ -41,6 +45,16 @@ let package = Package(
dependencies: ["A2A"],
path: "Tests/A2ATests"
),
.target(
name: "A2ATesting",
dependencies: ["A2A"],
path: "Sources/A2ATesting"
),
.testTarget(
name: "A2ATestingTests",
dependencies: ["A2ATesting"],
path: "Tests/A2ATestingTests"
),
.testTarget(
name: "A2AVaporTests",
dependencies: [
Expand Down
13 changes: 8 additions & 5 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@
- `sendStreamingMessageWithSession` / `subscribeToTaskWithSession` — rich streaming APIs
- Existing streaming methods unchanged (non-breaking)

### v0.5.0 — Testing Utilities
- `A2ATesting` library target with mock client, executor, handler, and fixture builders
- `MockA2AClient` — configurable mock with response configuration, call recording, and inspection
- `MockAgentExecutor` — closure-based executor with `.echo()`, `.failing()`, `.completing()`, `.inputRequired()` presets
- `MockAgentHandler` — pre-wired handler wrapping `DefaultRequestHandler` for router testing
- Fixture builders: `.fixture()` static methods on `AgentCard`, `Message`, `A2ATask`, `Part`, `Artifact`, events
- Stream helpers: `collectStreamEvents()`, `[StreamResponse]` filtering (`.tasks`, `.statusUpdates`), typed accessors

## Short Term

## Medium Term
Expand All @@ -68,11 +76,6 @@
- RESTful routes: `POST /message:send`, `GET /tasks/{id}`, `POST /tasks/{id}:cancel`, etc.
- Share the same handler/executor layer — just a different router

### Testing Utilities
- `A2ATesting` target with mock client and server
- Fixture builders for AgentCard, Task, Message, and other protocol types
- Test helpers for asserting streaming event sequences

### Persistent TaskStore Implementations
- `A2ASQLite` — lightweight local persistence using GRDB
- `A2APostgres` — production-grade store using PostgresNIO
Expand Down
87 changes: 86 additions & 1 deletion Sources/A2A/A2A.docc/GettingStarted.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Add A2A to your `Package.swift`:

```swift
dependencies: [
.package(url: "https://github.com/Victory-Apps/a2a-swift.git", from: "0.4.0")
.package(url: "https://github.com/Victory-Apps/a2a-swift.git", from: "0.5.0")
]
```

Expand Down Expand Up @@ -251,6 +251,91 @@ followUp.message.taskId = taskId
let response2 = try await client.sendMessage(followUp)
```

## Testing Your Agent

The `A2ATesting` library provides mocks, fixtures, and stream helpers so you can test agents without running a server or making HTTP requests.

Add it to your test target:

```swift
.testTarget(name: "MyAgentTests", dependencies: [
.product(name: "A2ATesting", package: "a2a-swift")
])
```

### Fixtures

Every A2A type has a `.fixture()` method with sensible defaults:

```swift
import A2ATesting

let card = AgentCard.fixture() // minimal valid card
let card = AgentCard.fixture(name: "My Agent") // override specific fields
let message = Message.fixture(text: "Hello") // user message with text
let task = A2ATask.fixture(status: .fixture(state: .completed))
```

### Testing AgentExecutor Logic

Use ``MockAgentExecutor`` presets or the ``DefaultRequestHandler`` with fixtures:

```swift
@Test func agentCompletesTask() async throws {
let handler = DefaultRequestHandler(
executor: MyAgent(),
card: .fixture()
)
let response = try await handler.handleSendMessage(.fixture(
message: .fixture(text: "What is 2+2?")
))

if case .task(let task) = response {
#expect(task.status.state == .completed)
#expect(task.artifacts?.first?.parts.first?.text == "4")
}
}
```

### Testing Streaming

Use `collectStreamEvents` and stream assertion helpers:

```swift
@Test func agentStreamsEvents() async throws {
let handler = DefaultRequestHandler(executor: MyAgent(), card: .fixture())
let stream = try await handler.handleSendStreamingMessage(.fixture())
let events = try await collectStreamEvents(stream)

// Filter by type
#expect(!events.tasks.isEmpty)
#expect(events.containsStatus(.completed))

// Typed access at specific indices
let task = try events.task(at: 0)
#expect(task.status.state == .submitted)
}
```

### Mock Client

Test code that calls an A2A agent without making HTTP requests:

```swift
@Test func orchestratorDelegates() async throws {
let client = MockA2AClient()
await client.setAgentCard(.fixture(name: "Product Agent"))
await client.setSendMessageResponse(.task(.fixture(
status: .fixture(state: .completed)
)))

// Pass client to your orchestration code...

let count = await client.sendMessageCallCount
#expect(count == 1)
}
```

## Next Steps

- Read <doc:Architecture> to understand how the SDK components fit together
Expand Down
1 change: 1 addition & 0 deletions Sources/A2ATesting/A2ATesting.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@_exported import A2A
54 changes: 54 additions & 0 deletions Sources/A2ATesting/Fixtures/AgentCard+Fixture.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
extension AgentCard {
/// Creates a fixture `AgentCard` with sensible defaults for testing.
public static func fixture(
name: String = "Test Agent",
description: String = "A test agent",
url: String = "http://localhost:8080",
version: String = "1.0.0",
capabilities: AgentCapabilities = AgentCapabilities(streaming: true),
defaultInputModes: [String] = ["text/plain"],
defaultOutputModes: [String] = ["text/plain"],
skills: [AgentSkill] = [.fixture()]
) -> AgentCard {
AgentCard(
name: name,
description: description,
supportedInterfaces: [AgentInterface.fixture(url: url)],
version: version,
capabilities: capabilities,
defaultInputModes: defaultInputModes,
defaultOutputModes: defaultOutputModes,
skills: skills
)
}
}

extension AgentSkill {
/// Creates a fixture `AgentSkill` with sensible defaults for testing.
public static func fixture(
id: String = "test-skill",
name: String = "Test Skill",
description: String = "A test skill",
tags: [String] = ["test"]
) -> AgentSkill {
AgentSkill(
id: id,
name: name,
description: description,
tags: tags
)
}
}

extension AgentInterface {
/// Creates a fixture `AgentInterface` with sensible defaults for testing.
public static func fixture(
url: String = "http://localhost:8080",
protocolVersion: String = "1.0"
) -> AgentInterface {
AgentInterface(
url: url,
protocolVersion: protocolVersion
)
}
}
53 changes: 53 additions & 0 deletions Sources/A2ATesting/Fixtures/Message+Fixture.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import Foundation

extension Message {
/// Creates a fixture `Message` with sensible defaults for testing.
///
/// If `parts` is provided, it takes precedence over `text`.
public static func fixture(
messageId: String = UUID().uuidString,
contextId: String? = nil,
taskId: String? = nil,
role: Role = .user,
text: String = "Hello",
parts: [Part]? = nil
) -> Message {
Message(
messageId: messageId,
contextId: contextId,
taskId: taskId,
role: role,
parts: parts ?? [.text(text)]
)
}
}

extension SendMessageRequest {
/// Creates a fixture `SendMessageRequest` with sensible defaults for testing.
public static func fixture(
message: Message = .fixture(),
configuration: SendMessageConfiguration? = nil
) -> SendMessageRequest {
SendMessageRequest(
message: message,
configuration: configuration
)
}
}

extension RequestContext {
/// Creates a fixture `RequestContext` with sensible defaults for testing.
public static func fixture(
task: A2ATask = .fixture(),
userMessage: Message = .fixture(),
request: SendMessageRequest = .fixture(),
isNewTask: Bool = true
) -> RequestContext {
RequestContext(
task: task,
userMessage: userMessage,
request: request,
isNewTask: isNewTask
)
}
}
27 changes: 27 additions & 0 deletions Sources/A2ATesting/Fixtures/Part+Fixture.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import Foundation

extension Part {
/// Creates a fixture `Part` with sensible defaults for testing.
public static func fixture(
text: String = "Test content"
) -> Part {
.text(text)
}
}

extension Artifact {
/// Creates a fixture `Artifact` with sensible defaults for testing.
public static func fixture(
artifactId: String = UUID().uuidString,
name: String? = nil,
description: String? = nil,
parts: [Part] = [.text("Test artifact content")]
) -> Artifact {
Artifact(
artifactId: artifactId,
name: name,
description: description,
parts: parts
)
}
}
69 changes: 69 additions & 0 deletions Sources/A2ATesting/Fixtures/Task+Fixture.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import Foundation

extension A2ATask {
/// Creates a fixture `A2ATask` with sensible defaults for testing.
public static func fixture(
id: String = UUID().uuidString,
contextId: String? = nil,
status: TaskStatus = .fixture(),
artifacts: [Artifact]? = nil,
history: [Message]? = nil
) -> A2ATask {
A2ATask(
id: id,
contextId: contextId,
status: status,
artifacts: artifacts,
history: history
)
}
}

extension TaskStatus {
/// Creates a fixture `TaskStatus` with sensible defaults for testing.
public static func fixture(
state: TaskState = .submitted,
message: Message? = nil,
timestamp: String? = nil
) -> TaskStatus {
TaskStatus(
state: state,
message: message,
timestamp: timestamp
)
}
}

extension TaskStatusUpdateEvent {
/// Creates a fixture `TaskStatusUpdateEvent` with sensible defaults for testing.
public static func fixture(
taskId: String = "test-task",
contextId: String = "test-context",
status: TaskStatus = .fixture()
) -> TaskStatusUpdateEvent {
TaskStatusUpdateEvent(
taskId: taskId,
contextId: contextId,
status: status
)
}
}

extension TaskArtifactUpdateEvent {
/// Creates a fixture `TaskArtifactUpdateEvent` with sensible defaults for testing.
public static func fixture(
taskId: String = "test-task",
contextId: String = "test-context",
artifact: Artifact = .fixture(),
append: Bool? = nil,
lastChunk: Bool? = nil
) -> TaskArtifactUpdateEvent {
TaskArtifactUpdateEvent(
taskId: taskId,
contextId: contextId,
artifact: artifact,
append: append,
lastChunk: lastChunk
)
}
}
Loading
Loading