diff --git a/.github/instructions/general-instructions.instructions.md b/.github/instructions/general-instructions.instructions.md index 35ba2c19..0fc200d7 100644 --- a/.github/instructions/general-instructions.instructions.md +++ b/.github/instructions/general-instructions.instructions.md @@ -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. === action.)`, branch on `idx < 0`, copy immutably (`list.slice()` / `[...list]`), then write or `splice`, and return `{ ...state, : 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8056ae5e..634e9cb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/clients/go/CHANGELOG.md b/clients/go/CHANGELOG.md index e7de2f5a..8c608bac 100644 --- a/clients/go/CHANGELOG.md +++ b/clients/go/CHANGELOG.md @@ -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. diff --git a/clients/go/ahp/reducers.go b/clients/go/ahp/reducers.go index c77c77dd..1348b7a3 100644 --- a/clients/go/ahp/reducers.go +++ b/clients/go/ahp/reducers.go @@ -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 diff --git a/clients/go/ahptypes/actions.generated.go b/clients/go/ahptypes/actions.generated.go index 7ae8b249..e98e7e90 100644 --- a/clients/go/ahptypes/actions.generated.go +++ b/clients/go/ahptypes/actions.generated.go @@ -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" @@ -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 @@ -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"` @@ -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. @@ -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() {} @@ -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 } diff --git a/clients/go/ahptypes/commands.generated.go b/clients/go/ahptypes/commands.generated.go index 17c8377a..259c307e 100644 --- a/clients/go/ahptypes/commands.generated.go +++ b/clients/go/ahptypes/commands.generated.go @@ -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"` diff --git a/clients/go/ahptypes/state.generated.go b/clients/go/ahptypes/state.generated.go index a4e4bb9e..8a5f885a 100644 --- a/clients/go/ahptypes/state.generated.go +++ b/clients/go/ahptypes/state.generated.go @@ -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 @@ -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 @@ -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"` diff --git a/clients/kotlin/CHANGELOG.md b/clients/kotlin/CHANGELOG.md index 52801d99..05a1a65f 100644 --- a/clients/kotlin/CHANGELOG.md +++ b/clients/kotlin/CHANGELOG.md @@ -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`, 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?` instead of `List?`, 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. diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt index 36f30413..7d78ad5d 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt @@ -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) diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt index 49444e1f..db42d1ac 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt @@ -80,10 +80,10 @@ enum class ActionType { SESSION_AGENT_CHANGED, @SerialName("session/serverToolsChanged") SESSION_SERVER_TOOLS_CHANGED, - @SerialName("session/activeClientChanged") - SESSION_ACTIVE_CLIENT_CHANGED, - @SerialName("session/activeClientToolsChanged") - SESSION_ACTIVE_CLIENT_TOOLS_CHANGED, + @SerialName("session/activeClientSet") + SESSION_ACTIVE_CLIENT_SET, + @SerialName("session/activeClientRemoved") + SESSION_ACTIVE_CLIENT_REMOVED, @SerialName("chat/pendingMessageSet") CHAT_PENDING_MESSAGE_SET, @SerialName("chat/pendingMessageRemoved") @@ -776,21 +776,21 @@ data class SessionServerToolsChangedAction( ) @Serializable -data class SessionActiveClientChangedAction( +data class SessionActiveClientSetAction( val type: ActionType, /** - * The new active client, or `null` to unset + * The active client to add or update, matched by `clientId`. */ - val activeClient: SessionActiveClient? = null + val activeClient: SessionActiveClient ) @Serializable -data class SessionActiveClientToolsChangedAction( +data class SessionActiveClientRemovedAction( val type: ActionType, /** - * Updated client tools list (full replacement) + * The `clientId` of the active client to remove. */ - val tools: List + val clientId: String ) @Serializable @@ -1365,8 +1365,8 @@ sealed interface StateAction @JvmInline value class StateActionSessionActivityChanged(val value: SessionActivityChangedAction) : StateAction @JvmInline value class StateActionSessionChangesetsChanged(val value: SessionChangesetsChangedAction) : StateAction @JvmInline value class StateActionSessionServerToolsChanged(val value: SessionServerToolsChangedAction) : StateAction -@JvmInline value class StateActionSessionActiveClientChanged(val value: SessionActiveClientChangedAction) : StateAction -@JvmInline value class StateActionSessionActiveClientToolsChanged(val value: SessionActiveClientToolsChangedAction) : StateAction +@JvmInline value class StateActionSessionActiveClientSet(val value: SessionActiveClientSetAction) : StateAction +@JvmInline value class StateActionSessionActiveClientRemoved(val value: SessionActiveClientRemovedAction) : StateAction @JvmInline value class StateActionChatPendingMessageSet(val value: ChatPendingMessageSetAction) : StateAction @JvmInline value class StateActionChatPendingMessageRemoved(val value: ChatPendingMessageRemovedAction) : StateAction @JvmInline value class StateActionChatQueuedMessagesReordered(val value: ChatQueuedMessagesReorderedAction) : StateAction @@ -1453,8 +1453,8 @@ internal object StateActionSerializer : KSerializer { "session/activityChanged" -> StateActionSessionActivityChanged(input.json.decodeFromJsonElement(SessionActivityChangedAction.serializer(), element)) "session/changesetsChanged" -> StateActionSessionChangesetsChanged(input.json.decodeFromJsonElement(SessionChangesetsChangedAction.serializer(), element)) "session/serverToolsChanged" -> StateActionSessionServerToolsChanged(input.json.decodeFromJsonElement(SessionServerToolsChangedAction.serializer(), element)) - "session/activeClientChanged" -> StateActionSessionActiveClientChanged(input.json.decodeFromJsonElement(SessionActiveClientChangedAction.serializer(), element)) - "session/activeClientToolsChanged" -> StateActionSessionActiveClientToolsChanged(input.json.decodeFromJsonElement(SessionActiveClientToolsChangedAction.serializer(), element)) + "session/activeClientSet" -> StateActionSessionActiveClientSet(input.json.decodeFromJsonElement(SessionActiveClientSetAction.serializer(), element)) + "session/activeClientRemoved" -> StateActionSessionActiveClientRemoved(input.json.decodeFromJsonElement(SessionActiveClientRemovedAction.serializer(), element)) "chat/pendingMessageSet" -> StateActionChatPendingMessageSet(input.json.decodeFromJsonElement(ChatPendingMessageSetAction.serializer(), element)) "chat/pendingMessageRemoved" -> StateActionChatPendingMessageRemoved(input.json.decodeFromJsonElement(ChatPendingMessageRemovedAction.serializer(), element)) "chat/queuedMessagesReordered" -> StateActionChatQueuedMessagesReordered(input.json.decodeFromJsonElement(ChatQueuedMessagesReorderedAction.serializer(), element)) @@ -1534,8 +1534,8 @@ internal object StateActionSerializer : KSerializer { is StateActionSessionActivityChanged -> output.json.encodeToJsonElement(SessionActivityChangedAction.serializer(), value.value) is StateActionSessionChangesetsChanged -> output.json.encodeToJsonElement(SessionChangesetsChangedAction.serializer(), value.value) is StateActionSessionServerToolsChanged -> output.json.encodeToJsonElement(SessionServerToolsChangedAction.serializer(), value.value) - is StateActionSessionActiveClientChanged -> output.json.encodeToJsonElement(SessionActiveClientChangedAction.serializer(), value.value) - is StateActionSessionActiveClientToolsChanged -> output.json.encodeToJsonElement(SessionActiveClientToolsChangedAction.serializer(), value.value) + is StateActionSessionActiveClientSet -> output.json.encodeToJsonElement(SessionActiveClientSetAction.serializer(), value.value) + is StateActionSessionActiveClientRemoved -> output.json.encodeToJsonElement(SessionActiveClientRemovedAction.serializer(), value.value) is StateActionChatPendingMessageSet -> output.json.encodeToJsonElement(ChatPendingMessageSetAction.serializer(), value.value) is StateActionChatPendingMessageRemoved -> output.json.encodeToJsonElement(ChatPendingMessageRemovedAction.serializer(), value.value) is StateActionChatQueuedMessagesReordered -> output.json.encodeToJsonElement(ChatQueuedMessagesReorderedAction.serializer(), value.value) diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt index 817d46bb..954ec12f 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt @@ -312,10 +312,10 @@ data class CreateSessionParams( */ val config: Map? = null, /** - * 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`. */ diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/State.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/State.generated.kt index cf05c3b4..55638b10 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/State.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/State.generated.kt @@ -1178,9 +1178,17 @@ data class SessionState( */ val serverTools: List? = null, /** - * The client currently providing tools and interactive capabilities to this session + * 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. */ - val activeClient: SessionActiveClient? = null, + val activeClients: List, /** * Catalog of chats in this session. */ @@ -1211,7 +1219,7 @@ data class SessionState( * 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 diff --git a/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/ReducersTest.kt b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/ReducersTest.kt index a00cb4bf..1576f4f3 100644 --- a/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/ReducersTest.kt +++ b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/ReducersTest.kt @@ -302,6 +302,7 @@ class ReducersTest { modifiedAt = 1000L, ), lifecycle = SessionLifecycle.READY, + activeClients = emptyList(), chats = emptyList(), ) diff --git a/clients/rust/CHANGELOG.md b/clients/rust/CHANGELOG.md index d1e7ca25..c21e9422 100644 --- a/clients/rust/CHANGELOG.md +++ b/clients/rust/CHANGELOG.md @@ -21,6 +21,8 @@ matching `## [X.Y.Z]` heading is missing from this file. optional fields for communicating model token limits. - `SessionSummary.meta` (`_meta` on the wire) optional provider metadata field for lightweight session-list presentation hints. +- `StateAction::SessionActiveClientRemoved` (`SessionActiveClientRemovedAction`) + to release a single active client by `client_id`. - `ahp-ws` TLS backend is now selectable via Cargo features: `native-tls`, `rustls-tls-native-roots` (default), and `rustls-tls-webpki-roots`. The crate no longer forces `tokio-tungstenite/native-tls` onto the dependency graph, so @@ -36,6 +38,19 @@ matching `## [X.Y.Z]` heading is missing from this file. `Option>`, allowing numeric, boolean, and null enum values. - `ModelSelection.config` values are now `AnyValue` instead of `String`, allowing numeric, boolean, and null configuration values. +- `SessionState.active_clients` (`Vec`, required) replaces + the single optional `SessionState.active_client`; the session reducer upserts + and removes entries keyed by `client_id`. +- `StateAction::SessionActiveClientChanged` is renamed to + `StateAction::SessionActiveClientSet` with upsert-by-`client_id` semantics; it + no longer unsets the active client (dispatch `SessionActiveClientRemoved` + instead). + +### Removed + +- `SessionActiveClientToolsChangedAction`. An active client now updates its + published tools by re-dispatching `SessionActiveClientSet` with its full, + updated entry. ## [0.4.0] — 2026-06-19 diff --git a/clients/rust/crates/ahp-types/src/actions.rs b/clients/rust/crates/ahp-types/src/actions.rs index 49a3c4cb..a6fd9e09 100644 --- a/clients/rust/crates/ahp-types/src/actions.rs +++ b/clients/rust/crates/ahp-types/src/actions.rs @@ -80,10 +80,10 @@ pub enum ActionType { SessionAgentChanged, #[serde(rename = "session/serverToolsChanged")] SessionServerToolsChanged, - #[serde(rename = "session/activeClientChanged")] - SessionActiveClientChanged, - #[serde(rename = "session/activeClientToolsChanged")] - SessionActiveClientToolsChanged, + #[serde(rename = "session/activeClientSet")] + SessionActiveClientSet, + #[serde(rename = "session/activeClientRemoved")] + SessionActiveClientRemoved, #[serde(rename = "chat/pendingMessageSet")] ChatPendingMessageSet, #[serde(rename = "chat/pendingMessageRemoved")] @@ -509,9 +509,10 @@ pub struct ChatToolCallConfirmedAction { /// 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 @@ -566,10 +567,11 @@ pub struct ChatToolCallResultConfirmedAction { /// 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`. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ChatToolCallContentChangedAction { @@ -781,30 +783,44 @@ pub struct SessionServerToolsChangedAction { pub tools: Vec, } -/// 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. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +/// 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. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct SessionActiveClientChangedAction { - /// The new active client, or `null` to unset - #[serde(default, skip_serializing_if = "Option::is_none")] - pub active_client: Option, +pub struct SessionActiveClientSetAction { + /// The active client to add or update, matched by `clientId`. + pub active_client: SessionActiveClient, } -/// 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. +/// 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.) #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct SessionActiveClientToolsChangedAction { - /// Updated client tools list (full replacement) - pub tools: Vec, +pub struct SessionActiveClientRemovedAction { + /// The `clientId` of the active client to remove. + pub client_id: String, } /// A pending message was set (upsert semantics: creates or replaces). @@ -1510,10 +1526,10 @@ pub enum StateAction { SessionChangesetsChanged(SessionChangesetsChangedAction), #[serde(rename = "session/serverToolsChanged")] SessionServerToolsChanged(SessionServerToolsChangedAction), - #[serde(rename = "session/activeClientChanged")] - SessionActiveClientChanged(SessionActiveClientChangedAction), - #[serde(rename = "session/activeClientToolsChanged")] - SessionActiveClientToolsChanged(SessionActiveClientToolsChangedAction), + #[serde(rename = "session/activeClientSet")] + SessionActiveClientSet(SessionActiveClientSetAction), + #[serde(rename = "session/activeClientRemoved")] + SessionActiveClientRemoved(SessionActiveClientRemovedAction), #[serde(rename = "chat/pendingMessageSet")] ChatPendingMessageSet(ChatPendingMessageSetAction), #[serde(rename = "chat/pendingMessageRemoved")] diff --git a/clients/rust/crates/ahp-types/src/commands.rs b/clients/rust/crates/ahp-types/src/commands.rs index bc92cf64..bc1c1b39 100644 --- a/clients/rust/crates/ahp-types/src/commands.rs +++ b/clients/rust/crates/ahp-types/src/commands.rs @@ -292,10 +292,10 @@ pub struct CreateSessionParams { /// Keys and values correspond to the schema returned by the server. #[serde(default, skip_serializing_if = "Option::is_none")] pub config: Option, - /// 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`. #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/clients/rust/crates/ahp-types/src/state.rs b/clients/rust/crates/ahp-types/src/state.rs index 2426c9bc..e9cab5dc 100644 --- a/clients/rust/crates/ahp-types/src/state.rs +++ b/clients/rust/crates/ahp-types/src/state.rs @@ -1008,9 +1008,16 @@ pub struct SessionState { /// Tools provided by the server (agent host) for this session #[serde(default, skip_serializing_if = "Option::is_none")] pub server_tools: Option>, - /// The client currently providing tools and interactive capabilities to this session - #[serde(default, skip_serializing_if = "Option::is_none")] - pub active_client: Option, + /// 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. + pub active_clients: Vec, /// Catalog of chats in this session. pub chats: Vec, /// The chat that receives input when the user addresses the session without @@ -1036,7 +1043,7 @@ pub struct SessionState { /// 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 @@ -1059,10 +1066,11 @@ pub struct SessionState { pub meta: Option, } -/// 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. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionActiveClient { diff --git a/clients/rust/crates/ahp/src/reducers.rs b/clients/rust/crates/ahp/src/reducers.rs index a84ac90d..74e83755 100644 --- a/clients/rust/crates/ahp/src/reducers.rs +++ b/clients/rust/crates/ahp/src/reducers.rs @@ -639,15 +639,27 @@ pub fn apply_action_to_session(state: &mut SessionState, action: &StateAction) - state.server_tools = Some(a.tools.clone()); ReduceOutcome::Applied } - StateAction::SessionActiveClientChanged(a) => { - state.active_client = a.active_client.clone(); + StateAction::SessionActiveClientSet(a) => { + if let Some(idx) = state + .active_clients + .iter() + .position(|client| client.client_id == a.active_client.client_id) + { + state.active_clients[idx] = a.active_client.clone(); + } else { + state.active_clients.push(a.active_client.clone()); + } ReduceOutcome::Applied } - StateAction::SessionActiveClientToolsChanged(a) => { - let Some(ac) = state.active_client.as_mut() else { + StateAction::SessionActiveClientRemoved(a) => { + let Some(idx) = state + .active_clients + .iter() + .position(|client| client.client_id == a.client_id) + else { return ReduceOutcome::NoOp; }; - ac.tools = a.tools.clone(); + state.active_clients.remove(idx); ReduceOutcome::Applied } StateAction::SessionCustomizationsChanged(a) => { @@ -1560,7 +1572,7 @@ mod tests { lifecycle: SessionLifecycle::Creating, creation_error: None, server_tools: None, - active_client: None, + active_clients: Vec::new(), chats: Vec::new(), default_chat: None, config: None, diff --git a/clients/rust/crates/ahp/tests/multi_host_state_mirror.rs b/clients/rust/crates/ahp/tests/multi_host_state_mirror.rs index 1ff13aae..6a272a8b 100644 --- a/clients/rust/crates/ahp/tests/multi_host_state_mirror.rs +++ b/clients/rust/crates/ahp/tests/multi_host_state_mirror.rs @@ -64,7 +64,7 @@ fn session_state(title: &str, resource: &str) -> SessionState { lifecycle: SessionLifecycle::Ready, creation_error: None, server_tools: None, - active_client: None, + active_clients: vec![], chats: vec![], default_chat: None, config: None, diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift index f6e99dc5..bc0edd10 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift @@ -33,8 +33,8 @@ public enum ActionType: String, Codable, Sendable { case sessionModelChanged = "session/modelChanged" case sessionAgentChanged = "session/agentChanged" case sessionServerToolsChanged = "session/serverToolsChanged" - case sessionActiveClientChanged = "session/activeClientChanged" - case sessionActiveClientToolsChanged = "session/activeClientToolsChanged" + case sessionActiveClientSet = "session/activeClientSet" + case sessionActiveClientRemoved = "session/activeClientRemoved" case chatPendingMessageSet = "chat/pendingMessageSet" case chatPendingMessageRemoved = "chat/pendingMessageRemoved" case chatQueuedMessagesReordered = "chat/queuedMessagesReordered" @@ -979,31 +979,31 @@ public struct SessionServerToolsChangedAction: Codable, Sendable { } } -public struct SessionActiveClientChangedAction: Codable, Sendable { +public struct SessionActiveClientSetAction: Codable, Sendable { public var type: ActionType - /// The new active client, or `null` to unset - public var activeClient: SessionActiveClient? + /// The active client to add or update, matched by `clientId`. + public var activeClient: SessionActiveClient public init( type: ActionType, - activeClient: SessionActiveClient? = nil + activeClient: SessionActiveClient ) { self.type = type self.activeClient = activeClient } } -public struct SessionActiveClientToolsChangedAction: Codable, Sendable { +public struct SessionActiveClientRemovedAction: Codable, Sendable { public var type: ActionType - /// Updated client tools list (full replacement) - public var tools: [ToolDefinition] + /// The `clientId` of the active client to remove. + public var clientId: String public init( type: ActionType, - tools: [ToolDefinition] + clientId: String ) { self.type = type - self.tools = tools + self.clientId = clientId } } @@ -1776,8 +1776,8 @@ public enum StateAction: Codable, Sendable { case sessionActivityChanged(SessionActivityChangedAction) case sessionChangesetsChanged(SessionChangesetsChangedAction) case sessionServerToolsChanged(SessionServerToolsChangedAction) - case sessionActiveClientChanged(SessionActiveClientChangedAction) - case sessionActiveClientToolsChanged(SessionActiveClientToolsChangedAction) + case sessionActiveClientSet(SessionActiveClientSetAction) + case sessionActiveClientRemoved(SessionActiveClientRemovedAction) case chatPendingMessageSet(ChatPendingMessageSetAction) case chatPendingMessageRemoved(ChatPendingMessageRemovedAction) case chatQueuedMessagesReordered(ChatQueuedMessagesReorderedAction) @@ -1892,10 +1892,10 @@ public enum StateAction: Codable, Sendable { self = .sessionChangesetsChanged(try SessionChangesetsChangedAction(from: decoder)) case "session/serverToolsChanged": self = .sessionServerToolsChanged(try SessionServerToolsChangedAction(from: decoder)) - case "session/activeClientChanged": - self = .sessionActiveClientChanged(try SessionActiveClientChangedAction(from: decoder)) - case "session/activeClientToolsChanged": - self = .sessionActiveClientToolsChanged(try SessionActiveClientToolsChangedAction(from: decoder)) + case "session/activeClientSet": + self = .sessionActiveClientSet(try SessionActiveClientSetAction(from: decoder)) + case "session/activeClientRemoved": + self = .sessionActiveClientRemoved(try SessionActiveClientRemovedAction(from: decoder)) case "chat/pendingMessageSet": self = .chatPendingMessageSet(try ChatPendingMessageSetAction(from: decoder)) case "chat/pendingMessageRemoved": @@ -2014,8 +2014,8 @@ public enum StateAction: Codable, Sendable { case .sessionActivityChanged(let v): try v.encode(to: encoder) case .sessionChangesetsChanged(let v): try v.encode(to: encoder) case .sessionServerToolsChanged(let v): try v.encode(to: encoder) - case .sessionActiveClientChanged(let v): try v.encode(to: encoder) - case .sessionActiveClientToolsChanged(let v): try v.encode(to: encoder) + case .sessionActiveClientSet(let v): try v.encode(to: encoder) + case .sessionActiveClientRemoved(let v): try v.encode(to: encoder) case .chatPendingMessageSet(let v): try v.encode(to: encoder) case .chatPendingMessageRemoved(let v): try v.encode(to: encoder) case .chatQueuedMessagesReordered(let v): try v.encode(to: encoder) diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift index 91d62916..a3ef250b 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift @@ -278,10 +278,10 @@ public struct CreateSessionParams: Codable, Sendable { /// Agent-specific configuration values collected via `resolveSessionConfig`. /// Keys and values correspond to the schema returned by the server. public var config: [String: AnyCodable]? - /// 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`. public var activeClient: SessionActiveClient? diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift index e1d2a117..3f6cdb1e 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift @@ -948,8 +948,16 @@ public struct SessionState: Codable, Sendable { public var creationError: ErrorInfo? /// Tools provided by the server (agent host) for this session public var serverTools: [ToolDefinition]? - /// The client currently providing tools and interactive capabilities to this session - public var activeClient: SessionActiveClient? + /// 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. + public var activeClients: [SessionActiveClient] /// Catalog of chats in this session. public var chats: [ChatSummary] /// The chat that receives input when the user addresses the session without @@ -973,7 +981,7 @@ public struct SessionState: Codable, Sendable { /// 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 @@ -997,7 +1005,7 @@ public struct SessionState: Codable, Sendable { case lifecycle case creationError case serverTools - case activeClient + case activeClients case chats case defaultChat case config @@ -1011,7 +1019,7 @@ public struct SessionState: Codable, Sendable { lifecycle: SessionLifecycle, creationError: ErrorInfo? = nil, serverTools: [ToolDefinition]? = nil, - activeClient: SessionActiveClient? = nil, + activeClients: [SessionActiveClient], chats: [ChatSummary], defaultChat: String? = nil, config: SessionConfigState? = nil, @@ -1023,7 +1031,7 @@ public struct SessionState: Codable, Sendable { self.lifecycle = lifecycle self.creationError = creationError self.serverTools = serverTools - self.activeClient = activeClient + self.activeClients = activeClients self.chats = chats self.defaultChat = defaultChat self.config = config diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift index 4f54a467..9ce944a4 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift @@ -572,16 +572,19 @@ public func sessionReducer(state: SessionState, action: StateAction) -> SessionS next.serverTools = a.tools return next - case .sessionActiveClientChanged(let a): + case .sessionActiveClientSet(let a): var next = state - next.activeClient = a.activeClient + if let idx = next.activeClients.firstIndex(where: { $0.clientId == a.activeClient.clientId }) { + next.activeClients[idx] = a.activeClient + } else { + next.activeClients.append(a.activeClient) + } return next - case .sessionActiveClientToolsChanged(let a): - guard var activeClient = state.activeClient else { return state } - activeClient.tools = a.tools + case .sessionActiveClientRemoved(let a): + guard let idx = state.activeClients.firstIndex(where: { $0.clientId == a.clientId }) else { return state } var next = state - next.activeClient = activeClient + next.activeClients.remove(at: idx) return next // ── Customizations ────────────────────────────────────────────────── @@ -674,8 +677,8 @@ public let clientDispatchableActions: Set = [ "chat/turnCancelled", "session/modelChanged", "session/agentChanged", - "session/activeClientChanged", - "session/activeClientToolsChanged", + "session/activeClientSet", + "session/activeClientRemoved", "chat/pendingMessageSet", "chat/pendingMessageRemoved", "chat/queuedMessagesReordered", @@ -691,8 +694,9 @@ public func isClientDispatchable(_ action: StateAction) -> Bool { switch action { case .chatTurnStarted, .chatToolCallConfirmed, .chatToolCallComplete, .chatToolCallResultConfirmed, .chatTurnCancelled, - .sessionModelChanged, .sessionAgentChanged, .sessionActiveClientChanged, - .sessionActiveClientToolsChanged, .chatPendingMessageSet, + .sessionModelChanged, .sessionAgentChanged, .sessionActiveClientSet, + .sessionActiveClientRemoved, + .chatPendingMessageSet, .chatPendingMessageRemoved, .chatQueuedMessagesReordered, .chatInputAnswerChanged, .chatInputCompleted, .sessionCustomizationToggled, .sessionIsReadChanged, diff --git a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/AHPClientTests.swift b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/AHPClientTests.swift index 35561a82..388ebe14 100644 --- a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/AHPClientTests.swift +++ b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/AHPClientTests.swift @@ -58,6 +58,7 @@ final class AHPClientTests: XCTestCase { createdAt: 1, modifiedAt: 1 ), lifecycle: .ready, + activeClients: [], chats: [] )), fromSeq: 0 diff --git a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/AHPStateMirrorTests.swift b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/AHPStateMirrorTests.swift index 9ba13d1a..7e6b2a2c 100644 --- a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/AHPStateMirrorTests.swift +++ b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/AHPStateMirrorTests.swift @@ -33,6 +33,7 @@ final class AHPStateMirrorTests: XCTestCase { createdAt: 1, modifiedAt: 1 ), lifecycle: .ready, + activeClients: [], chats: [] ) let snapshot = Snapshot( @@ -75,6 +76,7 @@ final class AHPStateMirrorTests: XCTestCase { createdAt: 1, modifiedAt: 1 ), lifecycle: .ready, + activeClients: [], chats: [] ) await mirror.applySnapshot(Snapshot( diff --git a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/MultiHostStateMirrorTests.swift b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/MultiHostStateMirrorTests.swift index 1d9c8568..4b98051e 100644 --- a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/MultiHostStateMirrorTests.swift +++ b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/MultiHostStateMirrorTests.swift @@ -39,14 +39,14 @@ final class MultiHostStateMirrorTests: XCTestCase { resource: "ahp-session:/s1", provider: "x", title: "A title", status: .idle, createdAt: 1, modifiedAt: 1 ), - lifecycle: .ready, chats: [] + lifecycle: .ready, activeClients: [], chats: [] ) let sessionB = SessionState( summary: SessionSummary( resource: "ahp-session:/s1", provider: "x", title: "B title", status: .idle, createdAt: 1, modifiedAt: 1 ), - lifecycle: .ready, chats: [] + lifecycle: .ready, activeClients: [], chats: [] ) await mirror.applySnapshot( @@ -101,7 +101,7 @@ final class MultiHostStateMirrorTests: XCTestCase { resource: "ahp-session:/s1", provider: "x", title: "Old", status: .idle, createdAt: 1, modifiedAt: 1 ), - lifecycle: .ready, chats: [] + lifecycle: .ready, activeClients: [], chats: [] ) await mirror.applySnapshot( host: "alpha", diff --git a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/NativeReducerTests.swift b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/NativeReducerTests.swift index 5bb111fc..8a875972 100644 --- a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/NativeReducerTests.swift +++ b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/NativeReducerTests.swift @@ -41,6 +41,7 @@ final class NativeReducerTests: XCTestCase { modifiedAt: 1000 ), lifecycle: lifecycle, + activeClients: [], chats: [] ) } diff --git a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/ReducersTests.swift b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/ReducersTests.swift index aa65ef55..6795bebf 100644 --- a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/ReducersTests.swift +++ b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/ReducersTests.swift @@ -37,6 +37,7 @@ final class ReducersTests: XCTestCase { modifiedAt: 1000 ), lifecycle: lifecycle, + activeClients: [], chats: [] ) } diff --git a/clients/swift/CHANGELOG.md b/clients/swift/CHANGELOG.md index 152a416e..904caf74 100644 --- a/clients/swift/CHANGELOG.md +++ b/clients/swift/CHANGELOG.md @@ -23,14 +23,30 @@ the tag matches the version pinned in [`VERSION`](VERSION). optional fields for communicating model token limits. - `SessionSummary.meta` (`_meta` on the wire) optional provider metadata field for lightweight session-list presentation hints. +- `SessionActiveClientRemovedAction` (`StateAction.sessionActiveClientRemoved`, + wire `session/activeClientRemoved`) to release a single active client by + `clientId`. ### Changed +- `SessionState.activeClients` (`[SessionActiveClient]`, required) replaces the + single optional `SessionState.activeClient`; the session reducer upserts and + removes entries keyed by `clientId`. +- `StateAction.sessionActiveClientChanged` is renamed to + `StateAction.sessionActiveClientSet` (wire `session/activeClientSet`) with + upsert-by-`clientId` semantics; it no longer unsets the active client + (dispatch `session/activeClientRemoved` instead). - `ConfigPropertySchema.enum` field is now `[AnyCodable]?` instead of `[String]?`, allowing numeric, boolean, and null enum values. - `ModelSelection.config` values are now `AnyCodable` instead of `String`, allowing numeric, boolean, and null configuration values. +### Removed + +- `SessionActiveClientToolsChangedAction`. An active client now updates its + published tools by re-dispatching `StateAction.sessionActiveClientSet` with its + full, updated entry. + ## [0.4.0] — 2026-06-19 Implements AHP 0.4.0. diff --git a/clients/typescript/CHANGELOG.md b/clients/typescript/CHANGELOG.md index 46335e8c..136c8d59 100644 --- a/clients/typescript/CHANGELOG.md +++ b/clients/typescript/CHANGELOG.md @@ -27,6 +27,8 @@ hotfix escape hatch. - `SessionSummary._meta` optional provider metadata field for lightweight session-list presentation hints. - Exported `JsonPrimitive` type alias (`string | number | boolean | null`). +- `SessionActiveClientRemovedAction` (`session/activeClientRemoved`) to release + a single active client by `clientId`. ### Changed @@ -34,6 +36,18 @@ hotfix escape hatch. `string[]`, allowing numeric, boolean, and null enum values. - `ModelSelection.config` values are now `JsonPrimitive` instead of `string`, allowing numeric, boolean, and null configuration values. +- `SessionState.activeClients` (a required array) replaces the single optional + `SessionState.activeClient`; `sessionReducer` upserts and removes entries + keyed by `clientId`. +- `SessionActiveClientChangedAction` is renamed to `SessionActiveClientSetAction` + (`session/activeClientSet`) with upsert-by-`clientId` semantics; it no longer + accepts `null` to unset (dispatch `session/activeClientRemoved` instead). + +### Removed + +- `SessionActiveClientToolsChangedAction`. An active client now updates its + published tools by re-dispatching `SessionActiveClientSetAction` with its + full, updated entry. ### Fixed diff --git a/docs/guide/actions.md b/docs/guide/actions.md index 1771e22f..55bf3d60 100644 --- a/docs/guide/actions.md +++ b/docs/guide/actions.md @@ -65,15 +65,15 @@ Tool calls follow a discriminated-union state machine — see [State Model — T | Type | Client-dispatchable? | When | |---|---|---| -| `session/toolCallStart` | No | Tool call created; LM begins streaming parameters | -| `session/toolCallDelta` | No | Streaming partial parameters appended | -| `session/toolCallReady` | No | Parameters complete (or running tool needs re-confirmation) | -| `session/toolCallConfirmed` | **Yes** | Client approves or denies a pending tool call | -| `session/toolCallComplete` | **Yes**¹ | Tool execution finished | -| `session/toolCallResultConfirmed` | **Yes** | Client approves or denies a pending result | -| `session/toolCallContentChanged` | **Yes**¹ | Streaming intermediate content while a tool is running | +| `chat/toolCallStart` | No | Tool call created; LM begins streaming parameters | +| `chat/toolCallDelta` | No | Streaming partial parameters appended | +| `chat/toolCallReady` | No | Parameters complete (or running tool needs re-confirmation) | +| `chat/toolCallConfirmed` | **Yes** | Client approves or denies a pending tool call | +| `chat/toolCallComplete` | **Yes**¹ | Tool execution finished | +| `chat/toolCallResultConfirmed` | **Yes** | Client approves or denies a pending result | +| `chat/toolCallContentChanged` | **Yes**¹ | Streaming intermediate content while a tool is running | -¹ Client-dispatchable for **client-provided tools** only (where `toolClientId` matches the dispatching client). For server-side tools, only the server produces these actions. +¹ Client-dispatchable for **client-provided tools** only (where the tool call's `contributor.clientId` matches the dispatching client). For server-side tools, only the server produces these actions. ### Activity & Metadata @@ -93,8 +93,8 @@ Tool calls follow a discriminated-union state machine — see [State Model — T | Type | Client-dispatchable? | When | |---|---|---| | `session/serverToolsChanged` | No | Server-provided tool list changed (full replacement) | -| `session/activeClientChanged` | **Yes** | A client claims (or releases) the active role, with its tools and customizations | -| `session/activeClientToolsChanged` | **Yes** | The active client's tool list changed (full replacement) | +| `session/activeClientSet` | **Yes** | A client joins or refreshes as an active client (keyed by `clientId`), with its tools and customizations | +| `session/activeClientRemoved` | **Yes** | A client leaves the active set (by `clientId`) | See [Customizations & Client Tools](/guide/customizations) for the full flow. @@ -185,7 +185,7 @@ The client applies the action **optimistically** to its local state before sendi | Action | Server-side effect | |---|---| | `session/turnStarted` | Begins agent processing for the new turn | -| `session/toolCallConfirmed` | Approves or denies a pending tool call; unblocks or cancels tool execution | +| `chat/toolCallConfirmed` | Approves or denies a pending tool call; unblocks or cancels tool execution | | `session/turnCancelled` | Aborts the in-progress turn | | `session/titleChanged` | Updates the session title (rename) | | `session/modelChanged` | Changes the model for subsequent turns | diff --git a/docs/guide/ahp-and-acp.md b/docs/guide/ahp-and-acp.md index 94afc631..1ce63eb5 100644 --- a/docs/guide/ahp-and-acp.md +++ b/docs/guide/ahp-and-acp.md @@ -50,7 +50,7 @@ An AHP host implementation can use ACP as its agent backend protocol. The intern 2. **Host sequences it** — assigns a `serverSeq`, applies it to the authoritative state, broadcasts the action envelope to all subscribed clients. 3. **Host translates to ACP** — sends a `session/prompt` to the ACP agent. 4. **Agent streams back** — the ACP agent sends `session/update` notifications with content chunks, tool calls, and permission requests. -5. **Host maps to AHP actions** — the agent event mapper converts ACP-specific events into agent-agnostic AHP actions (`session/delta`, `session/toolCallStart`, `session/toolCallReady`, etc.). +5. **Host maps to AHP actions** — the agent event mapper converts ACP-specific events into agent-agnostic AHP actions (`session/delta`, `chat/toolCallStart`, `chat/toolCallReady`, etc.). 6. **Host broadcasts** — each mapped action gets a `serverSeq` and flows to all subscribed clients through the normal state synchronization path. The host is acting as a bridge: it speaks AHP upstream (to clients) and ACP downstream (to agents). The agent event mapper is the translation layer between the two. @@ -64,7 +64,7 @@ When multiple clients connect to the same agent session, the host serializes the Concretely: - **Turn ownership**: Only one turn runs at a time. When Client A starts a turn, Clients B and C see the `session/turnStarted` action and know the session is busy. AHP's state tree makes this visible to everyone. -- **Tool call confirmation**: When the agent needs user approval for a tool call, the host surfaces it as a state action. Any client can resolve it — but only once (the first `session/toolCallConfirmed` wins; subsequent ones are rejected). The host arbitrates. +- **Tool call confirmation**: When the agent needs user approval for a tool call, the host surfaces it as a state action. Any client can resolve it — but only once (the first `chat/toolCallConfirmed` wins; subsequent ones are rejected). The host arbitrates. - **Cancellation**: Any client can cancel a running turn. The host sequences the `session/turnCancelled` action and forwards the cancellation to the agent via ACP's `session/cancel`. All clients see the result. - **Optimistic updates with reconciliation**: Clients apply their own actions immediately (write-ahead) and reconcile when the server echoes them back. This gives responsive UI without sacrificing consistency — something a 1:1 protocol doesn't need to worry about. diff --git a/docs/guide/customizations.md b/docs/guide/customizations.md index 193780b8..f7809865 100644 --- a/docs/guide/customizations.md +++ b/docs/guide/customizations.md @@ -14,7 +14,7 @@ For MCP-specific behaviour (server lifecycle, authentication, App support), see Customizations enter a session from two places: 1. **Server-provided** — The agent host declares containers on each agent via `AgentInfo.customizations`. When a session is created, the host resolves the containers, parses their contents, and exposes the result in `SessionState.customizations`. -2. **Client-provided** — The active client contributes `ClientPluginCustomization` entries via `SessionActiveClient.customizations` (a `PluginCustomization` with an optional `nonce`). The host MAY parse the published plugin and surface it (with its children) in the session's top-level list. +2. **Client-provided** — An active client contributes `ClientPluginCustomization` entries via `SessionActiveClient.customizations` (a `PluginCustomization` with an optional `nonce`). The host MAY parse the published plugin and surface it (with its children) in the session's top-level list. ```mermaid flowchart LR @@ -28,7 +28,7 @@ flowchart LR end AI -- "host resolves & parses\non session create" --> SC - AC -- "client publishes ClientPluginCustomization\nvia activeClientChanged" --> SC + AC -- "client publishes ClientPluginCustomization\nvia activeClientSet" --> SC ``` Clients publish in Open Plugins shape only. They MAY synthesize a virtual plugin in memory if their real source is on disk; mapping a workspace location to a physical directory is the host's job, not the client's. @@ -174,11 +174,11 @@ The protocol does not define a dedicated save action — directories plus `resou ## Client-Published Plugins -A client claims the active role and contributes plugins via `session/activeClientChanged`. Client customizations are `ClientPluginCustomization` values — `PluginCustomization` with an optional `nonce` the host can use to detect changes between publications. +A client joins a session as an active client and contributes plugins via `session/activeClientSet`. A session may have several active clients at once; entries are keyed by `clientId`. Client customizations are `ClientPluginCustomization` values — `PluginCustomization` with an optional `nonce` the host can use to detect changes between publications. ```typescript dispatch({ - type: 'session/activeClientChanged', + type: 'session/activeClientSet', activeClient: { clientId: 'my-client-id', displayName: 'VS Code', @@ -197,14 +197,14 @@ dispatch({ }); ``` -The host parses the plugin and surfaces it in `SessionState.customizations` with `clientId` set and `children` populated. When the active client disconnects or is replaced, the host SHOULD remove its customizations from the session list. +The host parses the plugin and surfaces it in `SessionState.customizations` with `clientId` set and `children` populated. When an active client disconnects (or is removed via `session/activeClientRemoved`), the host SHOULD remove its customizations from the session list. ```mermaid sequenceDiagram participant Client participant Server - Client->>Server: activeClientChanged (with ClientPluginCustomization[]) + Client->>Server: activeClientSet (with ClientPluginCustomization[]) Server->>Client: action echoed Note over Server: Host parses each plugin @@ -213,22 +213,22 @@ sequenceDiagram ## Client-Provided Tools -AHP sessions can expose tools from two sources: **server tools** provided by the agent host, and **client tools** provided by the active client (e.g. an IDE). Client tools let the agent invoke capabilities that only the client has access to. +AHP sessions can expose tools from two sources: **server tools** provided by the agent host, and **client tools** provided by an active client (e.g. an IDE). Client tools let the agent invoke capabilities that only the client has access to. Key design points: -- **Client tools are state, not RPC.** They live in `SessionState.activeClient.tools` and are visible to all subscribers. +- **Client tools are state, not RPC.** They live in `SessionState.activeClients[].tools` and are visible to all subscribers. - **Tool execution follows the same state machine** as server tools — the only difference is _who_ executes: for client tools, the owning client does. -- **The server identifies client tool calls** by setting `toolClientId` on `session/toolCallStart`. +- **The server identifies client tool calls** by setting the tool call's client `contributor` (with the owning `clientId`) on `chat/toolCallStart`. ### Registering Tools -A client registers its tools by including them in the `session/activeClientChanged` payload (the same action used to register customizations): +A client registers its tools by including them in the `session/activeClientSet` payload (the same action used to register customizations): ```typescript -// Client claims the active role with tools and customizations +// Client joins as an active client with tools and customizations dispatch({ - type: 'session/activeClientChanged', + type: 'session/activeClientSet', session: sessionUri, activeClient: { clientId: 'my-client-id', @@ -249,21 +249,26 @@ dispatch({ }); ``` -After registration, the reducer stores the tools in `state.activeClient.tools`. +After registration, the reducer stores the tools on the matching entry in `state.activeClients` (keyed by `clientId`). ### Updating Tools -To change the tool list without re-claiming the active role, dispatch `session/activeClientToolsChanged`: +To change its tool list, a client re-dispatches `session/activeClientSet` with its full, updated `SessionActiveClient` entry. The upsert (keyed by `clientId`) replaces the previous entry — tools and all: ```typescript dispatch({ - type: 'session/activeClientToolsChanged', + type: 'session/activeClientSet', session: sessionUri, - tools: updatedToolList, // full replacement + activeClient: { + clientId: 'my-client-id', + displayName: 'VS Code', + tools: updatedToolList, // full replacement + customizations: [ /* unchanged — host may skip re-parsing via nonce */ ], + }, }); ``` -Both actions use **full-replacement semantics** — the entire `tools` array is replaced. +There is no separate tools-only action: because each `activeClients` entry has a single owner, re-publishing the whole entry is the canonical way to update either its `tools` or its `customizations`. Hosts MAY use each `ClientPluginCustomization`'s `nonce` to detect unchanged customizations and skip re-parsing. ### Tool Name Uniqueness @@ -279,35 +284,35 @@ sequenceDiagram participant Client Note over Server: LLM selects a client tool - Server->>Client: toolCallStart (toolClientId = client's clientId) + Server->>Client: toolCallStart (contributor.clientId = client's clientId) Server->>Client: toolCallDelta (streaming parameters) Server->>Client: toolCallReady (confirmed: 'not-needed') - Note over Client: Client sees toolClientId matches,
begins execution + Note over Client: Client sees contributor.clientId matches,
begins execution Client->>Server: toolCallContentChanged (streaming progress) Client->>Server: toolCallComplete (result) ``` -1. **`session/toolCallStart`** — The server dispatches this with `toolClientId` set to the active client's `clientId`. This tells the client it owns the tool call. +1. **`chat/toolCallStart`** — The server dispatches this with the tool call's `contributor` set to a client contributor whose `clientId` is the owning client's. This tells the client it owns the tool call. -2. **`session/toolCallDelta`** (zero or more) — The server streams partial parameters as the LLM generates them. The client can observe `partialInput` on the tool call state to preview the arguments. +2. **`chat/toolCallDelta`** (zero or more) — The server streams partial parameters as the LLM generates them. The client can observe `partialInput` on the tool call state to preview the arguments. -3. **`session/toolCallReady`** — Parameters are complete. For client-provided tools, the server typically sets `confirmed: 'not-needed'` so the tool transitions directly to `running`. If the server wants user confirmation first, it omits `confirmed` and the standard confirmation flow applies. +3. **`chat/toolCallReady`** — Parameters are complete. For client-provided tools, the server typically sets `confirmed: 'not-needed'` so the tool transitions directly to `running`. If the server wants user confirmation first, it omits `confirmed` and the standard confirmation flow applies. 4. **Client executes** — When the tool call reaches `running` status, the owning client begins execution using the `toolInput` from the tool call state. -5. **`session/toolCallContentChanged`** (zero or more, client-dispatched) — While executing, the client MAY stream intermediate content (e.g. terminal output, partial results) by dispatching this action. This replaces the `content` array on the running tool call state. +5. **`chat/toolCallContentChanged`** (zero or more, client-dispatched) — While executing, the client MAY stream intermediate content (e.g. terminal output, partial results) by dispatching this action. This replaces the `content` array on the running tool call state. -6. **`session/toolCallComplete`** (client-dispatched) — The client dispatches this with the execution result. The server SHOULD reject this action if the dispatching client does not match `toolClientId`. +6. **`chat/toolCallComplete`** (client-dispatched) — The client dispatches this with the execution result. The server SHOULD reject this action if the dispatching client does not match the tool call's `contributor.clientId`. ### Denying an Unrecognized Tool -If the client receives a tool call for a tool it does not recognize (e.g. after a stale registration), it MUST dispatch `session/toolCallConfirmed` with `approved: false`: +If the client receives a tool call for a tool it does not recognize (e.g. after a stale registration), it MUST dispatch `chat/toolCallConfirmed` with `approved: false`: ```typescript dispatch({ - type: 'session/toolCallConfirmed', + type: 'chat/toolCallConfirmed', session: sessionUri, turnId, toolCallId, @@ -316,15 +321,64 @@ dispatch({ }); ``` -### Client Disconnect and Tool Calls - -When the active client disconnects, the server SHOULD: +### Active-Client Lifecycle + +Active membership is **session-scoped** and host-managed: + +- **Join.** A client adds itself with `session/activeClientSet` (an upsert keyed + by `clientId`). Several clients may be active at once. A client never needs to + "unset" itself — it simply stops refreshing and lets the host remove it. +- **Leave.** The host removes a client with `session/activeClientRemoved` (by + `clientId`). The host SHOULD do this when: + 1. the client **unsubscribes** from the session channel; + 2. the client **disconnects** and does not reconnect within a host-defined + grace period; or + 3. the client **reconnects but does not resubscribe** to a session it was + still active in — i.e. the `reconnect` command's `subscriptions` omit that + session URI. + +The grace-period duration and exact policy are host-defined; the protocol only +defines the `session/activeClientSet` / `session/activeClientRemoved` actions +used to express the result. + +### Cancelling a Removed Client's Tool Calls + +When the host removes an active client, it SHOULD also cancel that client's +in-flight tool calls so they do not remain stuck in `running` indefinitely. A +client tool call is one whose state carries a client `ToolCallContributor` with +the matching `clientId`; these may be spread across several chats in the +session. For each such call the host dispatches `chat/toolCallComplete` with +`result.success = false` and an explanatory message. + +::: tip +"Cancellation" here is a **failed completion**: the call ends in `completed` +status with `result.success = false`, not in `cancelled` status. There is no +per-tool-call server-initiated cancel action — `cancelled` status is reserved +for the user-driven denial / skip / result-denied confirmation flows (and the +whole-turn `chat/turnCancelled`, which force-cancels every in-progress call in +the turn regardless of owner). +::: -1. Dispatch `session/activeClientChanged` with `activeClient: null` to clear the active client (and its tools and customizations). -2. Allow a reasonable grace period for the client to reconnect. -3. If the client does not reconnect, cancel any in-progress tool calls owned by that client by dispatching `session/toolCallComplete` with `result.success = false` and an appropriate error message. +```mermaid +sequenceDiagram + participant Client + participant Server -This ensures tool calls do not remain stuck in `running` state indefinitely. + Note over Client,Server: Grace period after an unexpected disconnect + Client--xServer: connection dropped + + alt reconnects and resubscribes to the session in time + Client->>Server: reconnect (subscriptions include session) + Note over Server: client stays in activeClients + else grace period elapses, or reconnect omits the session + Note over Server: Host removes the client + Server->>Client: session/activeClientRemoved (clientId) + loop each running tool call owned by clientId (across the session's chats) + Server->>Client: chat/toolCallComplete (success: false) + end + Note over Server: Host also drops the client's
tools and customizations + end +``` ## Actions Summary @@ -334,11 +388,11 @@ This ensures tool calls do not remain stuck in `running` state indefinitely. | `session/customizationToggled` | **Yes** | Client toggled a container or child on or off by id | | `session/customizationUpdated` | No | Server upserts a top-level container by id (full-entry replacement, including children) | | `session/customizationRemoved` | No | Server removes a customization by id (containers cascade) | -| `session/activeClientChanged` | **Yes** | Client claims/releases the active role (with tools + customizations) | -| `session/activeClientToolsChanged` | **Yes** | Client updates its tool list without re-claiming | -| `session/toolCallStart` | No | Server begins a tool call (sets `toolClientId` for client tools) | -| `session/toolCallComplete` | **Yes** | Client finishes executing a tool call | -| `session/toolCallContentChanged` | **Yes** | Client streams intermediate tool output | +| `session/activeClientSet` | **Yes** | Client joins or refreshes as an active client (with tools + customizations), keyed by `clientId` | +| `session/activeClientRemoved` | **Yes** | Client leaves the active set (by `clientId`) | +| `chat/toolCallStart` | No | Server begins a tool call (sets the client `contributor` for client tools) | +| `chat/toolCallComplete` | **Yes** | Client finishes executing a tool call | +| `chat/toolCallContentChanged` | **Yes** | Client streams intermediate tool output | ## Full Session Flow @@ -357,7 +411,7 @@ sequenceDiagram Note over Client,Server: 2. Client becomes active with its own plugin - Client->>Server: activeClientChanged (tools + customizations: [Plugin C]) + Client->>Server: activeClientSet (tools + customizations: [Plugin C]) Server->>Client: action echoed Server->>Client: customizationUpdated (Plugin C: load: loading) @@ -370,7 +424,7 @@ sequenceDiagram Note over Client,Server: 4. Active client disconnects - Server->>Client: activeClientChanged (null) + Server->>Client: activeClientRemoved (clientId) Server->>Client: customizationRemoved (Plugin C) ``` diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 8f58e2e5..0af06b52 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -136,12 +136,12 @@ The server begins agent processing and streams back actions: When the agent invokes a tool, the server emits a sequence of actions modelling the tool call's lifecycle (see [State Model — Tool Call Lifecycle](/guide/state-model#tool-call-lifecycle) for the full state machine): -1. `session/toolCallStart` — a new tool call begins. -2. `session/toolCallDelta` — partial parameters stream in. -3. `session/toolCallReady` — parameters complete. If the tool requires user confirmation, the call transitions to `pending-confirmation`; otherwise it goes directly to `running`. -4. `session/toolCallComplete` — tool execution finished. +1. `chat/toolCallStart` — a new tool call begins. +2. `chat/toolCallDelta` — partial parameters stream in. +3. `chat/toolCallReady` — parameters complete. If the tool requires user confirmation, the call transitions to `pending-confirmation`; otherwise it goes directly to `running`. +4. `chat/toolCallComplete` — tool execution finished. -The client resolves a `pending-confirmation` tool call by dispatching `session/toolCallConfirmed`: +The client resolves a `pending-confirmation` tool call by dispatching `chat/toolCallConfirmed`: ```jsonc // Client → Server: approve the tool call @@ -152,7 +152,7 @@ The client resolves a `pending-confirmation` tool call by dispatching `session/t "channel": "ahp-session:/", "clientSeq": 2, "action": { - "type": "session/toolCallConfirmed", + "type": "chat/toolCallConfirmed", "turnId": "turn-1", "toolCallId": "tc-1", "approved": true, diff --git a/docs/guide/mcp.md b/docs/guide/mcp.md index a43a791f..cf654e45 100644 --- a/docs/guide/mcp.md +++ b/docs/guide/mcp.md @@ -126,7 +126,7 @@ The existing `authenticate` command requires `resource` to match one declared by MCP tools follow the normal AHP tool-call flow: -- The agent harness inside the host discovers tools from each `ready` MCP server, the host normalizes them into the agent's tool catalogue, and exposes invocations through `session/toolCallStart` / `session/toolCallReady` / `session/toolCallComplete`. +- The agent harness inside the host discovers tools from each `ready` MCP server, the host normalizes them into the agent's tool catalogue, and exposes invocations through `chat/toolCallStart` / `chat/toolCallReady` / `chat/toolCallComplete`. - The originating MCP server is identified by [`ToolCallContributor`](/reference/session#toolcallcontributor) on the tool call: `{ kind: 'mcp', customizationId: }`. Clients can use this to render the originating server's name/icon next to the tool call. There is no separate "MCP tool" state. From the client's perspective an MCP tool call is just a tool call with an MCP contributor. diff --git a/docs/guide/state-model.md b/docs/guide/state-model.md index c8b8cd87..7075dd95 100644 --- a/docs/guide/state-model.md +++ b/docs/guide/state-model.md @@ -315,11 +315,11 @@ stateDiagram-v2 ### Mid-execution Re-confirmation -When a running tool needs additional user approval (e.g. a shell permission), the server dispatches `session/toolCallReady` again without `confirmed`. This transitions the tool call from `running` back to `pending-confirmation`, updating `invocationMessage` and `_meta` with context about what needs approval. The client uses the standard `session/toolCallConfirmed` flow to approve or deny. +When a running tool needs additional user approval (e.g. a shell permission), the server dispatches `chat/toolCallReady` again without `confirmed`. This transitions the tool call from `running` back to `pending-confirmation`, updating `invocationMessage` and `_meta` with context about what needs approval. The client uses the standard `chat/toolCallConfirmed` flow to approve or deny. ### Editable Parameters -When `editable` is `true` on a `pending-confirmation` tool call, the client may allow the user to modify the tool's input parameters before confirming. If the user edits the parameters, the client includes `editedToolInput` on the `session/toolCallConfirmed` action. The reducer uses `editedToolInput` (if present) in place of the original `toolInput` when transitioning to `running`. +When `editable` is `true` on a `pending-confirmation` tool call, the client may allow the user to modify the tool's input parameters before confirming. If the user edits the parameters, the client includes `editedToolInput` on the `chat/toolCallConfirmed` action. The reducer uses `editedToolInput` (if present) in place of the original `toolInput` when transitioning to `running`. When a turn completes, non-terminal tool calls in `responseParts` are force-cancelled with reason `'skipped'`. @@ -329,12 +329,12 @@ By default, clients render a binary approve/deny UI for `pending-confirmation` t | Field | Type | Description | | ------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `id` | `string` | Unique identifier, returned in the `session/toolCallConfirmed` action as `selectedOptionId`. | +| `id` | `string` | Unique identifier, returned in the `chat/toolCallConfirmed` action as `selectedOptionId`. | | `label` | `string` | Human-readable text for the button or menu item. The server SHOULD localise this using the client's `locale` (sent in `initialize`). | | `kind` | `'approve' \| 'deny'` | Classifies the option so the server and client know whether it represents approval or denial. | | `group` | `number?` | Logical group number. Clients SHOULD display options in the order they are defined and MAY use differing group numbers to insert dividers between logical clusters. | -For example, a server might offer `"Approve"`, `"Approve in this Session"`, `"Deny"`, and `"Deny with reason"`. When the user picks an option, the client dispatches `session/toolCallConfirmed` with `selectedOptionId` set to the chosen option's `id`. The reducer resolves the full `ConfirmationOption` object and stores it as `selectedOption` on the resulting `running` or `cancelled` state, and it carries through to `completed`. +For example, a server might offer `"Approve"`, `"Approve in this Session"`, `"Deny"`, and `"Deny with reason"`. When the user picks an option, the client dispatches `chat/toolCallConfirmed` with `selectedOptionId` set to the chosen option's `id`. The reducer resolves the full `ConfirmationOption` object and stores it as `selectedOption` on the resulting `running` or `cancelled` state, and it carries through to `completed`. ## Session Input Requests diff --git a/schema/actions.schema.json b/schema/actions.schema.json index 55de11c2..d61424a7 100644 --- a/schema/actions.schema.json +++ b/schema/actions.schema.json @@ -412,21 +412,16 @@ "tools" ] }, - "SessionActiveClientChangedAction": { + "SessionActiveClientSetAction": { "type": "object", - "description": "The active client for this session has changed.\n\nA client dispatches this action with its own `SessionActiveClient` to claim\nthe active role, or with `null` to release it. The server SHOULD reject if\nanother client is already active. The server SHOULD automatically dispatch\nthis action with `activeClient: null` when the active client disconnects.", + "description": "An active client for this session was added or updated.\n\nUpsert semantics keyed by {@link SessionActiveClient.clientId | `clientId`}:\na client dispatches this action with its own `SessionActiveClient` to join\nthe session's active clients or refresh its entry, replacing any existing\nentry that has the same `clientId`. Multiple clients may be active at once.\nThis is also how a client updates its published tools or customizations —\nre-dispatch with the full, updated entry. Use\n{@link SessionActiveClientRemovedAction | `session/activeClientRemoved`} to\nleave. The server SHOULD automatically dispatch that removal when an active\nclient disconnects.", "properties": { "type": { - "$ref": "#/$defs/ActionType.SessionActiveClientChanged" + "$ref": "#/$defs/ActionType.SessionActiveClientSet" }, "activeClient": { - "oneOf": [ - { - "$ref": "#/$defs/SessionActiveClient" - }, - {} - ], - "description": "The new active client, or `null` to unset" + "$ref": "#/$defs/SessionActiveClient", + "description": "The active client to add or update, matched by `clientId`." } }, "required": [ @@ -434,24 +429,21 @@ "activeClient" ] }, - "SessionActiveClientToolsChangedAction": { + "SessionActiveClientRemovedAction": { "type": "object", - "description": "The active client's tool list has changed.\n\nFull-replacement semantics: the `tools` array replaces the active client's\nprevious tools entirely. The server SHOULD reject if the dispatching client\nis not the current active client.", + "description": "An active client was removed from this session.\n\nRemoves the entry for the client identified by `clientId` from\n{@link SessionState.activeClients}; a no-op when no entry matches.\n\nThe host SHOULD dispatch this automatically when a client stops participating\nin the session — for example when it unsubscribes from the session channel,\nwhen it disconnects and does not reconnect within a host-defined grace\nperiod, or when a `reconnect` command's `subscriptions` omit a session the\nclient was still active in. When removing a client, the host SHOULD also\ncancel that client's in-flight tool calls — those whose tool call state\ncarries a client `ToolCallContributor` with the matching `clientId` — by\ndispatching `chat/toolCallComplete` with `result.success = false`. (There is\nno per-tool-call server cancel; a failed completion is the cancellation\nmechanism, and the call ends in `completed` status with a failed result.)", "properties": { "type": { - "$ref": "#/$defs/ActionType.SessionActiveClientToolsChanged" + "$ref": "#/$defs/ActionType.SessionActiveClientRemoved" }, - "tools": { - "type": "array", - "items": { - "$ref": "#/$defs/ToolDefinition" - }, - "description": "Updated client tools list (full replacement)" + "clientId": { + "type": "string", + "description": "The `clientId` of the active client to remove." } }, "required": [ "type", - "tools" + "clientId" ] }, "SessionCustomizationsChangedAction": { @@ -954,7 +946,7 @@ }, "ChatToolCallCompleteAction": { "type": "object", - "description": "Tool execution finished. Transitions to `completed` or `pending-result-confirmation`\nif `requiresResultConfirmation` is `true`.\n\nFor client-provided tools (where `toolClientId` is set on the tool call state),\nthe owning client dispatches this action with the execution result. The server\nSHOULD reject this action if the dispatching client does not match `toolClientId`.\n\nServers waiting on a client tool call MAY time out after a reasonable duration\nif the implementing client disconnects or becomes unresponsive, and dispatch\nthis action with `result.success = false` and an appropriate error.", + "description": "Tool execution finished. Transitions to `completed` or `pending-result-confirmation`\nif `requiresResultConfirmation` is `true`.\n\nFor client-provided tools (whose tool call state carries a client\n`ToolCallContributor` with a `clientId`), the owning client dispatches this\naction with the execution result. The server SHOULD reject this action if the\ndispatching client does not match the contributor's `clientId`.\n\nServers waiting on a client tool call MAY time out after a reasonable duration\nif the implementing client disconnects or becomes unresponsive, and dispatch\nthis action with `result.success = false` and an appropriate error.", "properties": { "turnId": { "type": "string", @@ -1022,7 +1014,7 @@ }, "ChatToolCallContentChangedAction": { "type": "object", - "description": "Partial content produced while a tool is still executing.\n\nReplaces the `content` array on the running tool call state. Clients can\nuse this to display live feedback (e.g. a terminal reference) before the\ntool completes.\n\nFor client-provided tools (where `toolClientId` is set on the tool call state),\nthe owning client dispatches this action to stream intermediate content while\nexecuting. The server SHOULD reject this action if the dispatching client does\nnot match `toolClientId`.", + "description": "Partial content produced while a tool is still executing.\n\nReplaces the `content` array on the running tool call state. Clients can\nuse this to display live feedback (e.g. a terminal reference) before the\ntool completes.\n\nFor client-provided tools (whose tool call state carries a client\n`ToolCallContributor` with a `clientId`), the owning client dispatches this\naction to stream intermediate content while executing. The server SHOULD\nreject this action if the dispatching client does not match the contributor's\n`clientId`.", "properties": { "turnId": { "type": "string", @@ -2535,9 +2527,12 @@ }, "description": "Tools provided by the server (agent host) for this session" }, - "activeClient": { - "$ref": "#/$defs/SessionActiveClient", - "description": "The client currently providing tools and interactive capabilities to this session" + "activeClients": { + "type": "array", + "items": { + "$ref": "#/$defs/SessionActiveClient" + }, + "description": "The clients currently providing tools and interactive capabilities to this\nsession. If multiple tools or customizations are provided by the same\nactive client, an agent host MAY deduplicate them when exposed to a model,\nwith a preference given to the client that started the turn.\n\nMembership is host-managed: clients add (or refresh) themselves with\n`session/activeClientSet`, and the host removes them with\n`session/activeClientRemoved` when they unsubscribe, disconnect without\nreconnecting in time, or reconnect without resubscribing to the session." }, "chats": { "type": "array", @@ -2559,7 +2554,7 @@ "items": { "$ref": "#/$defs/Customization" }, - "description": "Top-level customizations active in this session.\n\nAlways one of the {@link Customization} variants:\n\n- Container customizations ({@link PluginCustomization},\n {@link DirectoryCustomization}) whose children — agents, skills,\n prompts, rules, hooks, MCP servers — live in each container's\n {@link ContainerCustomizationBase.children | `children`} array.\n- Top-level {@link McpServerCustomization} entries the host\n surfaces directly (for example a globally-configured MCP server\n that isn't bundled in a plugin or directory). MCP servers may\n also appear as children of a container.\n\nClient-published plugins arrive via\n{@link SessionActiveClient.customizations | `activeClient.customizations`}\nand the host propagates them into this list (typically with the\ncontainer's `clientId` set and `children` populated). Clients\npublish in container shape only; bare MCP servers at the top level\nare server-originated." + "description": "Top-level customizations active in this session.\n\nAlways one of the {@link Customization} variants:\n\n- Container customizations ({@link PluginCustomization},\n {@link DirectoryCustomization}) whose children — agents, skills,\n prompts, rules, hooks, MCP servers — live in each container's\n {@link ContainerCustomizationBase.children | `children`} array.\n- Top-level {@link McpServerCustomization} entries the host\n surfaces directly (for example a globally-configured MCP server\n that isn't bundled in a plugin or directory). MCP servers may\n also appear as children of a container.\n\nClient-published plugins arrive via\n{@link SessionActiveClient.customizations | `activeClients[].customizations`}\nand the host propagates them into this list (typically with the\ncontainer's `clientId` set and `children` populated). Clients\npublish in container shape only; bare MCP servers at the top level\nare server-originated." }, "changesets": { "type": "array", @@ -2577,12 +2572,13 @@ "required": [ "summary", "lifecycle", + "activeClients", "chats" ] }, "SessionActiveClient": { "type": "object", - "description": "The client currently providing tools and interactive capabilities to a session.\n\nOnly one client may be active per session at a time. The server SHOULD\nautomatically unset the active client if that client disconnects.", + "description": "A client currently providing tools and interactive capabilities to a session.\n\nA session MAY have several active clients at once; entries in\n{@link SessionState.activeClients} are keyed by `clientId`. The server SHOULD\nautomatically remove an active client when that client disconnects.", "properties": { "clientId": { "type": "string", @@ -6390,10 +6386,10 @@ "$ref": "#/$defs/SessionServerToolsChangedAction" }, { - "$ref": "#/$defs/SessionActiveClientChangedAction" + "$ref": "#/$defs/SessionActiveClientSetAction" }, { - "$ref": "#/$defs/SessionActiveClientToolsChangedAction" + "$ref": "#/$defs/SessionActiveClientRemovedAction" }, { "$ref": "#/$defs/SessionCustomizationsChangedAction" diff --git a/schema/commands.schema.json b/schema/commands.schema.json index 7a0ed130..8df2c20b 100644 --- a/schema/commands.schema.json +++ b/schema/commands.schema.json @@ -885,7 +885,7 @@ }, "activeClient": { "$ref": "#/$defs/SessionActiveClient", - "description": "Eagerly claim the active client role for the new session.\n\nWhen provided, the server initializes the session with this client as the\nactive client, equivalent to dispatching a `session/activeClientChanged`\naction immediately after creation. The `clientId` MUST match the\n`clientId` the creating client supplied in `initialize`." + "description": "Eagerly claim an active client role for the new session.\n\nWhen provided, the server initializes the session with this client as an\nactive client, equivalent to dispatching a `session/activeClientSet`\naction immediately after creation. The `clientId` MUST match the\n`clientId` the creating client supplied in `initialize`." } }, "required": [ @@ -1873,9 +1873,12 @@ }, "description": "Tools provided by the server (agent host) for this session" }, - "activeClient": { - "$ref": "#/$defs/SessionActiveClient", - "description": "The client currently providing tools and interactive capabilities to this session" + "activeClients": { + "type": "array", + "items": { + "$ref": "#/$defs/SessionActiveClient" + }, + "description": "The clients currently providing tools and interactive capabilities to this\nsession. If multiple tools or customizations are provided by the same\nactive client, an agent host MAY deduplicate them when exposed to a model,\nwith a preference given to the client that started the turn.\n\nMembership is host-managed: clients add (or refresh) themselves with\n`session/activeClientSet`, and the host removes them with\n`session/activeClientRemoved` when they unsubscribe, disconnect without\nreconnecting in time, or reconnect without resubscribing to the session." }, "chats": { "type": "array", @@ -1897,7 +1900,7 @@ "items": { "$ref": "#/$defs/Customization" }, - "description": "Top-level customizations active in this session.\n\nAlways one of the {@link Customization} variants:\n\n- Container customizations ({@link PluginCustomization},\n {@link DirectoryCustomization}) whose children — agents, skills,\n prompts, rules, hooks, MCP servers — live in each container's\n {@link ContainerCustomizationBase.children | `children`} array.\n- Top-level {@link McpServerCustomization} entries the host\n surfaces directly (for example a globally-configured MCP server\n that isn't bundled in a plugin or directory). MCP servers may\n also appear as children of a container.\n\nClient-published plugins arrive via\n{@link SessionActiveClient.customizations | `activeClient.customizations`}\nand the host propagates them into this list (typically with the\ncontainer's `clientId` set and `children` populated). Clients\npublish in container shape only; bare MCP servers at the top level\nare server-originated." + "description": "Top-level customizations active in this session.\n\nAlways one of the {@link Customization} variants:\n\n- Container customizations ({@link PluginCustomization},\n {@link DirectoryCustomization}) whose children — agents, skills,\n prompts, rules, hooks, MCP servers — live in each container's\n {@link ContainerCustomizationBase.children | `children`} array.\n- Top-level {@link McpServerCustomization} entries the host\n surfaces directly (for example a globally-configured MCP server\n that isn't bundled in a plugin or directory). MCP servers may\n also appear as children of a container.\n\nClient-published plugins arrive via\n{@link SessionActiveClient.customizations | `activeClients[].customizations`}\nand the host propagates them into this list (typically with the\ncontainer's `clientId` set and `children` populated). Clients\npublish in container shape only; bare MCP servers at the top level\nare server-originated." }, "changesets": { "type": "array", @@ -1915,12 +1918,13 @@ "required": [ "summary", "lifecycle", + "activeClients", "chats" ] }, "SessionActiveClient": { "type": "object", - "description": "The client currently providing tools and interactive capabilities to a session.\n\nOnly one client may be active per session at a time. The server SHOULD\nautomatically unset the active client if that client disconnects.", + "description": "A client currently providing tools and interactive capabilities to a session.\n\nA session MAY have several active clients at once; entries in\n{@link SessionState.activeClients} are keyed by `clientId`. The server SHOULD\nautomatically remove an active client when that client disconnects.", "properties": { "clientId": { "type": "string", @@ -5726,21 +5730,16 @@ "tools" ] }, - "SessionActiveClientChangedAction": { + "SessionActiveClientSetAction": { "type": "object", - "description": "The active client for this session has changed.\n\nA client dispatches this action with its own `SessionActiveClient` to claim\nthe active role, or with `null` to release it. The server SHOULD reject if\nanother client is already active. The server SHOULD automatically dispatch\nthis action with `activeClient: null` when the active client disconnects.", + "description": "An active client for this session was added or updated.\n\nUpsert semantics keyed by {@link SessionActiveClient.clientId | `clientId`}:\na client dispatches this action with its own `SessionActiveClient` to join\nthe session's active clients or refresh its entry, replacing any existing\nentry that has the same `clientId`. Multiple clients may be active at once.\nThis is also how a client updates its published tools or customizations —\nre-dispatch with the full, updated entry. Use\n{@link SessionActiveClientRemovedAction | `session/activeClientRemoved`} to\nleave. The server SHOULD automatically dispatch that removal when an active\nclient disconnects.", "properties": { "type": { - "$ref": "#/$defs/ActionType.SessionActiveClientChanged" + "$ref": "#/$defs/ActionType.SessionActiveClientSet" }, "activeClient": { - "oneOf": [ - { - "$ref": "#/$defs/SessionActiveClient" - }, - {} - ], - "description": "The new active client, or `null` to unset" + "$ref": "#/$defs/SessionActiveClient", + "description": "The active client to add or update, matched by `clientId`." } }, "required": [ @@ -5748,24 +5747,21 @@ "activeClient" ] }, - "SessionActiveClientToolsChangedAction": { + "SessionActiveClientRemovedAction": { "type": "object", - "description": "The active client's tool list has changed.\n\nFull-replacement semantics: the `tools` array replaces the active client's\nprevious tools entirely. The server SHOULD reject if the dispatching client\nis not the current active client.", + "description": "An active client was removed from this session.\n\nRemoves the entry for the client identified by `clientId` from\n{@link SessionState.activeClients}; a no-op when no entry matches.\n\nThe host SHOULD dispatch this automatically when a client stops participating\nin the session — for example when it unsubscribes from the session channel,\nwhen it disconnects and does not reconnect within a host-defined grace\nperiod, or when a `reconnect` command's `subscriptions` omit a session the\nclient was still active in. When removing a client, the host SHOULD also\ncancel that client's in-flight tool calls — those whose tool call state\ncarries a client `ToolCallContributor` with the matching `clientId` — by\ndispatching `chat/toolCallComplete` with `result.success = false`. (There is\nno per-tool-call server cancel; a failed completion is the cancellation\nmechanism, and the call ends in `completed` status with a failed result.)", "properties": { "type": { - "$ref": "#/$defs/ActionType.SessionActiveClientToolsChanged" + "$ref": "#/$defs/ActionType.SessionActiveClientRemoved" }, - "tools": { - "type": "array", - "items": { - "$ref": "#/$defs/ToolDefinition" - }, - "description": "Updated client tools list (full replacement)" + "clientId": { + "type": "string", + "description": "The `clientId` of the active client to remove." } }, "required": [ "type", - "tools" + "clientId" ] }, "SessionCustomizationsChangedAction": { @@ -6268,7 +6264,7 @@ }, "ChatToolCallCompleteAction": { "type": "object", - "description": "Tool execution finished. Transitions to `completed` or `pending-result-confirmation`\nif `requiresResultConfirmation` is `true`.\n\nFor client-provided tools (where `toolClientId` is set on the tool call state),\nthe owning client dispatches this action with the execution result. The server\nSHOULD reject this action if the dispatching client does not match `toolClientId`.\n\nServers waiting on a client tool call MAY time out after a reasonable duration\nif the implementing client disconnects or becomes unresponsive, and dispatch\nthis action with `result.success = false` and an appropriate error.", + "description": "Tool execution finished. Transitions to `completed` or `pending-result-confirmation`\nif `requiresResultConfirmation` is `true`.\n\nFor client-provided tools (whose tool call state carries a client\n`ToolCallContributor` with a `clientId`), the owning client dispatches this\naction with the execution result. The server SHOULD reject this action if the\ndispatching client does not match the contributor's `clientId`.\n\nServers waiting on a client tool call MAY time out after a reasonable duration\nif the implementing client disconnects or becomes unresponsive, and dispatch\nthis action with `result.success = false` and an appropriate error.", "properties": { "turnId": { "type": "string", @@ -6336,7 +6332,7 @@ }, "ChatToolCallContentChangedAction": { "type": "object", - "description": "Partial content produced while a tool is still executing.\n\nReplaces the `content` array on the running tool call state. Clients can\nuse this to display live feedback (e.g. a terminal reference) before the\ntool completes.\n\nFor client-provided tools (where `toolClientId` is set on the tool call state),\nthe owning client dispatches this action to stream intermediate content while\nexecuting. The server SHOULD reject this action if the dispatching client does\nnot match `toolClientId`.", + "description": "Partial content produced while a tool is still executing.\n\nReplaces the `content` array on the running tool call state. Clients can\nuse this to display live feedback (e.g. a terminal reference) before the\ntool completes.\n\nFor client-provided tools (whose tool call state carries a client\n`ToolCallContributor` with a `clientId`), the owning client dispatches this\naction to stream intermediate content while executing. The server SHOULD\nreject this action if the dispatching client does not match the contributor's\n`clientId`.", "properties": { "turnId": { "type": "string", diff --git a/schema/errors.schema.json b/schema/errors.schema.json index 9d8e2136..d7e341bf 100644 --- a/schema/errors.schema.json +++ b/schema/errors.schema.json @@ -725,9 +725,12 @@ }, "description": "Tools provided by the server (agent host) for this session" }, - "activeClient": { - "$ref": "#/$defs/SessionActiveClient", - "description": "The client currently providing tools and interactive capabilities to this session" + "activeClients": { + "type": "array", + "items": { + "$ref": "#/$defs/SessionActiveClient" + }, + "description": "The clients currently providing tools and interactive capabilities to this\nsession. If multiple tools or customizations are provided by the same\nactive client, an agent host MAY deduplicate them when exposed to a model,\nwith a preference given to the client that started the turn.\n\nMembership is host-managed: clients add (or refresh) themselves with\n`session/activeClientSet`, and the host removes them with\n`session/activeClientRemoved` when they unsubscribe, disconnect without\nreconnecting in time, or reconnect without resubscribing to the session." }, "chats": { "type": "array", @@ -749,7 +752,7 @@ "items": { "$ref": "#/$defs/Customization" }, - "description": "Top-level customizations active in this session.\n\nAlways one of the {@link Customization} variants:\n\n- Container customizations ({@link PluginCustomization},\n {@link DirectoryCustomization}) whose children — agents, skills,\n prompts, rules, hooks, MCP servers — live in each container's\n {@link ContainerCustomizationBase.children | `children`} array.\n- Top-level {@link McpServerCustomization} entries the host\n surfaces directly (for example a globally-configured MCP server\n that isn't bundled in a plugin or directory). MCP servers may\n also appear as children of a container.\n\nClient-published plugins arrive via\n{@link SessionActiveClient.customizations | `activeClient.customizations`}\nand the host propagates them into this list (typically with the\ncontainer's `clientId` set and `children` populated). Clients\npublish in container shape only; bare MCP servers at the top level\nare server-originated." + "description": "Top-level customizations active in this session.\n\nAlways one of the {@link Customization} variants:\n\n- Container customizations ({@link PluginCustomization},\n {@link DirectoryCustomization}) whose children — agents, skills,\n prompts, rules, hooks, MCP servers — live in each container's\n {@link ContainerCustomizationBase.children | `children`} array.\n- Top-level {@link McpServerCustomization} entries the host\n surfaces directly (for example a globally-configured MCP server\n that isn't bundled in a plugin or directory). MCP servers may\n also appear as children of a container.\n\nClient-published plugins arrive via\n{@link SessionActiveClient.customizations | `activeClients[].customizations`}\nand the host propagates them into this list (typically with the\ncontainer's `clientId` set and `children` populated). Clients\npublish in container shape only; bare MCP servers at the top level\nare server-originated." }, "changesets": { "type": "array", @@ -767,12 +770,13 @@ "required": [ "summary", "lifecycle", + "activeClients", "chats" ] }, "SessionActiveClient": { "type": "object", - "description": "The client currently providing tools and interactive capabilities to a session.\n\nOnly one client may be active per session at a time. The server SHOULD\nautomatically unset the active client if that client disconnects.", + "description": "A client currently providing tools and interactive capabilities to a session.\n\nA session MAY have several active clients at once; entries in\n{@link SessionState.activeClients} are keyed by `clientId`. The server SHOULD\nautomatically remove an active client when that client disconnects.", "properties": { "clientId": { "type": "string", @@ -5051,7 +5055,7 @@ }, "activeClient": { "$ref": "#/$defs/SessionActiveClient", - "description": "Eagerly claim the active client role for the new session.\n\nWhen provided, the server initializes the session with this client as the\nactive client, equivalent to dispatching a `session/activeClientChanged`\naction immediately after creation. The `clientId` MUST match the\n`clientId` the creating client supplied in `initialize`." + "description": "Eagerly claim an active client role for the new session.\n\nWhen provided, the server initializes the session with this client as an\nactive client, equivalent to dispatching a `session/activeClientSet`\naction immediately after creation. The `clientId` MUST match the\n`clientId` the creating client supplied in `initialize`." } }, "required": [ diff --git a/schema/notifications.schema.json b/schema/notifications.schema.json index ff363992..7f72b1c6 100644 --- a/schema/notifications.schema.json +++ b/schema/notifications.schema.json @@ -859,9 +859,12 @@ }, "description": "Tools provided by the server (agent host) for this session" }, - "activeClient": { - "$ref": "#/$defs/SessionActiveClient", - "description": "The client currently providing tools and interactive capabilities to this session" + "activeClients": { + "type": "array", + "items": { + "$ref": "#/$defs/SessionActiveClient" + }, + "description": "The clients currently providing tools and interactive capabilities to this\nsession. If multiple tools or customizations are provided by the same\nactive client, an agent host MAY deduplicate them when exposed to a model,\nwith a preference given to the client that started the turn.\n\nMembership is host-managed: clients add (or refresh) themselves with\n`session/activeClientSet`, and the host removes them with\n`session/activeClientRemoved` when they unsubscribe, disconnect without\nreconnecting in time, or reconnect without resubscribing to the session." }, "chats": { "type": "array", @@ -883,7 +886,7 @@ "items": { "$ref": "#/$defs/Customization" }, - "description": "Top-level customizations active in this session.\n\nAlways one of the {@link Customization} variants:\n\n- Container customizations ({@link PluginCustomization},\n {@link DirectoryCustomization}) whose children — agents, skills,\n prompts, rules, hooks, MCP servers — live in each container's\n {@link ContainerCustomizationBase.children | `children`} array.\n- Top-level {@link McpServerCustomization} entries the host\n surfaces directly (for example a globally-configured MCP server\n that isn't bundled in a plugin or directory). MCP servers may\n also appear as children of a container.\n\nClient-published plugins arrive via\n{@link SessionActiveClient.customizations | `activeClient.customizations`}\nand the host propagates them into this list (typically with the\ncontainer's `clientId` set and `children` populated). Clients\npublish in container shape only; bare MCP servers at the top level\nare server-originated." + "description": "Top-level customizations active in this session.\n\nAlways one of the {@link Customization} variants:\n\n- Container customizations ({@link PluginCustomization},\n {@link DirectoryCustomization}) whose children — agents, skills,\n prompts, rules, hooks, MCP servers — live in each container's\n {@link ContainerCustomizationBase.children | `children`} array.\n- Top-level {@link McpServerCustomization} entries the host\n surfaces directly (for example a globally-configured MCP server\n that isn't bundled in a plugin or directory). MCP servers may\n also appear as children of a container.\n\nClient-published plugins arrive via\n{@link SessionActiveClient.customizations | `activeClients[].customizations`}\nand the host propagates them into this list (typically with the\ncontainer's `clientId` set and `children` populated). Clients\npublish in container shape only; bare MCP servers at the top level\nare server-originated." }, "changesets": { "type": "array", @@ -901,12 +904,13 @@ "required": [ "summary", "lifecycle", + "activeClients", "chats" ] }, "SessionActiveClient": { "type": "object", - "description": "The client currently providing tools and interactive capabilities to a session.\n\nOnly one client may be active per session at a time. The server SHOULD\nautomatically unset the active client if that client disconnects.", + "description": "A client currently providing tools and interactive capabilities to a session.\n\nA session MAY have several active clients at once; entries in\n{@link SessionState.activeClients} are keyed by `clientId`. The server SHOULD\nautomatically remove an active client when that client disconnects.", "properties": { "clientId": { "type": "string", diff --git a/schema/state.schema.json b/schema/state.schema.json index b2b132f2..a47850dc 100644 --- a/schema/state.schema.json +++ b/schema/state.schema.json @@ -636,9 +636,12 @@ }, "description": "Tools provided by the server (agent host) for this session" }, - "activeClient": { - "$ref": "#/$defs/SessionActiveClient", - "description": "The client currently providing tools and interactive capabilities to this session" + "activeClients": { + "type": "array", + "items": { + "$ref": "#/$defs/SessionActiveClient" + }, + "description": "The clients currently providing tools and interactive capabilities to this\nsession. If multiple tools or customizations are provided by the same\nactive client, an agent host MAY deduplicate them when exposed to a model,\nwith a preference given to the client that started the turn.\n\nMembership is host-managed: clients add (or refresh) themselves with\n`session/activeClientSet`, and the host removes them with\n`session/activeClientRemoved` when they unsubscribe, disconnect without\nreconnecting in time, or reconnect without resubscribing to the session." }, "chats": { "type": "array", @@ -660,7 +663,7 @@ "items": { "$ref": "#/$defs/Customization" }, - "description": "Top-level customizations active in this session.\n\nAlways one of the {@link Customization} variants:\n\n- Container customizations ({@link PluginCustomization},\n {@link DirectoryCustomization}) whose children — agents, skills,\n prompts, rules, hooks, MCP servers — live in each container's\n {@link ContainerCustomizationBase.children | `children`} array.\n- Top-level {@link McpServerCustomization} entries the host\n surfaces directly (for example a globally-configured MCP server\n that isn't bundled in a plugin or directory). MCP servers may\n also appear as children of a container.\n\nClient-published plugins arrive via\n{@link SessionActiveClient.customizations | `activeClient.customizations`}\nand the host propagates them into this list (typically with the\ncontainer's `clientId` set and `children` populated). Clients\npublish in container shape only; bare MCP servers at the top level\nare server-originated." + "description": "Top-level customizations active in this session.\n\nAlways one of the {@link Customization} variants:\n\n- Container customizations ({@link PluginCustomization},\n {@link DirectoryCustomization}) whose children — agents, skills,\n prompts, rules, hooks, MCP servers — live in each container's\n {@link ContainerCustomizationBase.children | `children`} array.\n- Top-level {@link McpServerCustomization} entries the host\n surfaces directly (for example a globally-configured MCP server\n that isn't bundled in a plugin or directory). MCP servers may\n also appear as children of a container.\n\nClient-published plugins arrive via\n{@link SessionActiveClient.customizations | `activeClients[].customizations`}\nand the host propagates them into this list (typically with the\ncontainer's `clientId` set and `children` populated). Clients\npublish in container shape only; bare MCP servers at the top level\nare server-originated." }, "changesets": { "type": "array", @@ -678,12 +681,13 @@ "required": [ "summary", "lifecycle", + "activeClients", "chats" ] }, "SessionActiveClient": { "type": "object", - "description": "The client currently providing tools and interactive capabilities to a session.\n\nOnly one client may be active per session at a time. The server SHOULD\nautomatically unset the active client if that client disconnects.", + "description": "A client currently providing tools and interactive capabilities to a session.\n\nA session MAY have several active clients at once; entries in\n{@link SessionState.activeClients} are keyed by `clientId`. The server SHOULD\nautomatically remove an active client when that client disconnects.", "properties": { "clientId": { "type": "string", diff --git a/scripts/generate-go.ts b/scripts/generate-go.ts index ed5c72d2..014312ab 100644 --- a/scripts/generate-go.ts +++ b/scripts/generate-go.ts @@ -1246,8 +1246,8 @@ const ACTION_VARIANTS: { { type: 'session/activityChanged', variantName: 'SessionActivityChanged', tsInterface: 'SessionActivityChangedAction' }, { type: 'session/changesetsChanged', variantName: 'SessionChangesetsChanged', tsInterface: 'SessionChangesetsChangedAction' }, { type: 'session/serverToolsChanged', variantName: 'SessionServerToolsChanged', tsInterface: 'SessionServerToolsChangedAction' }, - { type: 'session/activeClientChanged', variantName: 'SessionActiveClientChanged', tsInterface: 'SessionActiveClientChangedAction' }, - { type: 'session/activeClientToolsChanged', variantName: 'SessionActiveClientToolsChanged', tsInterface: 'SessionActiveClientToolsChangedAction' }, + { type: 'session/activeClientSet', variantName: 'SessionActiveClientSet', tsInterface: 'SessionActiveClientSetAction' }, + { type: 'session/activeClientRemoved', variantName: 'SessionActiveClientRemoved', tsInterface: 'SessionActiveClientRemovedAction' }, { type: 'session/customizationsChanged', variantName: 'SessionCustomizationsChanged', tsInterface: 'SessionCustomizationsChangedAction' }, { type: 'session/customizationToggled', variantName: 'SessionCustomizationToggled', tsInterface: 'SessionCustomizationToggledAction' }, { type: 'session/customizationUpdated', variantName: 'SessionCustomizationUpdated', tsInterface: 'SessionCustomizationUpdatedAction' }, diff --git a/scripts/generate-kotlin.ts b/scripts/generate-kotlin.ts index 2e10f165..8201a3ed 100644 --- a/scripts/generate-kotlin.ts +++ b/scripts/generate-kotlin.ts @@ -1161,8 +1161,8 @@ const ACTION_VARIANTS: { type: string; caseName: string; tsInterface: string }[] { type: 'session/activityChanged', caseName: 'SessionActivityChanged', tsInterface: 'SessionActivityChangedAction' }, { type: 'session/changesetsChanged', caseName: 'SessionChangesetsChanged', tsInterface: 'SessionChangesetsChangedAction' }, { type: 'session/serverToolsChanged', caseName: 'SessionServerToolsChanged', tsInterface: 'SessionServerToolsChangedAction' }, - { type: 'session/activeClientChanged', caseName: 'SessionActiveClientChanged', tsInterface: 'SessionActiveClientChangedAction' }, - { type: 'session/activeClientToolsChanged', caseName: 'SessionActiveClientToolsChanged', tsInterface: 'SessionActiveClientToolsChangedAction' }, + { type: 'session/activeClientSet', caseName: 'SessionActiveClientSet', tsInterface: 'SessionActiveClientSetAction' }, + { type: 'session/activeClientRemoved', caseName: 'SessionActiveClientRemoved', tsInterface: 'SessionActiveClientRemovedAction' }, { type: 'chat/pendingMessageSet', caseName: 'ChatPendingMessageSet', tsInterface: 'ChatPendingMessageSetAction' }, { type: 'chat/pendingMessageRemoved', caseName: 'ChatPendingMessageRemoved', tsInterface: 'ChatPendingMessageRemovedAction' }, { type: 'chat/queuedMessagesReordered', caseName: 'ChatQueuedMessagesReordered', tsInterface: 'ChatQueuedMessagesReorderedAction' }, diff --git a/scripts/generate-rust.ts b/scripts/generate-rust.ts index b41e0578..55747102 100644 --- a/scripts/generate-rust.ts +++ b/scripts/generate-rust.ts @@ -1148,8 +1148,8 @@ const ACTION_VARIANTS: { { type: 'session/activityChanged', variantName: 'SessionActivityChanged', tsInterface: 'SessionActivityChangedAction' }, { type: 'session/changesetsChanged', variantName: 'SessionChangesetsChanged', tsInterface: 'SessionChangesetsChangedAction' }, { type: 'session/serverToolsChanged', variantName: 'SessionServerToolsChanged', tsInterface: 'SessionServerToolsChangedAction' }, - { type: 'session/activeClientChanged', variantName: 'SessionActiveClientChanged', tsInterface: 'SessionActiveClientChangedAction' }, - { type: 'session/activeClientToolsChanged', variantName: 'SessionActiveClientToolsChanged', tsInterface: 'SessionActiveClientToolsChangedAction' }, + { type: 'session/activeClientSet', variantName: 'SessionActiveClientSet', tsInterface: 'SessionActiveClientSetAction' }, + { type: 'session/activeClientRemoved', variantName: 'SessionActiveClientRemoved', tsInterface: 'SessionActiveClientRemovedAction' }, { type: 'chat/pendingMessageSet', variantName: 'ChatPendingMessageSet', tsInterface: 'ChatPendingMessageSetAction' }, { type: 'chat/pendingMessageRemoved', variantName: 'ChatPendingMessageRemoved', tsInterface: 'ChatPendingMessageRemovedAction' }, { type: 'chat/queuedMessagesReordered', variantName: 'ChatQueuedMessagesReordered', tsInterface: 'ChatQueuedMessagesReorderedAction' }, diff --git a/scripts/generate-swift.ts b/scripts/generate-swift.ts index fa2c53cc..90aac11a 100644 --- a/scripts/generate-swift.ts +++ b/scripts/generate-swift.ts @@ -1065,8 +1065,8 @@ const ACTION_VARIANTS: { type: string; caseName: string; tsInterface: string }[] { type: 'session/activityChanged', caseName: 'sessionActivityChanged', tsInterface: 'SessionActivityChangedAction' }, { type: 'session/changesetsChanged', caseName: 'sessionChangesetsChanged', tsInterface: 'SessionChangesetsChangedAction' }, { type: 'session/serverToolsChanged', caseName: 'sessionServerToolsChanged', tsInterface: 'SessionServerToolsChangedAction' }, - { type: 'session/activeClientChanged', caseName: 'sessionActiveClientChanged', tsInterface: 'SessionActiveClientChangedAction' }, - { type: 'session/activeClientToolsChanged', caseName: 'sessionActiveClientToolsChanged', tsInterface: 'SessionActiveClientToolsChangedAction' }, + { type: 'session/activeClientSet', caseName: 'sessionActiveClientSet', tsInterface: 'SessionActiveClientSetAction' }, + { type: 'session/activeClientRemoved', caseName: 'sessionActiveClientRemoved', tsInterface: 'SessionActiveClientRemovedAction' }, { type: 'chat/pendingMessageSet', caseName: 'chatPendingMessageSet', tsInterface: 'ChatPendingMessageSetAction' }, { type: 'chat/pendingMessageRemoved', caseName: 'chatPendingMessageRemoved', tsInterface: 'ChatPendingMessageRemovedAction' }, { type: 'chat/queuedMessagesReordered', caseName: 'chatQueuedMessagesReordered', tsInterface: 'ChatQueuedMessagesReorderedAction' }, diff --git a/types/action-origin.generated.ts b/types/action-origin.generated.ts index 4f1b9591..02a7c588 100644 --- a/types/action-origin.generated.ts +++ b/types/action-origin.generated.ts @@ -17,8 +17,8 @@ import type { SessionModelChangedAction, SessionAgentChangedAction, SessionServerToolsChangedAction, - SessionActiveClientChangedAction, - SessionActiveClientToolsChangedAction, + SessionActiveClientSetAction, + SessionActiveClientRemovedAction, SessionCustomizationsChangedAction, SessionCustomizationToggledAction, SessionCustomizationUpdatedAction, @@ -114,8 +114,8 @@ export type SessionAction = | SessionModelChangedAction | SessionAgentChangedAction | SessionServerToolsChangedAction - | SessionActiveClientChangedAction - | SessionActiveClientToolsChangedAction + | SessionActiveClientSetAction + | SessionActiveClientRemovedAction | SessionCustomizationsChangedAction | SessionCustomizationToggledAction | SessionCustomizationUpdatedAction @@ -134,8 +134,8 @@ export type ClientSessionAction = | SessionTitleChangedAction | SessionModelChangedAction | SessionAgentChangedAction - | SessionActiveClientChangedAction - | SessionActiveClientToolsChangedAction + | SessionActiveClientSetAction + | SessionActiveClientRemovedAction | SessionCustomizationToggledAction | SessionIsReadChangedAction | SessionIsArchivedChangedAction @@ -336,8 +336,8 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in StateAction['type']]: bool [ActionType.SessionModelChanged]: true, [ActionType.SessionAgentChanged]: true, [ActionType.SessionServerToolsChanged]: false, - [ActionType.SessionActiveClientChanged]: true, - [ActionType.SessionActiveClientToolsChanged]: true, + [ActionType.SessionActiveClientSet]: true, + [ActionType.SessionActiveClientRemoved]: true, [ActionType.SessionCustomizationsChanged]: false, [ActionType.SessionCustomizationToggled]: true, [ActionType.SessionCustomizationUpdated]: false, diff --git a/types/channels-chat/actions.ts b/types/channels-chat/actions.ts index a9d4904d..62543ca5 100644 --- a/types/channels-chat/actions.ts +++ b/types/channels-chat/actions.ts @@ -271,9 +271,10 @@ export type ChatToolCallConfirmedAction = * 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 @@ -313,10 +314,11 @@ export interface ChatToolCallResultConfirmedAction extends ToolCallActionBase { * 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`. * * @category Chat Actions * @version 1 diff --git a/types/channels-session/actions.ts b/types/channels-session/actions.ts index acdfa979..1ecf0d1a 100644 --- a/types/channels-session/actions.ts +++ b/types/channels-session/actions.ts @@ -243,38 +243,53 @@ export interface SessionServerToolsChangedAction { } /** - * The active client for this session has changed. - * - * 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. + * An active client for this session was added or updated. + * + * 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. * * @category Session Actions * @version 1 * @clientDispatchable */ -export interface SessionActiveClientChangedAction { - type: ActionType.SessionActiveClientChanged; - /** The new active client, or `null` to unset */ - activeClient: SessionActiveClient | null; +export interface SessionActiveClientSetAction { + type: ActionType.SessionActiveClientSet; + /** The active client to add or update, matched by `clientId`. */ + activeClient: SessionActiveClient; } /** - * The active client's tool list has changed. - * - * 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. + * An active client was removed from this session. + * + * 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.) * * @category Session Actions * @version 1 * @clientDispatchable */ -export interface SessionActiveClientToolsChangedAction { - type: ActionType.SessionActiveClientToolsChanged; - /** Updated client tools list (full replacement) */ - tools: ToolDefinition[]; +export interface SessionActiveClientRemovedAction { + type: ActionType.SessionActiveClientRemoved; + /** The `clientId` of the active client to remove. */ + clientId: string; } // ─── Customization Actions ─────────────────────────────────────────────────── diff --git a/types/channels-session/commands.ts b/types/channels-session/commands.ts index 560d9b56..fea2d433 100644 --- a/types/channels-session/commands.ts +++ b/types/channels-session/commands.ts @@ -90,10 +90,10 @@ export interface CreateSessionParams extends BaseParams { */ config?: Record; /** - * 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`. */ diff --git a/types/channels-session/reducer.ts b/types/channels-session/reducer.ts index 8e902cb0..e860c725 100644 --- a/types/channels-session/reducer.ts +++ b/types/channels-session/reducer.ts @@ -159,20 +159,27 @@ export function sessionReducer(state: SessionState, action: SessionAction, log?: case ActionType.SessionServerToolsChanged: return { ...state, serverTools: action.tools }; - case ActionType.SessionActiveClientChanged: - return { - ...state, - activeClient: action.activeClient ?? undefined, - }; + case ActionType.SessionActiveClientSet: { + const list = state.activeClients; + const idx = list.findIndex(c => c.clientId === action.activeClient.clientId); + if (idx < 0) { + return { ...state, activeClients: [...list, action.activeClient] }; + } + const updated = list.slice(); + updated[idx] = action.activeClient; + return { ...state, activeClients: updated }; + } - case ActionType.SessionActiveClientToolsChanged: - if (!state.activeClient) { + case ActionType.SessionActiveClientRemoved: { + const list = state.activeClients; + const idx = list.findIndex(c => c.clientId === action.clientId); + if (idx < 0) { return state; } - return { - ...state, - activeClient: { ...state.activeClient, tools: action.tools }, - }; + const updated = list.slice(); + updated.splice(idx, 1); + return { ...state, activeClients: updated }; + } // ── Customizations ────────────────────────────────────────────────── diff --git a/types/channels-session/state.ts b/types/channels-session/state.ts index 97d7222a..76d25bd2 100644 --- a/types/channels-session/state.ts +++ b/types/channels-session/state.ts @@ -68,8 +68,18 @@ export interface SessionState { creationError?: ErrorInfo; /** Tools provided by the server (agent host) for this session */ serverTools?: ToolDefinition[]; - /** The client currently providing tools and interactive capabilities to this session */ - activeClient?: SessionActiveClient; + /** + * 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[]; /** Catalog of chats in this session. */ chats: ChatSummary[]; /** @@ -96,7 +106,7 @@ export interface SessionState { * 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 @@ -122,10 +132,11 @@ export interface SessionState { } /** - * 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. * * @category Session State */ diff --git a/types/common/actions.ts b/types/common/actions.ts index d4710193..8687d706 100644 --- a/types/common/actions.ts +++ b/types/common/actions.ts @@ -26,8 +26,8 @@ import type { SessionModelChangedAction, SessionAgentChangedAction, SessionServerToolsChangedAction, - SessionActiveClientChangedAction, - SessionActiveClientToolsChangedAction, + SessionActiveClientSetAction, + SessionActiveClientRemovedAction, SessionCustomizationsChangedAction, SessionCustomizationToggledAction, SessionCustomizationUpdatedAction, @@ -137,8 +137,8 @@ export const enum ActionType { SessionModelChanged = 'session/modelChanged', SessionAgentChanged = 'session/agentChanged', SessionServerToolsChanged = 'session/serverToolsChanged', - SessionActiveClientChanged = 'session/activeClientChanged', - SessionActiveClientToolsChanged = 'session/activeClientToolsChanged', + SessionActiveClientSet = 'session/activeClientSet', + SessionActiveClientRemoved = 'session/activeClientRemoved', ChatPendingMessageSet = 'chat/pendingMessageSet', ChatPendingMessageRemoved = 'chat/pendingMessageRemoved', ChatQueuedMessagesReordered = 'chat/queuedMessagesReordered', @@ -233,8 +233,8 @@ export type StateAction = | SessionModelChangedAction | SessionAgentChangedAction | SessionServerToolsChangedAction - | SessionActiveClientChangedAction - | SessionActiveClientToolsChangedAction + | SessionActiveClientSetAction + | SessionActiveClientRemovedAction | SessionCustomizationsChangedAction | SessionCustomizationToggledAction | SessionCustomizationUpdatedAction diff --git a/types/index.ts b/types/index.ts index 9244e5fd..88b2db44 100644 --- a/types/index.ts +++ b/types/index.ts @@ -166,8 +166,8 @@ export type { SessionModelChangedAction, SessionAgentChangedAction, SessionServerToolsChangedAction, - SessionActiveClientChangedAction, - SessionActiveClientToolsChangedAction, + SessionActiveClientSetAction, + SessionActiveClientRemovedAction, ChatPendingMessageSetAction, ChatPendingMessageRemovedAction, ChatQueuedMessagesReorderedAction, diff --git a/types/test-cases/reducers/003-session-ready.json b/types/test-cases/reducers/003-session-ready.json index 575f9c79..79bdf3cc 100644 --- a/types/test-cases/reducers/003-session-ready.json +++ b/types/test-cases/reducers/003-session-ready.json @@ -11,6 +11,7 @@ "modifiedAt": 1000 }, "lifecycle": "creating", + "activeClients": [], "chats": [] }, "actions": [ @@ -28,6 +29,7 @@ "modifiedAt": 1000 }, "lifecycle": "ready", + "activeClients": [], "chats": [] } } diff --git a/types/test-cases/reducers/004-session-creationfailed.json b/types/test-cases/reducers/004-session-creationfailed.json index aa25cdd6..ca7e6c58 100644 --- a/types/test-cases/reducers/004-session-creationfailed.json +++ b/types/test-cases/reducers/004-session-creationfailed.json @@ -11,6 +11,7 @@ "modifiedAt": 1000 }, "lifecycle": "creating", + "activeClients": [], "chats": [] }, "actions": [ @@ -36,6 +37,7 @@ "errorType": "init", "message": "Failed to start" }, + "activeClients": [], "chats": [] } } diff --git a/types/test-cases/reducers/030-session-titlechanged-updates-title.json b/types/test-cases/reducers/030-session-titlechanged-updates-title.json index 9a8af04a..fd3d18eb 100644 --- a/types/test-cases/reducers/030-session-titlechanged-updates-title.json +++ b/types/test-cases/reducers/030-session-titlechanged-updates-title.json @@ -11,6 +11,7 @@ "modifiedAt": 1000 }, "lifecycle": "creating", + "activeClients": [], "chats": [] }, "actions": [ @@ -29,6 +30,7 @@ "modifiedAt": 9999 }, "lifecycle": "creating", + "activeClients": [], "chats": [] } } diff --git a/types/test-cases/reducers/033-session-modelchanged-updates-model.json b/types/test-cases/reducers/033-session-modelchanged-updates-model.json index 61bf7a6e..d4f1284a 100644 --- a/types/test-cases/reducers/033-session-modelchanged-updates-model.json +++ b/types/test-cases/reducers/033-session-modelchanged-updates-model.json @@ -11,6 +11,7 @@ "modifiedAt": 1000 }, "lifecycle": "creating", + "activeClients": [], "chats": [] }, "actions": [ @@ -34,6 +35,7 @@ } }, "lifecycle": "creating", + "activeClients": [], "chats": [] } } diff --git a/types/test-cases/reducers/034-session-servertoolschanged-sets-server-tools.json b/types/test-cases/reducers/034-session-servertoolschanged-sets-server-tools.json index 67919eed..855e054e 100644 --- a/types/test-cases/reducers/034-session-servertoolschanged-sets-server-tools.json +++ b/types/test-cases/reducers/034-session-servertoolschanged-sets-server-tools.json @@ -11,6 +11,7 @@ "modifiedAt": 1000 }, "lifecycle": "creating", + "activeClients": [], "chats": [] }, "actions": [ @@ -40,6 +41,7 @@ "description": "Run shell commands" } ], + "activeClients": [], "chats": [] } } diff --git a/types/test-cases/reducers/035-session-activeclientchanged-sets-client.json b/types/test-cases/reducers/035-session-activeclientset-adds-client.json similarity index 73% rename from types/test-cases/reducers/035-session-activeclientchanged-sets-client.json rename to types/test-cases/reducers/035-session-activeclientset-adds-client.json index 640d02a3..e00dc579 100644 --- a/types/test-cases/reducers/035-session-activeclientchanged-sets-client.json +++ b/types/test-cases/reducers/035-session-activeclientset-adds-client.json @@ -1,5 +1,5 @@ { - "description": "session/activeClientChanged sets client", + "description": "session/activeClientSet adds a client", "reducer": "session", "initial": { "summary": { @@ -11,11 +11,12 @@ "modifiedAt": 1000 }, "lifecycle": "creating", + "activeClients": [], "chats": [] }, "actions": [ { - "type": "session/activeClientChanged", + "type": "session/activeClientSet", "activeClient": { "clientId": "vscode-1", "displayName": "VS Code", @@ -33,11 +34,13 @@ "modifiedAt": 1000 }, "lifecycle": "creating", - "activeClient": { - "clientId": "vscode-1", - "displayName": "VS Code", - "tools": [] - }, + "activeClients": [ + { + "clientId": "vscode-1", + "displayName": "VS Code", + "tools": [] + } + ], "chats": [] } } diff --git a/types/test-cases/reducers/036-session-activeclientset-replaces-client.json b/types/test-cases/reducers/036-session-activeclientset-replaces-client.json new file mode 100644 index 00000000..c250e857 --- /dev/null +++ b/types/test-cases/reducers/036-session-activeclientset-replaces-client.json @@ -0,0 +1,62 @@ +{ + "description": "session/activeClientSet replaces an existing client", + "reducer": "session", + "initial": { + "summary": { + "resource": "copilot:/test-session", + "provider": "copilot", + "title": "Test Session", + "status": 1, + "createdAt": 1000, + "modifiedAt": 1000 + }, + "lifecycle": "creating", + "activeClients": [ + { + "clientId": "vscode-1", + "displayName": "VS Code", + "tools": [] + } + ], + "chats": [] + }, + "actions": [ + { + "type": "session/activeClientSet", + "activeClient": { + "clientId": "vscode-1", + "displayName": "VS Code Insiders", + "tools": [ + { + "name": "openFile", + "description": "Open a file" + } + ] + } + } + ], + "expected": { + "summary": { + "resource": "copilot:/test-session", + "provider": "copilot", + "title": "Test Session", + "status": 1, + "createdAt": 1000, + "modifiedAt": 1000 + }, + "lifecycle": "creating", + "activeClients": [ + { + "clientId": "vscode-1", + "displayName": "VS Code Insiders", + "tools": [ + { + "name": "openFile", + "description": "Open a file" + } + ] + } + ], + "chats": [] + } +} diff --git a/types/test-cases/reducers/037-session-activeclienttoolschanged-updates-tools.json b/types/test-cases/reducers/037-session-activeclienttoolschanged-updates-tools.json deleted file mode 100644 index c895cd60..00000000 --- a/types/test-cases/reducers/037-session-activeclienttoolschanged-updates-tools.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "description": "session/activeClientToolsChanged updates tools", - "reducer": "session", - "initial": { - "summary": { - "resource": "copilot:/test-session", - "provider": "copilot", - "title": "Test Session", - "status": 1, - "createdAt": 1000, - "modifiedAt": 1000 - }, - "lifecycle": "creating", - "activeClient": { - "clientId": "vscode-1", - "tools": [] - }, - "chats": [] - }, - "actions": [ - { - "type": "session/activeClientToolsChanged", - "tools": [ - { - "name": "openFile", - "description": "Open a file" - } - ] - } - ], - "expected": { - "summary": { - "resource": "copilot:/test-session", - "provider": "copilot", - "title": "Test Session", - "status": 1, - "createdAt": 1000, - "modifiedAt": 1000 - }, - "lifecycle": "creating", - "activeClient": { - "clientId": "vscode-1", - "tools": [ - { - "name": "openFile", - "description": "Open a file" - } - ] - }, - "chats": [] - } -} diff --git a/types/test-cases/reducers/058-session-customizationschanged-replaces-entire-list.json b/types/test-cases/reducers/058-session-customizationschanged-replaces-entire-list.json index 77509bbb..3f2f8b9a 100644 --- a/types/test-cases/reducers/058-session-customizationschanged-replaces-entire-list.json +++ b/types/test-cases/reducers/058-session-customizationschanged-replaces-entire-list.json @@ -11,6 +11,7 @@ "modifiedAt": 1000 }, "lifecycle": "creating", + "activeClients": [], "chats": [] }, "actions": [ @@ -62,6 +63,7 @@ "clientId": "client-1" } ], + "activeClients": [], "chats": [] } } diff --git a/types/test-cases/reducers/059-session-customizationschanged-replaces-existing-customizations.json b/types/test-cases/reducers/059-session-customizationschanged-replaces-existing-customizations.json index 0b45b67e..edd655b3 100644 --- a/types/test-cases/reducers/059-session-customizationschanged-replaces-existing-customizations.json +++ b/types/test-cases/reducers/059-session-customizationschanged-replaces-existing-customizations.json @@ -20,6 +20,7 @@ "enabled": true } ], + "activeClients": [], "chats": [] }, "actions": [ @@ -55,6 +56,7 @@ "enabled": false } ], + "activeClients": [], "chats": [] } } diff --git a/types/test-cases/reducers/060-session-customizationtoggled-toggles-by-id.json b/types/test-cases/reducers/060-session-customizationtoggled-toggles-by-id.json index 43904b20..5e9f4132 100644 --- a/types/test-cases/reducers/060-session-customizationtoggled-toggles-by-id.json +++ b/types/test-cases/reducers/060-session-customizationtoggled-toggles-by-id.json @@ -27,6 +27,7 @@ "enabled": true } ], + "activeClients": [], "chats": [] }, "actions": [ @@ -62,6 +63,7 @@ "enabled": true } ], + "activeClients": [], "chats": [] } } diff --git a/types/test-cases/reducers/061-session-customizationtoggled-is-no-op-for-unknown-id.json b/types/test-cases/reducers/061-session-customizationtoggled-is-no-op-for-unknown-id.json index 3df3cc84..2ade8b74 100644 --- a/types/test-cases/reducers/061-session-customizationtoggled-is-no-op-for-unknown-id.json +++ b/types/test-cases/reducers/061-session-customizationtoggled-is-no-op-for-unknown-id.json @@ -20,6 +20,7 @@ "enabled": true } ], + "activeClients": [], "chats": [] }, "actions": [ @@ -48,6 +49,7 @@ "enabled": true } ], + "activeClients": [], "chats": [] } } diff --git a/types/test-cases/reducers/062-session-customizationtoggled-is-no-op-when-customizations-undefined.json b/types/test-cases/reducers/062-session-customizationtoggled-is-no-op-when-customizations-undefined.json index 630a0805..b9b90d85 100644 --- a/types/test-cases/reducers/062-session-customizationtoggled-is-no-op-when-customizations-undefined.json +++ b/types/test-cases/reducers/062-session-customizationtoggled-is-no-op-when-customizations-undefined.json @@ -11,6 +11,7 @@ "modifiedAt": 1000 }, "lifecycle": "creating", + "activeClients": [], "chats": [] }, "actions": [ @@ -30,6 +31,7 @@ "modifiedAt": 1000 }, "lifecycle": "creating", + "activeClients": [], "chats": [] } } diff --git a/types/test-cases/reducers/071-session-isreadchanged-marks-session-as-read.json b/types/test-cases/reducers/071-session-isreadchanged-marks-session-as-read.json index 7d03fe34..b1eab884 100644 --- a/types/test-cases/reducers/071-session-isreadchanged-marks-session-as-read.json +++ b/types/test-cases/reducers/071-session-isreadchanged-marks-session-as-read.json @@ -11,6 +11,7 @@ "modifiedAt": 1000 }, "lifecycle": "ready", + "activeClients": [], "chats": [] }, "actions": [ @@ -29,6 +30,7 @@ "modifiedAt": 1000 }, "lifecycle": "ready", + "activeClients": [], "chats": [] } } diff --git a/types/test-cases/reducers/072-session-isreadchanged-marks-session-as-unread.json b/types/test-cases/reducers/072-session-isreadchanged-marks-session-as-unread.json index 94959691..0d5eaecd 100644 --- a/types/test-cases/reducers/072-session-isreadchanged-marks-session-as-unread.json +++ b/types/test-cases/reducers/072-session-isreadchanged-marks-session-as-unread.json @@ -11,6 +11,7 @@ "modifiedAt": 1000 }, "lifecycle": "ready", + "activeClients": [], "chats": [] }, "actions": [ @@ -29,6 +30,7 @@ "modifiedAt": 1000 }, "lifecycle": "ready", + "activeClients": [], "chats": [] } } diff --git a/types/test-cases/reducers/073-session-isarchivedchanged-marks-session-as-archived.json b/types/test-cases/reducers/073-session-isarchivedchanged-marks-session-as-archived.json index 738e724a..eb997382 100644 --- a/types/test-cases/reducers/073-session-isarchivedchanged-marks-session-as-archived.json +++ b/types/test-cases/reducers/073-session-isarchivedchanged-marks-session-as-archived.json @@ -11,6 +11,7 @@ "modifiedAt": 1000 }, "lifecycle": "ready", + "activeClients": [], "chats": [] }, "actions": [ @@ -29,6 +30,7 @@ "modifiedAt": 1000 }, "lifecycle": "ready", + "activeClients": [], "chats": [] } } diff --git a/types/test-cases/reducers/074-session-isarchivedchanged-unarchives-session.json b/types/test-cases/reducers/074-session-isarchivedchanged-unarchives-session.json index 299a9df6..159dd247 100644 --- a/types/test-cases/reducers/074-session-isarchivedchanged-unarchives-session.json +++ b/types/test-cases/reducers/074-session-isarchivedchanged-unarchives-session.json @@ -11,6 +11,7 @@ "modifiedAt": 1000 }, "lifecycle": "ready", + "activeClients": [], "chats": [] }, "actions": [ @@ -29,6 +30,7 @@ "modifiedAt": 1000 }, "lifecycle": "ready", + "activeClients": [], "chats": [] } } diff --git a/types/test-cases/reducers/088-session-unknown-action-type-is-no-op.json b/types/test-cases/reducers/088-session-unknown-action-type-is-no-op.json index c12d9f4f..1982a8a5 100644 --- a/types/test-cases/reducers/088-session-unknown-action-type-is-no-op.json +++ b/types/test-cases/reducers/088-session-unknown-action-type-is-no-op.json @@ -11,6 +11,7 @@ "modifiedAt": 2000 }, "lifecycle": "ready", + "activeClients": [], "chats": [] }, "actions": [ @@ -28,6 +29,7 @@ "modifiedAt": 2000 }, "lifecycle": "ready", + "activeClients": [], "chats": [] } } diff --git a/types/test-cases/reducers/113-session-configchanged-merges-into-config-values.json b/types/test-cases/reducers/113-session-configchanged-merges-into-config-values.json index 23e47469..20dddb34 100644 --- a/types/test-cases/reducers/113-session-configchanged-merges-into-config-values.json +++ b/types/test-cases/reducers/113-session-configchanged-merges-into-config-values.json @@ -38,6 +38,7 @@ } }, "lifecycle": "ready", + "activeClients": [], "chats": [] }, "actions": [ @@ -85,6 +86,7 @@ } }, "lifecycle": "ready", + "activeClients": [], "chats": [] } } diff --git a/types/test-cases/reducers/114-session-configchanged-noops-when-config-undefined.json b/types/test-cases/reducers/114-session-configchanged-noops-when-config-undefined.json index 412f0d6c..ca7dfb89 100644 --- a/types/test-cases/reducers/114-session-configchanged-noops-when-config-undefined.json +++ b/types/test-cases/reducers/114-session-configchanged-noops-when-config-undefined.json @@ -11,6 +11,7 @@ "modifiedAt": 2000 }, "lifecycle": "ready", + "activeClients": [], "chats": [] }, "actions": [ @@ -31,6 +32,7 @@ "modifiedAt": 2000 }, "lifecycle": "ready", + "activeClients": [], "chats": [] } } diff --git a/types/test-cases/reducers/126-session-modelchanged-with-modelconfig.json b/types/test-cases/reducers/126-session-modelchanged-with-modelconfig.json index 3b78b2d1..63edcfe1 100644 --- a/types/test-cases/reducers/126-session-modelchanged-with-modelconfig.json +++ b/types/test-cases/reducers/126-session-modelchanged-with-modelconfig.json @@ -14,6 +14,7 @@ } }, "lifecycle": "ready", + "activeClients": [], "chats": [] }, "actions": [ @@ -43,6 +44,7 @@ } }, "lifecycle": "ready", + "activeClients": [], "chats": [] } } diff --git a/types/test-cases/reducers/129-session-configchanged-replace-replaces-all-values.json b/types/test-cases/reducers/129-session-configchanged-replace-replaces-all-values.json index 29fc2907..5f447c2f 100644 --- a/types/test-cases/reducers/129-session-configchanged-replace-replaces-all-values.json +++ b/types/test-cases/reducers/129-session-configchanged-replace-replaces-all-values.json @@ -38,6 +38,7 @@ } }, "lifecycle": "ready", + "activeClients": [], "chats": [] }, "actions": [ @@ -85,6 +86,7 @@ } }, "lifecycle": "ready", + "activeClients": [], "chats": [] } } diff --git a/types/test-cases/reducers/133-session-activitychanged-sets-activity.json b/types/test-cases/reducers/133-session-activitychanged-sets-activity.json index c1028260..192ef802 100644 --- a/types/test-cases/reducers/133-session-activitychanged-sets-activity.json +++ b/types/test-cases/reducers/133-session-activitychanged-sets-activity.json @@ -11,6 +11,7 @@ "modifiedAt": 1000 }, "lifecycle": "ready", + "activeClients": [], "chats": [] }, "actions": [ @@ -30,6 +31,7 @@ "modifiedAt": 1000 }, "lifecycle": "ready", + "activeClients": [], "chats": [] } } diff --git a/types/test-cases/reducers/134-session-activitychanged-clears-activity.json b/types/test-cases/reducers/134-session-activitychanged-clears-activity.json index 33b7c8d4..fade30ec 100644 --- a/types/test-cases/reducers/134-session-activitychanged-clears-activity.json +++ b/types/test-cases/reducers/134-session-activitychanged-clears-activity.json @@ -12,6 +12,7 @@ "modifiedAt": 1000 }, "lifecycle": "ready", + "activeClients": [], "chats": [] }, "actions": [ @@ -31,6 +32,7 @@ "modifiedAt": 1000 }, "lifecycle": "ready", + "activeClients": [], "chats": [] } } diff --git a/types/test-cases/reducers/135-session-metachanged-sets-meta.json b/types/test-cases/reducers/135-session-metachanged-sets-meta.json index 6b2c4363..64cb1176 100644 --- a/types/test-cases/reducers/135-session-metachanged-sets-meta.json +++ b/types/test-cases/reducers/135-session-metachanged-sets-meta.json @@ -16,6 +16,7 @@ "branch": "main" } }, + "activeClients": [], "chats": [] }, "actions": [ @@ -45,6 +46,7 @@ }, "vscode.foo": 42 }, + "activeClients": [], "chats": [] } } diff --git a/types/test-cases/reducers/137-session-customizationupdated-replaces-existing-container.json b/types/test-cases/reducers/137-session-customizationupdated-replaces-existing-container.json index 77e692dd..89b91627 100644 --- a/types/test-cases/reducers/137-session-customizationupdated-replaces-existing-container.json +++ b/types/test-cases/reducers/137-session-customizationupdated-replaces-existing-container.json @@ -33,6 +33,7 @@ } } ], + "activeClients": [], "chats": [] }, "actions": [ @@ -84,6 +85,7 @@ } } ], + "activeClients": [], "chats": [] } } diff --git a/types/test-cases/reducers/138-session-customizationupdated-appends-unknown-id.json b/types/test-cases/reducers/138-session-customizationupdated-appends-unknown-id.json index a49ecd7c..478e7bee 100644 --- a/types/test-cases/reducers/138-session-customizationupdated-appends-unknown-id.json +++ b/types/test-cases/reducers/138-session-customizationupdated-appends-unknown-id.json @@ -20,6 +20,7 @@ "enabled": true } ], + "activeClients": [], "chats": [] }, "actions": [ @@ -66,6 +67,7 @@ } } ], + "activeClients": [], "chats": [] } } diff --git a/types/test-cases/reducers/139-session-customizationupdated-creates-list.json b/types/test-cases/reducers/139-session-customizationupdated-creates-list.json index c17e124c..010afaf5 100644 --- a/types/test-cases/reducers/139-session-customizationupdated-creates-list.json +++ b/types/test-cases/reducers/139-session-customizationupdated-creates-list.json @@ -11,6 +11,7 @@ "modifiedAt": 1000 }, "lifecycle": "creating", + "activeClients": [], "chats": [] }, "actions": [ @@ -48,6 +49,7 @@ "writable": true } ], + "activeClients": [], "chats": [] } } diff --git a/types/test-cases/reducers/145-session-changesetschanged-sets-catalogue.json b/types/test-cases/reducers/145-session-changesetschanged-sets-catalogue.json index 1592828f..60ba8033 100644 --- a/types/test-cases/reducers/145-session-changesetschanged-sets-catalogue.json +++ b/types/test-cases/reducers/145-session-changesetschanged-sets-catalogue.json @@ -11,6 +11,7 @@ "modifiedAt": 1000 }, "lifecycle": "ready", + "activeClients": [], "chats": [] }, "actions": [ @@ -42,6 +43,7 @@ "changeKind": "session" } ], + "activeClients": [], "chats": [] } } diff --git a/types/test-cases/reducers/146-session-changesetschanged-clears-catalogue.json b/types/test-cases/reducers/146-session-changesetschanged-clears-catalogue.json index 9c119c7c..73cda11f 100644 --- a/types/test-cases/reducers/146-session-changesetschanged-clears-catalogue.json +++ b/types/test-cases/reducers/146-session-changesetschanged-clears-catalogue.json @@ -18,6 +18,7 @@ "changeKind": "session" } ], + "activeClients": [], "chats": [] }, "actions": [ @@ -36,6 +37,7 @@ "modifiedAt": 1000 }, "lifecycle": "ready", + "activeClients": [], "chats": [] } } diff --git a/types/test-cases/reducers/147-session-agentchanged-sets-agent.json b/types/test-cases/reducers/147-session-agentchanged-sets-agent.json index f97f0d4f..159a58ab 100644 --- a/types/test-cases/reducers/147-session-agentchanged-sets-agent.json +++ b/types/test-cases/reducers/147-session-agentchanged-sets-agent.json @@ -11,6 +11,7 @@ "modifiedAt": 1000 }, "lifecycle": "creating", + "activeClients": [], "chats": [] }, "actions": [ @@ -34,6 +35,7 @@ } }, "lifecycle": "creating", + "activeClients": [], "chats": [] } } diff --git a/types/test-cases/reducers/147-session-ready-preserves-inprogress-status.json b/types/test-cases/reducers/147-session-ready-preserves-inprogress-status.json index da95e868..5a03402f 100644 --- a/types/test-cases/reducers/147-session-ready-preserves-inprogress-status.json +++ b/types/test-cases/reducers/147-session-ready-preserves-inprogress-status.json @@ -11,6 +11,7 @@ "modifiedAt": 1000 }, "lifecycle": "creating", + "activeClients": [], "chats": [] }, "actions": [ @@ -28,6 +29,7 @@ "modifiedAt": 1000 }, "lifecycle": "ready", + "activeClients": [], "chats": [] } } diff --git a/types/test-cases/reducers/148-session-agentchanged-clears-agent.json b/types/test-cases/reducers/148-session-agentchanged-clears-agent.json index 7b86ca22..d8aecc41 100644 --- a/types/test-cases/reducers/148-session-agentchanged-clears-agent.json +++ b/types/test-cases/reducers/148-session-agentchanged-clears-agent.json @@ -14,6 +14,7 @@ } }, "lifecycle": "creating", + "activeClients": [], "chats": [] }, "actions": [ @@ -32,6 +33,7 @@ "agent": null }, "lifecycle": "creating", + "activeClients": [], "chats": [] } } diff --git a/types/test-cases/reducers/149-session-agentchanged-replaces-agent.json b/types/test-cases/reducers/149-session-agentchanged-replaces-agent.json index f8ec4acb..71830b6d 100644 --- a/types/test-cases/reducers/149-session-agentchanged-replaces-agent.json +++ b/types/test-cases/reducers/149-session-agentchanged-replaces-agent.json @@ -14,6 +14,7 @@ } }, "lifecycle": "creating", + "activeClients": [], "chats": [] }, "actions": [ @@ -37,6 +38,7 @@ } }, "lifecycle": "creating", + "activeClients": [], "chats": [] } } diff --git a/types/test-cases/reducers/152-session-customizationremoved-removes-container-and-children.json b/types/test-cases/reducers/152-session-customizationremoved-removes-container-and-children.json index c0676061..27e46094 100644 --- a/types/test-cases/reducers/152-session-customizationremoved-removes-container-and-children.json +++ b/types/test-cases/reducers/152-session-customizationremoved-removes-container-and-children.json @@ -35,6 +35,7 @@ "enabled": true } ], + "activeClients": [], "chats": [] }, "actions": [ @@ -62,6 +63,7 @@ "enabled": true } ], + "activeClients": [], "chats": [] } } diff --git a/types/test-cases/reducers/153-session-customizationremoved-removes-child.json b/types/test-cases/reducers/153-session-customizationremoved-removes-child.json index c506cefc..31e2f42b 100644 --- a/types/test-cases/reducers/153-session-customizationremoved-removes-child.json +++ b/types/test-cases/reducers/153-session-customizationremoved-removes-child.json @@ -34,6 +34,7 @@ ] } ], + "activeClients": [], "chats": [] }, "actions": [ @@ -69,6 +70,7 @@ ] } ], + "activeClients": [], "chats": [] } } diff --git a/types/test-cases/reducers/154-session-customizationremoved-noop-unknown-id.json b/types/test-cases/reducers/154-session-customizationremoved-noop-unknown-id.json index 777a5793..ac37aebc 100644 --- a/types/test-cases/reducers/154-session-customizationremoved-noop-unknown-id.json +++ b/types/test-cases/reducers/154-session-customizationremoved-noop-unknown-id.json @@ -20,6 +20,7 @@ "enabled": true } ], + "activeClients": [], "chats": [] }, "actions": [ @@ -47,6 +48,7 @@ "enabled": true } ], + "activeClients": [], "chats": [] } } diff --git a/types/test-cases/reducers/156-session-default-chat-changed.json b/types/test-cases/reducers/156-session-default-chat-changed.json index 564f55b6..c4c7d0ef 100644 --- a/types/test-cases/reducers/156-session-default-chat-changed.json +++ b/types/test-cases/reducers/156-session-default-chat-changed.json @@ -11,6 +11,7 @@ "modifiedAt": 1000 }, "lifecycle": "ready", + "activeClients": [], "chats": [], "defaultChat": "ahp-chat:/old" }, @@ -30,6 +31,7 @@ "modifiedAt": 1000 }, "lifecycle": "ready", + "activeClients": [], "chats": [], "defaultChat": "ahp-chat:/new" } diff --git a/types/test-cases/reducers/159-session-mcpserverstatechanged-upserts-top-level-server.json b/types/test-cases/reducers/159-session-mcpserverstatechanged-upserts-top-level-server.json index 717f6a46..d18bda03 100644 --- a/types/test-cases/reducers/159-session-mcpserverstatechanged-upserts-top-level-server.json +++ b/types/test-cases/reducers/159-session-mcpserverstatechanged-upserts-top-level-server.json @@ -11,6 +11,7 @@ "modifiedAt": 1000 }, "lifecycle": "ready", + "activeClients": [], "chats": [], "customizations": [ { @@ -45,6 +46,7 @@ "modifiedAt": 1000 }, "lifecycle": "ready", + "activeClients": [], "chats": [], "customizations": [ { diff --git a/types/test-cases/reducers/160-session-default-chat-changed-unsets.json b/types/test-cases/reducers/160-session-default-chat-changed-unsets.json index 1766a487..e906373a 100644 --- a/types/test-cases/reducers/160-session-default-chat-changed-unsets.json +++ b/types/test-cases/reducers/160-session-default-chat-changed-unsets.json @@ -11,6 +11,7 @@ "modifiedAt": 1000 }, "lifecycle": "ready", + "activeClients": [], "chats": [], "defaultChat": "ahp-chat:/old" }, @@ -29,6 +30,7 @@ "modifiedAt": 1000 }, "lifecycle": "ready", + "activeClients": [], "chats": [], "defaultChat": null } diff --git a/types/test-cases/reducers/160-session-mcpserverstatechanged-upserts-container-child.json b/types/test-cases/reducers/160-session-mcpserverstatechanged-upserts-container-child.json index 5e674318..afe1935b 100644 --- a/types/test-cases/reducers/160-session-mcpserverstatechanged-upserts-container-child.json +++ b/types/test-cases/reducers/160-session-mcpserverstatechanged-upserts-container-child.json @@ -11,6 +11,7 @@ "modifiedAt": 1000 }, "lifecycle": "ready", + "activeClients": [], "chats": [], "customizations": [ { @@ -60,6 +61,7 @@ "modifiedAt": 1000 }, "lifecycle": "ready", + "activeClients": [], "chats": [], "customizations": [ { diff --git a/types/test-cases/reducers/161-session-mcpserverstatechanged-noop-unknown-id.json b/types/test-cases/reducers/161-session-mcpserverstatechanged-noop-unknown-id.json index a4b5c948..367fb86f 100644 --- a/types/test-cases/reducers/161-session-mcpserverstatechanged-noop-unknown-id.json +++ b/types/test-cases/reducers/161-session-mcpserverstatechanged-noop-unknown-id.json @@ -11,6 +11,7 @@ "modifiedAt": 1000 }, "lifecycle": "ready", + "activeClients": [], "chats": [], "customizations": [ { @@ -45,6 +46,7 @@ "modifiedAt": 1000 }, "lifecycle": "ready", + "activeClients": [], "chats": [], "customizations": [ { diff --git a/types/test-cases/reducers/162-session-mcpserverstatechanged-noop-non-mcp-id.json b/types/test-cases/reducers/162-session-mcpserverstatechanged-noop-non-mcp-id.json index 532658a0..9af51b10 100644 --- a/types/test-cases/reducers/162-session-mcpserverstatechanged-noop-non-mcp-id.json +++ b/types/test-cases/reducers/162-session-mcpserverstatechanged-noop-non-mcp-id.json @@ -11,6 +11,7 @@ "modifiedAt": 1000 }, "lifecycle": "ready", + "activeClients": [], "chats": [], "customizations": [ { @@ -58,6 +59,7 @@ "modifiedAt": 1000 }, "lifecycle": "ready", + "activeClients": [], "chats": [], "customizations": [ { diff --git a/types/test-cases/reducers/170-session-chatadded-appends.json b/types/test-cases/reducers/170-session-chatadded-appends.json index 781d920e..03a8019c 100644 --- a/types/test-cases/reducers/170-session-chatadded-appends.json +++ b/types/test-cases/reducers/170-session-chatadded-appends.json @@ -11,6 +11,7 @@ "modifiedAt": 1000 }, "lifecycle": "ready", + "activeClients": [], "chats": [] }, "actions": [ @@ -21,7 +22,9 @@ "title": "Chat 1", "status": 1, "modifiedAt": "1970-01-01T00:00:01.000Z", - "origin": { "kind": "user" } + "origin": { + "kind": "user" + } } } ], @@ -35,13 +38,16 @@ "modifiedAt": 1000 }, "lifecycle": "ready", + "activeClients": [], "chats": [ { "resource": "ahp-chat:/c1", "title": "Chat 1", "status": 1, "modifiedAt": "1970-01-01T00:00:01.000Z", - "origin": { "kind": "user" } + "origin": { + "kind": "user" + } } ] } diff --git a/types/test-cases/reducers/171-session-chatadded-upserts.json b/types/test-cases/reducers/171-session-chatadded-upserts.json index efe8d3ed..47c554c6 100644 --- a/types/test-cases/reducers/171-session-chatadded-upserts.json +++ b/types/test-cases/reducers/171-session-chatadded-upserts.json @@ -11,13 +11,16 @@ "modifiedAt": 1000 }, "lifecycle": "ready", + "activeClients": [], "chats": [ { "resource": "ahp-chat:/c1", "title": "Chat 1", "status": 1, "modifiedAt": "1970-01-01T00:00:01.000Z", - "origin": { "kind": "user" } + "origin": { + "kind": "user" + } } ] }, @@ -29,7 +32,9 @@ "title": "Chat 1 (renamed)", "status": 8, "modifiedAt": "1970-01-01T00:00:02.000Z", - "origin": { "kind": "user" } + "origin": { + "kind": "user" + } } } ], @@ -43,13 +48,16 @@ "modifiedAt": 1000 }, "lifecycle": "ready", + "activeClients": [], "chats": [ { "resource": "ahp-chat:/c1", "title": "Chat 1 (renamed)", "status": 8, "modifiedAt": "1970-01-01T00:00:02.000Z", - "origin": { "kind": "user" } + "origin": { + "kind": "user" + } } ] } diff --git a/types/test-cases/reducers/172-session-chatremoved.json b/types/test-cases/reducers/172-session-chatremoved.json index 7a9cfed6..5b12ce9e 100644 --- a/types/test-cases/reducers/172-session-chatremoved.json +++ b/types/test-cases/reducers/172-session-chatremoved.json @@ -11,13 +11,16 @@ "modifiedAt": 1000 }, "lifecycle": "ready", + "activeClients": [], "chats": [ { "resource": "ahp-chat:/c1", "title": "Chat 1", "status": 1, "modifiedAt": "1970-01-01T00:00:01.000Z", - "origin": { "kind": "user" } + "origin": { + "kind": "user" + } }, { "resource": "ahp-chat:/c2", @@ -25,7 +28,11 @@ "status": 8, "activity": "Thinking", "modifiedAt": "1970-01-01T00:00:02.000Z", - "origin": { "kind": "fork", "chat": "ahp-chat:/c1", "turnId": "t1" } + "origin": { + "kind": "fork", + "chat": "ahp-chat:/c1", + "turnId": "t1" + } } ], "defaultChat": "ahp-chat:/c1" @@ -46,6 +53,7 @@ "modifiedAt": 1000 }, "lifecycle": "ready", + "activeClients": [], "chats": [ { "resource": "ahp-chat:/c2", @@ -53,7 +61,11 @@ "status": 8, "activity": "Thinking", "modifiedAt": "1970-01-01T00:00:02.000Z", - "origin": { "kind": "fork", "chat": "ahp-chat:/c1", "turnId": "t1" } + "origin": { + "kind": "fork", + "chat": "ahp-chat:/c1", + "turnId": "t1" + } } ] } diff --git a/types/test-cases/reducers/173-session-chatupdated.json b/types/test-cases/reducers/173-session-chatupdated.json index 0e134492..323f6ec1 100644 --- a/types/test-cases/reducers/173-session-chatupdated.json +++ b/types/test-cases/reducers/173-session-chatupdated.json @@ -11,13 +11,16 @@ "modifiedAt": 1000 }, "lifecycle": "ready", + "activeClients": [], "chats": [ { "resource": "ahp-chat:/c1", "title": "Chat 1", "status": 1, "modifiedAt": "1970-01-01T00:00:01.000Z", - "origin": { "kind": "user" } + "origin": { + "kind": "user" + } } ] }, @@ -42,6 +45,7 @@ "modifiedAt": 1000 }, "lifecycle": "ready", + "activeClients": [], "chats": [ { "resource": "ahp-chat:/c1", @@ -49,7 +53,9 @@ "status": 24, "activity": "Waiting for approval", "modifiedAt": "1970-01-01T00:00:02.000Z", - "origin": { "kind": "user" } + "origin": { + "kind": "user" + } } ] } diff --git a/types/test-cases/reducers/174-session-chatremoved-noop.json b/types/test-cases/reducers/174-session-chatremoved-noop.json index 87c81019..38adec34 100644 --- a/types/test-cases/reducers/174-session-chatremoved-noop.json +++ b/types/test-cases/reducers/174-session-chatremoved-noop.json @@ -11,13 +11,16 @@ "modifiedAt": 1000 }, "lifecycle": "ready", + "activeClients": [], "chats": [ { "resource": "ahp-chat:/c1", "title": "Chat 1", "status": 1, "modifiedAt": "1970-01-01T00:00:01.000Z", - "origin": { "kind": "user" } + "origin": { + "kind": "user" + } } ], "defaultChat": "ahp-chat:/c1" @@ -38,13 +41,16 @@ "modifiedAt": 1000 }, "lifecycle": "ready", + "activeClients": [], "chats": [ { "resource": "ahp-chat:/c1", "title": "Chat 1", "status": 1, "modifiedAt": "1970-01-01T00:00:01.000Z", - "origin": { "kind": "user" } + "origin": { + "kind": "user" + } } ], "defaultChat": "ahp-chat:/c1" diff --git a/types/test-cases/reducers/175-session-chatupdated-noop.json b/types/test-cases/reducers/175-session-chatupdated-noop.json index d262b6c7..e0349c6f 100644 --- a/types/test-cases/reducers/175-session-chatupdated-noop.json +++ b/types/test-cases/reducers/175-session-chatupdated-noop.json @@ -11,13 +11,16 @@ "modifiedAt": 1000 }, "lifecycle": "ready", + "activeClients": [], "chats": [ { "resource": "ahp-chat:/c1", "title": "Chat 1", "status": 1, "modifiedAt": "1970-01-01T00:00:01.000Z", - "origin": { "kind": "user" } + "origin": { + "kind": "user" + } } ] }, @@ -25,7 +28,9 @@ { "type": "session/chatUpdated", "chat": "ahp-chat:/cX", - "changes": { "title": "Never written" } + "changes": { + "title": "Never written" + } } ], "expected": { @@ -38,13 +43,16 @@ "modifiedAt": 1000 }, "lifecycle": "ready", + "activeClients": [], "chats": [ { "resource": "ahp-chat:/c1", "title": "Chat 1", "status": 1, "modifiedAt": "1970-01-01T00:00:01.000Z", - "origin": { "kind": "user" } + "origin": { + "kind": "user" + } } ] } diff --git a/types/test-cases/reducers/038-session-activeclienttoolschanged-without-activeclient-is-no-op.json b/types/test-cases/reducers/221-session-activeclientremoved-removes-client.json similarity index 58% rename from types/test-cases/reducers/038-session-activeclienttoolschanged-without-activeclient-is-no-op.json rename to types/test-cases/reducers/221-session-activeclientremoved-removes-client.json index 8153b47e..cbe15a71 100644 --- a/types/test-cases/reducers/038-session-activeclienttoolschanged-without-activeclient-is-no-op.json +++ b/types/test-cases/reducers/221-session-activeclientremoved-removes-client.json @@ -1,5 +1,5 @@ { - "description": "session/activeClientToolsChanged without activeClient is no-op", + "description": "session/activeClientRemoved removes the named client", "reducer": "session", "initial": { "summary": { @@ -11,16 +11,22 @@ "modifiedAt": 1000 }, "lifecycle": "creating", + "activeClients": [ + { + "clientId": "vscode-1", + "tools": [] + }, + { + "clientId": "cli-1", + "tools": [] + } + ], "chats": [] }, "actions": [ { - "type": "session/activeClientToolsChanged", - "tools": [ - { - "name": "openFile" - } - ] + "type": "session/activeClientRemoved", + "clientId": "vscode-1" } ], "expected": { @@ -33,6 +39,12 @@ "modifiedAt": 1000 }, "lifecycle": "creating", + "activeClients": [ + { + "clientId": "cli-1", + "tools": [] + } + ], "chats": [] } } diff --git a/types/test-cases/reducers/036-session-activeclientchanged-unsets-client.json b/types/test-cases/reducers/222-session-activeclientremoved-no-op-unknown-client.json similarity index 61% rename from types/test-cases/reducers/036-session-activeclientchanged-unsets-client.json rename to types/test-cases/reducers/222-session-activeclientremoved-no-op-unknown-client.json index 9d330118..9b74c22d 100644 --- a/types/test-cases/reducers/036-session-activeclientchanged-unsets-client.json +++ b/types/test-cases/reducers/222-session-activeclientremoved-no-op-unknown-client.json @@ -1,5 +1,5 @@ { - "description": "session/activeClientChanged unsets client", + "description": "session/activeClientRemoved is a no-op for an unknown client", "reducer": "session", "initial": { "summary": { @@ -11,16 +11,18 @@ "modifiedAt": 1000 }, "lifecycle": "creating", - "activeClient": { - "clientId": "vscode-1", - "tools": [] - }, + "activeClients": [ + { + "clientId": "vscode-1", + "tools": [] + } + ], "chats": [] }, "actions": [ { - "type": "session/activeClientChanged", - "activeClient": null + "type": "session/activeClientRemoved", + "clientId": "unknown-client" } ], "expected": { @@ -33,7 +35,12 @@ "modifiedAt": 1000 }, "lifecycle": "creating", - "activeClient": null, + "activeClients": [ + { + "clientId": "vscode-1", + "tools": [] + } + ], "chats": [] } } diff --git a/types/version/registry.ts b/types/version/registry.ts index cb2d2408..473e317c 100644 --- a/types/version/registry.ts +++ b/types/version/registry.ts @@ -89,8 +89,8 @@ export const ACTION_INTRODUCED_IN: { readonly [K in StateAction['type']]: string [ActionType.SessionModelChanged]: '0.1.0', [ActionType.SessionAgentChanged]: '0.2.0', [ActionType.SessionServerToolsChanged]: '0.1.0', - [ActionType.SessionActiveClientChanged]: '0.1.0', - [ActionType.SessionActiveClientToolsChanged]: '0.1.0', + [ActionType.SessionActiveClientSet]: '0.5.0', + [ActionType.SessionActiveClientRemoved]: '0.5.0', [ActionType.SessionCustomizationsChanged]: '0.1.0', [ActionType.SessionCustomizationToggled]: '0.1.0', [ActionType.SessionCustomizationUpdated]: '0.1.0',