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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,23 @@ changes accumulate. Track in-flight protocol changes via PRs touching

Spec version: `0.5.2`

### Added

- Canvas channel — an opt-in surface for the agent to open rich, interactive UI
surfaces (document/spreadsheet editors, diff views, live previews) alongside a
session, following the "one channel per resource" model of terminals and
changesets. Adds the `ClientCapabilities.canvas` capability; the
`SessionState.canvases` registry and `SessionState.openCanvases` catalogue
(`session/canvasesChanged` / `session/openCanvasesChanged`) plus
`SessionActiveClient.canvasProviders` and the `SessionCanvasDeclaration` /
`ClientCanvasDeclaration` / `OpenCanvasRef` / `CanvasProviderSource` discovery
types; the per-instance `ahp-canvas:/<id>` channel and its `CanvasState`; the
`canvas/updated`, `canvas/closeRequested`, and `canvas/message` actions; the
`canvasOpen` / `canvasInvokeAction` / `canvasClose` provider request family and
the client → server `canvasReadResource` content-fetch request; and the
`CanvasProviderError` (`-32012`) error. See
[`docs/specification/canvas-channel.md`](docs/specification/canvas-channel.md).

## [0.5.1] — 2026-07-02

Spec version: `0.5.1`
Expand Down
15 changes: 15 additions & 0 deletions clients/go/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,21 @@ tag whose matching `## [X.Y.Z]` heading is missing from this file.

## [Unreleased]

### Added

- Canvas channel support: the per-instance `CanvasState` plus the
`CanvasUpdatedAction` (wire `canvas/updated`), `CanvasCloseRequestedAction`
(`canvas/closeRequested`), and `CanvasMessageAction` (`canvas/message`)
actions, the `SessionCanvasesChangedAction` (`session/canvasesChanged`) and
`SessionOpenCanvasesChangedAction` (`session/openCanvasesChanged`) session
actions, and the canvas discovery types (`SessionCanvasDeclaration`,
`ClientCanvasDeclaration`, `OpenCanvasRef`, `CanvasProviderSource`) on
`SessionState.Canvases` / `SessionState.OpenCanvases`. Adds the
`ClientCapabilities.Canvas` capability, the `canvasOpen` / `canvasInvokeAction`
/ `canvasClose` / `canvasReadResource` methods, and the `CanvasProviderError`
error. The session reducer replaces the canvas registry/catalogue and
`ApplyActionToCanvas` sparse-merges `canvas/updated`.

## [0.5.1] — 2026-07-02

Implements AHP 0.5.1.
Expand Down
38 changes: 38 additions & 0 deletions clients/go/ahp/reducers.go
Original file line number Diff line number Diff line change
Expand Up @@ -752,6 +752,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 @@ -1491,3 +1497,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 @@ -95,6 +95,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 @@ -744,6 +749,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 @@ -1249,6 +1277,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 @@ -1300,6 +1383,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 @@ -1336,6 +1421,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 @@ -1591,6 +1679,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 @@ -1807,6 +1907,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
Loading
Loading