Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .github/instructions/general-instructions.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ applyTo: 'types/**/*.ts'
- Generator note: variant interface names must differ from the union wrapper names emitted by the per-language generators (e.g. Kotlin emits `value class FooStateStarting(val value: FooStartingState)`), so name variants `Foo*State` rather than `FooStatus*`.
- After making your changes, check to make sure the documentation in `docs` is up to date. For significant new flows or features, consider adding new documentation for it. Note that Mermaid diagrams are allowed.
- Whenever you change or add an action, you must review the reducers in `types/reducers.ts` to see if that needs to be propagated into the state. If it does, add the appropriate logic and unit tests for it.
- Actions that mutate a keyed collection in state (an array whose entries are identified by a stable key such as `id`, `clientId`, `resource`, or a URI — e.g. `chats`, `customizations`, `files`, `annotations`, `activeClients`) MUST follow the established add/remove/update convention rather than inventing a new shape:
- **Upsert** (`Foo*Set`): the action carries the **full entry object**. The reducer finds the entry by key, **appends** it when absent and **replaces** it in place when present (never duplicating a key). Always name a generic create-or-replace action `Set` — not `Added`, `Changed`, or `Updated` — so the upsert convention is recognisable at a glance.
- **Remove** (`Foo*Removed`): the action carries **only the key** (e.g. `{ clientId }`, `{ fileId }`), never the whole object. The reducer is a **no-op returning the original `state`** when no entry matches.
- **Partial update** (`Foo*Updated`): the action carries the **key plus the optional fields that changed**; the reducer merges them onto the existing entry and is a **no-op returning `state`** when no entry matches. Ignore the key inside any `changes` payload so it can't be reassigned.
- Prefer a key-only **remove** action over an upsert that accepts a nullable/sentinel "unset" value (e.g. do not model removal as `Changed` with `entry: null`).
- Reducer mechanics are uniform: `const idx = list.findIndex(x => x.<key> === action.<key>)`, branch on `idx < 0`, copy immutably (`list.slice()` / `[...list]`), then write or `splice`, and return `{ ...state, <collection>: next }`. Every branch (insert, replace, remove, no-op) needs a fixture in `types/test-cases/reducers/` to keep `types/reducers.ts` at 100% branch coverage.
- Never update the protocol version unless you were instructed to do so.

## Finalizing changes
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,24 @@ changes accumulate. Track in-flight protocol changes via PRs touching
- `SessionSummary._meta` optional provider metadata field for lightweight
session-list presentation hints.
- `JsonPrimitive` type alias (`string | number | boolean | null`) in `types/common/state.ts`.
- `session/activeClientRemoved` action to release a single active client from a
session by `clientId`.

### Changed

- `ConfigPropertySchema.enum` now accepts `JsonPrimitive[]` instead of `string[]`, allowing numeric, boolean, and null enum values.
- `ModelSelection.config` values are now `JsonPrimitive` (`string | number | boolean | null`) instead of `string`, allowing numeric, boolean, and null configuration values.
- `SessionState.activeClients` (a required array, keyed by `clientId`) replaces
the single optional `SessionState.activeClient`. A session may now have
multiple concurrent active clients.
- `session/activeClientChanged` is renamed to `session/activeClientSet` with
upsert-by-`clientId` semantics. It no longer accepts `null` to unset the
active client — dispatch `session/activeClientRemoved` instead.

### Removed

- `session/activeClientToolsChanged`. An active client now updates its published
tools by re-dispatching `session/activeClientSet` with its full, updated entry.

## [0.5.0] — Unreleased

Expand Down
15 changes: 15 additions & 0 deletions clients/go/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,29 @@ tag whose matching `## [X.Y.Z]` heading is missing from this file.
optional fields for communicating model token limits.
- `SessionSummary.Meta` (wire `_meta`) optional provider metadata field for
lightweight session-list presentation hints.
- `SessionActiveClientRemovedAction` (wire `session/activeClientRemoved`) to
release a single active client by `ClientId`.

### Changed

- `SessionState.ActiveClients` (`[]SessionActiveClient`, required) replaces the
single pointer `SessionState.ActiveClient`; `ApplyActionToSession` upserts and
removes entries keyed by `ClientId`.
- `SessionActiveClientChangedAction` is renamed to `SessionActiveClientSetAction`
(wire `session/activeClientSet`) with upsert-by-`ClientId` semantics; it no
longer unsets the active client (dispatch `session/activeClientRemoved`
instead).
- `ConfigPropertySchema.Enum` field is now `[]json.RawMessage` instead of `[]string`,
allowing numeric, boolean, and null enum values.
- `ModelSelection.Config` values are now `json.RawMessage` instead of `string`,
allowing numeric, boolean, and null configuration values.

### Removed

- `SessionActiveClientToolsChangedAction`. An active client now updates its
published tools by re-dispatching `SessionActiveClientSetAction` with its
full, updated entry.

## [0.4.0] — 2026-06-19

Implements AHP 0.4.0.
Expand Down
22 changes: 15 additions & 7 deletions clients/go/ahp/reducers.go
Original file line number Diff line number Diff line change
Expand Up @@ -726,15 +726,23 @@ func ApplyActionToSession(state *ahptypes.SessionState, action ahptypes.StateAct
case *ahptypes.SessionServerToolsChangedAction:
state.ServerTools = append([]ahptypes.ToolDefinition(nil), a.Tools...)
return ReduceOutcomeApplied
case *ahptypes.SessionActiveClientChangedAction:
state.ActiveClient = a.ActiveClient
return ReduceOutcomeApplied
case *ahptypes.SessionActiveClientToolsChangedAction:
if state.ActiveClient == nil {
return ReduceOutcomeNoOp
case *ahptypes.SessionActiveClientSetAction:
for i := range state.ActiveClients {
if state.ActiveClients[i].ClientId == a.ActiveClient.ClientId {
state.ActiveClients[i] = a.ActiveClient
return ReduceOutcomeApplied
}
}
state.ActiveClient.Tools = append([]ahptypes.ToolDefinition(nil), a.Tools...)
state.ActiveClients = append(state.ActiveClients, a.ActiveClient)
return ReduceOutcomeApplied
case *ahptypes.SessionActiveClientRemovedAction:
for i := range state.ActiveClients {
if state.ActiveClients[i].ClientId == a.ClientId {
state.ActiveClients = append(state.ActiveClients[:i], state.ActiveClients[i+1:]...)
return ReduceOutcomeApplied
}
}
return ReduceOutcomeNoOp
case *ahptypes.SessionCustomizationsChangedAction:
state.Customizations = append([]ahptypes.Customization(nil), a.Customizations...)
return ReduceOutcomeApplied
Expand Down
77 changes: 47 additions & 30 deletions clients/go/ahptypes/actions.generated.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ const (
ActionTypeSessionModelChanged ActionType = "session/modelChanged"
ActionTypeSessionAgentChanged ActionType = "session/agentChanged"
ActionTypeSessionServerToolsChanged ActionType = "session/serverToolsChanged"
ActionTypeSessionActiveClientChanged ActionType = "session/activeClientChanged"
ActionTypeSessionActiveClientToolsChanged ActionType = "session/activeClientToolsChanged"
ActionTypeSessionActiveClientSet ActionType = "session/activeClientSet"
ActionTypeSessionActiveClientRemoved ActionType = "session/activeClientRemoved"
ActionTypeChatPendingMessageSet ActionType = "chat/pendingMessageSet"
ActionTypeChatPendingMessageRemoved ActionType = "chat/pendingMessageRemoved"
ActionTypeChatQueuedMessagesReordered ActionType = "chat/queuedMessagesReordered"
Expand Down Expand Up @@ -372,9 +372,10 @@ type ChatToolCallConfirmedAction struct {
// Tool execution finished. Transitions to `completed` or `pending-result-confirmation`
// if `requiresResultConfirmation` is `true`.
//
// For client-provided tools (where `toolClientId` is set on the tool call state),
// the owning client dispatches this action with the execution result. The server
// SHOULD reject this action if the dispatching client does not match `toolClientId`.
// For client-provided tools (whose tool call state carries a client
// `ToolCallContributor` with a `clientId`), the owning client dispatches this
// action with the execution result. The server SHOULD reject this action if the
// dispatching client does not match the contributor's `clientId`.
//
// Servers waiting on a client tool call MAY time out after a reasonable duration
// if the implementing client disconnects or becomes unresponsive, and dispatch
Expand Down Expand Up @@ -424,10 +425,11 @@ type ChatToolCallResultConfirmedAction struct {
// use this to display live feedback (e.g. a terminal reference) before the
// tool completes.
//
// For client-provided tools (where `toolClientId` is set on the tool call state),
// the owning client dispatches this action to stream intermediate content while
// executing. The server SHOULD reject this action if the dispatching client does
// not match `toolClientId`.
// For client-provided tools (whose tool call state carries a client
// `ToolCallContributor` with a `clientId`), the owning client dispatches this
// action to stream intermediate content while executing. The server SHOULD
// reject this action if the dispatching client does not match the contributor's
// `clientId`.
type ChatToolCallContentChangedAction struct {
// Turn identifier
TurnId string `json:"turnId"`
Expand Down Expand Up @@ -714,27 +716,42 @@ type SessionServerToolsChangedAction struct {
Tools []ToolDefinition `json:"tools"`
}

// The active client for this session has changed.
// An active client for this session was added or updated.
//
// A client dispatches this action with its own `SessionActiveClient` to claim
// the active role, or with `null` to release it. The server SHOULD reject if
// another client is already active. The server SHOULD automatically dispatch
// this action with `activeClient: null` when the active client disconnects.
type SessionActiveClientChangedAction struct {
// Upsert semantics keyed by {@link SessionActiveClient.clientId | `clientId`}:
// a client dispatches this action with its own `SessionActiveClient` to join
// the session's active clients or refresh its entry, replacing any existing
// entry that has the same `clientId`. Multiple clients may be active at once.
// This is also how a client updates its published tools or customizations —
// re-dispatch with the full, updated entry. Use
// {@link SessionActiveClientRemovedAction | `session/activeClientRemoved`} to
// leave. The server SHOULD automatically dispatch that removal when an active
// client disconnects.
type SessionActiveClientSetAction struct {
Type ActionType `json:"type"`
// The new active client, or `null` to unset
ActiveClient *SessionActiveClient `json:"activeClient,omitempty"`
// The active client to add or update, matched by `clientId`.
ActiveClient SessionActiveClient `json:"activeClient"`
}

// The active client's tool list has changed.
// An active client was removed from this session.
//
// Full-replacement semantics: the `tools` array replaces the active client's
// previous tools entirely. The server SHOULD reject if the dispatching client
// is not the current active client.
type SessionActiveClientToolsChangedAction struct {
// Removes the entry for the client identified by `clientId` from
// {@link SessionState.activeClients}; a no-op when no entry matches.
//
// The host SHOULD dispatch this automatically when a client stops participating
// in the session — for example when it unsubscribes from the session channel,
// when it disconnects and does not reconnect within a host-defined grace
// period, or when a `reconnect` command's `subscriptions` omit a session the
// client was still active in. When removing a client, the host SHOULD also
// cancel that client's in-flight tool calls — those whose tool call state
// carries a client `ToolCallContributor` with the matching `clientId` — by
// dispatching `chat/toolCallComplete` with `result.success = false`. (There is
// no per-tool-call server cancel; a failed completion is the cancellation
// mechanism, and the call ends in `completed` status with a failed result.)
type SessionActiveClientRemovedAction struct {
Type ActionType `json:"type"`
// Updated client tools list (full replacement)
Tools []ToolDefinition `json:"tools"`
// The `clientId` of the active client to remove.
ClientId string `json:"clientId"`
}

// The session's customizations have changed.
Expand Down Expand Up @@ -1222,8 +1239,8 @@ func (*SessionIsArchivedChangedAction) isStateAction() {}
func (*SessionActivityChangedAction) isStateAction() {}
func (*SessionChangesetsChangedAction) isStateAction() {}
func (*SessionServerToolsChangedAction) isStateAction() {}
func (*SessionActiveClientChangedAction) isStateAction() {}
func (*SessionActiveClientToolsChangedAction) isStateAction() {}
func (*SessionActiveClientSetAction) isStateAction() {}
func (*SessionActiveClientRemovedAction) isStateAction() {}
func (*SessionCustomizationsChangedAction) isStateAction() {}
func (*SessionCustomizationToggledAction) isStateAction() {}
func (*SessionCustomizationUpdatedAction) isStateAction() {}
Expand Down Expand Up @@ -1505,14 +1522,14 @@ func (u *StateAction) UnmarshalJSON(data []byte) error {
return err
}
u.Value = &value
case "session/activeClientChanged":
var value SessionActiveClientChangedAction
case "session/activeClientSet":
var value SessionActiveClientSetAction
if err := json.Unmarshal(data, &value); err != nil {
return err
}
u.Value = &value
case "session/activeClientToolsChanged":
var value SessionActiveClientToolsChangedAction
case "session/activeClientRemoved":
var value SessionActiveClientRemovedAction
if err := json.Unmarshal(data, &value); err != nil {
return err
}
Expand Down
6 changes: 3 additions & 3 deletions clients/go/ahptypes/commands.generated.go
Original file line number Diff line number Diff line change
Expand Up @@ -245,10 +245,10 @@ type CreateSessionParams struct {
// Agent-specific configuration values collected via `resolveSessionConfig`.
// Keys and values correspond to the schema returned by the server.
Config map[string]json.RawMessage `json:"config,omitempty"`
// Eagerly claim the active client role for the new session.
// Eagerly claim an active client role for the new session.
//
// When provided, the server initializes the session with this client as the
// active client, equivalent to dispatching a `session/activeClientChanged`
// When provided, the server initializes the session with this client as an
// active client, equivalent to dispatching a `session/activeClientSet`
// action immediately after creation. The `clientId` MUST match the
// `clientId` the creating client supplied in `initialize`.
ActiveClient *SessionActiveClient `json:"activeClient,omitempty"`
Expand Down
21 changes: 15 additions & 6 deletions clients/go/ahptypes/state.generated.go
Original file line number Diff line number Diff line change
Expand Up @@ -642,8 +642,16 @@ type SessionState struct {
CreationError *ErrorInfo `json:"creationError,omitempty"`
// Tools provided by the server (agent host) for this session
ServerTools []ToolDefinition `json:"serverTools,omitempty"`
// The client currently providing tools and interactive capabilities to this session
ActiveClient *SessionActiveClient `json:"activeClient,omitempty"`
// The clients currently providing tools and interactive capabilities to this
// session. If multiple tools or customizations are provided by the same
// active client, an agent host MAY deduplicate them when exposed to a model,
// with a preference given to the client that started the turn.
//
// Membership is host-managed: clients add (or refresh) themselves with
// `session/activeClientSet`, and the host removes them with
// `session/activeClientRemoved` when they unsubscribe, disconnect without
// reconnecting in time, or reconnect without resubscribing to the session.
ActiveClients []SessionActiveClient `json:"activeClients"`
// Catalog of chats in this session.
Chats []ChatSummary `json:"chats"`
// The chat that receives input when the user addresses the session without
Expand All @@ -667,7 +675,7 @@ type SessionState struct {
// also appear as children of a container.
//
// Client-published plugins arrive via
// {@link SessionActiveClient.customizations | `activeClient.customizations`}
// {@link SessionActiveClient.customizations | `activeClients[].customizations`}
// and the host propagates them into this list (typically with the
// container's `clientId` set and `children` populated). Clients
// publish in container shape only; bare MCP servers at the top level
Expand All @@ -687,10 +695,11 @@ type SessionState struct {
Meta map[string]json.RawMessage `json:"_meta,omitempty"`
}

// The client currently providing tools and interactive capabilities to a session.
// A client currently providing tools and interactive capabilities to a session.
//
// Only one client may be active per session at a time. The server SHOULD
// automatically unset the active client if that client disconnects.
// A session MAY have several active clients at once; entries in
// {@link SessionState.activeClients} are keyed by `clientId`. The server SHOULD
// automatically remove an active client when that client disconnects.
type SessionActiveClient struct {
// Client identifier (matches `clientId` from `initialize`)
ClientId string `json:"clientId"`
Expand Down
16 changes: 16 additions & 0 deletions clients/kotlin/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,30 @@ versions (`*-SNAPSHOT`) are explicitly rejected by the publish pipeline; bump
optional fields for communicating model token limits.
- `SessionSummary.meta` (`_meta` on the wire) optional provider metadata field
for lightweight session-list presentation hints.
- `SessionActiveClientRemovedAction` (`StateActionSessionActiveClientRemoved`,
wire `session/activeClientRemoved`) to release a single active client by
`clientId`.

### Changed

- `SessionState.activeClients` (`List<SessionActiveClient>`, required) replaces
the single nullable `SessionState.activeClient`; `sessionReducer` upserts and
removes entries keyed by `clientId`.
- `StateActionSessionActiveClientChanged` is renamed to
`StateActionSessionActiveClientSet` (wire `session/activeClientSet`) with
upsert-by-`clientId` semantics; it no longer unsets the active client
(dispatch `session/activeClientRemoved` instead).
- `ConfigPropertySchema.enum` field is now `List<JsonElement>?` instead of
`List<String>?`, allowing numeric, boolean, and null enum values.
- `ModelSelection.config` values are now `JsonElement` instead of `String`,
allowing numeric, boolean, and null configuration values.

### Removed

- `SessionActiveClientToolsChangedAction`. An active client now updates its
published tools by re-dispatching `StateActionSessionActiveClientSet` with its
full, updated entry.

## [0.4.0] — 2026-06-19

Implements AHP 0.4.0.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -530,11 +530,27 @@ public fun sessionReducer(state: SessionState, action: StateAction): SessionStat

is StateActionSessionServerToolsChanged -> state.copy(serverTools = action.value.tools)

is StateActionSessionActiveClientChanged -> state.copy(activeClient = action.value.activeClient)
is StateActionSessionActiveClientSet -> {
val client = action.value.activeClient
val idx = state.activeClients.indexOfFirst { it.clientId == client.clientId }
if (idx < 0) {
state.copy(activeClients = state.activeClients + client)
} else {
val updated = state.activeClients.toMutableList()
updated[idx] = client
state.copy(activeClients = updated)
}
}

is StateActionSessionActiveClientToolsChanged -> {
val client = state.activeClient
if (client == null) state else state.copy(activeClient = client.copy(tools = action.value.tools))
is StateActionSessionActiveClientRemoved -> {
val idx = state.activeClients.indexOfFirst { it.clientId == action.value.clientId }
if (idx < 0) {
state
} else {
val updated = state.activeClients.toMutableList()
updated.removeAt(idx)
state.copy(activeClients = updated)
}
}

is StateActionSessionCustomizationsChanged -> state.copy(customizations = action.value.customizations)
Expand Down
Loading
Loading