Skip to content

Latest commit

 

History

History
511 lines (425 loc) · 17.7 KB

09-acp-protocol-types.md

File metadata and controls

511 lines (425 loc) · 17.7 KB

ACP protocol types

This chapter documents the decoded protocol surface in Spettro/ACP/ACPProtocol.swift — the Swift types that mirror the acp-go-sdk wire types for the subset of ACP this app actually drives.

A note on how these types are shaped: the ACP discriminated unions (session updates, config options, permission outcomes) carry their variant tag in a sibling string field, not in a shape Swift's Codable can synthesize. So every union here is decoded by hand from JSONValue via a static parse(_:) function, while the flat structs (ACPImplementation, ACPInitializeResult, ACPPromptResult) are plain Codable. Everything in the file is nonisolated and Sendable so decoded values can cross from the connection's reader queue to @MainActor consumers.

Contents:

Initialize

Two plain structs carry the agent's answer to the initialize handshake:

nonisolated struct ACPImplementation: Codable, Hashable, Sendable {
    var name: String
    var title: String?
    var version: String?
}

nonisolated struct ACPInitializeResult: Decodable, Sendable {
    var protocolVersion: Int
    var agentInfo: ACPImplementation?
}

ACPImplementation does double duty: the app encodes its own identity into initialize params (clientInfo) and decodes the agent's identity from the result (agentInfo). Representative wire JSON for the result:

{
  "protocolVersion": 1,
  "agentInfo": { "name": "spettro", "version": "0.42.0" }
}

Content blocks

ACPContentBlock is the only outbound union in the file — the content variants of a session/prompt call. The app currently produces exactly two: text, and base64 images (from ImageAttachment). The variant tag is encoded by hand into a type field:

nonisolated enum ACPContentBlock: Encodable, Sendable {
    case text(String)
    case image(base64: String, mimeType: String)
}
{ "type": "text", "text": "Fix the failing test." }
{ "type": "image", "data": "/9j/4AAQSkZJRg…", "mimeType": "image/jpeg" }

There is deliberately no decoder — the app never reads prompt content back. ACPAgent.prompt(sessionId:blocks:) wraps the array in PromptParams and sends it as session/prompt.

Stop reasons and prompt results

The result of session/prompt is one field, the stop reason. The raw values the CLI sends are snake_case where multi-word; refusal, cancelled, and unknown use the default (the case name itself):

nonisolated enum ACPStopReason: String, Decodable, Sendable {
    case endTurn = "end_turn"
    case maxTokens = "max_tokens"
    case maxTurnRequests = "max_turn_requests"
    case refusal
    case cancelled
    case unknown
}

Note the custom init(from:): it decodes the string, falls back to .unknown on any unrecognized raw value, and never throws. A CLI that invents a new stop reason degrades gracefully instead of failing the whole prompt decode.

nonisolated struct ACPPromptResult: Decodable, Sendable {
    var stopReason: ACPStopReason
}
{ "stopReason": "end_turn" }

Config options

ACPConfigOption models one agent-advertised session setting — the mode/model/permission/thinking selectors and boolean toggles like "ultra" that appear in the chat toolbar (ConfigBar). Everything is data-driven: the app has no hardcoded knowledge of which options exist.

nonisolated struct ACPConfigOption: Identifiable, Hashable, Sendable, Codable {
    enum Kind {
        case select(current: String, groups: [OptionGroup])
        case boolean(current: Bool)
    }
    struct Option { var name, value: String; var description: String? }
    struct OptionGroup { var name: String?; var options: [Option] }

    var id, name: String
    var description, category: String?
    var kind: Kind
}

Two shapes on the wire:

  • Boolean — "type": "boolean", with currentValue a JSON bool.
  • Select (the default when type is anything else) — currentValue is the currently-selected value, and options is either a flat array of options or a grouped array whose elements each carry a nested options array. parse(_:) distinguishes the two by looking at whether the first element has an options key.

A grouped select (e.g. the model picker):

{
  "id": "model",
  "name": "Model",
  "type": "select",
  "category": "model",
  "currentValue": "claude-sonnet-4",
  "options": [
    {
      "name": "Anthropic",
      "options": [
        { "name": "Sonnet 4", "value": "claude-sonnet-4", "description": "Fast and capable" }
      ]
    }
  ]
}

A flat, ungrouped select parses into a single OptionGroup(name: nil, …) — that's what the nil group name means throughout, "ungrouped". A boolean:

{
  "id": "ultra",
  "name": "Ultrathink",
  "type": "boolean",
  "currentValue": true
}

The derived currentLabel property resolves the selected value back to its display name (scanning all groups for a matching value, falling back to the raw current string), and renders booleans as "On" / "Off" — that is what compact toolbar buttons show.

Changing an option goes through ACPAgent.setConfigOption(sessionId:configId:value:) (overloaded for String and Bool), which returns the refreshed full option set; the agent also pushes unsolicited changes via config_option_update.

Available commands

ACPCommand is one slash-command the agent advertises — the source of the composer's command palette:

nonisolated struct ACPCommand: Identifiable, Hashable, Sendable, Codable {
    var name: String
    var description: String
    var hint: String?
    var id: String { name }
}

The wire shape nests the hint under input:

{ "name": "commit", "description": "Commit staged changes", "input": { "hint": "optional message" } }

The type also owns a small UserDefaults cache (spettro.cachedCommands): saveCache(_:) persists the last non-empty command list any agent advertised (called by AppModel.handleUpdate whenever an available_commands_update arrives), and cached loads it back, so the palette works before — or without — a live ACP connection.

Tool calls

Tool-call state arrives split across two session-update variants (tool_call at start, tool_call_update thereafter), but both parse into the same ACPToolCallEvent — every field except toolCallId is optional, so a sparse delta and a full snapshot decode identically:

nonisolated struct ACPToolCallEvent: Sendable {
    var toolCallId: String
    var title: String?
    var kind: String?             // raw string, e.g. "execute", "edit"
    var status: ACPToolStatus?
    var rawInput: JSONValue?      // kept verbatim for display/debugging
    var content: [ACPToolContent]
    var locations: [String]       // flattened from location objects' "path"
}
{
  "sessionUpdate": "tool_call",
  "toolCallId": "call_7f3a",
  "title": "Run swift test",
  "kind": "execute",
  "status": "in_progress",
  "rawInput": { "command": "swift test" },
  "content": [],
  "locations": [{ "path": "/Users/me/project/Package.swift" }]
}

kind is kept as a raw String, not an enum — views compare against string literals (e.g. "execute"), and unknown kinds pass through. Status, by contrast, is a closed enum:

nonisolated enum ACPToolStatus: String, Sendable {
    case pending, inProgress = "in_progress", completed, failed, unknown
}

Like ACPStopReason, the custom init(_ raw: String?) maps anything unrecognized (including a missing field) to .unknown rather than failing. Note status on the event is ACPToolStatus? — absent on a start event, present once the tool begins reporting — whereas a parsed-but-unrecognized value is .unknown.

Tool content

ACPToolContent covers what a tool call emits as it runs:

nonisolated enum ACPToolContent: Sendable {
    case text(String)
    case diff(path: String, oldText: String?, newText: String)
}
{ "type": "content", "content": { "type": "text", "text": "Tests passed." } }
{ "type": "diff", "path": "Sources/main.swift", "oldText": "a", "newText": "b" }

The parser is forgiving about text: it accepts the standard { "type": "content", "content": { "text": … } } wrapper or a bare text field on the content object itself. For a diff, path and newText are required (parse fails otherwise); oldText is optional — a newly created file has none. The plainText derived property gives a one-line summary ("Edited <path>" for diffs, the text itself otherwise), used for compact rendering.

Session updates

ACPSessionUpdate is the inbound heart of the protocol — the decoded variants of session/update notifications the app acts on. The tag lives in the sessionUpdate string field of params.update:

nonisolated enum ACPSessionUpdate: Sendable {
    case agentMessageChunk(String)
    case agentThoughtChunk(String)
    case toolCall(ACPToolCallEvent)
    case toolCallUpdate(ACPToolCallEvent)
    case availableCommands([ACPCommand])
    case configOptions([ACPConfigOption])
    case plan([ACPPlanEntry])
    case usage(ACPUsageUpdate)
    case other(String)
}

All variants, by wire tag:

sessionUpdate tag Swift case Payload extracted
agent_message_chunk .agentMessageChunk content.text string (streamed reply text)
agent_thought_chunk .agentThoughtChunk content.text string (reasoning stream)
tool_call .toolCall full ACPToolCallEvent (tool start)
tool_call_update .toolCallUpdate sparse ACPToolCallEvent (progress delta)
available_commands_update .availableCommands availableCommands array
config_option_update .configOptions configOptions array
plan .plan entries array
usage_update .usage ACPUsageUpdate (fails to parse if used/size missing or size <= 0)
(anything else) .other(tag) just the tag string — logged-and-ignored

A representative chunk and a config push:

{
  "sessionId": "sess_123",
  "update": {
    "sessionUpdate": "agent_message_chunk",
    "content": { "type": "text", "text": "Looking at the failing test…" }
  }
}
{
  "sessionId": "sess_123",
  "update": {
    "sessionUpdate": "config_option_update",
    "configOptions": [ { "id": "mode", "name": "Mode", "currentValue": "code", "options": [] } ]
  }
}

Three parser details worth knowing:

  • Chunk text for both streaming variants is pulled from update.content.text via the shared chunkText(_:) helper; a missing or non-object content yields the empty string, not a parse failure.
  • Unknown tags are not dropped. parse returns .other(tag) so the enum is total; AppModel.handleUpdate explicitly ignores .other, meaning new CLI-side update kinds never break older app builds.
  • Sublists parse element-wise with compactMap — one malformed option, command, or plan entry doesn't poison the whole update.

ACPConnection.handleNotification guards on method == "session/update", pulls sessionId and update from the params, and only then calls parse — so ACPSessionUpdate.parse never sees the envelope, just the inner update object.

Usage updates

ACPUsageUpdate is live context-window accounting, shown as the context chip in the chat header:

nonisolated struct ACPUsageUpdate: Hashable, Sendable {
    var used: Int          // tokens currently in context
    var size: Int          // total window size in tokens
    var totalTokens: Int?  // cumulative tokens this session (Spettro extension)
}
{
  "sessionUpdate": "usage_update",
  "used": 41230,
  "size": 200000,
  "_meta": { "spettro.app/tokensUsed": 182004 }
}

Two quirks to note: the parse requires size > 0 (a zero window would make every percentage computation divide by zero, so the update is dropped entirely), and totalTokens is a Spettro-specific extension smuggled through the standard _meta escape hatch under the key spettro.app/tokensUsed — stock ACP agents won't send it, hence the optionality.

Plan entries

ACPPlanEntry is one line of the agent's live task plan:

nonisolated struct ACPPlanEntry: Identifiable, Hashable, Sendable {
    var content: String
    var status: String      // raw string; "pending" when absent
    var priority: String?
    var id: String { content }
}
{
  "sessionUpdate": "plan",
  "entries": [
    { "content": "Reproduce the failure", "status": "completed", "priority": "high" },
    { "content": "Fix the off-by-one", "status": "in_progress" }
  ]
}

Like tool-call kind, status and priority are kept as raw strings so the CLI can introduce new values without a client update; the identity of an entry is its content, which is how the plan chip diffs successive plan updates.

Permission requests

ACPPermissionRequest is the odd one out: it is not a session update but an agent→client JSON-RPC request (session/request_permission) that the app must answer. ACPConnection.handleIncomingRequest parses it, routes it to AppModel.onPermissionRequest, and refuses everything agent→client except _spettro/question/ask with -32601 (the app advertises no fs/terminal client capabilities, so the agent should never call those). Agent questions have their own types in ACP/ACPQuestion.swift — see Question sheet.

A permission request whose _meta carries spettro.app/question is an ask-user question an older CLI is walking over this transport; AppModel routes it to the question sheet rather than the permission sheet.

nonisolated struct ACPPermissionRequest: Identifiable, Sendable {
    struct Option: Identifiable, Hashable, Sendable {
        var optionId, name, kind: String
        var id: String { optionId }
    }

    var id = UUID()
    var rpcID: RPCID          // the JSON-RPC id the reply must echo back
    var sessionId: String
    var title: String
    var toolKind: String?
    var rawInput: JSONValue?
    var options: [Option]
}
{
  "jsonrpc": "2.0",
  "id": 42,
  "method": "session/request_permission",
  "params": {
    "sessionId": "sess_123",
    "toolCall": {
      "title": "Run `rm -rf build/ && swift build`?",
      "kind": "execute",
      "rawInput": { "command": "rm -rf build/ && swift build" }
    },
    "options": [
      { "optionId": "allow-once", "name": "Allow once", "kind": "allow_once" },
      { "optionId": "reject", "name": "Reject", "kind": "reject_once" }
    ]
  }
}

Parser details:

  • sessionId and a toolCall object are required; a malformed request gets a -32602 error reply from ACPConnection.
  • title falls back to "Permission requested" when the tool call carries none.
  • Option kind defaults to the empty string when absent; the PermissionSheet uses it only for tint and keyboard shortcut, never for logic — the reply carries optionId, not kind.
  • id is a fresh local UUID() (for SwiftUI sheet identity) while rpcID is the protocol id — the two are deliberately different, and the reply path (AppModel.resolvePermission / dismissPermission → ACPAgent.replyPermission / cancelPermission) uses only rpcID.

The reply encoding lives in ACPAgent.PermissionResponse: the outcome is a nested object, either { "outcome": { "outcome": "selected", "optionId": "allow-once" } } or { "outcome": { "outcome": "cancelled" } }.

Update routing cheat sheet

Where each decoded value lands, per AppModel.handleUpdate(sessionId:update:):

Case Routed to
.agentMessageChunk session.appendAssistant(_:)
.agentThoughtChunk session.appendReasoning(_:)
.toolCall session.applyToolEvent(_:isStart: true)
.toolCallUpdate session.applyToolEvent(_:isStart: false)
.availableCommands session.commands + ACPCommand.saveCache(_:)
.configOptions session.configOptions + remember + persist (non-empty only)
.plan session.plan
.usage session.usage
.other ignored

Where to go next

  • ACP agent façade — the calls that send these types and the param encodings around them.
  • JSONValue / RPCID — the dynamic JSON type every parse(_:) here consumes.
  • Chat session model — what applyToolEvent, appendAssistant, and friends do with the decoded values.