diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..82a7331 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,360 @@ +# ACP Kotlin SDK Architecture + +This document defines the intended extension architecture for the ACP Kotlin SDK. It is the reference for refactoring transport code, supporting the ACP remote transport profiles, and adding framework-specific integrations. + +## Goals + +- Keep ACP protocol behavior independent from any web framework. +- Make remote ACP transports reusable across Ktor and future runtimes. +- Support both ACP remote profiles: WebSocket and Streamable HTTP with SSE. +- Keep framework adapters thin, testable, and optional. +- Preserve the current Ktor integration as a first-class adapter. +- Allow applications to integrate ACP by adapting their native HTTP, SSE, and WebSocket APIs instead of forking protocol logic. +- Preserve consumer-facing compatibility where practical while moving implementation internals behind generic boundaries. + +## Non-Goals + +- A single universal web-framework module that depends on every framework. +- Replacing framework-native WebSocket lifecycles with a custom server runtime. +- Adding more framework support before the existing Ktor transport is split into a reusable remote transport core plus adapter layer. +- Treating WebSocket as the only remote transport profile. +- Guaranteeing production support for Kotlin/JS, Wasm, or Native until those targets are explicitly validated and documented. + +## Responsibility Diagram + +```mermaid +flowchart TB + App["Host application"] + KtorClient["acp-ktor-client"] + KtorServer["acp-ktor-server"] + KtorShared["acp-ktor"] + ServletClient["acp-servlet-client"] + ServletServer["acp-servlet-server"] + Future["Future framework adapter"] + Endpoint["Remote /acp endpoint adapter"] + WS["WebSocket profile"] + HTTP["Streamable HTTP/SSE profile"] + Remote["Generic remote transport core"] + T["Transport interface"] + P["ACP protocol runtime"] + M["ACP model and JSON-RPC types"] + + App --> KtorClient + App --> KtorServer + App --> ServletClient + App --> ServletServer + App --> Future + + KtorClient --> KtorShared + KtorServer --> KtorShared + KtorShared --> Endpoint + ServletClient --> Endpoint + ServletServer --> Endpoint + Future --> Endpoint + + Endpoint --> WS + Endpoint --> HTTP + WS --> Remote + HTTP --> Remote + Remote --> T + T --> P + P --> M +``` + +## Layer Responsibilities + +### `acp-model` + +Owns the protocol data model and JSON serialization contracts. + +Responsibilities: + +- ACP request, response, notification, capability, and session types. +- JSON-RPC message types and serializers. +- Protocol version constants and schema-aligned model behavior. + +Restrictions: + +- No dependency on transports. +- No dependency on web frameworks. +- No host application lifecycle assumptions. + +### `acp` + +Owns the protocol runtime and transport contract. + +Responsibilities: + +- `Protocol` request/response correlation. +- Notification dispatch. +- Cancellation behavior. +- Agent and client runtime support. +- The framework-neutral `Transport` interface. +- STDIO transport support. + +Restrictions: + +- No dependency on Ktor, servlet APIs, Netty APIs, or other web framework APIs. +- No framework-specific authentication, routing, or server lifecycle code. + +### Generic Remote Transport Layer + +Owns remote ACP transport behavior that is independent of a concrete framework. + +Target responsibilities: + +- Provide reusable transport behavior for ACP remote profiles. +- Encode and decode ACP JSON-RPC messages shared by WebSocket and Streamable HTTP. +- Define common remote connection state, including connection identity, session stream routing, close, cancellation, and error handling. +- Preserve the ACP lifecycle across both remote profiles. +- Keep profile-specific mechanics isolated behind small abstractions. + +Remote profiles: + +- WebSocket profile: full-duplex JSON-RPC over text frames. +- Streamable HTTP profile: client-to-server JSON-RPC over `POST`, server-to-client JSON-RPC over long-lived SSE `GET` streams, and connection termination over `DELETE`. + +Target restrictions: + +- No direct dependency on Ktor, Javax Servlet/WebSocket, or other framework APIs. +- No framework-specific routing or authentication policy. +- No framework-specific session type in public constructor signatures. + +The generic layer should model the ACP remote transport endpoint described by the Streamable HTTP and WebSocket transport RFD: + +- One `/acp` endpoint. +- `GET /acp` with `Upgrade: websocket` opens the WebSocket profile. +- `GET /acp` without WebSocket upgrade opens an SSE stream. +- `POST /acp` accepts client-to-server JSON-RPC. +- `DELETE /acp` terminates a connection. +- `Acp-Connection-Id` identifies transport connection state. +- `Acp-Session-Id` identifies session-scoped HTTP streams and POSTs. +- Streamable HTTP requires HTTP/2. +- HTTP-based transports must support cookies for connection-scoped affinity. + +### WebSocket Profile + +Owns WebSocket behavior that is independent of a concrete framework. + +Responsibilities: + +- Adapt a bidirectional text-frame connection to the generic remote transport core. +- Encode and decode ACP JSON-RPC messages. +- Ignore or reject unsupported binary frames according to the transport policy. +- Close the remote connection when the WebSocket closes. + +Implemented abstraction: + +```kotlin +public interface AcpWebSocketConnection : AutoCloseable { + public val incomingTextFrames: Flow + public suspend fun sendText(text: String) + override fun close() +} +``` + +Framework adapters translate native WebSocket APIs into this generic connection contract, then compose `RemoteWebSocketTransport` for shared ACP JSON-RPC framing, send/receive lifecycle, close, and error handling. + +### Streamable HTTP/SSE Profile + +Owns Streamable HTTP behavior that is independent of a concrete framework. + +Responsibilities: + +- Accept `initialize` as a special `POST /acp` request that returns `200 OK` with a JSON-RPC response body and `Acp-Connection-Id`. +- Accept other valid `POST /acp` messages and return `202 Accepted` while routing the eventual JSON-RPC response to the correct SSE stream. +- Open connection-scoped SSE streams for connection-level server-to-client messages. +- Open session-scoped SSE streams for session-level server-to-client messages. +- Route server-to-client messages by connection and session identity. +- Terminate connection state on `DELETE /acp`. +- Enforce content negotiation and validation rules that are transport-level rather than framework-specific. + +Restrictions: + +- No framework-specific HTTP request or response types in the generic implementation. +- No authentication policy beyond accepting caller-provided validated metadata. +- No durability or replay guarantees beyond the current ACP transport version. + +Candidate abstraction shapes: + +```kotlin +public data class AcpRemoteRequest( + public val method: AcpRemoteHttpMethod, + public val headers: AcpRemoteHeaders, + public val body: String?, +) + +public interface AcpSseStream { + public suspend fun sendEvent(data: String) + public suspend fun close() +} +``` + +The exact API should be decided during implementation. The important boundary is that framework adapters own HTTP/SSE primitives, while the generic layer owns ACP remote routing and JSON-RPC behavior. + +### `acp-ktor` + +Target role: Ktor adapter and shared Ktor-specific helpers. + +Responsibilities: + +- Adapt Ktor `WebSocketSession` to the WebSocket profile abstraction. +- Adapt Ktor HTTP requests and SSE responses to the Streamable HTTP profile abstraction when HTTP/SSE support is added. +- Keep Ktor-specific imports and lifecycle behavior outside the generic remote transport core. +- Preserve existing Ktor client/server ergonomics where possible. + +Restrictions: + +- No duplicate protocol logic. +- No Ktor-specific behavior inside the generic remote transport core. + +### `acp-ktor-server` + +Owns Ktor server route binding. + +Responsibilities: + +- Bind ACP to a Ktor `Route` or `Application` at the shared `/acp` endpoint. +- Route WebSocket upgrades to the WebSocket profile. +- Route Streamable HTTP `GET`, `POST`, and `DELETE` requests to the HTTP/SSE profile when supported. +- Provide route-level configuration hooks. +- Let applications supply authentication through Ktor-native routing wrappers. + +Restrictions: + +- Should only compose Ktor routing plus the generic remote transport profiles. +- Should not own protocol semantics. + +### `acp-ktor-client` + +Owns Ktor client binding. + +Responsibilities: + +- Open a Ktor client WebSocket session for the WebSocket profile. +- Add Streamable HTTP client support through Ktor HTTP client primitives when implemented. +- Adapt Ktor client primitives into generic remote profile abstractions. +- Return a `Protocol` ready for the caller to start. + +Restrictions: + +- Should not duplicate remote transport send/receive or routing logic. + +### Future JVM Adapters + +Target role: optional adapters for JVM stacks that are not already covered by Ktor or the Java SDK. + +Likely responsibilities: + +- Adapt the target framework's WebSocket APIs to the WebSocket profile abstraction. +- Adapt the target framework's HTTP and SSE APIs to the Streamable HTTP profile abstraction when HTTP/SSE support is added. +- Keep framework dependencies optional and isolated from non-consumers. + +Restrictions: + +- No dependency from core modules back into framework-specific APIs. +- No duplicate protocol or JSON-RPC handling. +- Prefer adapters that cover Kotlin-first or Atlassian-relevant JVM stacks not already covered by the Java SDK. + +### `acp-servlet-server` + +Target role: Javax Servlet/JSR-356 WebSocket server adapter. + +Responsibilities: + +- Adapt `javax.websocket.Session` to the generic WebSocket profile abstraction. +- Register ACP WebSocket endpoints through `javax.websocket.server.ServerContainer`. +- Provide a small `ServletContext` helper for servlet-style hosts. +- Keep Javax Servlet/WebSocket dependencies isolated from Ktor and core consumers. + +Restrictions: + +- No protocol or JSON-RPC framing logic. +- No Spring-specific configuration or auto-configuration. +- No Jakarta namespace dependency until a separate Jakarta variant is justified. + +### `acp-servlet-client` + +Target role: Javax/JSR-356 WebSocket client adapter. + +Responsibilities: + +- Adapt Javax `WebSocketContainer` client connections to the generic WebSocket profile abstraction. +- Provide helpers for callers that already have a `WebSocketContainer` and for callers that want the default `ContainerProvider` container. +- Return a `Protocol` ready for the caller to start, matching the Ktor client helper lifecycle. +- Keep Javax WebSocket dependencies isolated from Ktor and core consumers. + +Restrictions: + +- No protocol or JSON-RPC framing logic. +- No dependency on `acp-servlet-server`; shared behavior is the generic WebSocket transport in `acp`. +- No Spring-specific client configuration. +- No Jakarta namespace dependency until a separate Jakarta variant is justified. + +## Dependency Direction + +Allowed dependency direction: + +```text +framework adapter -> generic remote transport profiles -> acp -> acp-model +``` + +Disallowed dependency direction: + +```text +acp -> acp-ktor +generic remote transport -> acp-ktor +generic remote transport -> framework adapters +``` + +## Framework Integration Pattern + +Every framework adapter should follow the same pattern: + +1. Bind the framework-native `/acp` route or endpoint. +2. Route `GET` with WebSocket upgrade to the WebSocket profile. +3. Route non-upgrade `GET` to the Streamable HTTP SSE profile. +4. Route `POST` to the Streamable HTTP inbound message handler. +5. Route `DELETE` to remote connection termination. +6. Convert framework WebSocket, HTTP request, HTTP response, SSE, cookie, and close/error events into generic remote transport abstractions. +7. Construct `Protocol` through the generic profile implementation. + +The adapter should be small enough that behavior can be reviewed as lifecycle mapping, not protocol implementation. + +## Testing Strategy + +Tests should preserve the layer boundaries: + +- Core protocol tests use fake or in-memory `Transport` implementations. +- Generic WebSocket profile tests use fake `AcpWebSocketConnection` implementations. +- Generic Streamable HTTP profile tests use fake HTTP/SSE connection implementations. +- Ktor adapter tests verify Ktor lifecycle mapping and end-to-end compatibility. +- Framework adapter tests, when added, verify lifecycle mapping and end-to-end compatibility. + +Existing protocol behavior should remain covered before adding more framework support. + +## Compatibility Expectations + +The refactor should preserve: + +- Existing public protocol and model APIs unless a breaking change is explicitly accepted. +- Existing Ktor server helper behavior where practical. +- Existing Ktor client helper behavior where practical. +- ACP JSON-RPC wire format. + +API movement should favor additive compatibility. If a breaking change is unavoidable, document it in the implementation PR and update migration guidance. + +Compatibility strategy: + +- Keep existing Ktor helper function names and signatures where practical. +- Keep existing artifact coordinates working for current consumers. +- If implementation classes move, leave deprecated typealiases or wrapper classes in the old package when Kotlin allows it. +- Prefer adding new generic APIs alongside existing Ktor APIs before deprecating old Ktor-specific constructors. +- Keep wire compatibility for current WebSocket JSON-RPC behavior. + +Tradeoffs: + +- Keeping wrapper APIs may temporarily duplicate small construction paths, but it protects consumers while the generic remote layer stabilizes. +- Keeping generic remote transport inside `acp` avoids another dependency for consumers, but increases the core artifact surface. +- Splitting generic remote transport into a new module such as `acp-remote` keeps `acp` smaller, but requires Ktor consumers to receive a new transitive dependency and creates another public artifact to version. +- Full Streamable HTTP support introduces connection/session state that WebSocket does not need; forcing WebSocket through all HTTP state machinery would simplify architecture but may add unnecessary overhead. The preferred approach is a shared remote core with separate profile-specific mechanics. diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..22b100b --- /dev/null +++ b/PLAN.md @@ -0,0 +1,325 @@ +# ACP Runtime Extension Plan + +This plan sequences the work required to make the SDK framework-extensible, support ACP remote transport profiles, then add additional non-Spring JVM/Kotlin adapters where they add coverage beyond the Java SDK. It should be used with `ARCHITECTURE.md` as the reference for implementation decisions. + +## Objective + +Make ACP remote transport support generic enough that Ktor is one adapter rather than the transport implementation, support both WebSocket and Streamable HTTP/SSE profiles, then add the most useful non-Spring JVM/Kotlin adapter through the same extension path. + +## Guiding Principles + +- Refactor before expanding framework support. +- Preserve the current Ktor behavior while moving reusable logic into a framework-neutral layer. +- Keep each framework adapter optional and isolated in its own artifact. +- Do not duplicate JSON-RPC, protocol, or transport state-machine behavior across adapters. +- Validate the generic path with Ktor before implementing another adapter. +- Maintain source and binary compatibility for consumers where practical. +- Prefer additive APIs, wrappers, and deprecations over removals. +- Treat any unavoidable breaking change as a documented tradeoff requiring explicit acceptance. + +## Architecture Change + +Before: + +```mermaid +flowchart TB + App["Ktor host app"] + Server["acp-ktor-server route helper"] + Client["acp-ktor-client helper"] + KtorWS["Ktor WebSocketSession"] + WST["WebSocketTransport in acp-ktor"] + T["Transport"] + P["Protocol"] + M["acp-model"] + + App --> Server + App --> Client + Server --> KtorWS + Client --> KtorWS + KtorWS --> WST + WST --> T + T --> P + P --> M +``` + +After: + +```mermaid +flowchart TB + KtorApp["Ktor host app"] + JVMApp["Future JVM host app"] + OtherApp["Future host app"] + + KtorAdapter["Ktor adapter"] + JVMAdapter["Future JVM adapter"] + OtherAdapter["Future adapter"] + + Endpoint["Generic /acp endpoint boundary"] + WS["WebSocket profile"] + HTTP["Streamable HTTP/SSE profile"] + Remote["Generic remote transport core"] + T["Transport"] + P["Protocol"] + M["acp-model"] + + KtorApp --> KtorAdapter + JVMApp --> JVMAdapter + OtherApp --> OtherAdapter + + KtorAdapter --> Endpoint + JVMAdapter --> Endpoint + OtherAdapter --> Endpoint + + Endpoint --> WS + Endpoint --> HTTP + WS --> Remote + HTTP --> Remote + Remote --> T + T --> P + P --> M +``` + +The change is to move reusable JSON-RPC transport behavior out of Ktor-owned WebSocket code and into a generic remote transport layer. Ktor keeps its current public integration surface where practical, but becomes an adapter over the same boundary that future framework adapters will use. + +## Phase 1: Define the Generic Remote Transport Boundary + +Deliverables: + +- Decide where the generic remote transport abstractions live. +- Define the minimal public contracts for WebSocket and Streamable HTTP/SSE profiles. +- Define lifecycle expectations for receive, send, close, cancellation, errors, connection identity, and session stream routing. +- Confirm whether the generic remote transport belongs in `acp`, `acp-ktor`, or a new module. +- Define the compatibility strategy for existing Ktor APIs before moving code. + +Preferred direction: + +- Keep `Transport` in `acp`. +- Introduce a framework-neutral remote transport core outside Ktor-specific modules. +- Include a WebSocket profile and a Streamable HTTP/SSE profile in the architecture, even if implementation is staged. +- Keep Ktor server/client helpers as adapters over the generic remote transport. + +Acceptance criteria: + +- The boundary is documented in code comments and reflected in module dependencies. +- Generic remote transport code has no Ktor or other framework imports. +- Ktor-specific code only adapts Ktor APIs and wires protocol construction. +- Existing public Ktor entry points have a preservation or deprecation plan. + +## Phase 2: Refactor Existing Ktor WebSocket Code Compatibly + +Deliverables: + +- Extract the current Ktor-dependent `WebSocketTransport` behavior into a generic WebSocket profile implementation. +- Add a Ktor adapter for `WebSocketSession`. +- Update `acp-ktor-server` to compose the Ktor adapter with the generic transport. +- Update `acp-ktor-client` to compose the Ktor adapter with the generic transport. +- Preserve current helper function names where practical. +- Add deprecated wrappers or typealiases if public implementation types need to move. + +Implementation notes: + +- Start from the current `acp-ktor/src/commonMain/kotlin/com/agentclientprotocol/transport/WebSocketTransport.kt`. +- Move JSON encoding/decoding and send/receive loop behavior to the generic WebSocket profile. +- Leave Ktor frame conversion and session close/flush behavior in the Ktor adapter. +- Avoid changing `Protocol` unless the transport contract proves insufficient. +- Keep Ktor-facing helper signatures stable unless the old signature blocks correct lifecycle behavior. + +Acceptance criteria: + +- Existing Ktor tests pass. +- No framework-specific imports exist in the generic WebSocket profile. +- Ktor helpers still expose a straightforward route/client API. +- Public API changes are avoided, wrapped, or documented with migration guidance. +- Current consumers can keep using the existing Ktor dependency path for WebSocket support. + +## Phase 3: Strengthen Tests Around the Extension Boundary + +Deliverables: + +- Add generic WebSocket profile tests using a fake connection. +- Keep or update existing Ktor end-to-end tests. +- Add lifecycle tests for close, cancellation, invalid JSON, and send errors where practical. +- Confirm that the generic WebSocket profile behaves the same regardless of adapter. +- Add compatibility tests or binary API validation updates for preserved Ktor entry points. + +Acceptance criteria: + +- Tests prove the reusable WebSocket profile without needing Ktor. +- Tests prove the Ktor adapter still works end to end. +- Failure modes are covered at the generic transport level where possible. + +## Phase 4: Select and Add the Next JVM/Kotlin WebSocket Adapter + +Deliverables: + +- Add a Servlet/Jakarta-based Gradle module for WebSocket support. +- Adapt standard JVM WebSocket APIs to the generic WebSocket profile abstraction. +- Provide a minimal server registration helper or endpoint type. +- Add WebSocket integration tests for the Servlet/Jakarta adapter. +- Add README documentation for Servlet/Jakarta usage. + +Recommended first target: + +- Prefer `acp-servlet-server` over Spring, since Spring apps can use the Java SDK and Atlassian-relevant JVM environments commonly expose servlet-style extension points. +- Start with Javax Servlet/WebSocket compatibility for Atlassian-style Data Center/plugin environments, then add a Jakarta variant only if modern containers need a separate artifact. +- Keep the first adapter server-side and WebSocket-only. +- Keep the manual registration API small before considering container-specific helpers. +- Defer Streamable HTTP/SSE for Servlet/Jakarta until the generic HTTP/SSE profile is designed and proven with Ktor. + +Acceptance criteria: + +- Servlet/Jakarta environments can expose ACP over WebSocket without depending on Ktor. +- The implementation uses the same generic WebSocket profile as Ktor. +- Servlet/Jakarta dependencies are isolated in `acp-servlet-client` and `acp-servlet-server`. +- Adapter tests cover a basic ACP session. +- Existing Ktor and core tests still pass. + +Implementation status: + +- The generic WebSocket boundary and Ktor compatibility refactor were committed as the interim baseline in `62a0753`. +- `acp-servlet-server` is the first follow-on adapter and targets Javax Servlet/JSR-356 WebSocket APIs first. +- The adapter owns only Javax lifecycle mapping; JSON-RPC framing remains in `RemoteWebSocketTransport`. +- Jakarta package support remains a follow-up variant if modern containers require a separate artifact. + +## Phase 5: Add Streamable HTTP/SSE Remote Profile Design + +Deliverables: + +- Define server-side connection state for `Acp-Connection-Id`. +- Define session-scoped stream state for `Acp-Session-Id`. +- Define inbound `POST` handling, including special `initialize` behavior. +- Define connection-scoped and session-scoped SSE stream routing. +- Define `DELETE /acp` termination behavior. +- Define content negotiation, HTTP status mapping, HTTP/2 expectations, and cookie handling boundaries. +- Decide whether HTTP/SSE support is server-only first or includes client support in the first pass. + +Acceptance criteria: + +- The design matches the active ACP Streamable HTTP and WebSocket transport RFD. +- The HTTP/SSE profile design reuses protocol and JSON-RPC logic rather than duplicating it. +- The design does not require breaking existing WebSocket consumers. +- Ktor and future adapters can both implement the same HTTP/SSE boundary. + +## Phase 6: Implement Streamable HTTP/SSE for Ktor + +Deliverables: + +- Add Ktor server routing for `GET`, `POST`, and `DELETE /acp` without WebSocket upgrade. +- Add connection-scoped SSE stream support. +- Add session-scoped SSE stream support. +- Add Streamable HTTP validation and status responses. +- Add tests for initialize, session creation, prompt routing, permission response routing, cancellation, and connection termination. + +Acceptance criteria: + +- A Ktor server can expose ACP over WebSocket and Streamable HTTP/SSE on the same endpoint. +- Existing WebSocket Ktor consumers continue to work. +- HTTP/SSE tests prove connection and session stream routing. +- Unsupported or invalid requests return documented transport-level statuses. + +## Phase 7: Extend the Servlet/Jakarta Adapter to Streamable HTTP/SSE + +Deliverables: + +- Map Servlet/Jakarta HTTP/SSE APIs to the Streamable HTTP/SSE profile abstraction. +- Provide or extend a server registration helper for the full `/acp` endpoint. +- Add HTTP/SSE integration tests for the Servlet/Jakarta adapter. +- Add README documentation for HTTP/SSE usage. + +Acceptance criteria: + +- Servlet/Jakarta environments can expose ACP over Streamable HTTP/SSE without depending on Ktor. +- The implementation uses the same generic remote transport profiles as Ktor. +- Tests cover a basic HTTP/SSE ACP session. +- Existing Ktor and core tests still pass. + +## Phase 8: Documentation and Migration Guidance + +Deliverables: + +- Update README module table. +- Document the generic transport model. +- Document Ktor usage after the refactor. +- Document Streamable HTTP/SSE behavior and requirements. +- Document Servlet/Jakarta adapter usage. +- Note any source or binary compatibility changes. +- Document deprecations and replacement APIs. + +Acceptance criteria: + +- A Ktor user can keep using the SDK with minimal changes. +- Servlet/Jakarta users can identify the correct dependency and setup path. +- Adapter authors can understand how to add another framework. +- Consumers can identify whether they need WebSocket only or full Streamable HTTP/SSE support. + +## Compatibility Plan + +Compatibility priorities: + +- Preserve existing artifact coordinates for current Ktor WebSocket consumers. +- Preserve existing public helper function names where practical. +- Preserve existing WebSocket wire behavior. +- Use additive APIs for the generic remote layer. +- Deprecate old implementation-specific constructors only after replacements exist. + +Possible tradeoffs: + +- If the public `WebSocketTransport` constructor currently exposes Ktor types, a fully generic implementation cannot keep that constructor as the canonical API. Preferred mitigation: keep a deprecated Ktor-backed wrapper with the same constructor and delegate to the new generic implementation. +- If generic remote transport moves into a new module, current Ktor artifacts should depend on it transitively so existing consumers do not need to add another dependency manually. +- If binary compatibility prevents moving a type cleanly, prefer leaving a compatibility shell in the old module over forcing a breaking change. +- If Streamable HTTP requires new endpoint helpers, add them alongside current WebSocket helpers before combining them into a unified `/acp` helper. + +## Proposed Module Shape + +Target shape: + +```text +acp-model +acp +acp-remote optional if generic remote transport is split out +acp-ktor +acp-ktor-client +acp-ktor-server +acp-servlet-server implemented for Javax WebSocket +acp-servlet-client implemented for Javax WebSocket +``` + +If the generic remote transport remains inside `acp`, then `acp-remote` is unnecessary. The implementation should choose the smallest module structure that preserves clean dependencies and consumer compatibility. + +## First Implementation Milestone + +The first implementation milestone is complete when: + +- A generic remote transport boundary exists. +- A generic WebSocket profile abstraction exists. +- The current Ktor `WebSocketTransport` no longer owns generic JSON-RPC send/receive behavior directly against Ktor APIs. +- Ktor server and client integrations still pass tests. +- `ARCHITECTURE.md` still accurately describes the dependency boundaries. +- Existing Ktor consumers have a compatibility path. + +## Streamable HTTP/SSE Milestone + +The Streamable HTTP/SSE milestone is complete when: + +- Ktor can serve the shared `/acp` endpoint with WebSocket upgrade and Streamable HTTP behavior. +- `POST`, `GET`, and `DELETE` transport rules are implemented and tested. +- Connection-scoped and session-scoped SSE streams route messages correctly. +- Existing WebSocket behavior remains compatible. + +## Servlet/Jakarta Adapter Milestone + +The Servlet/Jakarta WebSocket adapter milestone is complete when: + +- Servlet/Jakarta environments can host ACP over WebSocket using the generic remote transport. +- Servlet/Jakarta environments can connect to ACP over WebSocket using the generic remote transport. +- Adapter code is isolated from Ktor code. +- Tests demonstrate a working Servlet/Jakarta WebSocket ACP server path. +- Tests demonstrate a working Javax WebSocket ACP client path. +- README documents the supported Servlet/Jakarta runtime and setup path. + +The Servlet/Jakarta Streamable HTTP/SSE adapter milestone is complete when: + +- Servlet/Jakarta environments can host ACP over Streamable HTTP/SSE using the generic remote transport. +- Servlet/Jakarta environments can expose the full `/acp` endpoint shape without depending on Ktor. +- Tests demonstrate a working Servlet/Jakarta HTTP/SSE ACP server path. +- README documents the supported Servlet/Jakarta HTTP/SSE runtime and setup path. diff --git a/README.md b/README.md index ffbb59c..08ede2a 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,8 @@ ACP standardises how AI agents and clients exchange messages, negotiate capabili | `:acp-ktor-client` | Ktor HTTP/WebSocket client helper | `ktor.client` | | `:acp-ktor-server` | Ktor server-side transport utilities | `ktor.server` | | `:acp-ktor-test` | Test fixtures and fake transports for ACP flows | `ktor.test` | +| `:acp-servlet-client` | Javax WebSocket client adapter | `transport` | +| `:acp-servlet-server` | Javax Servlet/WebSocket server adapter | `transport` | | `:samples:kotlin-acp-client-sample` | Complete runnable client + agent reference implementation | `samples` | ## Requirements @@ -55,11 +57,57 @@ dependencies { // Optional extras: // implementation("com.agentclientprotocol:acp-ktor-client:0.3.0-SNAPSHOT") // implementation("com.agentclientprotocol:acp-ktor-server:0.3.0-SNAPSHOT") + // implementation("com.agentclientprotocol:acp-servlet-client:0.3.0-SNAPSHOT") + // implementation("com.agentclientprotocol:acp-servlet-server:0.3.0-SNAPSHOT") } ``` > **Snapshot builds:** When consuming the `-SNAPSHOT` artifacts outside Maven Central, add the repository that hosts your snapshot (e.g. GitHub Packages or an internal mirror). +### Javax Servlet/WebSocket server + +Use `acp-servlet-server` when a JVM host exposes Javax Servlet and JSR-356 WebSocket APIs instead of Ktor. The adapter registers a server endpoint on the container `ServerContainer` and reuses the framework-neutral WebSocket transport from `acp`. + +```kotlin +import com.agentclientprotocol.agent.Agent +import com.agentclientprotocol.protocol.Protocol +import com.agentclientprotocol.protocol.ProtocolOptions +import com.agentclientprotocol.transport.acpProtocolOnServerWebSocket +import kotlinx.coroutines.CoroutineScope +import javax.servlet.ServletContext + +fun ServletContext.registerAcp(scope: CoroutineScope, agentSupport: TerminalAgentSupport) { + acpProtocolOnServerWebSocket( + parentScope = scope, + protocolOptions = ProtocolOptions(protocolDebugName = "servlet agent") + ) { protocol: Protocol -> + Agent(protocol, agentSupport) + protocol.start() + } +} +``` + +### Javax WebSocket client + +Use `acp-servlet-client` when a JVM host exposes a Javax `WebSocketContainer` client instead of Ktor. + +```kotlin +import com.agentclientprotocol.protocol.ProtocolOptions +import com.agentclientprotocol.transport.acpProtocolOnClientWebSocket +import kotlinx.coroutines.CoroutineScope +import java.net.URI +import javax.websocket.WebSocketContainer + +fun connectAcp(container: WebSocketContainer, scope: CoroutineScope) = + container.acpProtocolOnClientWebSocket( + uri = URI.create("ws://localhost:8080/acp"), + parentScope = scope, + protocolOptions = ProtocolOptions(protocolDebugName = "servlet client") + ).also { protocol -> + protocol.start() + } +``` + ## Quick start ### Write your first agent diff --git a/acp-ktor-test/build.gradle.kts b/acp-ktor-test/build.gradle.kts index 711b828..f6fdbac 100644 --- a/acp-ktor-test/build.gradle.kts +++ b/acp-ktor-test/build.gradle.kts @@ -20,5 +20,11 @@ kotlin { implementation(libs.ktor.client.core) } } + jvmTest { + dependencies { + implementation(project(":acp-servlet-client")) + implementation(project(":acp-servlet-server")) + } + } } } diff --git a/acp-ktor-test/src/jvmTest/kotlin/com/agentclientprotocol/ServletWebSocketFeaturesTest.kt b/acp-ktor-test/src/jvmTest/kotlin/com/agentclientprotocol/ServletWebSocketFeaturesTest.kt new file mode 100644 index 0000000..36536aa --- /dev/null +++ b/acp-ktor-test/src/jvmTest/kotlin/com/agentclientprotocol/ServletWebSocketFeaturesTest.kt @@ -0,0 +1,5 @@ +package com.agentclientprotocol + +import com.agentclientprotocol.framework.ServletWebSocketProtocolDriver + +class ServletWebSocketFeaturesTest : FeaturesTest(ServletWebSocketProtocolDriver()) diff --git a/acp-ktor-test/src/jvmTest/kotlin/com/agentclientprotocol/ServletWebSocketNesTest.kt b/acp-ktor-test/src/jvmTest/kotlin/com/agentclientprotocol/ServletWebSocketNesTest.kt new file mode 100644 index 0000000..fa53bb3 --- /dev/null +++ b/acp-ktor-test/src/jvmTest/kotlin/com/agentclientprotocol/ServletWebSocketNesTest.kt @@ -0,0 +1,5 @@ +package com.agentclientprotocol + +import com.agentclientprotocol.framework.ServletWebSocketProtocolDriver + +class ServletWebSocketNesTest : NesTest(ServletWebSocketProtocolDriver()) diff --git a/acp-ktor-test/src/jvmTest/kotlin/com/agentclientprotocol/ServletWebSocketProtocolTest.kt b/acp-ktor-test/src/jvmTest/kotlin/com/agentclientprotocol/ServletWebSocketProtocolTest.kt new file mode 100644 index 0000000..0736a39 --- /dev/null +++ b/acp-ktor-test/src/jvmTest/kotlin/com/agentclientprotocol/ServletWebSocketProtocolTest.kt @@ -0,0 +1,5 @@ +package com.agentclientprotocol + +import com.agentclientprotocol.framework.ServletWebSocketProtocolDriver + +class ServletWebSocketProtocolTest : ProtocolTest(ServletWebSocketProtocolDriver()) diff --git a/acp-ktor-test/src/jvmTest/kotlin/com/agentclientprotocol/ServletWebSocketSimpleAgentTest.kt b/acp-ktor-test/src/jvmTest/kotlin/com/agentclientprotocol/ServletWebSocketSimpleAgentTest.kt new file mode 100644 index 0000000..169041e --- /dev/null +++ b/acp-ktor-test/src/jvmTest/kotlin/com/agentclientprotocol/ServletWebSocketSimpleAgentTest.kt @@ -0,0 +1,5 @@ +package com.agentclientprotocol + +import com.agentclientprotocol.framework.ServletWebSocketProtocolDriver + +class ServletWebSocketSimpleAgentTest : SimpleAgentTest(ServletWebSocketProtocolDriver()) diff --git a/acp-ktor-test/src/jvmTest/kotlin/com/agentclientprotocol/framework/ServletWebSocketProtocolDriver.kt b/acp-ktor-test/src/jvmTest/kotlin/com/agentclientprotocol/framework/ServletWebSocketProtocolDriver.kt new file mode 100644 index 0000000..0f25d94 --- /dev/null +++ b/acp-ktor-test/src/jvmTest/kotlin/com/agentclientprotocol/framework/ServletWebSocketProtocolDriver.kt @@ -0,0 +1,162 @@ +package com.agentclientprotocol.framework + +import com.agentclientprotocol.protocol.Protocol +import com.agentclientprotocol.protocol.ProtocolOptions +import com.agentclientprotocol.transport.acpJavaxWebSocketServerEndpointConfig +import com.agentclientprotocol.transport.acpProtocolOnClientWebSocket +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.TestResult +import java.lang.reflect.Proxy +import java.net.URI +import javax.websocket.ClientEndpointConfig +import javax.websocket.Endpoint +import javax.websocket.EndpointConfig +import javax.websocket.MessageHandler +import javax.websocket.RemoteEndpoint +import javax.websocket.SendHandler +import javax.websocket.SendResult +import javax.websocket.Session +import javax.websocket.WebSocketContainer + +class ServletWebSocketProtocolDriver : ProtocolDriver { + override fun testWithProtocols(block: suspend CoroutineScope.(clientProtocol: Protocol, agentProtocol: Protocol) -> Unit): TestResult = + runBlocking { + coroutineScope { + val serverProtocolDeferred = CompletableDeferred() + val protocolScope = CoroutineScope(coroutineContext + SupervisorJob()) + val serverEndpointConfig = acpJavaxWebSocketServerEndpointConfig( + path = "/acp", + parentScope = protocolScope, + protocolOptions = ProtocolOptions(protocolDebugName = "servlet agent protocol"), + ) { protocol -> + serverProtocolDeferred.complete(protocol) + awaitCancellation() + } + val serverEndpoint = serverEndpointConfig.configurator + .getEndpointInstance(serverEndpointConfig.endpointClass) as Endpoint + + val clientSession = LinkedJavaxWebSocketSession() + val serverSession = LinkedJavaxWebSocketSession() + clientSession.peer = serverSession + serverSession.peer = clientSession + + val container = fakeWebSocketContainer( + clientSession = clientSession, + serverSession = serverSession, + serverEndpoint = serverEndpoint, + serverEndpointConfig = serverEndpointConfig, + ) + + var clientProtocol: Protocol? = null + var agentProtocol: Protocol? = null + try { + clientProtocol = container.acpProtocolOnClientWebSocket( + uri = URI.create("ws://localhost/acp"), + parentScope = protocolScope, + protocolOptions = ProtocolOptions(protocolDebugName = "servlet client protocol"), + ) + agentProtocol = serverProtocolDeferred.await() + agentProtocol.start() + clientProtocol.start() + block(clientProtocol, agentProtocol) + } finally { + agentProtocol?.close() + clientProtocol?.close() + protocolScope.cancel() + } + } + } +} + +private class LinkedJavaxWebSocketSession { + var peer: LinkedJavaxWebSocketSession? = null + var closed: Boolean = false + private set + private var textHandler: MessageHandler.Whole? = null + private var sendTimeout: Long = -1 + + val asyncRemote: RemoteEndpoint.Async = Proxy.newProxyInstance( + RemoteEndpoint.Async::class.java.classLoader, + arrayOf(RemoteEndpoint.Async::class.java), + ) { _, method, args -> + when (method.name) { + "sendText" -> { + val arguments = requireNotNull(args) + peer?.receiveText(arguments[0] as String) + (arguments[1] as? SendHandler)?.onResult(SendResult()) + null + } + "getSendTimeout" -> sendTimeout + "setSendTimeout" -> { + val arguments = requireNotNull(args) + sendTimeout = arguments[0] as Long + null + } + "toString" -> "LinkedRemoteEndpointAsync" + "hashCode" -> System.identityHashCode(this) + else -> null + } + } as RemoteEndpoint.Async + + val session: Session = Proxy.newProxyInstance( + Session::class.java.classLoader, + arrayOf(Session::class.java), + ) { _, method, args -> + when (method.name) { + "addMessageHandler" -> { + val arguments = requireNotNull(args) + @Suppress("UNCHECKED_CAST") + textHandler = arguments[1] as MessageHandler.Whole + null + } + "removeMessageHandler" -> { + textHandler = null + null + } + "getAsyncRemote" -> asyncRemote + "isOpen" -> !closed + "close" -> { + closed = true + null + } + "toString" -> "LinkedJavaxWebSocketSession" + "hashCode" -> System.identityHashCode(this) + else -> null + } + } as Session + + fun receiveText(text: String) { + textHandler?.onMessage(text) + } +} + +private fun fakeWebSocketContainer( + clientSession: LinkedJavaxWebSocketSession, + serverSession: LinkedJavaxWebSocketSession, + serverEndpoint: Endpoint, + serverEndpointConfig: EndpointConfig, +): WebSocketContainer = + Proxy.newProxyInstance( + WebSocketContainer::class.java.classLoader, + arrayOf(WebSocketContainer::class.java), + ) { _, method, args -> + when (method.name) { + "connectToServer" -> { + val arguments = requireNotNull(args) + val clientEndpoint = arguments[0] as Endpoint + val clientEndpointConfig = arguments[1] as ClientEndpointConfig + serverEndpoint.onOpen(serverSession.session, serverEndpointConfig) + clientEndpoint.onOpen(clientSession.session, clientEndpointConfig) + clientSession.session + } + "toString" -> "FakeWebSocketContainer" + "hashCode" -> System.identityHashCode(clientSession) + else -> null + } + } as WebSocketContainer diff --git a/acp-ktor/src/commonMain/kotlin/com/agentclientprotocol/transport/WebSocketTransport.kt b/acp-ktor/src/commonMain/kotlin/com/agentclientprotocol/transport/WebSocketTransport.kt index d2f50fc..475019c 100644 --- a/acp-ktor/src/commonMain/kotlin/com/agentclientprotocol/transport/WebSocketTransport.kt +++ b/acp-ktor/src/commonMain/kotlin/com/agentclientprotocol/transport/WebSocketTransport.kt @@ -1,103 +1,73 @@ package com.agentclientprotocol.transport -import com.agentclientprotocol.rpc.ACPJson -import com.agentclientprotocol.rpc.decodeJsonRpcMessage import com.agentclientprotocol.rpc.JsonRpcMessage import io.github.oshai.kotlinlogging.KotlinLogging import io.ktor.websocket.* import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.launch -import kotlinx.serialization.SerializationException -import kotlinx.serialization.encodeToString -import kotlin.coroutines.cancellation.CancellationException private val logger = KotlinLogging.logger {} public const val ACP_PATH: String = "acp" -public class WebSocketTransport(private val parentScope: CoroutineScope, private val wss: WebSocketSession) : BaseTransport() { - private val scope = CoroutineScope(parentScope.coroutineContext + SupervisorJob(parentScope.coroutineContext[Job])) - private val sendChannel = Channel(Channel.UNLIMITED) - - override fun start() { - scope.launch { - try { - for (message in sendChannel) { - val jsonText = try { - ACPJson.encodeToString(message) - } catch (e: SerializationException) { - logger.trace(e) { "Failed to serialize message: ${message}" } - fireError(e) - continue - } - val frame = Frame.Text(jsonText) - logger.trace { "Sending message to channel: '$jsonText'" } - wss.send(frame) - wss.flush() - } - logger.trace { "No more messages in channel, closing connection" } - wss.close() - wss.flush() - } - catch (ce: CancellationException) { - logger.trace(ce) { "Send job cancelled" } - wss.close(CloseReason(CloseReason.Codes.NORMAL, "Cancelled")) - } - catch (e: Throwable) { - logger.trace(e) { "Failed to send message to channel" } - fireError(e) - wss.close(CloseReason(CloseReason.Codes.INTERNAL_ERROR, e.message ?: "Internal error")) - wss.flush() +internal class KtorAcpWebSocketConnection( + private val parentScope: CoroutineScope, + private val wss: WebSocketSession, +) : AcpWebSocketConnection { + override val incomingTextFrames: Flow = flow { + for (message in wss.incoming) { + when (message) { + is Frame.Text -> emit(message.readText()) + else -> logger.trace { "Received unexpected message from channel: '$message'" } } } - scope.launch { - try { - for (message in wss.incoming) { - when (message) { - is Frame.Text -> { - val text = message.readText() - val decodedMessage = try { - decodeJsonRpcMessage(text) - } catch (e: SerializationException) { - logger.trace(e) { "Failed to deserialize message: '$text'" } - fireError(e) - continue - } - logger.trace { "Received message from channel: '$text'" } - fireMessage(decodedMessage) - } + } - else -> { - logger.trace { "Received unexpected message from channel: '$message'" } - } - } - } - } - catch (ce: CancellationException) { - logger.trace(ce) { "Receive job cancelled" } - wss.close(CloseReason(CloseReason.Codes.NORMAL, "Cancelled")) - } - catch (e: Throwable) { - logger.trace(e) { "Failed to receive message from channel" } - fireError(e) - } - finally { - close() + override suspend fun sendText(text: String) { + logger.trace { "Sending message to channel: '$text'" } + wss.send(Frame.Text(text)) + wss.flush() + } + + override fun close() { + parentScope.launch { + wss.close() + wss.flush() + } + } +} + +public class WebSocketTransport(private val parentScope: CoroutineScope, private val wss: WebSocketSession) : BaseTransport() { + private val delegate = RemoteWebSocketTransport( + parentScope = parentScope, + connection = KtorAcpWebSocketConnection(parentScope, wss), + name = WebSocketTransport::class.simpleName!!, + ) + + init { + delegate.onMessage { fireMessage(it) } + delegate.onError { fireError(it) } + delegate.onClose { fireClose() } + parentScope.launch { + delegate.state.collect { + _state.value = it } - logger.trace { "Exiting read job..." } } } + override fun start() { + delegate.start() + _state.value = delegate.state.value + } + override fun send(message: JsonRpcMessage) { - sendChannel.trySend(message) + delegate.send(message) } override fun close() { - if (sendChannel.close()) { - fireClose() - } + delegate.close() + _state.value = delegate.state.value } -} \ No newline at end of file +} diff --git a/acp-servlet-client/api/acp-servlet-client.api b/acp-servlet-client/api/acp-servlet-client.api new file mode 100644 index 0000000..ae5df6e --- /dev/null +++ b/acp-servlet-client/api/acp-servlet-client.api @@ -0,0 +1,22 @@ +public final class com/agentclientprotocol/acpservletclient/LibVersionKt { + public static final field LIB_VERSION Ljava/lang/String; +} + +public final class com/agentclientprotocol/transport/AcpServletClientWebSocketKt { + public static final fun acpProtocolOnClientWebSocket (Ljava/lang/String;Lkotlinx/coroutines/CoroutineScope;Lcom/agentclientprotocol/protocol/ProtocolOptions;Ljavax/websocket/ClientEndpointConfig;Lcom/agentclientprotocol/transport/JavaxWebSocketClientConnectionOptions;)Lcom/agentclientprotocol/protocol/Protocol; + public static final fun acpProtocolOnClientWebSocket (Ljava/net/URI;Lkotlinx/coroutines/CoroutineScope;Lcom/agentclientprotocol/protocol/ProtocolOptions;Ljavax/websocket/ClientEndpointConfig;Lcom/agentclientprotocol/transport/JavaxWebSocketClientConnectionOptions;)Lcom/agentclientprotocol/protocol/Protocol; + public static final fun acpProtocolOnClientWebSocket (Ljavax/websocket/WebSocketContainer;Ljava/lang/String;Lkotlinx/coroutines/CoroutineScope;Lcom/agentclientprotocol/protocol/ProtocolOptions;Ljavax/websocket/ClientEndpointConfig;Lcom/agentclientprotocol/transport/JavaxWebSocketClientConnectionOptions;)Lcom/agentclientprotocol/protocol/Protocol; + public static final fun acpProtocolOnClientWebSocket (Ljavax/websocket/WebSocketContainer;Ljava/net/URI;Lkotlinx/coroutines/CoroutineScope;Lcom/agentclientprotocol/protocol/ProtocolOptions;Ljavax/websocket/ClientEndpointConfig;Lcom/agentclientprotocol/transport/JavaxWebSocketClientConnectionOptions;)Lcom/agentclientprotocol/protocol/Protocol; + public static synthetic fun acpProtocolOnClientWebSocket$default (Ljava/lang/String;Lkotlinx/coroutines/CoroutineScope;Lcom/agentclientprotocol/protocol/ProtocolOptions;Ljavax/websocket/ClientEndpointConfig;Lcom/agentclientprotocol/transport/JavaxWebSocketClientConnectionOptions;ILjava/lang/Object;)Lcom/agentclientprotocol/protocol/Protocol; + public static synthetic fun acpProtocolOnClientWebSocket$default (Ljava/net/URI;Lkotlinx/coroutines/CoroutineScope;Lcom/agentclientprotocol/protocol/ProtocolOptions;Ljavax/websocket/ClientEndpointConfig;Lcom/agentclientprotocol/transport/JavaxWebSocketClientConnectionOptions;ILjava/lang/Object;)Lcom/agentclientprotocol/protocol/Protocol; + public static synthetic fun acpProtocolOnClientWebSocket$default (Ljavax/websocket/WebSocketContainer;Ljava/lang/String;Lkotlinx/coroutines/CoroutineScope;Lcom/agentclientprotocol/protocol/ProtocolOptions;Ljavax/websocket/ClientEndpointConfig;Lcom/agentclientprotocol/transport/JavaxWebSocketClientConnectionOptions;ILjava/lang/Object;)Lcom/agentclientprotocol/protocol/Protocol; + public static synthetic fun acpProtocolOnClientWebSocket$default (Ljavax/websocket/WebSocketContainer;Ljava/net/URI;Lkotlinx/coroutines/CoroutineScope;Lcom/agentclientprotocol/protocol/ProtocolOptions;Ljavax/websocket/ClientEndpointConfig;Lcom/agentclientprotocol/transport/JavaxWebSocketClientConnectionOptions;ILjava/lang/Object;)Lcom/agentclientprotocol/protocol/Protocol; +} + +public final class com/agentclientprotocol/transport/JavaxWebSocketClientConnectionOptions { + public fun ()V + public fun (J)V + public synthetic fun (JILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getSendTimeoutMillis ()J +} + diff --git a/acp-servlet-client/build.gradle.kts b/acp-servlet-client/build.gradle.kts new file mode 100644 index 0000000..85c1fff --- /dev/null +++ b/acp-servlet-client/build.gradle.kts @@ -0,0 +1,59 @@ +import org.jetbrains.kotlin.gradle.dsl.ExplicitApiMode +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + kotlin("jvm") + kotlin("plugin.serialization") + id("acp.publishing") + alias(libs.plugins.kotlinx.binary.compatibility.validator) +} + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +val generateLibVersion by tasks.registering { + val outputDir = layout.buildDirectory.dir("generated-sources/libVersion") + outputs.dir(outputDir) + val packageName = project.name.replace("-", "") + val versionString = project.version + + doLast { + val sourceFile = outputDir.get().file("com/agentclientprotocol/$packageName/LibVersion.kt").asFile + sourceFile.parentFile.mkdirs() + sourceFile.writeText( + """ + package com.agentclientprotocol.$packageName + + public const val LIB_VERSION: String = "$versionString" + + """.trimIndent() + ) + } +} + +kotlin { + explicitApi = ExplicitApiMode.Strict + jvmToolchain(21) + + compilerOptions { + jvmTarget = JvmTarget.JVM_1_8 + } + + sourceSets { + main { + kotlin.srcDir(generateLibVersion) + } + } +} + +dependencies { + api(project(":acp")) + api(libs.javax.websocket.api) + implementation(libs.kotlinx.coroutines.core) + + testImplementation(kotlin("test")) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.slf4j.simple) +} diff --git a/acp-servlet-client/src/main/kotlin/com/agentclientprotocol/transport/AcpServletClientWebSocket.kt b/acp-servlet-client/src/main/kotlin/com/agentclientprotocol/transport/AcpServletClientWebSocket.kt new file mode 100644 index 0000000..cdcc275 --- /dev/null +++ b/acp-servlet-client/src/main/kotlin/com/agentclientprotocol/transport/AcpServletClientWebSocket.kt @@ -0,0 +1,192 @@ +package com.agentclientprotocol.transport + +import com.agentclientprotocol.protocol.Protocol +import com.agentclientprotocol.protocol.ProtocolOptions +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.suspendCancellableCoroutine +import java.io.IOException +import java.net.URI +import java.util.concurrent.atomic.AtomicBoolean +import javax.websocket.ClientEndpointConfig +import javax.websocket.CloseReason +import javax.websocket.ContainerProvider +import javax.websocket.Endpoint +import javax.websocket.EndpointConfig +import javax.websocket.MessageHandler +import javax.websocket.SendHandler +import javax.websocket.SendResult +import javax.websocket.Session +import javax.websocket.WebSocketContainer +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +public class JavaxWebSocketClientConnectionOptions( + public val sendTimeoutMillis: Long = 30_000, +) { + init { + require(sendTimeoutMillis >= 0) { "sendTimeoutMillis must be non-negative" } + } +} + +/** + * Creates an ACP [Protocol] over a Javax WebSocket client connection. + * + * The protocol should be started manually by the calling site. + */ +public fun WebSocketContainer.acpProtocolOnClientWebSocket( + uri: URI, + parentScope: CoroutineScope, + protocolOptions: ProtocolOptions, + clientEndpointConfig: ClientEndpointConfig = ClientEndpointConfig.Builder.create().build(), + connectionOptions: JavaxWebSocketClientConnectionOptions = JavaxWebSocketClientConnectionOptions(), +): Protocol { + val endpoint = AcpJavaxWebSocketClientEndpoint(parentScope, protocolOptions, connectionOptions) + connectToServer(endpoint, clientEndpointConfig, uri) + return endpoint.protocol() +} + +/** + * Creates an ACP [Protocol] over a Javax WebSocket client connection. + * + * The protocol should be started manually by the calling site. + */ +public fun WebSocketContainer.acpProtocolOnClientWebSocket( + url: String, + parentScope: CoroutineScope, + protocolOptions: ProtocolOptions, + clientEndpointConfig: ClientEndpointConfig = ClientEndpointConfig.Builder.create().build(), + connectionOptions: JavaxWebSocketClientConnectionOptions = JavaxWebSocketClientConnectionOptions(), +): Protocol = + acpProtocolOnClientWebSocket(URI.create(url), parentScope, protocolOptions, clientEndpointConfig, connectionOptions) + +/** + * Creates an ACP [Protocol] with the default Javax [WebSocketContainer]. + * + * The protocol should be started manually by the calling site. + */ +public fun acpProtocolOnClientWebSocket( + uri: URI, + parentScope: CoroutineScope, + protocolOptions: ProtocolOptions, + clientEndpointConfig: ClientEndpointConfig = ClientEndpointConfig.Builder.create().build(), + connectionOptions: JavaxWebSocketClientConnectionOptions = JavaxWebSocketClientConnectionOptions(), +): Protocol = + ContainerProvider.getWebSocketContainer() + .acpProtocolOnClientWebSocket(uri, parentScope, protocolOptions, clientEndpointConfig, connectionOptions) + +/** + * Creates an ACP [Protocol] with the default Javax [WebSocketContainer]. + * + * The protocol should be started manually by the calling site. + */ +public fun acpProtocolOnClientWebSocket( + url: String, + parentScope: CoroutineScope, + protocolOptions: ProtocolOptions, + clientEndpointConfig: ClientEndpointConfig = ClientEndpointConfig.Builder.create().build(), + connectionOptions: JavaxWebSocketClientConnectionOptions = JavaxWebSocketClientConnectionOptions(), +): Protocol = + acpProtocolOnClientWebSocket(URI.create(url), parentScope, protocolOptions, clientEndpointConfig, connectionOptions) + +private class AcpJavaxWebSocketClientEndpoint( + private val parentScope: CoroutineScope, + private val protocolOptions: ProtocolOptions, + private val connectionOptions: JavaxWebSocketClientConnectionOptions, +) : Endpoint() { + private var protocol: Protocol? = null + private var endpointScope: CoroutineScope? = null + + fun protocol(): Protocol = + protocol ?: error("Javax WebSocket client endpoint did not open") + + override fun onOpen(session: Session, config: EndpointConfig) { + val scope = CoroutineScope( + parentScope.coroutineContext + + SupervisorJob(parentScope.coroutineContext[Job]) + + CoroutineName(AcpJavaxWebSocketClientEndpoint::class.simpleName!!) + ) + val transport = RemoteWebSocketTransport( + parentScope = scope, + connection = JavaxWebSocketClientConnection(session, connectionOptions), + name = AcpJavaxWebSocketClientEndpoint::class.simpleName!!, + ) + transport.onClose { + scope.cancel("ACP Javax WebSocket client transport closed") + } + endpointScope = scope + protocol = Protocol(scope, transport, protocolOptions) + } + + override fun onClose(session: Session, closeReason: CloseReason) { + closeProtocol() + } + + override fun onError(session: Session?, thr: Throwable) { + closeProtocol(thr) + } + + private fun closeProtocol(cause: Throwable? = null) { + protocol?.close() + endpointScope?.cancel("ACP Javax WebSocket client endpoint closed", cause) + protocol = null + endpointScope = null + } +} + +private class JavaxWebSocketClientConnection( + private val session: Session, + private val options: JavaxWebSocketClientConnectionOptions, +) : AcpWebSocketConnection { + private val inboundTextFrames = Channel(Channel.UNLIMITED) + private val textHandler = MessageHandler.Whole { text -> + inboundTextFrames.trySend(text) + } + + override val incomingTextFrames: Flow = inboundTextFrames.receiveAsFlow() + + init { + session.asyncRemote.sendTimeout = options.sendTimeoutMillis + session.addMessageHandler(String::class.java, textHandler) + } + + override suspend fun sendText(text: String): Unit = suspendCancellableCoroutine { continuation -> + val completed = AtomicBoolean(false) + continuation.invokeOnCancellation { + if (completed.compareAndSet(false, true)) { + close() + } + } + + try { + session.asyncRemote.sendText(text, SendHandler { result: SendResult -> + if (!completed.compareAndSet(false, true)) { + return@SendHandler + } + if (result.isOK) { + continuation.resume(Unit) + } else { + continuation.resumeWithException(result.exception ?: IOException("WebSocket send failed")) + } + }) + } catch (e: Throwable) { + if (completed.compareAndSet(false, true)) { + continuation.resumeWithException(e) + } + } + } + + override fun close() { + inboundTextFrames.close() + runCatching { session.removeMessageHandler(textHandler) } + if (runCatching { session.isOpen }.getOrDefault(false)) { + runCatching { session.close() } + } + } +} diff --git a/acp-servlet-client/src/test/kotlin/com/agentclientprotocol/transport/AcpServletClientWebSocketTest.kt b/acp-servlet-client/src/test/kotlin/com/agentclientprotocol/transport/AcpServletClientWebSocketTest.kt new file mode 100644 index 0000000..3b1f8a9 --- /dev/null +++ b/acp-servlet-client/src/test/kotlin/com/agentclientprotocol/transport/AcpServletClientWebSocketTest.kt @@ -0,0 +1,246 @@ +package com.agentclientprotocol.transport + +import com.agentclientprotocol.model.AcpMethod +import com.agentclientprotocol.model.AcpRequest +import com.agentclientprotocol.model.AcpResponse +import com.agentclientprotocol.protocol.Protocol +import com.agentclientprotocol.protocol.ProtocolOptions +import com.agentclientprotocol.protocol.sendRequest +import com.agentclientprotocol.protocol.setRequestHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import java.lang.reflect.Proxy +import java.net.URI +import javax.websocket.ClientEndpointConfig +import javax.websocket.Endpoint +import javax.websocket.MessageHandler +import javax.websocket.RemoteEndpoint +import javax.websocket.SendHandler +import javax.websocket.SendResult +import javax.websocket.Session +import javax.websocket.WebSocketContainer +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +class AcpServletClientWebSocketTest { + private companion object { + object TestMethod : AcpMethod.AcpRequestResponseMethod( + "test/testRequest", + TestRequest.serializer(), + TestResponse.serializer(), + ) + } + + @Test + fun `container helper creates protocol that carries request response and close lifecycle`(): Unit = runTest { + val protocolScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val clientSession = FakeJavaxWebSocketSession() + val serverConnection = LinkedServerWebSocketConnection() + val container = FakeWebSocketContainer(clientSession) + clientSession.onSend = { text -> serverConnection.receiveText(text) } + serverConnection.onSend = { text -> clientSession.receiveText(text) } + + var clientProtocol: Protocol? = null + var serverProtocol: Protocol? = null + try { + clientProtocol = container.container.acpProtocolOnClientWebSocket( + uri = URI.create("ws://localhost/acp"), + parentScope = protocolScope, + protocolOptions = ProtocolOptions(protocolDebugName = "servlet client protocol"), + ) + serverProtocol = Protocol( + parentScope = protocolScope, + transport = RemoteWebSocketTransport(protocolScope, serverConnection, "servlet server transport"), + options = ProtocolOptions(protocolDebugName = "servlet server protocol"), + ) + serverProtocol.setRequestHandler(TestMethod) { request -> + TestResponse("server:${request.message}") + } + serverProtocol.start() + clientProtocol.start() + + val response = withContext(Dispatchers.Default) { + withTimeout(5.seconds) { + clientProtocol.sendRequest(TestMethod, TestRequest("ping")) + } + } + + assertEquals("server:ping", response.message) + assertEquals(30_000, clientSession.sendTimeout) + + clientProtocol.close() + + assertTrue(clientSession.closed) + } finally { + clientProtocol?.close() + serverProtocol?.close() + protocolScope.cancel() + } + } + + @Test + fun `container helper applies configured send timeout`(): Unit = runTest { + val protocolScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val clientSession = FakeJavaxWebSocketSession() + val container = FakeWebSocketContainer(clientSession) + val protocol = container.container.acpProtocolOnClientWebSocket( + uri = URI.create("ws://localhost/acp"), + parentScope = protocolScope, + protocolOptions = ProtocolOptions(protocolDebugName = "servlet client protocol"), + connectionOptions = JavaxWebSocketClientConnectionOptions(sendTimeoutMillis = 1234), + ) + + try { + assertEquals(1234, clientSession.sendTimeout) + } finally { + protocol.close() + protocolScope.cancel() + } + } + + private class FakeWebSocketContainer( + private val clientSession: FakeJavaxWebSocketSession, + ) { + var connectedUri: URI? = null + private set + + val container: WebSocketContainer = Proxy.newProxyInstance( + WebSocketContainer::class.java.classLoader, + arrayOf(WebSocketContainer::class.java), + ) { _, method, args -> + when (method.name) { + "connectToServer" -> { + val arguments = requireNotNull(args) + val endpoint = arguments[0] as Endpoint + @Suppress("UNUSED_VARIABLE") + val config = arguments[1] as ClientEndpointConfig + connectedUri = arguments[2] as URI + endpoint.onOpen(clientSession.session, emptyClientEndpointConfig()) + clientSession.session + } + "toString" -> "FakeWebSocketContainer" + "hashCode" -> System.identityHashCode(this) + else -> null + } + } as WebSocketContainer + } + + private class FakeJavaxWebSocketSession { + var onSend: ((String) -> Unit)? = null + var closed: Boolean = false + private set + private var textHandler: MessageHandler.Whole? = null + var sendTimeout: Long = -1 + private set + + val asyncRemote: RemoteEndpoint.Async = Proxy.newProxyInstance( + RemoteEndpoint.Async::class.java.classLoader, + arrayOf(RemoteEndpoint.Async::class.java), + ) { _, method, args -> + when (method.name) { + "sendText" -> { + val arguments = requireNotNull(args) + val text = arguments[0] as String + onSend?.invoke(text) + (arguments[1] as? SendHandler)?.onResult(SendResult()) + null + } + "getSendTimeout" -> sendTimeout + "setSendTimeout" -> { + val arguments = requireNotNull(args) + sendTimeout = arguments[0] as Long + null + } + "toString" -> "FakeRemoteEndpointAsync" + "hashCode" -> System.identityHashCode(this) + else -> null + } + } as RemoteEndpoint.Async + + val session: Session = Proxy.newProxyInstance( + Session::class.java.classLoader, + arrayOf(Session::class.java), + ) { _, method, args -> + when (method.name) { + "addMessageHandler" -> { + val arguments = requireNotNull(args) + @Suppress("UNCHECKED_CAST") + textHandler = arguments[1] as MessageHandler.Whole + null + } + "removeMessageHandler" -> { + textHandler = null + null + } + "getAsyncRemote" -> asyncRemote + "isOpen" -> !closed + "close" -> { + closed = true + null + } + "toString" -> "FakeSession" + "hashCode" -> System.identityHashCode(this) + else -> null + } + } as Session + + fun receiveText(text: String) { + textHandler?.onMessage(text) + } + } + + private class LinkedServerWebSocketConnection : AcpWebSocketConnection { + private val inboundTextFrames = Channel(Channel.UNLIMITED) + var onSend: ((String) -> Unit)? = null + + override val incomingTextFrames: Flow = inboundTextFrames.receiveAsFlow() + + override suspend fun sendText(text: String) { + onSend?.invoke(text) + } + + override fun close() { + inboundTextFrames.close() + } + + fun receiveText(text: String) { + inboundTextFrames.trySend(text) + } + } + +} + +@Serializable +private data class TestRequest(val message: String) : AcpRequest { + override val _meta: JsonElement? = null +} + +@Serializable +private data class TestResponse(val message: String) : AcpResponse { + override val _meta: JsonElement? = null +} + +private fun emptyClientEndpointConfig(): javax.websocket.EndpointConfig = + Proxy.newProxyInstance( + javax.websocket.EndpointConfig::class.java.classLoader, + arrayOf(javax.websocket.EndpointConfig::class.java), + ) { _, method, _ -> + when (method.name) { + "getUserProperties" -> mutableMapOf() + "toString" -> "EmptyEndpointConfig" + "hashCode" -> 0 + else -> null + } + } as javax.websocket.EndpointConfig diff --git a/acp-servlet-server/api/acp-servlet-server.api b/acp-servlet-server/api/acp-servlet-server.api new file mode 100644 index 0000000..f97c479 --- /dev/null +++ b/acp-servlet-server/api/acp-servlet-server.api @@ -0,0 +1,30 @@ +public final class com/agentclientprotocol/acpservletserver/LibVersionKt { + public static final field LIB_VERSION Ljava/lang/String; +} + +public final class com/agentclientprotocol/transport/AcpServletServerWebSocketKt { + public static final field ACP_SERVLET_PATH Ljava/lang/String; + public static final fun acpJavaxWebSocketServerEndpointConfig (Ljava/lang/String;Lkotlinx/coroutines/CoroutineScope;Lcom/agentclientprotocol/protocol/ProtocolOptions;Lkotlin/jvm/functions/Function2;)Ljavax/websocket/server/ServerEndpointConfig; + public static synthetic fun acpJavaxWebSocketServerEndpointConfig$default (Ljava/lang/String;Lkotlinx/coroutines/CoroutineScope;Lcom/agentclientprotocol/protocol/ProtocolOptions;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Ljavax/websocket/server/ServerEndpointConfig; + public static final fun acpProtocolOnServerWebSocket (Ljavax/servlet/ServletContext;Ljava/lang/String;Lkotlinx/coroutines/CoroutineScope;Lcom/agentclientprotocol/protocol/ProtocolOptions;Lkotlin/jvm/functions/Function2;)V + public static synthetic fun acpProtocolOnServerWebSocket$default (Ljavax/servlet/ServletContext;Ljava/lang/String;Lkotlinx/coroutines/CoroutineScope;Lcom/agentclientprotocol/protocol/ProtocolOptions;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V +} + +public final class com/agentclientprotocol/transport/JavaxWebSocketConnection : com/agentclientprotocol/transport/AcpWebSocketConnection { + public fun (Ljavax/websocket/Session;)V + public fun (Ljavax/websocket/Session;Lcom/agentclientprotocol/transport/JavaxWebSocketConnectionOptions;)V + public synthetic fun (Ljavax/websocket/Session;Lcom/agentclientprotocol/transport/JavaxWebSocketConnectionOptions;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun close ()V + public fun getIncomingTextFrames ()Lkotlinx/coroutines/flow/Flow; + public final fun getOptions ()Lcom/agentclientprotocol/transport/JavaxWebSocketConnectionOptions; + public final fun getSession ()Ljavax/websocket/Session; + public fun sendText (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/agentclientprotocol/transport/JavaxWebSocketConnectionOptions { + public fun ()V + public fun (J)V + public synthetic fun (JILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getSendTimeoutMillis ()J +} + diff --git a/acp-servlet-server/build.gradle.kts b/acp-servlet-server/build.gradle.kts new file mode 100644 index 0000000..3cf9f4a --- /dev/null +++ b/acp-servlet-server/build.gradle.kts @@ -0,0 +1,60 @@ +import org.jetbrains.kotlin.gradle.dsl.ExplicitApiMode +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + kotlin("jvm") + kotlin("plugin.serialization") + id("acp.publishing") + alias(libs.plugins.kotlinx.binary.compatibility.validator) +} + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +val generateLibVersion by tasks.registering { + val outputDir = layout.buildDirectory.dir("generated-sources/libVersion") + outputs.dir(outputDir) + val packageName = project.name.replace("-", "") + val versionString = project.version + + doLast { + val sourceFile = outputDir.get().file("com/agentclientprotocol/$packageName/LibVersion.kt").asFile + sourceFile.parentFile.mkdirs() + sourceFile.writeText( + """ + package com.agentclientprotocol.$packageName + + public const val LIB_VERSION: String = "$versionString" + + """.trimIndent() + ) + } +} + +kotlin { + explicitApi = ExplicitApiMode.Strict + jvmToolchain(21) + + compilerOptions { + jvmTarget = JvmTarget.JVM_1_8 + } + + sourceSets { + main { + kotlin.srcDir(generateLibVersion) + } + } +} + +dependencies { + api(project(":acp")) + api(libs.javax.servlet.api) + api(libs.javax.websocket.api) + implementation(libs.kotlinx.coroutines.core) + + testImplementation(kotlin("test")) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.slf4j.simple) +} diff --git a/acp-servlet-server/src/main/kotlin/com/agentclientprotocol/transport/AcpServletServerWebSocket.kt b/acp-servlet-server/src/main/kotlin/com/agentclientprotocol/transport/AcpServletServerWebSocket.kt new file mode 100644 index 0000000..c457518 --- /dev/null +++ b/acp-servlet-server/src/main/kotlin/com/agentclientprotocol/transport/AcpServletServerWebSocket.kt @@ -0,0 +1,178 @@ +package com.agentclientprotocol.transport + +import com.agentclientprotocol.protocol.Protocol +import com.agentclientprotocol.protocol.ProtocolOptions +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import java.io.IOException +import java.util.concurrent.atomic.AtomicBoolean +import javax.servlet.ServletContext +import javax.websocket.CloseReason +import javax.websocket.Endpoint +import javax.websocket.EndpointConfig +import javax.websocket.MessageHandler +import javax.websocket.SendHandler +import javax.websocket.SendResult +import javax.websocket.Session +import javax.websocket.server.ServerContainer +import javax.websocket.server.ServerEndpointConfig +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +public class JavaxWebSocketConnectionOptions( + public val sendTimeoutMillis: Long = 30_000, +) { + init { + require(sendTimeoutMillis >= 0) { "sendTimeoutMillis must be non-negative" } + } +} + +public const val ACP_SERVLET_PATH: String = "/acp" + +/** + * Javax WebSocket connection adapter for servlet container WebSocket sessions. + */ +public class JavaxWebSocketConnection @JvmOverloads constructor( + public val session: Session, + public val options: JavaxWebSocketConnectionOptions = JavaxWebSocketConnectionOptions(), +) : AcpWebSocketConnection { + private val inboundTextFrames = Channel(Channel.UNLIMITED) + private val textHandler = MessageHandler.Whole { text -> + inboundTextFrames.trySend(text) + } + + override val incomingTextFrames: Flow = inboundTextFrames.receiveAsFlow() + + init { + session.asyncRemote.sendTimeout = options.sendTimeoutMillis + session.addMessageHandler(String::class.java, textHandler) + } + + override suspend fun sendText(text: String): Unit = suspendCancellableCoroutine { continuation -> + val completed = AtomicBoolean(false) + continuation.invokeOnCancellation { + if (completed.compareAndSet(false, true)) { + close() + } + } + + try { + session.asyncRemote.sendText(text, SendHandler { result: SendResult -> + if (!completed.compareAndSet(false, true)) { + return@SendHandler + } + if (result.isOK) { + continuation.resume(Unit) + } else { + continuation.resumeWithException(result.exception ?: IOException("WebSocket send failed")) + } + }) + } catch (e: Throwable) { + if (completed.compareAndSet(false, true)) { + continuation.resumeWithException(e) + } + } + } + + override fun close() { + inboundTextFrames.close() + runCatching { session.removeMessageHandler(textHandler) } + if (runCatching { session.isOpen }.getOrDefault(false)) { + runCatching { session.close() } + } + } +} + +/** + * Builds a Javax WebSocket endpoint config that serves ACP over WebSocket. + */ +public fun acpJavaxWebSocketServerEndpointConfig( + path: String = ACP_SERVLET_PATH, + parentScope: CoroutineScope, + protocolOptions: ProtocolOptions, + block: suspend (Protocol) -> Unit, +): ServerEndpointConfig = + ServerEndpointConfig.Builder + .create(AcpJavaxWebSocketEndpoint::class.java, normalizeServletWebSocketPath(path)) + .configurator(object : ServerEndpointConfig.Configurator() { + override fun getEndpointInstance(endpointClass: Class): T { + @Suppress("UNCHECKED_CAST") + return AcpJavaxWebSocketEndpoint(parentScope, protocolOptions, block) as T + } + }) + .build() + +/** + * Registers an ACP WebSocket endpoint on a Javax servlet container. + */ +public fun ServletContext.acpProtocolOnServerWebSocket( + path: String = ACP_SERVLET_PATH, + parentScope: CoroutineScope, + protocolOptions: ProtocolOptions, + block: suspend (Protocol) -> Unit, +) { + val serverContainer = getAttribute(ServerContainer::class.java.name) as? ServerContainer + ?: error("No javax.websocket.server.ServerContainer found in ServletContext") + serverContainer.addEndpoint(acpJavaxWebSocketServerEndpointConfig(path, parentScope, protocolOptions, block)) +} + +private class AcpJavaxWebSocketEndpoint( + private val parentScope: CoroutineScope, + private val protocolOptions: ProtocolOptions, + private val block: suspend (Protocol) -> Unit, +) : Endpoint() { + private var protocol: Protocol? = null + private var endpointScope: CoroutineScope? = null + + override fun onOpen(session: Session, config: EndpointConfig) { + val scope = CoroutineScope( + parentScope.coroutineContext + + SupervisorJob(parentScope.coroutineContext[Job]) + + CoroutineName(AcpJavaxWebSocketEndpoint::class.simpleName!!) + ) + val transport = RemoteWebSocketTransport( + parentScope = scope, + connection = JavaxWebSocketConnection(session), + name = AcpJavaxWebSocketEndpoint::class.simpleName!!, + ) + val connectionProtocol = Protocol(scope, transport, protocolOptions) + + endpointScope = scope + protocol = connectionProtocol + scope.launch { + try { + block(connectionProtocol) + awaitCancellation() + } finally { + connectionProtocol.close() + } + } + } + + override fun onClose(session: Session, closeReason: CloseReason) { + closeProtocol() + } + + override fun onError(session: Session?, thr: Throwable) { + closeProtocol(thr) + } + + private fun closeProtocol(cause: Throwable? = null) { + protocol?.close() + endpointScope?.cancel("ACP Javax WebSocket endpoint closed", cause) + protocol = null + endpointScope = null + } +} + +private fun normalizeServletWebSocketPath(path: String): String = + if (path.startsWith("/")) path else "/$path" diff --git a/acp-servlet-server/src/test/kotlin/com/agentclientprotocol/transport/AcpServletServerWebSocketTest.kt b/acp-servlet-server/src/test/kotlin/com/agentclientprotocol/transport/AcpServletServerWebSocketTest.kt new file mode 100644 index 0000000..0df603c --- /dev/null +++ b/acp-servlet-server/src/test/kotlin/com/agentclientprotocol/transport/AcpServletServerWebSocketTest.kt @@ -0,0 +1,352 @@ +package com.agentclientprotocol.transport + +import com.agentclientprotocol.model.AcpMethod +import com.agentclientprotocol.model.AcpRequest +import com.agentclientprotocol.model.AcpResponse +import com.agentclientprotocol.protocol.Protocol +import com.agentclientprotocol.protocol.ProtocolOptions +import com.agentclientprotocol.protocol.sendRequest +import com.agentclientprotocol.protocol.setRequestHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancel +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import java.io.IOException +import java.lang.reflect.Proxy +import javax.servlet.ServletContext +import javax.websocket.CloseReason +import javax.websocket.Endpoint +import javax.websocket.EndpointConfig +import javax.websocket.MessageHandler +import javax.websocket.RemoteEndpoint +import javax.websocket.SendHandler +import javax.websocket.SendResult +import javax.websocket.Session +import javax.websocket.server.ServerContainer +import javax.websocket.server.ServerEndpointConfig +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +class AcpServletServerWebSocketTest { + private companion object { + object TestMethod : AcpMethod.AcpRequestResponseMethod( + "test/testRequest", + TestRequest.serializer(), + TestResponse.serializer(), + ) + } + + @Test + fun `connection adapts incoming javax text messages`(): Unit = runTest { + val fakeSession = FakeJavaxWebSocketSession() + val connection = JavaxWebSocketConnection(fakeSession.session) + + fakeSession.receiveText("hello") + + assertEquals("hello", connection.incomingTextFrames.first()) + } + + @Test + fun `connection sends text through async remote endpoint`(): Unit = runTest { + val fakeSession = FakeJavaxWebSocketSession() + val connection = JavaxWebSocketConnection(fakeSession.session) + + connection.sendText("outbound") + + assertEquals("outbound", fakeSession.sentText.receive()) + } + + @Test + fun `connection applies configured javax send timeout`(): Unit = runTest { + val fakeSession = FakeJavaxWebSocketSession() + + JavaxWebSocketConnection(fakeSession.session, JavaxWebSocketConnectionOptions(sendTimeoutMillis = 1234)) + + assertEquals(1234, fakeSession.sendTimeout) + } + + @Test + fun `connection cancellation closes stuck javax send`(): Unit = runTest { + val fakeSession = FakeJavaxWebSocketSession(completeSends = false) + val connection = JavaxWebSocketConnection(fakeSession.session) + + val sendJob = launch { + connection.sendText("stuck") + } + assertEquals("stuck", fakeSession.sentText.receive()) + + sendJob.cancelAndJoin() + + assertTrue(fakeSession.closed) + } + + @Test + fun `connection close closes javax session`(): Unit = runTest { + val fakeSession = FakeJavaxWebSocketSession() + val connection = JavaxWebSocketConnection(fakeSession.session) + + connection.close() + + assertTrue(fakeSession.closed) + } + + @Test + fun `connection close suppresses expected javax close exception`(): Unit = runTest { + val fakeSession = FakeJavaxWebSocketSession(closeException = IOException("already closed")) + val connection = JavaxWebSocketConnection(fakeSession.session) + + connection.close() + + assertTrue(fakeSession.closed) + } + + @Test + fun `endpoint config normalizes websocket path`(): Unit = runTest { + val config = acpJavaxWebSocketServerEndpointConfig( + path = "acp", + parentScope = backgroundScope, + protocolOptions = ProtocolOptions(), + block = {}, + ) + + assertEquals("/acp", config.path) + } + + @Test + fun `servlet context registration adds endpoint to server container`(): Unit = runTest { + val fakeContainer = FakeServerContainer() + val servletContext = fakeServletContext(fakeContainer.serverContainer) + + servletContext.acpProtocolOnServerWebSocket( + path = "/acp", + parentScope = backgroundScope, + protocolOptions = ProtocolOptions(), + block = {}, + ) + + assertNotNull(fakeContainer.endpointConfig) + assertEquals("/acp", fakeContainer.endpointConfig?.path) + } + + @Test + fun `endpoint config creates endpoint that carries protocol request response and close lifecycle`(): Unit = runTest { + val protocolScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val serverSession = FakeJavaxWebSocketSession() + val clientConnection = LinkedClientWebSocketConnection(serverSession) + serverSession.onServerSend = { text -> clientConnection.receiveText(text) } + val serverProtocol = CompletableDeferred() + val serverBlockClosed = CompletableDeferred() + val config = acpJavaxWebSocketServerEndpointConfig( + path = "/acp", + parentScope = protocolScope, + protocolOptions = ProtocolOptions(protocolDebugName = "servlet agent protocol"), + ) { protocol -> + protocol.setRequestHandler(TestMethod) { request -> + TestResponse("server:${request.message}") + } + protocol.start() + serverProtocol.complete(protocol) + try { + awaitCancellation() + } finally { + serverBlockClosed.complete(Unit) + } + } + val endpoint = config.configurator.getEndpointInstance(config.endpointClass) as Endpoint + endpoint.onOpen(serverSession.session, emptyEndpointConfig()) + + var clientProtocol: Protocol? = null + try { + withContext(Dispatchers.Default) { + withTimeout(5.seconds) { serverProtocol.await() } + } + clientProtocol = Protocol( + parentScope = protocolScope, + transport = RemoteWebSocketTransport(protocolScope, clientConnection, "servlet client transport"), + options = ProtocolOptions(protocolDebugName = "servlet client protocol"), + ) + clientProtocol.start() + + val response = withContext(Dispatchers.Default) { + withTimeout(5.seconds) { + clientProtocol.sendRequest(TestMethod, TestRequest("ping")) + } + } + assertEquals("server:ping", response.message) + + endpoint.onClose(serverSession.session, CloseReason(CloseReason.CloseCodes.NORMAL_CLOSURE, "done")) + + withContext(Dispatchers.Default) { + withTimeout(5.seconds) { serverBlockClosed.await() } + } + assertTrue(serverSession.closed) + } finally { + clientProtocol?.close() + protocolScope.cancel() + } + } + + private class FakeJavaxWebSocketSession( + private val completeSends: Boolean = true, + private val closeException: Throwable? = null, + ) { + val sentText = Channel(Channel.UNLIMITED) + var onServerSend: ((String) -> Unit)? = null + var closed: Boolean = false + private set + private var textHandler: MessageHandler.Whole? = null + var sendTimeout: Long = -1 + private set + + val asyncRemote: RemoteEndpoint.Async = Proxy.newProxyInstance( + RemoteEndpoint.Async::class.java.classLoader, + arrayOf(RemoteEndpoint.Async::class.java), + ) { _, method, args -> + when (method.name) { + "sendText" -> { + val text = args?.get(0) as String + sentText.trySend(text) + onServerSend?.invoke(text) + if (completeSends) { + (args?.get(1) as? SendHandler)?.onResult(SendResult()) + } + null + } + "getSendTimeout" -> sendTimeout + "setSendTimeout" -> { + sendTimeout = args?.get(0) as Long + null + } + "toString" -> "FakeRemoteEndpointAsync" + "hashCode" -> System.identityHashCode(this) + else -> null + } + } as RemoteEndpoint.Async + + val session: Session = Proxy.newProxyInstance( + Session::class.java.classLoader, + arrayOf(Session::class.java), + ) { _, method, args -> + when (method.name) { + "addMessageHandler" -> { + @Suppress("UNCHECKED_CAST") + textHandler = args?.get(1) as MessageHandler.Whole + null + } + "removeMessageHandler" -> { + textHandler = null + null + } + "getAsyncRemote" -> asyncRemote + "isOpen" -> !closed + "close" -> { + closed = true + if (closeException != null) { + throw closeException + } + null + } + "toString" -> "FakeSession" + "hashCode" -> System.identityHashCode(this) + else -> null + } + } as Session + + fun receiveText(text: String) { + textHandler?.onMessage(text) + } + } + + private class LinkedClientWebSocketConnection( + private val serverSession: FakeJavaxWebSocketSession, + ) : AcpWebSocketConnection { + private val inboundTextFrames = Channel(Channel.UNLIMITED) + + override val incomingTextFrames = inboundTextFrames.receiveAsFlow() + + override suspend fun sendText(text: String) { + serverSession.receiveText(text) + } + + override fun close() { + inboundTextFrames.close() + } + + fun receiveText(text: String) { + inboundTextFrames.trySend(text) + } + } + + private class FakeServerContainer { + var endpointConfig: ServerEndpointConfig? = null + + val serverContainer: ServerContainer = Proxy.newProxyInstance( + ServerContainer::class.java.classLoader, + arrayOf(ServerContainer::class.java), + ) { _, method, args -> + when (method.name) { + "addEndpoint" -> { + endpointConfig = args?.get(0) as ServerEndpointConfig + null + } + "toString" -> "FakeServerContainer" + "hashCode" -> System.identityHashCode(this) + else -> null + } + } as ServerContainer + } + + private fun fakeServletContext(serverContainer: ServerContainer): ServletContext = + Proxy.newProxyInstance( + ServletContext::class.java.classLoader, + arrayOf(ServletContext::class.java), + ) { _, method, args -> + when (method.name) { + "getAttribute" -> if (args?.get(0) == ServerContainer::class.java.name) serverContainer else null + "toString" -> "FakeServletContext" + "hashCode" -> System.identityHashCode(serverContainer) + else -> null + } + } as ServletContext + + private fun emptyEndpointConfig(): EndpointConfig = + Proxy.newProxyInstance( + EndpointConfig::class.java.classLoader, + arrayOf(EndpointConfig::class.java), + ) { _, method, _ -> + when (method.name) { + "getUserProperties" -> emptyMap() + "getEncoders" -> emptyList>() + "getDecoders" -> emptyList>() + "toString" -> "EmptyEndpointConfig" + else -> null + } + } as EndpointConfig +} + +@Serializable +private data class TestRequest( + val message: String, + override val _meta: JsonElement? = null, +) : AcpRequest + +@Serializable +private data class TestResponse( + val message: String, + override val _meta: JsonElement? = null, +) : AcpResponse diff --git a/acp/api/acp.api b/acp/api/acp.api index 98ce982..1ab0a84 100644 --- a/acp/api/acp.api +++ b/acp/api/acp.api @@ -488,6 +488,12 @@ public final class com/agentclientprotocol/protocol/RpcMethodsOperations$Default public static synthetic fun setRequestHandlerRaw$default (Lcom/agentclientprotocol/protocol/RpcMethodsOperations;Lcom/agentclientprotocol/model/AcpMethod$AcpRequestResponseMethod;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V } +public abstract interface class com/agentclientprotocol/transport/AcpWebSocketConnection : java/lang/AutoCloseable { + public abstract fun close ()V + public abstract fun getIncomingTextFrames ()Lkotlinx/coroutines/flow/Flow; + public abstract fun sendText (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + public abstract class com/agentclientprotocol/transport/BaseTransport : com/agentclientprotocol/transport/Transport { public fun ()V protected final fun fireClose ()V @@ -500,6 +506,14 @@ public abstract class com/agentclientprotocol/transport/BaseTransport : com/agen public fun onMessage (Lkotlin/jvm/functions/Function1;)V } +public final class com/agentclientprotocol/transport/RemoteWebSocketTransport : com/agentclientprotocol/transport/BaseTransport { + public fun (Lkotlinx/coroutines/CoroutineScope;Lcom/agentclientprotocol/transport/AcpWebSocketConnection;Ljava/lang/String;)V + public synthetic fun (Lkotlinx/coroutines/CoroutineScope;Lcom/agentclientprotocol/transport/AcpWebSocketConnection;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun close ()V + public fun send (Lcom/agentclientprotocol/rpc/JsonRpcMessage;)V + public fun start ()V +} + public final class com/agentclientprotocol/transport/StdioTransport : com/agentclientprotocol/transport/BaseTransport { public fun (Lkotlinx/coroutines/CoroutineScope;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/flow/Flow;Lkotlin/jvm/functions/Function2;Ljava/lang/String;)V public synthetic fun (Lkotlinx/coroutines/CoroutineScope;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/flow/Flow;Lkotlin/jvm/functions/Function2;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V diff --git a/acp/src/commonMain/kotlin/com/agentclientprotocol/transport/AcpWebSocketConnection.kt b/acp/src/commonMain/kotlin/com/agentclientprotocol/transport/AcpWebSocketConnection.kt new file mode 100644 index 0000000..132b600 --- /dev/null +++ b/acp/src/commonMain/kotlin/com/agentclientprotocol/transport/AcpWebSocketConnection.kt @@ -0,0 +1,24 @@ +package com.agentclientprotocol.transport + +import kotlinx.coroutines.flow.Flow + +/** + * Framework-neutral WebSocket connection used by ACP remote transports. + * + * Framework adapters own conversion from their native WebSocket/session APIs + * into this contract. The protocol transport owns JSON-RPC serialization and + * lifecycle behavior on top of the text-frame stream. + */ +public interface AcpWebSocketConnection : AutoCloseable { + public val incomingTextFrames: Flow + + public suspend fun sendText(text: String) + + /** + * Closes the native connection. + * + * Implementations should be idempotent and should suppress native close + * exceptions caused by normal close races or already-closed sessions. + */ + override fun close() +} diff --git a/acp/src/commonMain/kotlin/com/agentclientprotocol/transport/RemoteWebSocketTransport.kt b/acp/src/commonMain/kotlin/com/agentclientprotocol/transport/RemoteWebSocketTransport.kt new file mode 100644 index 0000000..ad477d5 --- /dev/null +++ b/acp/src/commonMain/kotlin/com/agentclientprotocol/transport/RemoteWebSocketTransport.kt @@ -0,0 +1,123 @@ +package com.agentclientprotocol.transport + +import com.agentclientprotocol.rpc.ACPJson +import com.agentclientprotocol.rpc.JsonRpcMessage +import com.agentclientprotocol.rpc.decodeJsonRpcMessage +import io.github.oshai.kotlinlogging.KotlinLogging +import kotlinx.atomicfu.atomic +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.serialization.SerializationException +import kotlinx.serialization.encodeToString + +private val remoteWebSocketLogger = KotlinLogging.logger {} + +/** + * Framework-neutral ACP transport for the WebSocket remote profile. + * + * This class owns JSON-RPC framing over text messages. Framework modules should + * adapt their native WebSocket/session APIs to [AcpWebSocketConnection] and then + * compose this transport instead of duplicating protocol behavior. + */ +public class RemoteWebSocketTransport( + parentScope: CoroutineScope, + private val connection: AcpWebSocketConnection, + private val name: String = RemoteWebSocketTransport::class.simpleName!!, +) : BaseTransport() { + private val scope = CoroutineScope(parentScope.coroutineContext + SupervisorJob(parentScope.coroutineContext[Job]) + CoroutineName(name)) + private val sendChannel = Channel(Channel.UNLIMITED) + private val closeFired = atomic(false) + + override fun start() { + if (!_state.compareAndSet(Transport.State.CREATED, Transport.State.STARTING)) { + error("Transport is not in ${Transport.State.CREATED.name} state") + } + _state.value = Transport.State.STARTED + + scope.launch(CoroutineName("$name.write-to-websocket")) { + try { + for (message in sendChannel) { + val jsonText = try { + ACPJson.encodeToString(message) + } catch (e: SerializationException) { + remoteWebSocketLogger.trace(e) { "Failed to serialize message: $message" } + fireError(e) + continue + } + remoteWebSocketLogger.trace { "Sending message to WebSocket: '$jsonText'" } + connection.sendText(jsonText) + } + remoteWebSocketLogger.trace { "No more messages in send channel, closing WebSocket connection" } + closeConnection() + } catch (ce: CancellationException) { + remoteWebSocketLogger.trace(ce) { "WebSocket send job cancelled" } + closeConnection() + } catch (e: Throwable) { + remoteWebSocketLogger.trace(e) { "Failed to send message to WebSocket" } + fireError(e) + closeConnection() + } finally { + close() + } + } + + scope.launch(CoroutineName("$name.read-from-websocket")) { + try { + connection.incomingTextFrames.collect { text -> + val decodedMessage = try { + decodeJsonRpcMessage(text) + } catch (e: SerializationException) { + remoteWebSocketLogger.trace(e) { "Failed to deserialize message: '$text'" } + fireError(e) + return@collect + } + remoteWebSocketLogger.trace { "Received message from WebSocket: '$text'" } + fireMessage(decodedMessage) + } + } catch (ce: CancellationException) { + remoteWebSocketLogger.trace(ce) { "WebSocket receive job cancelled" } + closeConnection() + } catch (e: Throwable) { + remoteWebSocketLogger.trace(e) { "Failed to receive message from WebSocket" } + fireError(e) + } finally { + close() + } + remoteWebSocketLogger.trace { "Exiting WebSocket read job" } + } + } + + override fun send(message: JsonRpcMessage) { + sendChannel.trySend(message) + } + + override fun close() { + while (true) { + val old = _state.value + if (old == Transport.State.CLOSED || old == Transport.State.CLOSING) return + if (_state.compareAndSet(old, Transport.State.CLOSING)) break + } + + sendChannel.close() + closeConnection() + scope.cancel() + _state.value = Transport.State.CLOSED + + if (closeFired.compareAndSet(false, true)) { + fireClose() + } + } + + private fun closeConnection() { + runCatching { connection.close() }.onFailure { e -> + fireError(e) + } + } +} diff --git a/acp/src/commonTest/kotlin/com/agentclientprotocol/transport/RemoteWebSocketTransportTest.kt b/acp/src/commonTest/kotlin/com/agentclientprotocol/transport/RemoteWebSocketTransportTest.kt new file mode 100644 index 0000000..7e58f97 --- /dev/null +++ b/acp/src/commonTest/kotlin/com/agentclientprotocol/transport/RemoteWebSocketTransportTest.kt @@ -0,0 +1,120 @@ +package com.agentclientprotocol.transport + +import com.agentclientprotocol.rpc.JsonRpcNotification +import com.agentclientprotocol.rpc.JsonRpcMessage +import com.agentclientprotocol.rpc.JsonRpcRequest +import com.agentclientprotocol.rpc.MethodName +import com.agentclientprotocol.rpc.RequestId +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.test.fail +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +class RemoteWebSocketTransportTest { + @Test + fun `send writes encoded text frame`(): TestResult = runTest { + val connection = FakeAcpWebSocketConnection() + val transport = RemoteWebSocketTransport(backgroundScope, connection) + transport.start() + transport.expectState(Transport.State.STARTED) + + transport.send(JsonRpcRequest(RequestId.create(7), MethodName("ping"))) + + val text = withTimeout(1.seconds) { connection.sentTextFrames.receive() } + assertTrue(text.contains("\"method\":\"ping\""), "encoded frame should carry the method, was: $text") + assertTrue(text.contains("\"id\":7"), "encoded frame should carry the id, was: $text") + transport.close() + } + + @Test + fun `incoming text frame fires onMessage`(): TestResult = runTest { + val connection = FakeAcpWebSocketConnection() + val transport = RemoteWebSocketTransport(backgroundScope, connection) + val received = transport.asMessageChannel() + transport.start() + transport.expectState(Transport.State.STARTED) + + connection.inboundTextFrames.send("""{"jsonrpc":"2.0","method":"hello"}""") + + val message = withTimeout(1.seconds) { received.receive() } + assertTrue(message is JsonRpcNotification) + assertEquals(MethodName("hello"), message.method) + transport.close() + } + + @Test + fun `invalid incoming frame reports error and continues`(): TestResult = runTest { + val errors = mutableListOf() + val connection = FakeAcpWebSocketConnection() + val transport = RemoteWebSocketTransport(backgroundScope, connection).apply { + onError { errors.add(it) } + } + val received = Channel(Channel.UNLIMITED) + transport.onMessage { received.trySend(it) } + transport.start() + transport.expectState(Transport.State.STARTED) + + connection.inboundTextFrames.send("not json") + connection.inboundTextFrames.send("""{"jsonrpc":"2.0","method":"after-invalid"}""") + + val message = withTimeout(1.seconds) { received.receive() } + assertTrue(errors.isNotEmpty(), "invalid JSON should be reported through onError") + assertTrue(message is JsonRpcNotification) + assertEquals(MethodName("after-invalid"), message.method) + transport.close() + } + + @Test + fun `close closes connection and reaches closed state`(): TestResult = runTest { + val connection = FakeAcpWebSocketConnection() + val transport = RemoteWebSocketTransport(backgroundScope, connection) + transport.start() + transport.expectState(Transport.State.STARTED) + + transport.close() + + transport.expectState(Transport.State.CLOSED) + assertTrue(connection.closed, "close should close the underlying WebSocket connection") + } + + private class FakeAcpWebSocketConnection : AcpWebSocketConnection { + val inboundTextFrames = Channel(Channel.UNLIMITED) + val sentTextFrames = Channel(Channel.UNLIMITED) + var closed: Boolean = false + private set + + override val incomingTextFrames = inboundTextFrames.receiveAsFlow() + + override suspend fun sendText(text: String) { + sentTextFrames.send(text) + } + + override fun close() { + closed = true + inboundTextFrames.close() + } + } + + private suspend fun Transport.expectState(state: Transport.State, timeout: Duration = 1.seconds) { + val observed = mutableListOf() + try { + withTimeout(timeout) { + this@expectState.state + .onEach { observed.add(it) } + .first { it == state } + } + } catch (_: TimeoutCancellationException) { + fail("Timed out waiting for state $state after $timeout, observed: ${observed.joinToString { it.name }}") + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1df1422..fda2053 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,6 +10,8 @@ slf4j = "2.0.16" kotest = "6.0.3" atomicfu = "0.25.0" mavenPublish = "0.34.0" +javax-servlet = "4.0.1" +javax-websocket = "1.1" [libraries] kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } @@ -32,8 +34,10 @@ ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" } kotlin-logging = { module = "io.github.oshai:kotlin-logging", version.ref = "kotlin-logging" } slf4j-simple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4j" } +javax-servlet-api = { module = "javax.servlet:javax.servlet-api", version.ref = "javax-servlet" } +javax-websocket-api = { module = "javax.websocket:javax.websocket-api", version.ref = "javax-websocket" } kotest-assertions-json = { module = "io.kotest:kotest-assertions-json-jvm", version.ref = "kotest" } [plugins] -kotlinx-binary-compatibility-validator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version = "0.16.3" } \ No newline at end of file +kotlinx-binary-compatibility-validator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version = "0.16.3" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 9b08da9..b9ea244 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -23,6 +23,8 @@ include(":acp-ktor") include(":acp-ktor-client") include(":acp-ktor-server") include(":acp-ktor-test") +include(":acp-servlet-client") +include(":acp-servlet-server") // Include sample projects -include(":samples:kotlin-acp-client-sample") \ No newline at end of file +include(":samples:kotlin-acp-client-sample")