SalmonEgg.Acp 1.2.0

dotnet add package SalmonEgg.Acp --version 1.2.0
                    
NuGet\Install-Package SalmonEgg.Acp -Version 1.2.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="SalmonEgg.Acp" Version="1.2.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="SalmonEgg.Acp" Version="1.2.0" />
                    
Directory.Packages.props
<PackageReference Include="SalmonEgg.Acp" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add SalmonEgg.Acp --version 1.2.0
                    
#r "nuget: SalmonEgg.Acp, 1.2.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package SalmonEgg.Acp@1.2.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=SalmonEgg.Acp&version=1.2.0
                    
Install as a Cake Addin
#tool nuget:?package=SalmonEgg.Acp&version=1.2.0
                    
Install as a Cake Tool

SalmonEgg.Acp

.NET 10 Agent Client Protocol (ACP) SDK: wire contracts, client primitives, and source-generated serialization.

What is public

  • Protocol / content / tool / plan / MCP wire DTOs (AcpProtocolObject hierarchy)
  • IAcpClient / AcpClient and host seams (IAcpTransport, logger, session store, terminal manager)
  • AcpJsonContext source-generated serialization entry point
  • Host helpers: AcpMetaJson, McpServerSnapshots, ProtocolPathRules, capability defaults
  • AcpException / JsonRpcErrorCode

JSON-RPC envelopes, message parser/validator, and all JsonConverters are assembly-internal implementation details. Hosts should serialize ACP wire types through AcpJsonContext rather than re-source-generating them.

Requirements

  • .NET 10 (net10.0)
  • Zero package dependencies; AOT/trim compatible (IsAotCompatible)

Protocol versions

The live client runtime defaults to stable ACP v1 (AcpProtocolVersion.Default). ACP v2 is still an upstream Draft. A host can evaluate its full runtime only with explicit client options and an offered version of 2; ordinary clients reject that offer. AcpProtocolVersion.HighestModeled denotes the highest modeled wire version, not the production default. AcpProtocolVersion.Latest is the obsolete former name of HighestModeled and is kept only so 1.0.0 consumers still compile.

Prompt acknowledgement/state updates, versioned entity views, permission subjects, configuration workflows and JSON-RPC batches share the existing client owners. Experimental negotiation is separate from the stable default. The modeled v2 contracts are marked [Experimental("SEACP002")]; see ACP v2 draft surface.

V2 connections reject the removed session/set_mode method before transport delivery; hosts must use the Agent-provided configuration option instead. Structured diff content uses the same contract through direct DTO and enclosing-message entry points: changes is required, invalid entries are skipped, optional malformed patch/metadata fields recover as specified, and unknown operations retain their raw payload. V1 flat diff behavior and the stable default are preserved.

Capability support boundaries

The presence of a wire type does not advertise a capability or supply its host implementation. ClientCapabilityDefaults.Create() is the authority for the capabilities SalmonEgg advertises; hosts must enable optional capabilities only after implementing their interaction and lifecycle.

Surface Current behavior Remaining work
Agent authentication Only an absent discriminator or the exact agent type can reach authenticate. Unsupported strings round-trip without being selected; non-string discriminators are rejected. Interactive Windows WinUI stdio connections advertise terminal authentication and use a separate ConPTY after user consent. Other hosts need their own interactive login and trustworthy process-exit acceptance before opting in; see #147. SalmonEgg credential injection binds a stored value to an explicit transport destination independently of the ACP authenticate request.
Request cancellation The SDK sends $/cancel_request, recognizes -32800, and retains the original request ID until its terminal response or disconnection. Transports preserve caller cancellation; each cancellation notification has a two-second send budget. A terminal response received first wins. Peer cancellation is best effort. session/cancel remains a separate session operation. #148 still requires the deployed stdio-to-WebSocket bridge acceptance gate.
Form elicitation SalmonEgg's capability defaults advertise form mode. Hosts handle ElicitationRequested and return a typed accept, decline, or cancel response. The host owns the form UI and must preserve the request's scope and connection ownership.
URL elicitation The SDK owns consent-response availability, connection lifetime, and completion in ElicitationRequestEventArgs.State. URL mode stays off in SDK defaults; SalmonEgg enables it on WASM, Linux/macOS desktop, Windows WinUI and Android through platform services. Native hosts use the system browser after explicit consent. Product gates exercise real cards, protocol peers, native input and isolated external browsers; Windows uses the installed MSIX and Android uses the installed APK. iOS and independent real-Agent acceptance are tracked in #154; #146 tracks complete elicitation delivery.
ACP v2 Explicit opt-in enables the staged lifecycle while ordinary initialization continues to reject V2. The existing application event stream carries complete entity views without a draft-type dependency. V2 remains an upstream draft. The official Rust echo Agent is interoperable; LLM Agent/platform acceptance must be reported separately in #149.

Internal v2 batch validation follows the upstream schema at 5ebaf0aceb04a4ba6574cd63fa6355352dc6d931 and JSON-RPC 2.0 section 6. Call batches may mix requests and notifications; responses are collected into one array after all requests have answers. Notification-only batches produce no response. Empty batches return a single -32600 response; malformed JSON returns a single -32700, both with explicit id: null. An invalid call item receives -32600 without discarding valid siblings. Response batches correlate each valid response by its original id type and value. Malformed response items, including responses embedded in a call batch, are logged and ignored without sending replies to responses.

The transport recognizes object and array frames independently of negotiation; a v1 connection explicitly rejects batches. Cancellation and disconnect retain each response's original connection and batch ownership. Failed response batches remain retryable through their original pending requests. A batch with no remaining retry owner is abandoned after its failed send and releases its retained responses, without starting an automatic retry loop. tests/SalmonEgg.Acp.Desktop.Tests and scripts/gates/run-acp-batch-stdio-gate.sh exercise real Linux pipes through the production transport and adapter. This gate does not establish interoperability with a public v2 Agent. The separate pinned official Rust Agent gate covers that boundary.

Terminal methods append their arguments to the invocation used by the active stdio connection and override its effective environment. SalmonEgg asks for consent before starting a separate PTY, reclaims that process tree on cancellation, and reconnects through its existing connection owner only after a normal zero exit. Terminal methods never reach authenticate. Interactive Windows WinUI stdio connections advertise the capability; the installed MSIX gate verifies consent, terminal input, exit, reconnect, retry and cancellation. The dedicated Windows process gate also validates ConPTY independently. Other platforms remain disabled until their process exit, cleanup and installed-product interaction are verified. Commands and platform prerequisites are described in the repository's BUILD_GUIDE.md.

V2 wire coverage includes configId/groupId, required messageId values, text/custom command inputs, and v1-only session fields and MCP variants. Unknown extension fields are preserved; default-on-error and skip-invalid-item behavior applies only where the upstream schema permits it. Resource-link icons are available through the experimental ResourceLinkDraftExtensions helper, so constructing them requires an explicit draft opt-in. Permission subjects reach the staged v2 handler through the same pending-request owner as v1. Draft session snapshots supply message upserts, streaming tool content, and Agent-owned terminal projections. The same projection supplies stable AcpSessionUpdateView values on SessionUpdateEventArgs, so application consumers do not have to name draft DTOs. Message content replaces one authoritative message, tool views are complete, terminal views only display Agent-owned state, and idle ends work even when no reason is supplied.

SendPromptAsync always waits for completed foreground work. The internal v2 development path records the prompt acknowledgement separately and finishes on an idle state_update; cancellation keeps accepting trailing updates until that idle arrives. One controller owns session work, including unsolicited running/requires-action/idle updates, and uses the connection's existing lifetime token to reject stale callbacks. This path is exercised through the actual JSON parser and client handlers, available only with explicit experimental client options. V1 still completes from its terminal prompt response.

The pinned v2 lifecycle says an ending idle must carry a stop reason, while its schema allows an omitted/null/default-on-error reason. The client preserves that unknown reason and still finishes on idle (HasStopReason == false), without fabricating end_turn or waiting indefinitely.

AcpSessionDraftExtensions.ReplaySession(sessionId, updates) is an offline SDK host API for recorded histories. Each input is a JSON update object with sessionUpdate, in receive order and scoped to the supplied session. It uses the same v2 reader and projection as the normal client handler without connecting, executing terminal commands, or changing a client's state. client.GetSessionSnapshot(sessionId) reads the same owner on an internally staged v2 connection; it returns null on the public v1 runtime and after session close or disconnect. Suppressing SEACP002 alone does not enable live v2 initialization.

Snapshots expose messages, tool calls, and terminal output in first-seen order. Patch omission keeps a value, null clears it, and arrays replace complete content; chunks append. Terminal chunks are independently decoded into immutable bytes. Message/tool/terminal metadata is separate from chunk metadata. Entity ExtensionData retains the last-seen value of each unknown top-level field, including explicit null, without interpreting its semantics. Protocol DTO getters return detached copies because wire DTO collections are mutable; editing a returned DTO cannot alter an existing snapshot or client state. A full replayFrom: { type: "start" } begins a fresh projection, while resume without replay retains prior history. Overlapping full replays are rejected because session updates carry no request id that could separate them; cancelling the local wait or receiving an unknown write outcome retains this claim until the peer responds or the connection ends. Only a request that never started transport I/O releases the claim immediately.

Configuration follows the same session and connection owner. session/new, session/resume, session/set_config_option responses and config_option_update replace the complete list in wire receive order, including replies received after the caller abandons its wait. A later notification cannot be overwritten by an earlier response's delayed continuation. HasConfigOptions distinguishes an unreported list from an authoritative empty list; ConfigOptions returns detached DTOs in Agent priority order. Offline replay uses the same rules. The configuration contract is checked against schema revision f1293d8e. Known option types are select and boolean; unknown types preserve their complete payload through ConfigOption.RawPayload and round-trip serialization. Unknown value types also round-trip raw session/set_config_option data. V2 value IDs carry type: "id"; V1 retains its default string variant with no discriminator. Configuration metadata recovers only at schema-authorized V2 fields. The application configuration projection retains boolean values and unknown payloads through its existing workspace path; only supported option types produce editable rows.

A snapshot is a current view, not a lossless event archive. UnprojectedUpdates retains recent unhandled updates verbatim within MaxUnprojectedUpdates (64 entries) and MaxUnprojectedUtf8Bytes (256 KiB of JSON). A fitting update evicts the oldest entries as needed; a single oversized update is omitted without evicting existing entries. Both cases increment OmittedUnprojectedUpdateCount. A fresh full replay resets this accounting. These limits do not truncate known message/tool/terminal content or suppress SessionUpdateReceived. Hosts record that event for complete update history, or raw transport messages for a wire-exact archive; snapshots do not retain replaced entity values or chunk-scoped metadata and extension history.

The pinned v2 schema explicitly allows default-on-error for optional patch fields and skip-invalid-items for message, tool-content, and location arrays. Those recoveries apply only to the v2 contracts that declare them. Required identities and chunks remain strict, unknown string discriminators survive, and content annotations, metadata, optional image URIs, and optional resource-link/resource fields recover independently before an enclosing list may discard an invalid content item. Valid payloads survive malformed hints; required field type errors remain invalid. Embedded resources follow the schema's untagged union, selecting a valid text branch before a valid blob branch and rejecting a payload that satisfies neither. V1 optional-field type validation is unchanged. The actual nupkg consumer gate replays mixed history and asserts replacement, append, clear, terminal bytes, and snapshot isolation.

AcpPermissionDraftExtensions.ReadRequest(parameters) parses recorded v2 permission params; permissionEvent.GetDraftRequest() returns the same immutable view on the existing PermissionRequestReceived event (null for v1). It supplies the required prompt title, optional description, optional tool-call/command/custom subject, and detached options. RawParameters keeps the entire input for recording or forwarding. Missing/null subjects stay absent; unknown subject types stay opaque. Prompt text and subjects never update transcript, tool, or terminal projections, and a command subject never executes a local command. V2 events leave the legacy ToolCall null. The host explicitly selects an offered option or cancels; failed sends and invalid choices retain the original request for retry, and stale callbacks cannot answer a new request with a reused id. Subscriber failures also claim that original request, so an exception after an answer cannot send a second response. The nupkg consumer gate verifies optional subjects, opaque custom payloads, detached prompt data, and rejection of unapproved v2 before any connection or write.

The V1 runtime and existing public interfaces remain compatible. To evaluate V2, construct the client with new AcpClientOptions { ExperimentalProtocolVersions = [2] } as the fifth constructor argument and explicitly offer InitializeParams.ProtocolVersion = 2. The list is copied at construction and defaults to empty. The Agent may choose V1, after which all wire behavior follows V1. Other experimental version numbers are rejected. The application exposes this through the process-level SALMONEGG_EXPERIMENTAL_ACP_V2=1 opt-in; the same immutable policy configures both client creation and initialization. This is experimental support, not a change to Default or RuntimeServed, and suppressing SEACP002 does not change that policy.

Configuration replies publish IsResponseProjection=true through the existing ordered session stream. Hosts that see PublishesConfigurationResponses must use that stream rather than reapply the returned full list from a delayed await. Wire recorders can distinguish these projections from peer notifications. Captured events expose IsCurrent so buffered consumers reject a closed connection's update. The SDK maps the application's stable history-load intent to V2 session/resume with replayFrom: { type: "start" }; V2 never sends the removed session/load.

Cancellation transport integration

Existing IAcpTransport.SendMessageAsync(string, CancellationToken) implementations remain source and binary compatible. The three-argument overload defaults to the original method. Implementations can override it to honor AcpTransportSendOptions.DiagnosticOnly: return false or throw for that send's transient failure without raising ErrorOccurred. Connection loss, process exit, and reader failures must still raise normal events and settle pending requests. Errors without operation ownership from legacy transports remain visible. Implementations must honor the supplied token; the SDK bounds its own wait even if an external transport ignores cancellation.

scripts/gates/run-acp-cancellation-transport-gates.sh exercises real stdio, direct WebSocket, HTTP/2 and SSE traffic and checks that every required case passes without skips. The separate scripts/gates/run-acp-cancellation-bridge-gate.sh requires a deployed bridge fronting scripts/gates/fixtures/cancellation-peer.py, SALMONEGG_ACP_CANCELLATION_BRIDGE_URL, and a fresh absolute SALMONEGG_ACP_CANCELLATION_PEER_LOG path. Missing deployment evidence fails that gate; the ordinary test suite explicitly skips this external integration when it is unconfigured.

Permission response ownership

Hosts should retain the PermissionRequestEventArgs they receive and answer through TryRespondAsync(outcome, optionId). It reports whether the original request's response was sent; CanRespond checks whether that exact pending request still belongs to its receiving client. Disconnecting, completing a request, or reusing its ID invalidates the old event. Failed sends remain retryable while the original request is still current. A host must dismiss only the prompt associated with the successful response, even if the active conversation or connection has changed.

IsResponsePrepared means an answer is prepared or in flight without confirming delivery. IsResponseSending distinguishes a physical send from a prepared batch answer waiting for siblings. Keep an independent prompt in place during that send, with the native asynchronous command disabled; only a batch answer waiting for other answers may yield its input surface before delivery. A failed write makes the original request retryable. When IsCancellationRequested is true, offer cancellation retry instead of authorization. Title and Description expose the peer's prompt text without a draft-type dependency; missing titles should use the host's localized fallback.

If a request cannot be associated with a conversation, attempt cancellation once. A failed cancellation retains a standalone cancellation-only retry in the same interaction owner, bound to the original request and connection. Successful delivery, disconnection or service replacement removes that retry; it never creates a conversation binding or an automatic retry loop. When no chat surface can offer that retry, the connection coordinator closes only the original service and retains a localized connection failure asking the user to reconnect. Its bounded cleanup never clears a newer service or publishes the old failure over a replacement connection.

Subscribe to the request's Changed event and immediately read its properties to cover the subscription window. Notifications are asynchronous and may coalesce; marshal UI projection to its dispatcher, recheck request ownership there, and unsubscribe when the interaction is removed. An already captured callback may still run after unsubscription. Observers for one request execute sequentially, outside the send path; slow observers cannot delay delivery. Observer failures are logged and isolated, including a host logger that itself throws.

ACP v1 peer $/cancel_request for a permission request latches cancellation on that original owner. An answer already being written may finish successfully; otherwise cancellation returns -32800. Failed cancellation writes retain the owner for explicit retry, and later user selection cannot replace the cancellation. Queued callbacks retain the connection that originally received them, so a delayed cancellation cannot withdraw a replacement request after reconnecting.

The existing public constructor and Respond callback retain their signatures. For events created through that constructor, the SDK cannot query a client lifetime: CanRespond returns true, and normal completion of the supplied Task callback makes TryRespondAsync return true. Exceptions from that callback remain observable. Callbacks received from AcpClient retain the actual send result without casting a Task to Task<bool>.

Hosts display the complete URL and destination host before navigation. Opening requires an explicit user action and an isolated external browser context; WASM uses native noopener,noreferrer and rejects application-origin URLs. A browser dispatch does not prove that a popup opened, so the UI allows a new explicit Open again action without sending a second ACP response. An accept response carries no form content and acknowledges consent only; elicitation/complete independently marks the existing request complete. Unknown or duplicate completion IDs do nothing.

The state is scoped to the connection that received the request. Hosts observe State.Changed and State.ConnectionClosed, marshal updates to their UI thread, and stop using expired callbacks. Connection shutdown marks the token cancelled and clears pending requests synchronously; host cancellation callbacks run asynchronously and cannot delay disconnect. Callback failures are observed without logging their potentially private data.

SalmonEgg currently presents session-scoped requests. Request-scoped requests are explicitly cancelled when no conversation surface can present them. WASM, Linux/macOS desktop, Windows WinUI and Android register URL capability with their product acceptance gates. Windows exercises the installed MSIX; Android installs the current APK on an isolated emulator; desktop gates use the current Skia app and system browser. These deterministic fixtures do not substitute for independent Agent interoperability or physical-device release validation. Other native targets require their own installed-application acceptance before enabling the capability.

ACP v2 draft surface (SEACP002)

Every v2 draft contract on the public surface carries [Experimental("SEACP002")], so naming one is a compile error by default rather than a warning. That is deliberate: v2 is still an upstream draft and AcpProtocolVersion.RuntimeServed remains V1. Naming draft contracts and allowing experimental negotiation are separate opt-ins. The 46 marked types are the state_update work-state family, the whole-message upsert updates, the terminal updates, streaming tool-call content, the v2 plan_update envelope, permission subjects, the v2 capability markers, the structured diff, ResourceLinkDraftExtensions for resource-link icons, and six session-projection types (the draft entry point, message kind, and session/message/tool/terminal snapshots). The projection types are not source-generated wire DTOs and introduce no serialization-context bypass. The permission draft entry point and request snapshot likewise add no serialization-context bypass.

To evaluate them anyway, opt in explicitly:


<NoWarn>$(NoWarn);SEACP002</NoWarn>
#pragma warning disable SEACP002 // one region
var update = new StateSessionUpdate(new IdleSessionWorkState());
#pragma warning restore SEACP002

[SuppressMessage] does not work here. SEACP002 is produced by the compiler itself rather than by an analyzer, so no attribute-based suppression applies, whatever category you give it.

Known residual channel: the serialization context

AcpJsonContext is public and registers the draft contracts, and the JSON source generator emits a public JsonTypeInfo<T> property per registered type without copying the attribute onto it. So this compiles clean today, with no SEACP002 anywhere:

var info = AcpJsonContext.Default.StateSessionUpdate;   // never names the draft type
var update = JsonSerializer.Deserialize(json, info);    // draft contract in hand

26 context members are reachable this way. The hole cannot be closed while the DTOs are public - an internal context over public types is CS0053 - so it is pinned by name in the SDK's gate tests instead: a new draft registration fails the build until someone decides deliberately. What you get through it is a draft contract, not a draft-populated connection: the context resolves the stable surface, so nothing a v1 connection reads binds to one of these.

Reads are a different story now, and a better one. SessionUpdate declares only the v1 surface as static polymorphic metadata, and the negotiated surface is assembled per connection - so a v1 connection whose Agent sends a v2 update gets the base type with the payload preserved, not a draft contract. Reading through AcpJsonContext.Default yields the same stable surface. You still cannot assume the set of variants is closed: an update your version does not define arrives as SessionUpdate with UnknownUpdateKind set, which on v1 means the Agent sent something v1 does not have.

Collection equality

Wire DTOs are record types, but collection properties remain mutable List<T> / Dictionary<TKey,TValue> for STJ source-gen friendliness. Record equality is therefore reference equality on collections, not deep structural equality. Hosts that need value snapshots should clone via helpers such as McpServerSnapshots / AcpMetaJson.Clone or re-serialize through AcpJsonContext.

License

MIT

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • net10.0

    • No dependencies.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.2.0 82 10/1/2026
1.1.0 97 9/24/2026
1.0.0 535 8/25/2026