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 c170c009..f4798b49 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt @@ -82,6 +82,7 @@ import com.microsoft.agenthostprotocol.generated.StateActionSessionInputComplete import com.microsoft.agenthostprotocol.generated.StateActionSessionInputRequested import com.microsoft.agenthostprotocol.generated.StateActionSessionIsArchivedChanged import com.microsoft.agenthostprotocol.generated.StateActionSessionIsReadChanged +import com.microsoft.agenthostprotocol.generated.StateActionSessionMcpServerStateChanged import com.microsoft.agenthostprotocol.generated.StateActionSessionMetaChanged import com.microsoft.agenthostprotocol.generated.StateActionSessionModelChanged import com.microsoft.agenthostprotocol.generated.StateActionSessionPendingMessageRemoved @@ -1051,6 +1052,60 @@ public fun sessionReducer(state: SessionState, action: StateAction): SessionStat } } + is StateActionSessionMcpServerStateChanged -> { + // Full-replacement of an MCP server customization's `state` + `channel`, + // located by id. Mirrors the canonical TS reducer (and the Go/Rust/Swift + // ports): a top-level McpServer entry is matched first (hosts MAY surface + // MCP servers directly at the top level); otherwise the search descends + // into container children. A no-op when no customization carries the id, + // or when the matched id belongs to a non-MCP customization type. + val a = action.value + val list = state.customizations + if (list == null) { + state + } else { + val topIdx = list.indexOfFirst { customizationId(it) == a.id } + if (topIdx >= 0) { + val entry = list[topIdx] + if (entry !is CustomizationMcpServer) { + state + } else { + val updated = list.toMutableList() + updated[topIdx] = CustomizationMcpServer( + entry.value.copy(state = a.state, channel = a.channel), + ) + state.copy(customizations = updated) + } + } else { + var changed = false + val updated = list.map { container -> + val children = customizationChildren(container) + if (children == null) { + container + } else { + val childIdx = children.indexOfFirst { childCustomizationId(it) == a.id } + if (childIdx < 0) { + container + } else { + val child = children[childIdx] + if (child !is ChildCustomizationMcpServer) { + container + } else { + changed = true + val newChildren = children.toMutableList() + newChildren[childIdx] = ChildCustomizationMcpServer( + child.value.copy(state = a.state, channel = a.channel), + ) + withCustomizationChildren(container, newChildren) + } + } + } + } + if (!changed) state else state.copy(customizations = updated) + } + } + } + // ── Truncation ──────────────────────────────────────────────────────── is StateActionSessionTruncated -> { 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 new file mode 100644 index 00000000..2ea7247a --- /dev/null +++ b/types/test-cases/reducers/159-session-mcpserverstatechanged-upserts-top-level-server.json @@ -0,0 +1,57 @@ +{ + "description": "session/mcpServerStateChanged replaces the state and channel of a top-level McpServer customization", + "reducer": "session", + "initial": { + "summary": { + "resource": "copilot:/test-session", + "provider": "copilot", + "title": "Test Session", + "status": 1, + "createdAt": 1000, + "modifiedAt": 1000 + }, + "lifecycle": "ready", + "turns": [], + "customizations": [ + { + "type": "mcpServer", + "id": "mcp-1", + "uri": "file:///workspace/.mcp/servers.json", + "name": "Filesystem", + "enabled": true, + "state": { "kind": "starting" } + } + ] + }, + "actions": [ + { + "type": "session/mcpServerStateChanged", + "id": "mcp-1", + "state": { "kind": "ready" }, + "channel": "mcp://filesystem" + } + ], + "expected": { + "summary": { + "resource": "copilot:/test-session", + "provider": "copilot", + "title": "Test Session", + "status": 1, + "createdAt": 1000, + "modifiedAt": 1000 + }, + "lifecycle": "ready", + "turns": [], + "customizations": [ + { + "type": "mcpServer", + "id": "mcp-1", + "uri": "file:///workspace/.mcp/servers.json", + "name": "Filesystem", + "enabled": true, + "state": { "kind": "ready" }, + "channel": "mcp://filesystem" + } + ] + } +} 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 new file mode 100644 index 00000000..7218521a --- /dev/null +++ b/types/test-cases/reducers/160-session-mcpserverstatechanged-upserts-container-child.json @@ -0,0 +1,87 @@ +{ + "description": "session/mcpServerStateChanged replaces the state and channel of an McpServer customization nested inside a container", + "reducer": "session", + "initial": { + "summary": { + "resource": "copilot:/test-session", + "provider": "copilot", + "title": "Test Session", + "status": 1, + "createdAt": 1000, + "modifiedAt": 1000 + }, + "lifecycle": "ready", + "turns": [], + "customizations": [ + { + "type": "plugin", + "id": "plugin-a", + "uri": "https://plugins.example/a", + "name": "Plugin A", + "enabled": true, + "children": [ + { + "type": "skill", + "id": "skill-1", + "uri": "https://plugins.example/a#skills/lint", + "name": "lint" + }, + { + "type": "mcpServer", + "id": "mcp-child", + "uri": "https://plugins.example/a#mcp/search", + "name": "Search", + "enabled": true, + "state": { "kind": "starting" } + } + ] + } + ] + }, + "actions": [ + { + "type": "session/mcpServerStateChanged", + "id": "mcp-child", + "state": { "kind": "ready" }, + "channel": "mcp://search" + } + ], + "expected": { + "summary": { + "resource": "copilot:/test-session", + "provider": "copilot", + "title": "Test Session", + "status": 1, + "createdAt": 1000, + "modifiedAt": 1000 + }, + "lifecycle": "ready", + "turns": [], + "customizations": [ + { + "type": "plugin", + "id": "plugin-a", + "uri": "https://plugins.example/a", + "name": "Plugin A", + "enabled": true, + "children": [ + { + "type": "skill", + "id": "skill-1", + "uri": "https://plugins.example/a#skills/lint", + "name": "lint" + }, + { + "type": "mcpServer", + "id": "mcp-child", + "uri": "https://plugins.example/a#mcp/search", + "name": "Search", + "enabled": true, + "state": { "kind": "ready" }, + "channel": "mcp://search" + } + ] + } + ] + } +} 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 new file mode 100644 index 00000000..e9712e57 --- /dev/null +++ b/types/test-cases/reducers/161-session-mcpserverstatechanged-noop-unknown-id.json @@ -0,0 +1,57 @@ +{ + "description": "session/mcpServerStateChanged is a no-op when no customization carries the targeted id", + "reducer": "session", + "initial": { + "summary": { + "resource": "copilot:/test-session", + "provider": "copilot", + "title": "Test Session", + "status": 1, + "createdAt": 1000, + "modifiedAt": 1000 + }, + "lifecycle": "ready", + "turns": [], + "customizations": [ + { + "type": "mcpServer", + "id": "mcp-1", + "uri": "file:///workspace/.mcp/servers.json", + "name": "Filesystem", + "enabled": true, + "state": { "kind": "ready" }, + "channel": "mcp://filesystem" + } + ] + }, + "actions": [ + { + "type": "session/mcpServerStateChanged", + "id": "mcp-does-not-exist", + "state": { "kind": "stopped" } + } + ], + "expected": { + "summary": { + "resource": "copilot:/test-session", + "provider": "copilot", + "title": "Test Session", + "status": 1, + "createdAt": 1000, + "modifiedAt": 1000 + }, + "lifecycle": "ready", + "turns": [], + "customizations": [ + { + "type": "mcpServer", + "id": "mcp-1", + "uri": "file:///workspace/.mcp/servers.json", + "name": "Filesystem", + "enabled": true, + "state": { "kind": "ready" }, + "channel": "mcp://filesystem" + } + ] + } +} 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 new file mode 100644 index 00000000..0dd4e187 --- /dev/null +++ b/types/test-cases/reducers/162-session-mcpserverstatechanged-noop-non-mcp-id.json @@ -0,0 +1,76 @@ +{ + "description": "session/mcpServerStateChanged is a no-op when the targeted id belongs to a non-McpServer customization (a top-level container or one of its non-MCP children)", + "reducer": "session", + "initial": { + "summary": { + "resource": "copilot:/test-session", + "provider": "copilot", + "title": "Test Session", + "status": 1, + "createdAt": 1000, + "modifiedAt": 1000 + }, + "lifecycle": "ready", + "turns": [], + "customizations": [ + { + "type": "plugin", + "id": "plugin-a", + "uri": "https://plugins.example/a", + "name": "Plugin A", + "enabled": true, + "children": [ + { + "type": "skill", + "id": "skill-1", + "uri": "https://plugins.example/a#skills/lint", + "name": "lint" + } + ] + } + ] + }, + "actions": [ + { + "type": "session/mcpServerStateChanged", + "id": "plugin-a", + "state": { "kind": "ready" }, + "channel": "mcp://nope" + }, + { + "type": "session/mcpServerStateChanged", + "id": "skill-1", + "state": { "kind": "ready" }, + "channel": "mcp://nope" + } + ], + "expected": { + "summary": { + "resource": "copilot:/test-session", + "provider": "copilot", + "title": "Test Session", + "status": 1, + "createdAt": 1000, + "modifiedAt": 1000 + }, + "lifecycle": "ready", + "turns": [], + "customizations": [ + { + "type": "plugin", + "id": "plugin-a", + "uri": "https://plugins.example/a", + "name": "Plugin A", + "enabled": true, + "children": [ + { + "type": "skill", + "id": "skill-1", + "uri": "https://plugins.example/a#skills/lint", + "name": "lint" + } + ] + } + ] + } +}