Vectrum
Sign In
308 lines · 10.9 KB · Go
package acp

import "encoding/json"

// ProtocolVersion is the ACP revision this package speaks. T3 Code sends 1 in
// its initialize request and every other client we know of does too; a client
// that asks for something else still gets 1 back, because answering with a
// version we do not implement would be a lie the handshake cannot recover
// from.
const ProtocolVersion = 1

// The method names, spelled once. A typo in a string literal is a method the
// peer never hears about and an error nobody can grep for.
const (
	MethodInitialize        = "initialize"
	MethodAuthenticate      = "authenticate"
	MethodNewSession        = "session/new"
	MethodPrompt            = "session/prompt"
	MethodCancel            = "session/cancel"
	MethodSessionUpdate     = "session/update"
	MethodRequestPermission = "session/request_permission"
)

// Implementation names a peer: the agent to the client, or the client to the
// agent.
type Implementation struct {
	Name    string `json:"name"`
	Title   string `json:"title,omitempty"`
	Version string `json:"version"`
}

// InitializeRequest is the first thing a client sends.
//
// ClientCapabilities is kept raw. We advertise no filesystem or terminal
// capability of our own, so there is nothing in there we act on, and decoding
// a structure we ignore is a decode that can fail for no reason.
type InitializeRequest struct {
	ProtocolVersion    int             `json:"protocolVersion"`
	ClientCapabilities json.RawMessage `json:"clientCapabilities,omitempty"`
	ClientInfo         *Implementation `json:"clientInfo,omitempty"`
}

// InitializeResponse answers it. Only ProtocolVersion is required by the
// protocol; everything else is how the client decides what to ask us for
// later, so leaving a field out is the way to say "do not ask".
type InitializeResponse struct {
	ProtocolVersion   int                `json:"protocolVersion"`
	AgentInfo         *Implementation    `json:"agentInfo,omitempty"`
	AgentCapabilities *AgentCapabilities `json:"agentCapabilities,omitempty"`
	// AuthMethods is serialized even when empty: a client that sees the key
	// knows we answered the question, where a missing key means "unstated".
	AuthMethods []AuthMethod `json:"authMethods"`
	// Meta is the protocol's escape hatch, and the one thing we put in it is
	// ModelStateMeta. A client probing which models an agent has does not want
	// to open a session to find out, and answering here is what lets its model
	// picker be filled from the endpoint rather than from parsing our own
	// command-line output.
	Meta map[string]any `json:"_meta,omitempty"`
}

// ModelStateMeta is the key under InitializeResponse.Meta that carries a
// [SessionModelState].
const ModelStateMeta = "modelState"

// AgentCapabilities is what the agent can be asked to do beyond one prompt.
type AgentCapabilities struct {
	LoadSession        bool                `json:"loadSession,omitempty"`
	PromptCapabilities *PromptCapabilities `json:"promptCapabilities,omitempty"`
}

// PromptCapabilities is what a prompt may contain.
type PromptCapabilities struct {
	Image           bool `json:"image,omitempty"`
	Audio           bool `json:"audio,omitempty"`
	EmbeddedContext bool `json:"embeddedContext,omitempty"`
}

// AuthMethod is one way of logging in that the agent offers. We offer none —
// Codey takes its credential from the environment — so this exists to make the
// empty list well typed rather than to be constructed.
type AuthMethod struct {
	ID          string `json:"id"`
	Name        string `json:"name"`
	Description string `json:"description,omitempty"`
}

// AuthenticateRequest asks the agent to log in with one of the methods it
// advertised. Clients send it unconditionally, including a method id we never
// offered, so the id is informational.
type AuthenticateRequest struct {
	MethodID string `json:"methodId"`
}

// NewSessionRequest opens a conversation rooted at a directory.
//
// AdditionalDirectories is not in every published copy of the schema but T3
// Code sends it, and McpServers is accepted and ignored: an agent that refuses
// to decode a field it does not use is an agent that breaks on the client's
// next release.
type NewSessionRequest struct {
	Cwd                   string            `json:"cwd"`
	McpServers            []json.RawMessage `json:"mcpServers,omitempty"`
	AdditionalDirectories []string          `json:"additionalDirectories,omitempty"`
}

// NewSessionResponse hands back the id every later message carries.
type NewSessionResponse struct {
	SessionID string             `json:"sessionId"`
	Models    *SessionModelState `json:"models,omitempty"`
}

// SessionModelState is the model picker: what is selected, and what else there
// is. A client that gets this uses it in preference to anything the agent
// printed on a command line.
type SessionModelState struct {
	CurrentModelID  string      `json:"currentModelId"`
	AvailableModels []ModelInfo `json:"availableModels"`
}

// ModelInfo is one entry in that picker.
type ModelInfo struct {
	ModelID     string `json:"modelId"`
	Name        string `json:"name"`
	Description string `json:"description,omitempty"`
}

// SetModelRequest selects a model mid-session.
type SetModelRequest struct {
	SessionID string `json:"sessionId"`
	ModelID   string `json:"modelId"`
}

// PromptRequest is one user turn.
type PromptRequest struct {
	SessionID string         `json:"sessionId"`
	Prompt    []ContentBlock `json:"prompt"`
}

// ContentBlock is a piece of a message. The union is decoded into one struct
// because Go has no sum types and the alternative — a custom unmarshaller per
// variant — buys nothing when the only variant we read is text.
type ContentBlock struct {
	Type     string `json:"type"`
	Text     string `json:"text,omitempty"`
	Data     string `json:"data,omitempty"`
	MimeType string `json:"mimeType,omitempty"`
	URI      string `json:"uri,omitempty"`
	Name     string `json:"name,omitempty"`
}

// The stop reasons a prompt can end with.
const (
	StopEndTurn         = "end_turn"
	StopCancelled       = "cancelled"
	StopRefusal         = "refusal"
	StopMaxTokens       = "max_tokens"
	StopMaxTurnRequests = "max_turn_requests"
)

// PromptResponse ends the turn. StopReason is the only field the clients we
// know of read.
type PromptResponse struct {
	StopReason string `json:"stopReason"`
}

// CancelParams asks for the turn in flight to stop. It is a notification, so
// there is nothing to answer: the acknowledgement is the prompt coming back
// with StopCancelled.
type CancelParams struct {
	SessionID string `json:"sessionId"`
}

// SessionNotification is everything the agent says while a turn runs. One
// struct with a tagged Update, because that is how the wire is shaped.
type SessionNotification struct {
	SessionID string `json:"sessionId"`
	Update    Update `json:"update"`
}

// The sessionUpdate discriminators we emit.
const (
	UpdateAgentMessageChunk = "agent_message_chunk"
	UpdateAgentThoughtChunk = "agent_thought_chunk"
	UpdateToolCall          = "tool_call"
	UpdateToolCallUpdate    = "tool_call_update"
)

// Update is the tagged body of a session notification. Every field beyond
// SessionUpdate is optional, and which ones are read depends on the tag.
//
// Content is `any` because the wire reuses one key for two shapes: on a chunk
// it is a single content block, on a tool call it is a list of tool call
// content. Updates are only ever written, never decoded, so carrying the union
// as an interface costs nothing and spells out that the shape follows the tag.
type Update struct {
	SessionUpdate string `json:"sessionUpdate"`
	Content       any    `json:"content,omitempty"`

	// Set on the tool-call variants. ToolCallID is what pairs an update with
	// the call it belongs to, so it is the one field that is never optional
	// there.
	ToolCallID string          `json:"toolCallId,omitempty"`
	Title      string          `json:"title,omitempty"`
	Kind       string          `json:"kind,omitempty"`
	Status     string          `json:"status,omitempty"`
	RawInput   json.RawMessage `json:"rawInput,omitempty"`
}

// The tool kinds ACP defines. A client uses them to pick an icon and to decide
// what to fold away, so a wrong one is cosmetic and a missing one is fine.
const (
	KindRead    = "read"
	KindEdit    = "edit"
	KindDelete  = "delete"
	KindMove    = "move"
	KindSearch  = "search"
	KindExecute = "execute"
	KindThink   = "think"
	KindFetch   = "fetch"
	KindOther   = "other"
)

// The tool call statuses.
const (
	StatusPending    = "pending"
	StatusInProgress = "in_progress"
	StatusCompleted  = "completed"
	StatusFailed     = "failed"
)

// ToolCallContent is what a tool produced: text, or a diff a client can render
// side by side.
type ToolCallContent struct {
	Type    string        `json:"type"`
	Content *ContentBlock `json:"content,omitempty"`

	// Set when Type is "diff".
	Path    string `json:"path,omitempty"`
	OldText string `json:"oldText,omitempty"`
	NewText string `json:"newText,omitempty"`
}

// TextContent is the content block a chunk carries, which is always text in
// what Codey emits.
func TextContent(text string) *ContentBlock {
	return &ContentBlock{Type: "text", Text: text}
}

// ToolCallText wraps tool output as tool call content.
func ToolCallText(text string) ToolCallContent {
	return ToolCallContent{Type: "content", Content: TextContent(text)}
}

// RequestPermissionParams asks the client to decide about one tool call.
type RequestPermissionParams struct {
	SessionID string             `json:"sessionId"`
	ToolCall  PermissionToolCall `json:"toolCall"`
	Options   []PermissionOption `json:"options"`
}

// PermissionToolCall describes what is waiting on the answer.
type PermissionToolCall struct {
	ToolCallID string          `json:"toolCallId"`
	Title      string          `json:"title,omitempty"`
	Kind       string          `json:"kind,omitempty"`
	Status     string          `json:"status,omitempty"`
	RawInput   json.RawMessage `json:"rawInput,omitempty"`
}

// PermissionOption is one answer the client may give.
type PermissionOption struct {
	OptionID string `json:"optionId"`
	Name     string `json:"name"`
	Kind     string `json:"kind"`
}

// The option kinds, and the option ids we use.
//
// The ids are not ours to invent freely. Cursor's adapter in T3 Code replies
// with these three literals regardless of what the agent offered, so choosing
// them here is what lets the same binary sit in a Cursor-shaped slot as well
// as a Grok-shaped one.
const (
	KindAllowOnce   = "allow_once"
	KindAllowAlways = "allow_always"
	KindRejectOnce  = "reject_once"

	OptAllowOnce   = "allow-once"
	OptAllowAlways = "allow-always"
	OptRejectOnce  = "reject-once"
)

// RequestPermissionResult is the answer.
type RequestPermissionResult struct {
	Outcome PermissionOutcome `json:"outcome"`
}

// PermissionOutcome is either a selection or a cancellation.
type PermissionOutcome struct {
	Outcome  string `json:"outcome"`
	OptionID string `json:"optionId,omitempty"`
}

// The outcome tags.
const (
	OutcomeSelected  = "selected"
	OutcomeCancelled = "cancelled"
)