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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions clients/go/ahp/reducers.go
Original file line number Diff line number Diff line change
Expand Up @@ -808,6 +808,12 @@ func ApplyActionToSession(state *ahptypes.SessionState, action ahptypes.StateAct
case *ahptypes.SessionServerToolsChangedAction:
state.ServerTools = append([]ahptypes.ToolDefinition(nil), a.Tools...)
return ReduceOutcomeApplied
case *ahptypes.SessionCanvasesChangedAction:
state.Canvases = append([]ahptypes.SessionCanvasDeclaration(nil), a.Canvases...)
return ReduceOutcomeApplied
case *ahptypes.SessionOpenCanvasesChangedAction:
state.OpenCanvases = append([]ahptypes.OpenCanvasRef(nil), a.OpenCanvases...)
return ReduceOutcomeApplied
case *ahptypes.SessionActiveClientSetAction:
for i := range state.ActiveClients {
if state.ActiveClients[i].ClientId == a.ActiveClient.ClientId {
Expand Down Expand Up @@ -1577,3 +1583,35 @@ func ApplyActionToResourceWatch(state *ahptypes.ResourceWatchState, action ahpty
}
return ReduceOutcomeOutOfScope
}

// ApplyActionToCanvas applies action to the [ahptypes.CanvasState] in
// place. `canvas/updated` is a sparse merge — a presented field
// (title, status, url, availability) overwrites the corresponding
// state field and an absent field preserves the current value.
// `canvas/closeRequested` and `canvas/message` are side-effect-only
// client→host signals the host acts on out of band, so they leave the
// state unchanged. Returns [ReduceOutcomeOutOfScope] for actions that
// target a different state tree.
func ApplyActionToCanvas(state *ahptypes.CanvasState, action ahptypes.StateAction) ReduceOutcome {
switch a := action.Value.(type) {
case *ahptypes.CanvasUpdatedAction:
if a.Title != nil {
state.Title = a.Title
}
if a.Status != nil {
state.Status = a.Status
}
if a.Url != nil {
state.Url = a.Url
}
if a.Availability != nil {
state.Availability = *a.Availability
}
return ReduceOutcomeApplied
case *ahptypes.CanvasCloseRequestedAction:
return ReduceOutcomeNoOp
case *ahptypes.CanvasMessageAction:
return ReduceOutcomeNoOp
}
return ReduceOutcomeOutOfScope
}
2 changes: 2 additions & 0 deletions clients/go/ahp/reducers_fixture_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,8 @@ func TestFixtureDrivenReducerParity(t *testing.T) {
runFixture[ahptypes.AnnotationsState](tt, fixture.Initial, fixture.Expected, actions, ApplyActionToAnnotations)
case "resourceWatch":
runFixture[ahptypes.ResourceWatchState](tt, fixture.Initial, fixture.Expected, actions, ApplyActionToResourceWatch)
case "canvas":
runFixture[ahptypes.CanvasState](tt, fixture.Initial, fixture.Expected, actions, ApplyActionToCanvas)
default:
tt.Fatalf("unknown reducer kind %q", fixture.Reducer)
}
Expand Down
118 changes: 118 additions & 0 deletions clients/go/ahptypes/actions.generated.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,11 @@ const (
ActionTypeTerminalCommandExecuted ActionType = "terminal/commandExecuted"
ActionTypeTerminalCommandFinished ActionType = "terminal/commandFinished"
ActionTypeResourceWatchChanged ActionType = "resourceWatch/changed"
ActionTypeSessionCanvasesChanged ActionType = "session/canvasesChanged"
ActionTypeSessionOpenCanvasesChanged ActionType = "session/openCanvasesChanged"
ActionTypeCanvasUpdated ActionType = "canvas/updated"
ActionTypeCanvasCloseRequested ActionType = "canvas/closeRequested"
ActionTypeCanvasMessage ActionType = "canvas/message"
)

// ─── Action Envelope ─────────────────────────────────────────────────
Expand Down Expand Up @@ -747,6 +752,29 @@ type SessionServerToolsChangedAction struct {
Tools []ToolDefinition `json:"tools"`
}

// The aggregated canvas registry for this session changed.
//
// Full-replacement semantics: the `canvases` array replaces
// {@link SessionState.canvases} entirely, mirroring
// `session/serverToolsChanged`. The host republishes the union of every
// connected provider (server-side and client-declared) whenever it changes.
type SessionCanvasesChangedAction struct {
Type ActionType `json:"type"`
// Updated canvas registry (full replacement).
Canvases []SessionCanvasDeclaration `json:"canvases"`
}

// The catalogue of open canvas instances for this session changed.
//
// Full-replacement semantics: the `openCanvases` array replaces
// {@link SessionState.openCanvases} entirely. The host republishes the
// catalogue as instances open and close.
type SessionOpenCanvasesChangedAction struct {
Type ActionType `json:"type"`
// Updated open-instance catalogue (full replacement).
OpenCanvases []OpenCanvasRef `json:"openCanvases"`
}

// An active client for this session was added or updated.
//
// Upsert semantics keyed by {@link SessionActiveClient.clientId | `clientId`}:
Expand Down Expand Up @@ -1314,6 +1342,61 @@ type ResourceWatchChangedAction struct {
Changes json.RawMessage `json:"changes"`
}

// The canvas instance's presentation state changed.
//
// Emitted by the server for a server-side provider, or dispatched by the client
// that provides the instance to push its own presentation changes — mirroring
// how `terminal/titleChanged` lets a client-owned terminal rename itself. This
// is the only path a client-side provider has to update the structured
// {@link CanvasState} (and the {@link OpenCanvasRef} fields mirrored from it)
// that other subscribers render. The host stays the authoritative reducer: it
// SHOULD reject an update from a client that is not the instance's resolved
// provider, then applies the merge and re-broadcasts to subscribers.
//
// Sparse-merge semantics: each present field overwrites the corresponding
// {@link CanvasState} field, and an absent field preserves the current value.
// There is no clear-to-absent via this action — that three-state distinction
// cannot survive JSON transport uniformly across languages, so a provider that
// needs to reset a field re-publishes it, and a full reset arrives as a fresh
// {@link CanvasState} snapshot on (re)subscribe.
type CanvasUpdatedAction struct {
Type ActionType `json:"type"`
// New title. Absent preserves the current title.
Title *string `json:"title,omitempty"`
// New provider-defined status. Absent preserves the current status.
Status *string `json:"status,omitempty"`
// New content address. Absent preserves the current url.
Url *string `json:"url,omitempty"`
// New availability. Absent preserves the current availability.
Availability *CanvasAvailability `json:"availability,omitempty"`
}

// The user asked to close this canvas (e.g. hit the ✕ on the surface).
//
// A pure client→host signal with a no-op reducer, mirroring how
// `terminal/input` is a side-effect-only client action. The host runs the
// close flow in response — resolving `canvasClose` against the provider and
// dropping the instance from {@link SessionState.openCanvases} — rather than
// the reducer mutating channel state.
type CanvasCloseRequestedAction struct {
Type ActionType `json:"type"`
}

// An opaque message relayed between the rendered canvas View and the
// instance's provider — the relay-carried analogue of a `postMessage` bridge.
//
// Bidirectional: a client dispatches it to carry a View→provider message, and
// the host emits it to carry a provider→View message (routed to the provider
// resolved for the instance, or handled host-internally for a server-side
// provider). Like `terminal/input` and {@link CanvasCloseRequestedAction} it
// is a pure signal with a no-op reducer, so it never bloats channel state. See
// {@link /specification/canvas-channel | Canvas Channel}.
type CanvasMessageAction struct {
Type ActionType `json:"type"`
// Opaque, provider-defined message payload.
Payload json.RawMessage `json:"payload"`
}

// ─── StateAction Union ───────────────────────────────────────────────

// StateAction is the discriminated union of every state action.
Expand Down Expand Up @@ -1365,6 +1448,8 @@ func (*SessionIsArchivedChangedAction) isStateAction() {}
func (*SessionActivityChangedAction) isStateAction() {}
func (*SessionChangesetsChangedAction) isStateAction() {}
func (*SessionServerToolsChangedAction) isStateAction() {}
func (*SessionCanvasesChangedAction) isStateAction() {}
func (*SessionOpenCanvasesChangedAction) isStateAction() {}
func (*SessionActiveClientSetAction) isStateAction() {}
func (*SessionActiveClientRemovedAction) isStateAction() {}
func (*SessionInputNeededSetAction) isStateAction() {}
Expand Down Expand Up @@ -1404,6 +1489,9 @@ func (*TerminalCommandDetectionAvailableAction) isStateAction() {}
func (*TerminalCommandExecutedAction) isStateAction() {}
func (*TerminalCommandFinishedAction) isStateAction() {}
func (*ResourceWatchChangedAction) isStateAction() {}
func (*CanvasUpdatedAction) isStateAction() {}
func (*CanvasCloseRequestedAction) isStateAction() {}
func (*CanvasMessageAction) isStateAction() {}

// StateActionUnknown carries an unrecognized StateAction variant — typically a discriminator value introduced by a newer protocol version. The original JSON object is preserved verbatim so that re-encoding round-trips faithfully.
type StateActionUnknown struct {
Expand Down Expand Up @@ -1659,6 +1747,18 @@ func (u *StateAction) UnmarshalJSON(data []byte) error {
return err
}
u.Value = &value
case "session/canvasesChanged":
var value SessionCanvasesChangedAction
if err := json.Unmarshal(data, &value); err != nil {
return err
}
u.Value = &value
case "session/openCanvasesChanged":
var value SessionOpenCanvasesChangedAction
if err := json.Unmarshal(data, &value); err != nil {
return err
}
u.Value = &value
case "session/activeClientSet":
var value SessionActiveClientSetAction
if err := json.Unmarshal(data, &value); err != nil {
Expand Down Expand Up @@ -1893,6 +1993,24 @@ func (u *StateAction) UnmarshalJSON(data []byte) error {
return err
}
u.Value = &value
case "canvas/updated":
var value CanvasUpdatedAction
if err := json.Unmarshal(data, &value); err != nil {
return err
}
u.Value = &value
case "canvas/closeRequested":
var value CanvasCloseRequestedAction
if err := json.Unmarshal(data, &value); err != nil {
return err
}
u.Value = &value
case "canvas/message":
var value CanvasMessageAction
if err := json.Unmarshal(data, &value); err != nil {
return err
}
u.Value = &value
default:
raw := make(json.RawMessage, len(data))
copy(raw, data)
Expand Down
130 changes: 130 additions & 0 deletions clients/go/ahptypes/commands.generated.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,16 @@ type ClientCapabilities struct {
// capability is declared. Clients that omit it MUST treat
// App-bearing tool calls as ordinary MCP tool calls.
McpApps map[string]json.RawMessage `json:"mcpApps,omitempty"`
// Client can render canvases and host client-declared canvas providers — it
// can render an opaque canvas URL in an isolated surface, and it can answer
// `canvasOpen` / `canvasInvokeAction` / `canvasClose` requests for canvases
// it declares via {@link SessionActiveClient.canvasProviders}.
//
// Hosts SHOULD only populate {@link SessionState.canvases} /
// {@link SessionState.openCanvases} and only route canvas requests to a
// client that declared this capability. Clients that omit it see no canvas
// surface. See {@link /specification/canvas-channel | Canvas Channel}.
Canvas map[string]json.RawMessage `json:"canvas,omitempty"`
}

// Identifies a protocol implementation — the software (and build) on one end
Expand Down Expand Up @@ -1011,6 +1021,126 @@ type ChangesetOperationFollowUp struct {
External *bool `json:"external,omitempty"`
}

// Opens a canvas instance against its provider.
//
// Sent by the host to the client that declared the target canvas via
// {@link SessionActiveClient.canvasProviders} (a client that also declared
// {@link ClientCapabilities.canvas}). For a server-side provider the host
// resolves the open host-internally and emits no request. The provider returns
// the initial render target and presentation fields, which the host folds into
// the new instance's {@link CanvasState}.
//
// Mirrors the `resource*` precedent: registered in `ServerCommandMap` and
// mirrored in `CommandMap` for symmetry. A client normally never initiates it —
// the host is not a canvas provider — and a receiver SHOULD reject a request
// whose target is not one of its declared providers.
type CanvasOpenParams struct {
// Channel URI this command targets.
Channel URI `json:"channel"`
// Provider-local canvas id to open.
CanvasId string `json:"canvasId"`
// Owning provider id.
ExtensionId string `json:"extensionId"`
// Caller-minted handle for the new instance.
InstanceId string `json:"instanceId"`
// Open input, validated by the provider against its declared schema.
Input map[string]json.RawMessage `json:"input,omitempty"`
}

// Result of the `canvasOpen` command.
type CanvasOpenResult struct {
// Initial content address for the instance (see {@link CanvasState.url}).
Url *string `json:"url,omitempty"`
// Initial title.
Title *string `json:"title,omitempty"`
// Initial provider-defined status.
Status *string `json:"status,omitempty"`
}

// Invokes one of a canvas's declared actions against its provider.
//
// Sent by the host to the providing client (or resolved host-internally for a
// server-side provider) when the agent invokes a
// {@link SessionCanvasAction | declared action} on an open instance. The
// provider returns an opaque, provider-defined value. Registered symmetrically
// with the rest of the provider family (see {@link CanvasOpenParams}).
type CanvasInvokeActionParams struct {
// Channel URI this command targets.
Channel URI `json:"channel"`
// Instance handle the action targets.
InstanceId string `json:"instanceId"`
// Provider-local canvas id of the instance.
CanvasId string `json:"canvasId"`
// Owning provider id.
ExtensionId string `json:"extensionId"`
// Declared action name to invoke.
ActionName string `json:"actionName"`
// Action input, validated by the provider against its declared schema.
Input map[string]json.RawMessage `json:"input,omitempty"`
}

// Result of the `canvasInvokeAction` command.
type CanvasInvokeActionResult struct {
// Opaque, provider-defined return value.
Value *json.RawMessage `json:"value,omitempty"`
}

// Closes a canvas instance against its provider.
//
// Sent by the host to the providing client (or resolved host-internally for a
// server-side provider) as part of the close flow — typically after a client
// dispatches `canvas/closeRequested`. The host then drops the instance from
// {@link SessionState.openCanvases}. Registered symmetrically with the rest of
// the provider family (see {@link CanvasOpenParams}).
type CanvasCloseParams struct {
// Channel URI this command targets.
Channel URI `json:"channel"`
// Instance handle to close.
InstanceId string `json:"instanceId"`
// Provider-local canvas id of the instance.
CanvasId string `json:"canvasId"`
// Owning provider id.
ExtensionId string `json:"extensionId"`
}

// Reads channel-served canvas content by `ahp-canvas-content:` URI.
//
// A client → host request, modeled on MCP's `resources/read`. When a canvas's
// {@link CanvasState.url} (or a sub-resource the rendered document references)
// is an `ahp-canvas-content:/<instanceId>/<path>` address, the renderer cannot
// dial the host directly — for example in a relayed deployment behind a broker
// — so it resolves the bytes over the instance's existing `ahp-canvas:/<id>`
// channel with this request instead of loading a network URL. The `<instanceId>`
// segment of the URI identifies which canvas channel to read from. See
// {@link /specification/canvas-channel | Canvas Channel}.
type CanvasReadResourceParams struct {
// Channel URI this command targets.
Channel URI `json:"channel"`
// An `ahp-canvas-content:/<instanceId>/<path>` content URI to read.
Uri string `json:"uri"`
}

// Result of the `canvasReadResource` command.
type CanvasReadResourceResult struct {
// The resolved content parts, wrapped for forward compatibility.
Contents []CanvasResourceContent `json:"contents"`
}

// One resolved piece of channel-served canvas content.
//
// Carries exactly one of {@link text} (text payloads) or {@link blob}
// (base64-encoded binary payloads).
type CanvasResourceContent struct {
// The content URI this part resolves.
Uri string `json:"uri"`
// MIME type of the content, when known.
MimeType *string `json:"mimeType,omitempty"`
// UTF-8 text content, for text payloads.
Text *string `json:"text,omitempty"`
// Base64-encoded content, for binary payloads.
Blob *string `json:"blob,omitempty"`
}

// ─── ReconnectResult Union ────────────────────────────────────────────

// ReconnectResult is the result of the `reconnect` command.
Expand Down
10 changes: 10 additions & 0 deletions clients/go/ahptypes/errors.generated.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ const (
ErrorCodeNotFound int32 = -32008
ErrorCodePermissionDenied int32 = -32009
ErrorCodeAlreadyExists int32 = -32010
ErrorCodeConflict int32 = -32011
ErrorCodeCanvasProviderError int32 = -32012
)

// AhpErrorCode is the type alias used by AHP application error codes.
Expand Down Expand Up @@ -63,3 +65,11 @@ type PermissionDeniedErrorData struct {
type UnsupportedProtocolVersionErrorData struct {
SupportedVersions []string `json:"supportedVersions"`
}

// CanvasProviderErrorData is the detail payload of a
// CanvasProviderError (-32012) error. The Code is a provider-defined
// string (opaque to AHP) identifying the failure.
type CanvasProviderErrorData struct {
Code string `json:"code"`
Message string `json:"message"`
}
Loading
Loading