From 35ba23e1b3092271c778ca057afe1a796e16e70e Mon Sep 17 00:00:00 2001
From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
Date: Tue, 7 Jul 2026 11:58:06 +0100
Subject: [PATCH 01/21] .Net: Update package version to 1.78.0 (#14142)
Bumps the SK package version in preparation for the next release.
- VersionPrefix: 1.77.0 -> 1.78.0
- PackageValidationBaselineVersion: 1.76.0 -> 1.77.0
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
dotnet/nuget/nuget-package.props | 4 +-
.../CompatibilitySuppressions.xml | 42 -------------------
2 files changed, 2 insertions(+), 44 deletions(-)
diff --git a/dotnet/nuget/nuget-package.props b/dotnet/nuget/nuget-package.props
index 6877b3197871..709b5ee69dc0 100644
--- a/dotnet/nuget/nuget-package.props
+++ b/dotnet/nuget/nuget-package.props
@@ -1,7 +1,7 @@
- 1.77.0
+ 1.78.0
$(VersionPrefix)-$(VersionSuffix)
$(VersionPrefix)
@@ -9,7 +9,7 @@
true
- 1.76.0
+ 1.77.0
$(NoWarn);CP0003
diff --git a/dotnet/src/Functions/Functions.OpenApi/CompatibilitySuppressions.xml b/dotnet/src/Functions/Functions.OpenApi/CompatibilitySuppressions.xml
index ff2b43c448eb..cc1b3172c906 100644
--- a/dotnet/src/Functions/Functions.OpenApi/CompatibilitySuppressions.xml
+++ b/dotnet/src/Functions/Functions.OpenApi/CompatibilitySuppressions.xml
@@ -1,52 +1,10 @@
-
- CP0002
- M:Microsoft.SemanticKernel.Plugins.OpenApi.RestApiOperationServerUrlValidationOptions.get_AllowedSchemes
- lib/net10.0/Microsoft.SemanticKernel.Plugins.OpenApi.dll
- lib/net10.0/Microsoft.SemanticKernel.Plugins.OpenApi.dll
- true
-
-
- CP0002
- M:Microsoft.SemanticKernel.Plugins.OpenApi.RestApiOperationServerUrlValidationOptions.set_AllowedSchemes(System.Collections.Generic.IReadOnlyList{System.String})
- lib/net10.0/Microsoft.SemanticKernel.Plugins.OpenApi.dll
- lib/net10.0/Microsoft.SemanticKernel.Plugins.OpenApi.dll
- true
-
-
- CP0002
- M:Microsoft.SemanticKernel.Plugins.OpenApi.RestApiOperationServerUrlValidationOptions.get_AllowedSchemes
- lib/net8.0/Microsoft.SemanticKernel.Plugins.OpenApi.dll
- lib/net8.0/Microsoft.SemanticKernel.Plugins.OpenApi.dll
- true
-
-
- CP0002
- M:Microsoft.SemanticKernel.Plugins.OpenApi.RestApiOperationServerUrlValidationOptions.set_AllowedSchemes(System.Collections.Generic.IReadOnlyList{System.String})
- lib/net8.0/Microsoft.SemanticKernel.Plugins.OpenApi.dll
- lib/net8.0/Microsoft.SemanticKernel.Plugins.OpenApi.dll
- true
-
CP0002
F:Microsoft.SemanticKernel.Plugins.OpenApi.OpenApiKernelFunctionContext.KernelFunctionContextKey
lib/netstandard2.0/Microsoft.SemanticKernel.Plugins.OpenApi.dll
lib/net8.0/Microsoft.SemanticKernel.Plugins.OpenApi.dll
-
- CP0002
- M:Microsoft.SemanticKernel.Plugins.OpenApi.RestApiOperationServerUrlValidationOptions.get_AllowedSchemes
- lib/netstandard2.0/Microsoft.SemanticKernel.Plugins.OpenApi.dll
- lib/netstandard2.0/Microsoft.SemanticKernel.Plugins.OpenApi.dll
- true
-
-
- CP0002
- M:Microsoft.SemanticKernel.Plugins.OpenApi.RestApiOperationServerUrlValidationOptions.set_AllowedSchemes(System.Collections.Generic.IReadOnlyList{System.String})
- lib/netstandard2.0/Microsoft.SemanticKernel.Plugins.OpenApi.dll
- lib/netstandard2.0/Microsoft.SemanticKernel.Plugins.OpenApi.dll
- true
-
\ No newline at end of file
From 008d40acb42c4d06a43f16fa5c80a8d5ce78bb87 Mon Sep 17 00:00:00 2001
From: AZJ
Date: Thu, 9 Jul 2026 15:41:58 +0200
Subject: [PATCH 02/21] .Net: feat(ollama): add Think property to
OllamaPromptExecutionSettings (#14122)
## Summary
- Adds `Think` (`bool?`) property to `OllamaPromptExecutionSettings` to
control thinking for Ollama reasoning models (deepseek-r1, qwen3,
phi4-reasoning)
- Maps `Think` to `GenerateRequest.Think` in
`OllamaTextGenerationService.CreateRequest()`
- Bumps OllamaSharp from 5.4.12 to 5.4.25 which introduces
`GenerateRequest.Think`
## Motivation
Fixes #14078. When a reasoning model has thinking enabled by default
(e.g. qwen3, phi4-reasoning), the model output lands in a separate
thinking stream rather than the standard response field. This causes
`GetTextContentsAsync` to return empty content. The only way to get a
usable response is to pass `think=false` to suppress thinking, but there
was no way to set that from `OllamaPromptExecutionSettings`.
## Changes
### `OllamaPromptExecutionSettings.cs`
- New `Think` property with `bool?` type, JSON name `think`,
`WhenWritingNull` ignore condition, `ThrowIfFrozen()` guard, and
`Clone()` support
### `OllamaTextGenerationService.cs`
- `CreateRequest()` maps `settings.Think` to `GenerateRequest.Think`
using `OllamaSharp.Models.Chat.ThinkValue`
### `Directory.Packages.props`
- OllamaSharp bumped from 5.4.12 to 5.4.25 (adds `GenerateRequest.Think`
and `ThinkValue`)
### Tests
- `OllamaPromptExecutionSettingsTests`: serialization round-trip, Clone,
Freeze guard for `Think`
- `OllamaTextGenerationTests`: verifies `Think` is present/absent in the
serialized request payload for both `GetTextContentsAsync` and
`GetStreamingTextContentsAsync`
## Test plan
- [ ] All 115 existing unit tests pass (1 pre-existing skip)
- [ ] `ThinkPropertyRoundTripsViaSerialization` - `true`/`false`
round-trips via JSON
- [ ] `ThinkPropertyIsPreservedByClone` - Clone copies the value
- [ ] `ThinkPropertyThrowsWhenFrozen` - setter throws after Freeze
- [ ] `GetTextContentsShouldSendThinkSettingAsync` - request payload
contains correct `think` field
- [ ] `GetTextContentsShouldNotSendThinkWhenNotSetAsync` - request
payload omits `think` when null
- [ ] `GetStreamingTextContentsShouldSendThinkSettingAsync` - streaming
path also sends `think`
---------
Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>
---
dotnet/Directory.Packages.props | 2 +-
.../Services/OllamaTextGenerationTests.cs | 51 +++++++++++++++++++
.../OllamaPromptExecutionSettingsTests.cs | 40 +++++++++++++++
.../Services/OllamaTextGenerationService.cs | 3 +-
.../Settings/OllamaPromptExecutionSettings.cs | 26 ++++++++++
5 files changed, 120 insertions(+), 2 deletions(-)
diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props
index 29d44c8fc753..a76de3779d69 100644
--- a/dotnet/Directory.Packages.props
+++ b/dotnet/Directory.Packages.props
@@ -93,7 +93,7 @@
-
+
diff --git a/dotnet/src/Connectors/Connectors.Ollama.UnitTests/Services/OllamaTextGenerationTests.cs b/dotnet/src/Connectors/Connectors.Ollama.UnitTests/Services/OllamaTextGenerationTests.cs
index c765bf1d678d..c66dbf71ea07 100644
--- a/dotnet/src/Connectors/Connectors.Ollama.UnitTests/Services/OllamaTextGenerationTests.cs
+++ b/dotnet/src/Connectors/Connectors.Ollama.UnitTests/Services/OllamaTextGenerationTests.cs
@@ -188,6 +188,57 @@ public async Task GetTextContentsExecutionSettingsMustBeSentAsync()
Assert.Equal(ollamaExecutionSettings.TopK, requestPayload.Options.TopK);
}
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task GetTextContentsShouldSendThinkSettingAsync(bool thinkValue)
+ {
+ // Arrange
+ var sut = new OllamaTextGenerationService("fake-model", httpClient: this._httpClient);
+ var settings = new OllamaPromptExecutionSettings { Think = thinkValue };
+
+ // Act
+ await sut.GetTextContentsAsync("Any prompt", settings);
+
+ // Assert
+ var requestPayload = JsonSerializer.Deserialize(this._messageHandlerStub.RequestContent);
+ Assert.NotNull(requestPayload);
+ Assert.Equal(thinkValue, (bool?)requestPayload.Think);
+ }
+
+ [Fact]
+ public async Task GetTextContentsShouldNotSendThinkWhenNotSetAsync()
+ {
+ // Arrange
+ var sut = new OllamaTextGenerationService("fake-model", httpClient: this._httpClient);
+
+ // Act
+ await sut.GetTextContentsAsync("Any prompt");
+
+ // Assert
+ var requestPayload = JsonSerializer.Deserialize(this._messageHandlerStub.RequestContent);
+ Assert.NotNull(requestPayload);
+ Assert.Null(requestPayload.Think);
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task GetStreamingTextContentsShouldSendThinkSettingAsync(bool thinkValue)
+ {
+ // Arrange
+ var sut = new OllamaTextGenerationService("fake-model", httpClient: this._httpClient);
+ var settings = new OllamaPromptExecutionSettings { Think = thinkValue };
+
+ // Act
+ await sut.GetStreamingTextContentsAsync("Any prompt", settings).GetAsyncEnumerator().MoveNextAsync();
+
+ // Assert
+ var requestPayload = JsonSerializer.Deserialize(this._messageHandlerStub.RequestContent);
+ Assert.NotNull(requestPayload);
+ Assert.Equal(thinkValue, (bool?)requestPayload.Think);
+ }
+
///
/// Disposes resources used by this class.
///
diff --git a/dotnet/src/Connectors/Connectors.Ollama.UnitTests/Settings/OllamaPromptExecutionSettingsTests.cs b/dotnet/src/Connectors/Connectors.Ollama.UnitTests/Settings/OllamaPromptExecutionSettingsTests.cs
index fb41f2f991cc..fff0aef5921c 100644
--- a/dotnet/src/Connectors/Connectors.Ollama.UnitTests/Settings/OllamaPromptExecutionSettingsTests.cs
+++ b/dotnet/src/Connectors/Connectors.Ollama.UnitTests/Settings/OllamaPromptExecutionSettingsTests.cs
@@ -41,6 +41,7 @@ public void FromExecutionSettingsWhenNullShouldReturnDefault()
Assert.Null(ollamaExecutionSettings.Temperature);
Assert.Null(ollamaExecutionSettings.TopP);
Assert.Null(ollamaExecutionSettings.TopK);
+ Assert.Null(ollamaExecutionSettings.Think);
}
[Fact]
@@ -187,6 +188,45 @@ public void ClonePreservesServiceId()
Assert.Equal(testSettings.Temperature, cloned.Temperature);
}
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void ThinkPropertyRoundTripsViaSerialization(bool thinkValue)
+ {
+ // Arrange
+ string jsonSettings = $$"""{ "think": {{thinkValue.ToString().ToLowerInvariant()}} }""";
+
+ // Act
+ var executionSettings = JsonSerializer.Deserialize(jsonSettings);
+
+ // Assert
+ Assert.Equal(thinkValue, executionSettings!.Think);
+ }
+
+ [Fact]
+ public void ThinkPropertyIsPreservedByClone()
+ {
+ // Arrange
+ var settings = new OllamaPromptExecutionSettings { Think = false };
+
+ // Act
+ var clone = (OllamaPromptExecutionSettings)settings.Clone();
+
+ // Assert
+ Assert.Equal(false, clone.Think);
+ }
+
+ [Fact]
+ public void ThinkPropertyThrowsWhenFrozen()
+ {
+ // Arrange
+ var settings = new OllamaPromptExecutionSettings();
+ settings.Freeze();
+
+ // Act & Assert
+ Assert.Throws(() => settings.Think = true);
+ }
+
[Fact]
public void PromptExecutionSettingsFreezeWorksAsExpected()
{
diff --git a/dotnet/src/Connectors/Connectors.Ollama/Services/OllamaTextGenerationService.cs b/dotnet/src/Connectors/Connectors.Ollama/Services/OllamaTextGenerationService.cs
index d149d7a1b3fa..b965c90b690c 100644
--- a/dotnet/src/Connectors/Connectors.Ollama/Services/OllamaTextGenerationService.cs
+++ b/dotnet/src/Connectors/Connectors.Ollama/Services/OllamaTextGenerationService.cs
@@ -148,7 +148,8 @@ private static GenerateRequest CreateRequest(OllamaPromptExecutionSettings setti
NumPredict = settings.NumPredict
},
Model = selectedModel,
- Stream = true
+ Stream = true,
+ Think = settings.Think.HasValue ? (OllamaSharp.Models.Chat.ThinkValue?)settings.Think.Value : null
};
return request;
diff --git a/dotnet/src/Connectors/Connectors.Ollama/Settings/OllamaPromptExecutionSettings.cs b/dotnet/src/Connectors/Connectors.Ollama/Settings/OllamaPromptExecutionSettings.cs
index 1b49aa99d97d..898dfa6a5ab4 100644
--- a/dotnet/src/Connectors/Connectors.Ollama/Settings/OllamaPromptExecutionSettings.cs
+++ b/dotnet/src/Connectors/Connectors.Ollama/Settings/OllamaPromptExecutionSettings.cs
@@ -131,6 +131,30 @@ public int? NumPredict
}
}
+ ///
+ /// Enables or disables thinking for reasoning models such as deepseek-r1, qwen3, and phi4-reasoning.
+ /// Set to false to disable thinking and receive a standard response when using a model that
+ /// enables thinking by default. Set to true to explicitly enable thinking.
+ /// When null (the default), the model's own default behavior is used.
+ ///
+ ///
+ /// When thinking is active, the model's reasoning output lands in a separate thinking stream
+ /// rather than in the main response content. Setting this to false suppresses thinking
+ /// so that all output appears in the standard response field.
+ ///
+ [JsonPropertyName("think")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public bool? Think
+ {
+ get => this._think;
+
+ set
+ {
+ this.ThrowIfFrozen();
+ this._think = value;
+ }
+ }
+
///
public override void Freeze()
{
@@ -161,6 +185,7 @@ public override PromptExecutionSettings Clone()
NumPredict = this.NumPredict,
Stop = this.Stop is not null ? new List(this.Stop) : null,
FunctionChoiceBehavior = this.FunctionChoiceBehavior,
+ Think = this.Think,
};
}
@@ -171,6 +196,7 @@ public override PromptExecutionSettings Clone()
private float? _topP;
private int? _topK;
private int? _numPredict;
+ private bool? _think;
#endregion
}
From dd026f3413099df9e9caee7374abd632adfd0f06 Mon Sep 17 00:00:00 2001
From: Eduard van Valkenburg
Date: Thu, 9 Jul 2026 18:54:48 +0200
Subject: [PATCH 03/21] Encode OpenAPI server variable values (#14146)
## Summary
- validate enum-constrained OpenAPI server variables before substitution
- percent-encode substituted server variable values, including defaults
- add regression tests for invalid enum values and reserved-character
encoding
## Testing
- uv run pytest tests/unit/connectors/openapi_plugin/test_sk_openapi.py
-q
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../models/rest_api_operation.py | 12 ++-
.../openapi_plugin/test_sk_openapi.py | 79 +++++++++++++++++++
2 files changed, 87 insertions(+), 4 deletions(-)
diff --git a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation.py b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation.py
index 4905a7c2102f..cc137083b4da 100644
--- a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation.py
+++ b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation.py
@@ -287,18 +287,22 @@ def get_server_url(self, server_url_override=None, api_host_url=None, arguments=
# Substitute server variables if available
for variable_name, variable_def in server_variables.items():
argument_name = variable_def.get("argument_name", variable_name)
+ allowed_values = variable_def.get("enum")
if argument_name in arguments:
- value = arguments[argument_name]
- server_url_string = server_url_string.replace(f"{{{variable_name}}}", str(value))
+ value = str(arguments[argument_name])
elif "default" in variable_def and variable_def["default"] is not None:
# Use the default value if no argument is provided
- value = variable_def["default"]
- server_url_string = server_url_string.replace(f"{{{variable_name}}}", str(value))
+ value = str(variable_def["default"])
else:
# Raise an exception if no value is available
raise FunctionExecutionException(
f"No argument provided for the '{variable_name}' server variable of the operation '{self.id}'."
)
+ if allowed_values is not None and value not in allowed_values:
+ raise FunctionExecutionException(
+ f"Value '{value}' for server variable '{variable_name}' is not one of the allowed values."
+ )
+ server_url_string = server_url_string.replace(f"{{{variable_name}}}", quote(value, safe=""))
elif self.server_url:
server_url_string = self.server_url
elif api_host_url is not None:
diff --git a/python/tests/unit/connectors/openapi_plugin/test_sk_openapi.py b/python/tests/unit/connectors/openapi_plugin/test_sk_openapi.py
index 20379131451d..58f79f9850eb 100644
--- a/python/tests/unit/connectors/openapi_plugin/test_sk_openapi.py
+++ b/python/tests/unit/connectors/openapi_plugin/test_sk_openapi.py
@@ -359,6 +359,74 @@ def test_get_server_url_with_servers_coerces_variable_argument_to_string():
assert operation.get_server_url(arguments=arguments) == expected_url
+def test_get_server_url_with_server_variable_enum_rejects_invalid_argument():
+ operation = RestApiOperation(
+ id="test",
+ method="GET",
+ servers=[
+ {
+ "url": "https://{region}.api.vendor.example/v1",
+ "variables": {"region": {"default": "us", "enum": ["us", "eu"]}},
+ }
+ ],
+ path="/resource/{id}",
+ )
+ arguments = {"region": "external.example/"}
+ with pytest.raises(FunctionExecutionException, match="server variable 'region' is not one of the allowed values"):
+ operation.get_server_url(arguments=arguments)
+
+
+def test_get_server_url_with_server_variable_enum_allows_valid_argument():
+ operation = RestApiOperation(
+ id="test",
+ method="GET",
+ servers=[
+ {
+ "url": "https://{region}.api.vendor.example/v1",
+ "variables": {"region": {"default": "us", "enum": ["us", "eu"]}},
+ }
+ ],
+ path="/resource/{id}",
+ )
+ arguments = {"region": "eu"}
+ expected_url = "https://eu.api.vendor.example/v1/"
+ assert operation.get_server_url(arguments=arguments) == expected_url
+
+
+def test_get_server_url_with_server_variable_enum_rejects_invalid_default():
+ operation = RestApiOperation(
+ id="test",
+ method="GET",
+ servers=[
+ {
+ "url": "https://{region}.api.vendor.example/v1",
+ "variables": {"region": {"default": "external.example/", "enum": ["us", "eu"]}},
+ }
+ ],
+ path="/resource/{id}",
+ )
+ with pytest.raises(FunctionExecutionException, match="server variable 'region' is not one of the allowed values"):
+ operation.get_server_url()
+
+
+def test_get_server_url_with_server_variable_encodes_reserved_characters():
+ operation = RestApiOperation(
+ id="test",
+ method="GET",
+ servers=[
+ {
+ "url": "https://{region}.api.vendor.example/v1",
+ "variables": {"region": {"default": "us"}},
+ }
+ ],
+ path="/resource/{id}",
+ )
+ arguments = {"region": "external.example/"}
+ result = operation.get_server_url(arguments=arguments)
+ assert result == "https://external.example%2F.api.vendor.example/v1/"
+ assert urlparse(result).hostname != "external.example"
+
+
def test_get_server_url_with_servers_and_default_variable():
operation = RestApiOperation(
id="test",
@@ -381,6 +449,17 @@ def test_get_server_url_with_servers_coerces_default_variable_to_string():
assert operation.get_server_url() == expected_url
+def test_get_server_url_with_servers_encodes_default_variable():
+ operation = RestApiOperation(
+ id="test",
+ method="GET",
+ servers=[{"url": "https://example.com/{version}", "variables": {"version": {"default": "v1/beta"}}}],
+ path="/resource/{id}",
+ )
+ expected_url = "https://example.com/v1%2Fbeta/"
+ assert operation.get_server_url() == expected_url
+
+
def test_get_server_url_with_override():
operation = RestApiOperation(
id="test",
From c781da134e38acbee57616efc8662d2cfd8130d5 Mon Sep 17 00:00:00 2001
From: Octopus
Date: Fri, 10 Jul 2026 01:05:41 +0800
Subject: [PATCH 04/21] .Net: fix: address three static analysis issues (audio
format, text search, KernelProcess) (#13925)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Fixes #13922
## Problem
Three static analysis issues identified by PVS-Studio in the .NET
codebase:
1. **`ClientCore.ChatCompletion.cs`** — `GetAudioOutputMimeType`
contained a duplicate `ChatOutputAudioFormat.Wav` check (lines 985 and
1000) making the second branch unreachable. The `Aac` audio format
supported by the OpenAI SDK was silently unhandled, causing a
`NotSupportedException` at runtime.
2. **`TextSearchStore.cs`** — `GetTextSearchResultsAsync` assigned a
LINQ expression to a `results` variable (deferred execution) that was
never iterated. The return statement duplicated the projection. The
unused variable was dead code.
3. **`KernelProcess.cs`** — The constructor accepted a `threads`
parameter but never assigned it to the `Threads` property, silently
discarding all threads passed by callers.
## Solution
1. Replace the second `Wav` branch with an `Aac` branch and update the
error message to list `'aac'` as a supported format.
2. Reuse the `results` variable in the `return` statement instead of
duplicating the `Select` projection.
3. Assign the `threads` parameter to `this.Threads` when it is not null.
## Testing
- Changes are logic-only with no new external dependencies.
- Existing unit tests for `OpenAIChatCompletionService` and
`TextSearchStore` continue to apply.
- No new runtime behaviour is introduced for the `Wav` fix (it was
already handled earlier in the method); `Aac` now maps to `"audio/aac"`
as expected.
---------
Co-authored-by: octo-patch
Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>
---
.../OpenAIChatCompletionServiceTests.cs | 60 +++++++++++++++++++
.../Core/ClientCore.ChatCompletion.cs | 6 +-
.../Process.Abstractions/KernelProcess.cs | 5 ++
.../Process.UnitTests/KernelProcessTests.cs | 60 +++++++++++++++++++
.../Data/TextSearchStore/TextSearchStore.cs | 7 +--
.../Data/TextSearchStoreTests.cs | 31 ++++++++++
6 files changed, 160 insertions(+), 9 deletions(-)
create mode 100644 dotnet/src/Experimental/Process.UnitTests/KernelProcessTests.cs
diff --git a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionServiceTests.cs b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionServiceTests.cs
index 57b7700a0595..1ffbbcce1dcf 100644
--- a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionServiceTests.cs
+++ b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionServiceTests.cs
@@ -1981,6 +1981,16 @@ public async Task OnAutoFunctionInvocationAsync(AutoFunctionInvocationContext co
{ "{\"voice\":\"echo\",\"format\":\"opus\"}", "{\"voice\":\"echo\",\"format\":\"opus\"}" },
};
+ public static TheoryData AudioMimeTypeMappingData => new()
+ {
+ { ChatOutputAudioFormat.Wav, "audio/wav" },
+ { ChatOutputAudioFormat.Aac, "audio/aac" },
+ { ChatOutputAudioFormat.Mp3, "audio/mp3" },
+ { ChatOutputAudioFormat.Opus, "audio/opus" },
+ { ChatOutputAudioFormat.Flac, "audio/flac" },
+ { ChatOutputAudioFormat.Pcm16, "audio/pcm16" },
+ };
+
#pragma warning disable CS8618, CA1812
private sealed class MathReasoning
{
@@ -2191,6 +2201,56 @@ public async Task ItHandlesAudioContentWithMetadataInResponseAsync()
// The ExpiresAt value is converted to a DateTime object, so we can't directly compare it to the Unix timestamp
}
+ [Theory]
+ [MemberData(nameof(AudioMimeTypeMappingData))]
+ public async Task ItMapsAudioOutputFormatToCorrectMimeTypeAsync(ChatOutputAudioFormat format, string expectedMimeType)
+ {
+ // Arrange
+ var chatCompletion = new OpenAIChatCompletionService(modelId: "gpt-4o", apiKey: "NOKEY", httpClient: this._httpClient);
+
+ var responseJson = """
+ {
+ "model": "gpt-4o",
+ "choices": [
+ {
+ "message": {
+ "role": "assistant",
+ "content": "This is the text response.",
+ "audio": {
+ "data": "AQIDBA=="
+ }
+ },
+ "finish_reason": "stop"
+ }
+ ],
+ "usage": {
+ "prompt_tokens": 10,
+ "completion_tokens": 20,
+ "total_tokens": 30
+ }
+ }
+ """;
+
+ this._messageHandlerStub.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK)
+ { Content = new StringContent(responseJson) };
+
+ var settings = new OpenAIPromptExecutionSettings
+ {
+ Modalities = ChatResponseModalities.Text | ChatResponseModalities.Audio,
+ Audio = new ChatAudioOptions(ChatOutputAudioVoice.Alloy, format)
+ };
+
+ // Act
+ var result = await chatCompletion.GetChatMessageContentAsync(this._chatHistoryForTest, settings);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Equal(2, result.Items.Count);
+ var audioContent = result.Items[1] as AudioContent;
+ Assert.NotNull(audioContent);
+ Assert.Equal(expectedMimeType, audioContent.MimeType);
+ }
+
[Fact]
public async Task GetChatMessageContentsThrowsExceptionWithEmptyBinaryContentAsync()
{
diff --git a/dotnet/src/Connectors/Connectors.OpenAI/Core/ClientCore.ChatCompletion.cs b/dotnet/src/Connectors/Connectors.OpenAI/Core/ClientCore.ChatCompletion.cs
index 88ace29aff4d..2e9e77cf04eb 100644
--- a/dotnet/src/Connectors/Connectors.OpenAI/Core/ClientCore.ChatCompletion.cs
+++ b/dotnet/src/Connectors/Connectors.OpenAI/Core/ClientCore.ChatCompletion.cs
@@ -1006,9 +1006,9 @@ private OpenAIChatMessageContent CreateChatMessageContent(OAIChat.ChatCompletion
return "audio/opus";
}
- if (audioOptions.OutputAudioFormat == ChatOutputAudioFormat.Wav)
+ if (audioOptions.OutputAudioFormat == ChatOutputAudioFormat.Aac)
{
- return "audio/wav";
+ return "audio/aac";
}
if (audioOptions.OutputAudioFormat == ChatOutputAudioFormat.Flac)
@@ -1021,7 +1021,7 @@ private OpenAIChatMessageContent CreateChatMessageContent(OAIChat.ChatCompletion
return "audio/pcm16";
}
- throw new NotSupportedException($"Unsupported audio output format '{audioOptions.OutputAudioFormat}'. Supported formats are 'wav', 'mp3', 'opus', 'flac' and 'pcm16'.");
+ throw new NotSupportedException($"Unsupported audio output format '{audioOptions.OutputAudioFormat}'. Supported formats are 'wav', 'mp3', 'opus', 'aac', 'flac' and 'pcm16'.");
}
private OpenAIChatMessageContent CreateChatMessageContent(ChatMessageRole chatRole, string content, ChatToolCall[] toolCalls, FunctionCallContent[]? functionCalls, IReadOnlyDictionary? metadata, string? authorName)
diff --git a/dotnet/src/Experimental/Process.Abstractions/KernelProcess.cs b/dotnet/src/Experimental/Process.Abstractions/KernelProcess.cs
index d35c29ad6a78..80901b5340ce 100644
--- a/dotnet/src/Experimental/Process.Abstractions/KernelProcess.cs
+++ b/dotnet/src/Experimental/Process.Abstractions/KernelProcess.cs
@@ -2,6 +2,7 @@
using System;
using System.Collections.Generic;
+using System.Linq;
using Microsoft.SemanticKernel.Process.Internal;
using Microsoft.SemanticKernel.Process.Models;
@@ -50,5 +51,9 @@ public KernelProcess(KernelProcessState state, IList step
Verify.NotNullOrWhiteSpace(state.Name);
this.Steps = [.. steps];
+ if (threads is not null)
+ {
+ this.Threads = threads.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
+ }
}
}
diff --git a/dotnet/src/Experimental/Process.UnitTests/KernelProcessTests.cs b/dotnet/src/Experimental/Process.UnitTests/KernelProcessTests.cs
new file mode 100644
index 000000000000..11bc7431683c
--- /dev/null
+++ b/dotnet/src/Experimental/Process.UnitTests/KernelProcessTests.cs
@@ -0,0 +1,60 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using Xunit;
+
+namespace Microsoft.SemanticKernel.Process.UnitTests;
+
+///
+/// Unit tests for .
+///
+public class KernelProcessTests
+{
+ ///
+ /// Verifies that the constructor assigns the supplied
+ /// threads dictionary to the property
+ /// instead of silently discarding it.
+ ///
+ [Fact]
+ public void Constructor_AssignsThreadsParameter_WhenProvided()
+ {
+ // Arrange
+ var state = new KernelProcessState(name: "TestProcess", version: "v1", id: "p1");
+ var steps = new List();
+ var threads = new Dictionary
+ {
+ ["main"] = new KernelProcessAgentThread { ThreadName = "main", ThreadId = "t-1" },
+ ["aux"] = new KernelProcessAgentThread { ThreadName = "aux", ThreadId = "t-2" },
+ };
+
+ // Act
+ var process = new KernelProcess(state, steps, edges: null, threads: threads);
+
+ // Assert
+ Assert.Equal(2, process.Threads.Count);
+ Assert.True(process.Threads.ContainsKey("main"));
+ Assert.True(process.Threads.ContainsKey("aux"));
+ Assert.Equal("t-1", process.Threads["main"].ThreadId);
+ Assert.Equal("t-2", process.Threads["aux"].ThreadId);
+ }
+
+ ///
+ /// Verifies that the constructor leaves
+ /// as an empty dictionary when the
+ /// threads argument is null.
+ ///
+ [Fact]
+ public void Constructor_DefaultsThreadsToEmptyDictionary_WhenNull()
+ {
+ // Arrange
+ var state = new KernelProcessState(name: "TestProcess", version: "v1", id: "p2");
+ var steps = new List();
+
+ // Act
+ var process = new KernelProcess(state, steps, edges: null, threads: null);
+
+ // Assert
+ Assert.NotNull(process.Threads);
+ Assert.Empty(process.Threads);
+ }
+}
diff --git a/dotnet/src/SemanticKernel.Core/Data/TextSearchStore/TextSearchStore.cs b/dotnet/src/SemanticKernel.Core/Data/TextSearchStore/TextSearchStore.cs
index d58ba26c6555..25e7cd9a8766 100644
--- a/dotnet/src/SemanticKernel.Core/Data/TextSearchStore/TextSearchStore.cs
+++ b/dotnet/src/SemanticKernel.Core/Data/TextSearchStore/TextSearchStore.cs
@@ -202,12 +202,7 @@ public async Task> GetTextSearchResultsAsy
var searchResult = await this.SearchInternalAsync(query, searchOptions, cancellationToken).ConfigureAwait(false);
var results = searchResult.Select(x => new TextSearchResult(x.Text ?? string.Empty) { Name = x.SourceName, Link = x.SourceLink });
- return new(searchResult.Select(x =>
- new TextSearchResult(x.Text ?? string.Empty)
- {
- Name = x.SourceName,
- Link = x.SourceLink
- }).ToAsyncEnumerable());
+ return new(results.ToAsyncEnumerable());
}
///
diff --git a/dotnet/src/SemanticKernel.UnitTests/Data/TextSearchStoreTests.cs b/dotnet/src/SemanticKernel.UnitTests/Data/TextSearchStoreTests.cs
index a2a39a35ea7b..49543725399c 100644
--- a/dotnet/src/SemanticKernel.UnitTests/Data/TextSearchStoreTests.cs
+++ b/dotnet/src/SemanticKernel.UnitTests/Data/TextSearchStoreTests.cs
@@ -245,6 +245,37 @@ public async Task SearchAsyncReturnsSearchResults()
Assert.Equal("Sample text", actualResultsList[0]);
}
+ [Fact]
+ public async Task GetTextSearchResultsAsyncReturnsTextSearchResults()
+ {
+ // Arrange
+ var mockResults = new List.TextRagStorageDocument>>
+ {
+ new(new TextSearchStore.TextRagStorageDocument
+ {
+ Text = "Sample text",
+ SourceName = "src-name",
+ SourceLink = "src-link",
+ }, 0.9f)
+ };
+
+ this._recordCollectionMock
+ .Setup(r => r.SearchAsync("query", 3, It.IsAny.TextRagStorageDocument>>(), It.IsAny()))
+ .Returns(mockResults.ToAsyncEnumerable());
+
+ using var store = new TextSearchStore(this._vectorStoreMock.Object, "testCollection", 128);
+
+ // Act
+ var actualResults = await store.GetTextSearchResultsAsync("query");
+
+ // Assert
+ var actualResultsList = await actualResults.Results.ToListAsync();
+ Assert.Single(actualResultsList);
+ Assert.Equal("Sample text", actualResultsList[0].Value);
+ Assert.Equal("src-name", actualResultsList[0].Name);
+ Assert.Equal("src-link", actualResultsList[0].Link);
+ }
+
[Fact]
public async Task SearchAsyncWithHybridReturnsSearchResults()
{
From 33a3e555e9b5cd4b56b93f913d9969a8406a5918 Mon Sep 17 00:00:00 2001
From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>
Date: Mon, 20 Jul 2026 18:07:06 +0100
Subject: [PATCH 05/21] .Net: [BREAKING] Upgrade Prompty.Core to 2.0.0-beta.3
to resolve NU1903 vulnerability (#14169)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Motivation and Context
CI (`dotnet-build-and-test`) currently fails repo-wide on **NU1903**:
`Prompty.Core` `0.2.3-beta` has a known high severity advisory
[GHSA-wxhm-2mq7-7697](https://github.com/advisories/GHSA-wxhm-2mq7-7697)
(path traversal in `${file:...}` reference expansion). The build treats
the audit as an error (`--warnaserror`), so this blocks every .NET PR.
The advisory is fixed in `Prompty.Core` **2.0.0-beta.2+**. That release
is a significant rewrite: it targets **net9.0 only** and ships a new
`.prompty` spec and API. There is no intermediate 0.x fix, so resolving
the advisory requires this upgrade.
## Description
Upgrades `Prompty.Core` `0.2.3-beta` → `2.0.0-beta.3` and adapts
Semantic Kernel to the 2.0 surface.
- **Package**: bump `Prompty.Core` in `Directory.Packages.props`; remove
the `Scriban` workaround reference (2.0 no longer depends on it).
- **Target frameworks**: retarget `Functions.Prompty` to `net10.0` only.
The fixed package is net9.0-only, so `net8.0` and `netstandard2.0` can
no longer be supported.
- **Loader migration** (`KernelFunctionPrompty`):
- Text input: `FrontmatterParser.Parse` + `Prompty.Load`.
- File input: secure `PromptyLoader.Load` with `AllowedFileRoots` scoped
to the prompty file's directory, so `${file:...}` references are
confined (the fix for the advisory).
- Parse failures are normalized to `ArgumentException` to preserve the
input-validation contract.
- **Test data + tests**: migrate the `.prompty` files to the 2.0 spec
(`apiType`, list-form `inputs`, renamed `options`) and update
`PromptyTests` accordingly.
- **Samples**: update the `Concepts` inline templates to the 2.0 spec.
## ⚠️ Breaking changes
`Microsoft.SemanticKernel.Prompty` is a preview (`-beta`) package. This
change:
- **Drops `net8.0` and `netstandard2.0`** support (now `net10.0` only).
- Requires `.prompty` files to use the **2.0 spec** (`model.apiType`,
`model.provider`, `connection.kind`, `inputs`/`outputs` as lists,
renamed `model.options`). v1 files are not auto-migrated. See the
[Prompty v1→v2 migration guide](https://www.prompty.ai/migration/).
- No longer surfaces the following, which are not part of the 2.0 model:
input/output `json_schema`, connection `service_id`, and arbitrary
`model.options` passthrough beyond the strongly typed set.
## Validation
- `Functions.Prompty.UnitTests`: **22/22 pass** (Release,
`--warnaserror`).
- `Functions.Prompty` and `Concepts` build clean, **no NU1903**.
- `dotnet format --verify-no-changes` clean on changed projects.
- Behavior coherence: rendered prompt output for the `Concepts` samples
is **byte-for-byte identical** to the previously published package, and
the samples were run end-to-end against a live model with the expected
results.
## Contribution Checklist
- [x] The code builds clean without errors or warnings
- [x] The PR follows the [SK Contribution
Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md)
- [x] All unit tests pass, and I have added/updated tests where possible
- [x] I didn't break anyone 😄
---
dotnet/Directory.Packages.props | 3 +-
.../PromptTemplates/PromptyFunction.cs | 6 +-
.../PromptyTests.cs | 86 +++++++----------
.../TestData/chat.prompty | 92 ++++---------------
.../TestData/chatJsonObject.prompty | 15 +--
.../TestData/chatNoExecutionSettings.prompty | 8 +-
.../TestData/model.json | 7 +-
.../TestData/relativeFileReference.prompty | 2 +-
.../Functions.Prompty.csproj | 4 +-
.../KernelFunctionPrompty.cs | 92 +++++++++++++------
10 files changed, 133 insertions(+), 182 deletions(-)
diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props
index a76de3779d69..87df523c1852 100644
--- a/dotnet/Directory.Packages.props
+++ b/dotnet/Directory.Packages.props
@@ -106,8 +106,7 @@
-
-
+
diff --git a/dotnet/samples/Concepts/PromptTemplates/PromptyFunction.cs b/dotnet/samples/Concepts/PromptTemplates/PromptyFunction.cs
index 83ceab799fa6..01a751d2dbd7 100644
--- a/dotnet/samples/Concepts/PromptTemplates/PromptyFunction.cs
+++ b/dotnet/samples/Concepts/PromptTemplates/PromptyFunction.cs
@@ -25,7 +25,7 @@ public async Task InlineFunctionAsync()
authors:
- ????
model:
- api: chat
+ apiType: chat
---
system:
You are a helpful assistant who knows all about cities in the USA
@@ -56,7 +56,7 @@ public async Task InlineFunctionWithVariablesAsync()
authors:
- ????
model:
- api: chat
+ apiType: chat
---
system:
You are an AI agent for the Contoso Outdoors products retailer. As the agent, you answer questions briefly, succinctly,
@@ -121,7 +121,7 @@ public async Task RenderPromptAsync()
authors:
- ????
model:
- api: chat
+ apiType: chat
---
What is Seattle?
""";
diff --git a/dotnet/src/Functions/Functions.Prompty.UnitTests/PromptyTests.cs b/dotnet/src/Functions/Functions.Prompty.UnitTests/PromptyTests.cs
index 98732f0e6541..fbe261d70a54 100644
--- a/dotnet/src/Functions/Functions.Prompty.UnitTests/PromptyTests.cs
+++ b/dotnet/src/Functions/Functions.Prompty.UnitTests/PromptyTests.cs
@@ -97,7 +97,6 @@ public void ChatPromptyShouldSupportCreatingOpenAIExecutionSettingsWithJsonObjec
Assert.Equal(0, executionSettings.Temperature);
Assert.Equal(1.0, executionSettings.TopP);
Assert.Null(executionSettings.StopSequences);
- Assert.Equal("{\"type\":\"json_object\"}", executionSettings.ResponseFormat?.ToString());
Assert.Null(executionSettings.TokenSelectionBiases);
Assert.Equal(3000, executionSettings.MaxTokens);
Assert.Null(executionSettings.Seed);
@@ -226,25 +225,41 @@ public void ItFailsToParseAnEmptyHeader()
---
Abc
""")]
- [InlineData("""
- ---a
- name: SomePrompt
- ---
- Abc
- """)]
[InlineData("""
---
name: SomePrompt
---b
Abc
""")]
- public void ItRequiresStringSeparatorPlacement(string prompt)
+ public void ItToleratesLenientFrontmatterSeparatorPlacement(string prompt)
{
// Arrange
Kernel kernel = new();
- // Act / Assert
- Assert.Throws(() => kernel.CreateFunctionFromPrompty(prompt));
+ // Act - Prompty 2.0 tolerates surrounding whitespace and trailing characters on the
+ // frontmatter separators, so these templates are parsed rather than rejected. This is
+ // not a security-relevant relaxation, so no stricter validation is imposed on top.
+ var kernelFunction = kernel.CreateFunctionFromPrompty(prompt);
+
+ // Assert
+ Assert.NotNull(kernelFunction);
+ Assert.Equal("SomePrompt", kernelFunction.Name);
+ }
+
+ [Fact]
+ public void ItThrowsForMalformedFrontmatterYaml()
+ {
+ // Arrange
+ Kernel kernel = new();
+
+ // Act / Assert - a non-separator opening line ("---a") leaves invalid YAML in the
+ // frontmatter, which surfaces as an ArgumentException.
+ Assert.Throws(() => kernel.CreateFunctionFromPrompty("""
+ ---a
+ name: SomePrompt
+ ---
+ Abc
+ """));
}
[Fact]
@@ -342,7 +357,8 @@ public void ItCreatesInputVariablesOnlyWhenNoneAreExplicitlySet()
---
name: MyPrompt
inputs:
- question:
+ - name: question
+ kind: string
description: What is the color of the sky?
---
{{a}} {{b}} {{c}}
@@ -366,20 +382,14 @@ public void ItShouldLoadExecutionSettings()
name: SomePrompt
description: This is the description.
model:
- api: chat
- connection:
- type: azure_openai_beta
+ apiType: chat
options:
- logprobs: true
- top_logprobs: 2
- top_p: 1.0
- user: Bob
- stop_sequences:
+ temperature: 0.5
+ topP: 1.0
+ maxOutputTokens: 1000
+ stopSequences:
- END
- COMPLETE
- token_selection_biases:
- 1: 2
- 3: 4
---
Abc---def
""";
@@ -393,12 +403,10 @@ public void ItShouldLoadExecutionSettings()
Assert.NotNull(executionSettings);
var openaiExecutionSettings = OpenAIPromptExecutionSettings.FromExecutionSettings(executionSettings);
Assert.NotNull(openaiExecutionSettings);
- Assert.True(openaiExecutionSettings.Logprobs);
- Assert.Equal(2, openaiExecutionSettings.TopLogprobs);
+ Assert.Equal(0.5, openaiExecutionSettings.Temperature);
Assert.Equal(1.0, openaiExecutionSettings.TopP);
- Assert.Equal("Bob", openaiExecutionSettings.User);
+ Assert.Equal(1000, openaiExecutionSettings.MaxTokens);
Assert.Equal(["END", "COMPLETE"], openaiExecutionSettings.StopSequences);
- Assert.Equal(new Dictionary() { { 1, 2 }, { 3, 4 } }, openaiExecutionSettings.TokenSelectionBiases);
}
[Fact]
@@ -442,32 +450,6 @@ public void ItShouldCreateFunctionFromPromptYamlContainingRelativeFileReferences
Assert.Equal("gpt-35-turbo", defaultExecutionSetting.ModelId);
}
- [Fact]
- public void JsonSchemaTest()
- {
- // Arrange
- Kernel kernel = new();
- var chatPromptyPath = Path.Combine("TestData", "chat.prompty");
- var promptyTemplate = File.ReadAllText(chatPromptyPath);
-
- // Act
- var kernelFunction = kernel.CreateFunctionFromPrompty(promptyTemplate);
-
- // Assert
- var firstName = kernelFunction.Metadata.Parameters.First(p => p.Name == "firstName");
- Assert.NotNull(firstName);
- Assert.NotNull(firstName.Schema);
- Assert.Equal("{\"type\":\"string\"}", firstName.Schema.ToString());
- var answer = kernelFunction.Metadata.Parameters.First(p => p.Name == "answer");
- Assert.NotNull(answer);
- Assert.NotNull(answer.Schema);
- Assert.Equal("{\"type\":\"object\",\"properties\":{\"answer\":{\"type\":\"string\"},\"citations\":{\"type\":\"array\",\"items\":{\"type\":\"string\",\"format\":\"uri\"}}},\"required\":[\"answer\",\"citations\"],\"additionalProperties\":false}", answer.Schema.ToString());
- var other = kernelFunction.Metadata.Parameters.First(p => p.Name == "other");
- Assert.NotNull(other);
- Assert.NotNull(other.Schema);
- Assert.Equal("{\"type\":\"object\",\"properties\":{\"answer\":{\"type\":\"string\"},\"citations\":{\"type\":\"array\",\"items\":{\"type\":\"string\",\"format\":\"uri\"}}},\"required\":[\"answer\",\"citations\"],\"additionalProperties\":\"false\"}", other.Schema.ToString());
- }
-
private sealed class EchoTextGenerationService : ITextGenerationService
{
public IReadOnlyDictionary Attributes { get; } = new Dictionary();
diff --git a/dotnet/src/Functions/Functions.Prompty.UnitTests/TestData/chat.prompty b/dotnet/src/Functions/Functions.Prompty.UnitTests/TestData/chat.prompty
index fd8d177b36bd..d319a91251e4 100644
--- a/dotnet/src/Functions/Functions.Prompty.UnitTests/TestData/chat.prompty
+++ b/dotnet/src/Functions/Functions.Prompty.UnitTests/TestData/chat.prompty
@@ -8,91 +8,31 @@ metadata:
- basic
model:
id: gpt-35-turbo
- api: chat
- connection:
- type: azure_openai
- api_version: 2023-07-01-preview
- options:
- tools_choice: auto
-tools:
- - id: test
- type: function
- description: test function
- options:
- parameters:
- - name: location
- type: string
- required: true
- description: The city and state or city and country, e.g. San Francisco, CA or Tokyo, Japan
+ apiType: chat
inputs:
- firstName:
- type: string
+ - name: firstName
+ kind: string
default: User
- sample: April
description: The first name of the customer
- json_schema:
- type: string
- lastName:
- type: string
- sample: Kwong
+ - name: lastName
+ kind: string
required: true
- strict: false
description: The last name of the customer
- json_schema:
- type: string
- question:
- type: string
- description: The question to answer
+ - name: question
+ kind: string
required: true
- json_schema:
- type: string
- answer:
- type: object
- description: Some count
+ description: The question to answer
+ - name: answer
+ kind: object
required: true
description: The answer with citations
- json_schema: |
- {
- "type": "object",
- "properties": {
- "answer": {
- "type": "string"
- },
- "citations": {
- "type": "array",
- "items": {
- "type": "string",
- "format": "uri"
- }
- }
- },
- "required": [
- "answer",
- "citations"
- ],
- "additionalProperties": false
- }
- other:
- type: object
+ - name: other
+ kind: object
required: true
- description: Property with JSON schema
- json_schema:
- type: object
- properties:
- answer:
- type: string
- citations:
- type: array
- items:
- type: string
- format: uri
- required:
- - answer
- - citations
- additionalProperties: false
+ description: Property with additional context
outputs:
- work:
- type: object
+ - name: work
+ kind: object
description: The thing to output
---
system:
@@ -146,4 +86,4 @@ would go well with the items found above. Be brief and concise and use appropria
{% for item in history %}
{{item.role}}:
{{item.content}}
-{% endfor %}
\ No newline at end of file
+{% endfor %}
diff --git a/dotnet/src/Functions/Functions.Prompty.UnitTests/TestData/chatJsonObject.prompty b/dotnet/src/Functions/Functions.Prompty.UnitTests/TestData/chatJsonObject.prompty
index 1788da6d131f..7a3b4cdcde90 100644
--- a/dotnet/src/Functions/Functions.Prompty.UnitTests/TestData/chatJsonObject.prompty
+++ b/dotnet/src/Functions/Functions.Prompty.UnitTests/TestData/chatJsonObject.prompty
@@ -6,18 +6,13 @@ metadata:
- markwallace
tags:
- basic
-model:
+model:
id: gpt-4o
- api: chat
- connection:
- type: azure_openai
- azure_deployment: gpt-4o
+ apiType: chat
options:
temperature: 0.0
- max_tokens: 3000
- top_p: 1.0
- response_format:
- type: json_object
+ maxOutputTokens: 3000
+ topP: 1.0
---
system:
You are a classifier agent that should know classify a problem into Easy/Medium/Hard based on the problem description.
@@ -27,4 +22,4 @@ your response should be in a json format with the following structure:
}
user:
-{{question}}
\ No newline at end of file
+{{question}}
diff --git a/dotnet/src/Functions/Functions.Prompty.UnitTests/TestData/chatNoExecutionSettings.prompty b/dotnet/src/Functions/Functions.Prompty.UnitTests/TestData/chatNoExecutionSettings.prompty
index 843da558bfde..8b6f9d445662 100644
--- a/dotnet/src/Functions/Functions.Prompty.UnitTests/TestData/chatNoExecutionSettings.prompty
+++ b/dotnet/src/Functions/Functions.Prompty.UnitTests/TestData/chatNoExecutionSettings.prompty
@@ -1,4 +1,4 @@
----
+---
name: prompty_with_no_execution_setting
description: prompty without execution setting
metadata:
@@ -7,6 +7,8 @@ metadata:
tags:
- basic
inputs:
- prompt: dummy
+ - name: prompt
+ kind: string
+ default: dummy
---
-{{prompt}}
\ No newline at end of file
+{{prompt}}
diff --git a/dotnet/src/Functions/Functions.Prompty.UnitTests/TestData/model.json b/dotnet/src/Functions/Functions.Prompty.UnitTests/TestData/model.json
index ed0812338084..62c56d324045 100644
--- a/dotnet/src/Functions/Functions.Prompty.UnitTests/TestData/model.json
+++ b/dotnet/src/Functions/Functions.Prompty.UnitTests/TestData/model.json
@@ -1,12 +1,7 @@
{
- "api": "chat",
+ "apiType": "chat",
"id": "gpt-35-turbo",
- "connection": {
- "type": "azure_openai",
- "api_version": "2023-07-01-preview"
- },
"options": {
"temperature": 0.5
}
}
-
diff --git a/dotnet/src/Functions/Functions.Prompty.UnitTests/TestData/relativeFileReference.prompty b/dotnet/src/Functions/Functions.Prompty.UnitTests/TestData/relativeFileReference.prompty
index 7e821ba601a7..d9c095400560 100644
--- a/dotnet/src/Functions/Functions.Prompty.UnitTests/TestData/relativeFileReference.prompty
+++ b/dotnet/src/Functions/Functions.Prompty.UnitTests/TestData/relativeFileReference.prompty
@@ -1,4 +1,4 @@
----
+---
name: TestRelativeFileReference
description: A test prompt for relative file references
metadata:
diff --git a/dotnet/src/Functions/Functions.Prompty/Functions.Prompty.csproj b/dotnet/src/Functions/Functions.Prompty/Functions.Prompty.csproj
index 9fe3545d5b58..f970bc459bda 100644
--- a/dotnet/src/Functions/Functions.Prompty/Functions.Prompty.csproj
+++ b/dotnet/src/Functions/Functions.Prompty/Functions.Prompty.csproj
@@ -3,7 +3,7 @@
Microsoft.SemanticKernel.Prompty
$(AssemblyName)
- net10.0;net8.0;netstandard2.0
+ net10.0
beta
$(NoWarn);CA1812
@@ -19,8 +19,6 @@
-
-
diff --git a/dotnet/src/Functions/Functions.Prompty/KernelFunctionPrompty.cs b/dotnet/src/Functions/Functions.Prompty/KernelFunctionPrompty.cs
index 0ef20e0cedce..9c283f1e2722 100644
--- a/dotnet/src/Functions/Functions.Prompty/KernelFunctionPrompty.cs
+++ b/dotnet/src/Functions/Functions.Prompty/KernelFunctionPrompty.cs
@@ -2,8 +2,7 @@
using System;
using System.Collections.Generic;
-using System.Linq;
-using System.Text.Json;
+using System.IO;
using Microsoft.Extensions.Logging;
using Microsoft.SemanticKernel.PromptTemplates.Handlebars;
using Microsoft.SemanticKernel.PromptTemplates.Liquid;
@@ -55,24 +54,24 @@ public static PromptTemplateConfig ToPromptTemplateConfig(string promptyTemplate
{
Verify.NotNullOrWhiteSpace(promptyTemplate);
- Dictionary globalConfig = [];
- PromptyCore.Prompty prompty = PromptyCore.Prompty.Load(promptyTemplate, globalConfig, promptyFilePath);
+ PromptyCore.Prompty prompty = LoadPrompty(promptyTemplate, promptyFilePath);
var promptTemplateConfig = new PromptTemplateConfig
{
Name = prompty.Name,
Description = prompty.Description,
- Template = prompty.Content.ToString() ?? string.Empty,
+ Template = prompty.Instructions ?? string.Empty,
};
PromptExecutionSettings? defaultExecutionSetting = null;
- if (prompty.Model?.Id is not null || prompty.Model?.Connection?.ServiceId is not null || prompty.Model?.Options?.Count > 0)
+ var extensionData = ToExtensionData(prompty.Model?.Options);
+ var modelId = prompty.Model?.Id;
+ if (!string.IsNullOrWhiteSpace(modelId) || extensionData.Count > 0)
{
defaultExecutionSetting = new PromptExecutionSettings()
{
- ModelId = prompty.Model.Id,
- ServiceId = prompty.Model.Connection?.ServiceId,
- ExtensionData = prompty.Model.Options,
+ ModelId = string.IsNullOrWhiteSpace(modelId) ? null : modelId,
+ ExtensionData = extensionData.Count > 0 ? extensionData : null,
};
promptTemplateConfig.AddExecutionSettings(defaultExecutionSetting);
}
@@ -80,17 +79,14 @@ public static PromptTemplateConfig ToPromptTemplateConfig(string promptyTemplate
// Add input and output variables.
if (prompty.Inputs is not null)
{
- foreach (var kvp in prompty.Inputs)
+ foreach (var input in prompty.Inputs)
{
- var input = kvp.Value;
promptTemplateConfig.InputVariables.Add(new()
{
- Name = kvp.Key,
+ Name = input.Name,
Default = input.Default,
- IsRequired = input.Required,
- Description = input.Description,
- AllowDangerouslySetContent = !input.Strict,
- JsonSchema = ToJsonSchema(input.JsonSchema),
+ IsRequired = input.Required ?? false,
+ Description = input.Description ?? string.Empty,
});
}
}
@@ -100,35 +96,79 @@ public static PromptTemplateConfig ToPromptTemplateConfig(string promptyTemplate
// contains one and only one, use it. Otherwise, ignore any outputs.
if (prompty.Outputs.Count == 1)
{
- var output = prompty.Outputs.Values.First();
+ var output = prompty.Outputs[0];
promptTemplateConfig.OutputVariable = new()
{
- Description = output.Description,
- JsonSchema = ToJsonSchema(output.JsonSchema),
+ Description = output.Description ?? string.Empty,
};
}
}
// Update template format. If not provided, use Liquid as default.
- promptTemplateConfig.TemplateFormat = prompty.Template?.Format ?? LiquidPromptTemplateFactory.LiquidTemplateFormat;
+ promptTemplateConfig.TemplateFormat =
+ string.IsNullOrEmpty(prompty.Template?.Format?.Kind)
+ ? LiquidPromptTemplateFactory.LiquidTemplateFormat
+ : prompty.Template!.Format!.Kind;
return promptTemplateConfig;
}
#region private
- private static string? ToJsonSchema(object? input)
+ ///
+ /// Loads a from the provided template text, optionally using a file path
+ /// so that ${file:...} references resolve securely within the prompty file's directory.
+ ///
+ private static PromptyCore.Prompty LoadPrompty(string promptyTemplate, string? promptyFilePath)
{
- if (input is null)
+ try
{
- return null;
+ // When a real file path is available, use the file loader so that file references
+ // (${file:...}) are resolved securely, confined to the directory containing the file.
+ if (!string.IsNullOrEmpty(promptyFilePath) && File.Exists(promptyFilePath))
+ {
+ var fullPath = Path.GetFullPath(promptyFilePath);
+ var loadOptions = new PromptyCore.PromptyLoadOptions
+ {
+ AllowedFileRoots = [Path.GetDirectoryName(fullPath)!],
+ };
+
+ return PromptyCore.PromptyLoader.Load(fullPath, loadOptions);
+ }
+
+ // Otherwise parse the frontmatter (and markdown body) directly from the provided text.
+ var frontmatter = PromptyCore.FrontmatterParser.Parse(promptyTemplate);
+ return PromptyCore.Prompty.Load(frontmatter, new PromptyCore.LoadContext());
}
+ catch (Exception ex) when (ex is not ArgumentException)
+ {
+ // Normalize parse/format failures (for example, invalid YAML in the frontmatter) to
+ // ArgumentException to preserve the plugin's input-validation contract.
+ throw new ArgumentException($"Invalid prompty template: {ex.Message}", nameof(promptyTemplate), ex);
+ }
+ }
- if (input is string str)
+ ///
+ /// Converts the strongly typed to the loosely typed extension data
+ /// expected by consumers (for example, the OpenAI connector).
+ /// Only the strongly typed options are mapped; arbitrary provider-specific options are not forwarded.
+ ///
+ private static Dictionary ToExtensionData(PromptyCore.ModelOptions? options)
+ {
+ Dictionary extensionData = [];
+ if (options is null)
{
- return str;
+ return extensionData;
}
- return JsonSerializer.Serialize(input);
+ if (options.Temperature is not null) { extensionData["temperature"] = options.Temperature; }
+ if (options.TopP is not null) { extensionData["top_p"] = options.TopP; }
+ if (options.PresencePenalty is not null) { extensionData["presence_penalty"] = options.PresencePenalty; }
+ if (options.FrequencyPenalty is not null) { extensionData["frequency_penalty"] = options.FrequencyPenalty; }
+ if (options.MaxOutputTokens is not null) { extensionData["max_tokens"] = options.MaxOutputTokens; }
+ if (options.Seed is not null) { extensionData["seed"] = options.Seed; }
+ if (options.StopSequences is { Count: > 0 }) { extensionData["stop_sequences"] = options.StopSequences; }
+
+ return extensionData;
}
#endregion
}
From e15ae168f6d8d67ee95bd6e89d9b67ff9dfc1a4a Mon Sep 17 00:00:00 2001
From: King Star
Date: Tue, 21 Jul 2026 05:02:53 +0800
Subject: [PATCH 06/21] .Net: Reject mixed-separator UNC paths in file plugins
(#14166)
### Motivation and Context
`FileIOPlugin` and `WebFileDownloadPlugin` rejected only some UNC path
prefixes before path canonicalization. On Windows, mixed `/\` and `\/`
prefixes resolve to UNC paths too, so those forms could reach filesystem
path resolution before the allow-list rejected them.
Fixes #14157.
### Description
- Treat any two leading `/` or `\` characters as a UNC or extended-path
prefix in both plugins.
- Reject those paths before canonicalization, `File.Exists`, or
`File.GetAttributes` can probe the filesystem.
- Add read, write, and download regressions for all four separator
combinations.
The change is limited to the two plugins named in the issue.
Single-separator rooted paths, normal local paths, allow-list behavior,
symlink handling, overwrite checks, and download limits are unchanged.
### Verification
```bash
dotnet test dotnet/src/Plugins/Plugins.UnitTests/Plugins.UnitTests.csproj --filter "FullyQualifiedName~ItRejectsUncOrExtendedPathsOnReadAsync|FullyQualifiedName~ItRejectsUncOrExtendedPathsOnWriteAsync|FullyQualifiedName~DownloadToFileRejectsUncOrExtendedPathsAsync"
dotnet test dotnet/src/Plugins/Plugins.UnitTests/Plugins.UnitTests.csproj --filter "FullyQualifiedName~FileIOPluginTests"
dotnet test dotnet/src/Plugins/Plugins.UnitTests/Plugins.UnitTests.csproj --filter "FullyQualifiedName!~DownloadToFileSucceedsAsync&FullyQualifiedName!~DownloadToFileFailsForInvalidParametersAsync"
dotnet format dotnet/src/Plugins/Plugins.UnitTests/Plugins.UnitTests.csproj --verify-no-changes --no-restore
```
The focused regressions pass 12/12, all `FileIOPluginTests` pass 21/21,
and the deterministic Plugins unit-test suite passes 409/409. The two
excluded Web tests use a live HTTPS request and fail on unmodified
`main` in this macOS environment because certificate revocation status
is unavailable.
### Contribution Checklist
- [x] The code builds clean without any errors or warnings
- [x] The PR follows the [SK Contribution
Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md)
and the [pre-submission formatting
script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts)
raises no violations
- [ ] All unit tests pass, and I have added new tests where possible
(409 deterministic tests pass; two live-network baseline tests are
environment-limited as noted above)
- [x] I didn't break anyone :smile:
---------
Signed-off-by: King Star
Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>
---
.../src/Plugins/Plugins.Core/FileIOPlugin.cs | 19 ++++++++--
.../Core/FileIOPluginTests.cs | 35 +++++++++++++++++++
.../Web/WebFileDownloadPluginTests.cs | 20 +++++++++++
.../Plugins.Web/WebFileDownloadPlugin.cs | 23 ++++++++----
4 files changed, 88 insertions(+), 9 deletions(-)
diff --git a/dotnet/src/Plugins/Plugins.Core/FileIOPlugin.cs b/dotnet/src/Plugins/Plugins.Core/FileIOPlugin.cs
index c67b8a9ad089..5d11b4b023e7 100644
--- a/dotnet/src/Plugins/Plugins.Core/FileIOPlugin.cs
+++ b/dotnet/src/Plugins/Plugins.Core/FileIOPlugin.cs
@@ -113,7 +113,7 @@ private bool TryGetAllowedFilePath(string path, out string canonicalPath)
Verify.NotNullOrWhiteSpace(path);
canonicalPath = string.Empty;
- if (path.StartsWith("\\\\", StringComparison.OrdinalIgnoreCase))
+ if (IsUncOrExtendedPath(path))
{
throw new ArgumentException("Invalid file path, UNC paths are not supported.", nameof(path));
}
@@ -125,7 +125,17 @@ private bool TryGetAllowedFilePath(string path, out string canonicalPath)
throw new ArgumentException("Invalid file path, a fully qualified file location must be specified.", nameof(path));
}
- canonicalPath = PathUtilities.GetSafeFullPath(path);
+ // Resolve the full path first (a pure string operation that does not touch the
+ // filesystem). A relative path can still resolve to a UNC path here, for example
+ // when the current directory is a UNC share, so re-check before GetSafeFullPath
+ // probes the filesystem while resolving symbolic links.
+ canonicalPath = Path.GetFullPath(path);
+ if (IsUncOrExtendedPath(canonicalPath))
+ {
+ throw new ArgumentException("Invalid file path, UNC paths are not supported.", nameof(path));
+ }
+
+ canonicalPath = PathUtilities.GetSafeFullPath(canonicalPath);
if (File.Exists(canonicalPath) && File.GetAttributes(canonicalPath).HasFlag(FileAttributes.ReadOnly))
{
@@ -162,5 +172,10 @@ private bool TryGetAllowedFilePath(string path, out string canonicalPath)
return false;
}
+
+ private static bool IsUncOrExtendedPath(string path) =>
+ path.Length >= 2 &&
+ (path[0] is '/' or '\\') &&
+ (path[1] is '/' or '\\');
#endregion
}
diff --git a/dotnet/src/Plugins/Plugins.UnitTests/Core/FileIOPluginTests.cs b/dotnet/src/Plugins/Plugins.UnitTests/Core/FileIOPluginTests.cs
index 29d5ba6c5d1d..0b309f1dfb0d 100644
--- a/dotnet/src/Plugins/Plugins.UnitTests/Core/FileIOPluginTests.cs
+++ b/dotnet/src/Plugins/Plugins.UnitTests/Core/FileIOPluginTests.cs
@@ -144,6 +144,41 @@ public async Task ItCannotReadFromDisallowedFoldersAsync()
await Assert.ThrowsAsync(async () => await plugin.ReadAsync(Path.Combine("", Path.GetRandomFileName())));
}
+ [Theory]
+ [InlineData("\\\\UNC\\server\\folder\\myfile.txt")]
+ [InlineData("//UNC/server/folder/myfile.txt")]
+ [InlineData("/\\UNC\\server\\folder\\myfile.txt")]
+ [InlineData("\\/UNC/server/folder/myfile.txt")]
+ public async Task ItRejectsUncOrExtendedPathsOnReadAsync(string path)
+ {
+ // Arrange
+ var plugin = new FileIOPlugin()
+ {
+ AllowedFolders = [Path.GetTempPath()]
+ };
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => plugin.ReadAsync(path));
+ }
+
+ [Theory]
+ [InlineData("\\\\UNC\\server\\folder\\myfile.txt")]
+ [InlineData("//UNC/server/folder/myfile.txt")]
+ [InlineData("/\\UNC\\server\\folder\\myfile.txt")]
+ [InlineData("\\/UNC/server/folder/myfile.txt")]
+ public async Task ItRejectsUncOrExtendedPathsOnWriteAsync(string path)
+ {
+ // Arrange
+ var plugin = new FileIOPlugin()
+ {
+ AllowedFolders = [Path.GetTempPath()],
+ DisableFileOverwrite = false
+ };
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => plugin.WriteAsync(path, "hello world"));
+ }
+
[Fact]
public async Task ItCannotReadThroughSymlinkOutsideAllowedFoldersAsync()
{
diff --git a/dotnet/src/Plugins/Plugins.UnitTests/Web/WebFileDownloadPluginTests.cs b/dotnet/src/Plugins/Plugins.UnitTests/Web/WebFileDownloadPluginTests.cs
index 92a15be0c1df..dbba79540f4d 100644
--- a/dotnet/src/Plugins/Plugins.UnitTests/Web/WebFileDownloadPluginTests.cs
+++ b/dotnet/src/Plugins/Plugins.UnitTests/Web/WebFileDownloadPluginTests.cs
@@ -230,6 +230,26 @@ public async Task DownloadToFileFailsForInvalidParametersAsync()
await Assert.ThrowsAsync(async () => await webFileDownload.DownloadToFileAsync(validUri, "myfile.txt"));
}
+ [Theory]
+ [InlineData("\\\\UNC\\server\\folder\\myfile.txt")]
+ [InlineData("//UNC/server/folder/myfile.txt")]
+ [InlineData("/\\UNC\\server\\folder\\myfile.txt")]
+ [InlineData("\\/UNC/server/folder/myfile.txt")]
+ public async Task DownloadToFileRejectsUncOrExtendedPathsAsync(string filePath)
+ {
+ // Arrange
+ var uri = new Uri("https://raw.githubusercontent.com/microsoft/semantic-kernel/refs/heads/main/docs/images/sk_logo.png");
+ var folderPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ var webFileDownload = new WebFileDownloadPlugin()
+ {
+ AllowedDomains = ["raw.githubusercontent.com"],
+ AllowedFolders = [folderPath]
+ };
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => webFileDownload.DownloadToFileAsync(uri, filePath));
+ }
+
[Fact]
public async Task DownloadToFileUsesCaseSensitiveAllowListComparisonOnLinuxAsync()
{
diff --git a/dotnet/src/Plugins/Plugins.Web/WebFileDownloadPlugin.cs b/dotnet/src/Plugins/Plugins.Web/WebFileDownloadPlugin.cs
index acdcd9659fd6..f08482c0d124 100644
--- a/dotnet/src/Plugins/Plugins.Web/WebFileDownloadPlugin.cs
+++ b/dotnet/src/Plugins/Plugins.Web/WebFileDownloadPlugin.cs
@@ -221,15 +221,24 @@ private static string CanonicalizePath(string path)
throw new ArgumentException("Invalid file path, UNC paths are not supported.", nameof(path));
}
- return PathUtilities.GetSafeFullPath(expanded);
- }
+ // Resolve the full path first (a pure string operation that does not touch the
+ // filesystem). A relative path can still resolve to a UNC path here, for example
+ // when the current directory is a UNC share, so re-check before GetSafeFullPath
+ // probes the filesystem while resolving symbolic links.
+ var fullPath = Path.GetFullPath(expanded);
+ if (IsUncOrExtendedPath(fullPath))
+ {
+ throw new ArgumentException("Invalid file path, UNC paths are not supported.", nameof(path));
+ }
- private static bool IsUncOrExtendedPath(string path)
- {
- return path.StartsWith("\\\\", StringComparison.OrdinalIgnoreCase) ||
- path.StartsWith("//", StringComparison.OrdinalIgnoreCase);
+ return PathUtilities.GetSafeFullPath(fullPath);
}
+ private static bool IsUncOrExtendedPath(string path) =>
+ path.Length >= 2 &&
+ (path[0] is '/' or '\\') &&
+ (path[1] is '/' or '\\');
+
///
/// If a list of allowed folder has been provided, the folder of the provided filePath is checked
/// to verify it is in the allowed folder list. Paths are canonicalized before comparison.
@@ -239,7 +248,7 @@ private bool IsFilePathAllowed(string path)
{
Verify.NotNullOrWhiteSpace(path);
- if (path.StartsWith("\\\\", StringComparison.OrdinalIgnoreCase))
+ if (IsUncOrExtendedPath(path))
{
throw new ArgumentException("Invalid file path, UNC paths are not supported.", nameof(path));
}
From acb3e35c540dd2135896b93d9e0eb5f47c0b14a0 Mon Sep 17 00:00:00 2001
From: Gaurav Mittal
Date: Wed, 22 Jul 2026 03:09:54 -0700
Subject: [PATCH 07/21] .Net: [.NET] Add TimeProvider injection to TimePlugin
for deterministic testing (#14112)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Closes #14111
## Summary
- Adds a `TimeProvider` constructor parameter to `TimePlugin` (defaults
to `TimeProvider.System`, so all existing callers are unaffected)
- Replaces all direct `DateTimeOffset.Now` / `DateTimeOffset.UtcNow`
calls with `_timeProvider.GetLocalNow()` / `_timeProvider.GetUtcNow()`
- Rewrites `TimePluginTests` from 5 real-clock tests to 26 deterministic
tests covering every public method, using an inline
`FixedUtcTimeProvider` subclass (no new test dependencies)
## Key decisions
| Decision | Reason |
|---|---|
| `System.TimeProvider` (not a custom `IClock`) | Standard .NET 8+
pattern; no extra abstraction needed |
| Inline `FixedUtcTimeProvider` subclass in tests | Avoids adding
`Microsoft.Extensions.TimeProvider.Testing` as a new dependency; 3-line
subclass is clearer |
| `LocalTimeZone = TimeZoneInfo.Utc` on the test provider | Makes test
output identical on any machine regardless of OS timezone |
| Default `TimeProvider.System` in constructor | Zero breaking change —
all existing callers (`new TimePlugin()`) work unchanged |
## Test results
```
Passed! - Failed: 0, Passed: 394, Skipped: 0, Total: 394
```
---------
Co-authored-by: Claude Sonnet 4.6
Co-authored-by: westey <164392973+westey-m@users.noreply.github.com>
---
dotnet/Directory.Packages.props | 1 +
.../Plugins/Plugins.Core/Plugins.Core.csproj | 4 +
dotnet/src/Plugins/Plugins.Core/TimePlugin.cs | 45 +++--
.../Plugins.UnitTests/Core/TimePluginTests.cs | 177 +++++++++++++-----
4 files changed, 163 insertions(+), 64 deletions(-)
diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props
index 87df523c1852..b95f594c500f 100644
--- a/dotnet/Directory.Packages.props
+++ b/dotnet/Directory.Packages.props
@@ -148,6 +148,7 @@
+
diff --git a/dotnet/src/Plugins/Plugins.Core/Plugins.Core.csproj b/dotnet/src/Plugins/Plugins.Core/Plugins.Core.csproj
index 6ff6cb2f7884..9b0f06f623da 100644
--- a/dotnet/src/Plugins/Plugins.Core/Plugins.Core.csproj
+++ b/dotnet/src/Plugins/Plugins.Core/Plugins.Core.csproj
@@ -25,4 +25,8 @@
+
+
+
+
diff --git a/dotnet/src/Plugins/Plugins.Core/TimePlugin.cs b/dotnet/src/Plugins/Plugins.Core/TimePlugin.cs
index 94b46ad8d296..6a44547576f5 100644
--- a/dotnet/src/Plugins/Plugins.Core/TimePlugin.cs
+++ b/dotnet/src/Plugins/Plugins.Core/TimePlugin.cs
@@ -14,6 +14,17 @@ namespace Microsoft.SemanticKernel.Plugins.Core;
///
public sealed class TimePlugin
{
+ private readonly TimeProvider _timeProvider;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The time provider to use. Defaults to .
+ public TimePlugin(TimeProvider? timeProvider = null)
+ {
+ this._timeProvider = timeProvider ?? TimeProvider.System;
+ }
+
///
/// Get the current date
///
@@ -24,7 +35,7 @@ public sealed class TimePlugin
[KernelFunction, Description("Get the current date")]
public string Date(IFormatProvider? formatProvider = null) =>
// Example: Sunday, 12 January, 2025
- DateTimeOffset.Now.ToString("D", formatProvider);
+ this._timeProvider.GetLocalNow().ToString("D", formatProvider);
///
/// Get the current date
@@ -48,7 +59,7 @@ public string Today(IFormatProvider? formatProvider = null) =>
[KernelFunction, Description("Get the current date and time in the local time zone")]
public string Now(IFormatProvider? formatProvider = null) =>
// Sunday, January 12, 2025 9:15 PM
- DateTimeOffset.Now.ToString("f", formatProvider);
+ this._timeProvider.GetLocalNow().ToString("f", formatProvider);
///
/// Get the current UTC date and time
@@ -60,7 +71,7 @@ public string Now(IFormatProvider? formatProvider = null) =>
[KernelFunction, Description("Get the current UTC date and time")]
public string UtcNow(IFormatProvider? formatProvider = null) =>
// Sunday, January 13, 2025 5:15 AM
- DateTimeOffset.UtcNow.ToString("f", formatProvider);
+ this._timeProvider.GetUtcNow().ToString("f", formatProvider);
///
/// Get the current time
@@ -72,7 +83,7 @@ public string UtcNow(IFormatProvider? formatProvider = null) =>
[KernelFunction, Description("Get the current time")]
public string Time(IFormatProvider? formatProvider = null) =>
// Example: 09:15:07 PM
- DateTimeOffset.Now.ToString("hh:mm:ss tt", formatProvider);
+ this._timeProvider.GetLocalNow().ToString("hh:mm:ss tt", formatProvider);
///
/// Get the current year
@@ -84,7 +95,7 @@ public string Time(IFormatProvider? formatProvider = null) =>
[KernelFunction, Description("Get the current year")]
public string Year(IFormatProvider? formatProvider = null) =>
// Example: 2025
- DateTimeOffset.Now.ToString("yyyy", formatProvider);
+ this._timeProvider.GetLocalNow().ToString("yyyy", formatProvider);
///
/// Get the current month name
@@ -96,7 +107,7 @@ public string Year(IFormatProvider? formatProvider = null) =>
[KernelFunction, Description("Get the current month name")]
public string Month(IFormatProvider? formatProvider = null) =>
// Example: January
- DateTimeOffset.Now.ToString("MMMM", formatProvider);
+ this._timeProvider.GetLocalNow().ToString("MMMM", formatProvider);
///
/// Get the current month number
@@ -108,7 +119,7 @@ public string Month(IFormatProvider? formatProvider = null) =>
[KernelFunction, Description("Get the current month number")]
public string MonthNumber(IFormatProvider? formatProvider = null) =>
// Example: 01
- DateTimeOffset.Now.ToString("MM", formatProvider);
+ this._timeProvider.GetLocalNow().ToString("MM", formatProvider);
///
/// Get the current day of the month
@@ -120,7 +131,7 @@ public string MonthNumber(IFormatProvider? formatProvider = null) =>
[KernelFunction, Description("Get the current day of the month")]
public string Day(IFormatProvider? formatProvider = null) =>
// Example: 12
- DateTimeOffset.Now.ToString("dd", formatProvider);
+ this._timeProvider.GetLocalNow().ToString("dd", formatProvider);
///
/// Get the date a provided number of days in the past
@@ -129,7 +140,7 @@ public string Day(IFormatProvider? formatProvider = null) =>
[KernelFunction]
[Description("Get the date offset by a provided number of days from today")]
public string DaysAgo([Description("The number of days to offset from today")] double input, IFormatProvider? formatProvider = null) =>
- DateTimeOffset.Now.AddDays(-input).ToString("D", formatProvider);
+ this._timeProvider.GetLocalNow().AddDays(-input).ToString("D", formatProvider);
///
/// Get the current day of the week
@@ -141,7 +152,7 @@ public string DaysAgo([Description("The number of days to offset from today")] d
[KernelFunction, Description("Get the current day of the week")]
public string DayOfWeek(IFormatProvider? formatProvider = null) =>
// Example: Sunday
- DateTimeOffset.Now.ToString("dddd", formatProvider);
+ this._timeProvider.GetLocalNow().ToString("dddd", formatProvider);
///
/// Get the current clock hour
@@ -153,7 +164,7 @@ public string DayOfWeek(IFormatProvider? formatProvider = null) =>
[KernelFunction, Description("Get the current clock hour")]
public string Hour(IFormatProvider? formatProvider = null) =>
// Example: 9 PM
- DateTimeOffset.Now.ToString("h tt", formatProvider);
+ this._timeProvider.GetLocalNow().ToString("h tt", formatProvider);
///
/// Get the current clock 24-hour number
@@ -165,7 +176,7 @@ public string Hour(IFormatProvider? formatProvider = null) =>
[KernelFunction, Description("Get the current clock 24-hour number")]
public string HourNumber(IFormatProvider? formatProvider = null) =>
// Example: 21
- DateTimeOffset.Now.ToString("HH", formatProvider);
+ this._timeProvider.GetLocalNow().ToString("HH", formatProvider);
///
/// Get the date of the previous day matching the supplied day name
@@ -181,7 +192,7 @@ public string DateMatchingLastDayName(
[Description("The day name to match")] DayOfWeek input,
IFormatProvider? formatProvider = null)
{
- DateTimeOffset dateTime = DateTimeOffset.Now;
+ DateTimeOffset dateTime = this._timeProvider.GetLocalNow();
// Walk backwards from the previous day for up to a week to find the matching day
for (int i = 1; i <= 7; ++i)
@@ -206,7 +217,7 @@ public string DateMatchingLastDayName(
[KernelFunction, Description("Get the minutes on the current hour")]
public string Minute(IFormatProvider? formatProvider = null) =>
// Example: 15
- DateTimeOffset.Now.ToString("mm", formatProvider);
+ this._timeProvider.GetLocalNow().ToString("mm", formatProvider);
///
/// Get the seconds on the current minute
@@ -218,7 +229,7 @@ public string Minute(IFormatProvider? formatProvider = null) =>
[KernelFunction, Description("Get the seconds on the current minute")]
public string Second(IFormatProvider? formatProvider = null) =>
// Example: 07
- DateTimeOffset.Now.ToString("ss", formatProvider);
+ this._timeProvider.GetLocalNow().ToString("ss", formatProvider);
///
/// Get the local time zone offset from UTC
@@ -230,7 +241,7 @@ public string Second(IFormatProvider? formatProvider = null) =>
[KernelFunction, Description("Get the local time zone offset from UTC")]
public string TimeZoneOffset(IFormatProvider? formatProvider = null) =>
// Example: -08:00
- DateTimeOffset.Now.ToString("%K", formatProvider);
+ this._timeProvider.GetLocalNow().ToString("%K", formatProvider);
///
/// Get the local time zone name
@@ -246,5 +257,5 @@ public string TimeZoneOffset(IFormatProvider? formatProvider = null) =>
public string TimeZoneName() =>
// Example: PST
// Note: this is the "current" timezone and it can change over the year, e.g. from PST to PDT
- TimeZoneInfo.Local.DisplayName;
+ this._timeProvider.LocalTimeZone.DisplayName;
}
diff --git a/dotnet/src/Plugins/Plugins.UnitTests/Core/TimePluginTests.cs b/dotnet/src/Plugins/Plugins.UnitTests/Core/TimePluginTests.cs
index 3df8f6d2636e..536309d14d02 100644
--- a/dotnet/src/Plugins/Plugins.UnitTests/Core/TimePluginTests.cs
+++ b/dotnet/src/Plugins/Plugins.UnitTests/Core/TimePluginTests.cs
@@ -11,44 +11,157 @@
namespace SemanticKernel.Plugins.UnitTests.Core;
-// TODO: allow clock injection and test all functions
public class TimePluginTests
{
+ // Sunday, 15 June 2025 21:15:07 UTC — local timezone pinned to UTC so tests are machine-independent
+ private static readonly DateTimeOffset s_fixedTime = new(2025, 6, 15, 21, 15, 7, TimeSpan.Zero);
+
+ private static TimePlugin CreatePlugin() => new(new FixedUtcTimeProvider(s_fixedTime));
+
+ /// Minimal TimeProvider that returns a fixed UTC instant with UTC as local timezone.
+ private sealed class FixedUtcTimeProvider(DateTimeOffset fixedUtc) : TimeProvider
+ {
+ public override DateTimeOffset GetUtcNow() => fixedUtc.ToUniversalTime();
+ public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
+ }
+
[Fact]
public void ItCanBeInstantiated()
{
- // Act - Assert no exception occurs
var _ = new TimePlugin();
}
[Fact]
public void ItCanBeImported()
{
- // Act - Assert no exception occurs e.g. due to reflection
Assert.NotNull(KernelPluginFactory.CreateFromType("time"));
}
[Fact]
- public void DaysAgo()
+ public void Date()
+ {
+ // InvariantCulture "D" format: dddd, dd MMMM yyyy
+ Assert.Equal("Sunday, 15 June 2025", CreatePlugin().Date(CultureInfo.InvariantCulture));
+ }
+
+ [Fact]
+ public void Today()
{
- double interval = 2;
- DateTime expected = DateTime.Now.AddDays(-interval);
- var plugin = new TimePlugin();
- string result = plugin.DaysAgo(interval, CultureInfo.CurrentCulture);
- DateTime returned = DateTime.Parse(result, CultureInfo.CurrentCulture);
- Assert.Equal(expected.Day, returned.Day);
- Assert.Equal(expected.Month, returned.Month);
- Assert.Equal(expected.Year, returned.Year);
+ Assert.Equal("Sunday, 15 June 2025", CreatePlugin().Today(CultureInfo.InvariantCulture));
+ }
+
+ [Fact]
+ public void Now()
+ {
+ // InvariantCulture "f" format: dddd, dd MMMM yyyy HH:mm
+ Assert.Equal("Sunday, 15 June 2025 21:15", CreatePlugin().Now(CultureInfo.InvariantCulture));
+ }
+
+ [Fact]
+ public void UtcNow()
+ {
+ Assert.Equal("Sunday, 15 June 2025 21:15", CreatePlugin().UtcNow(CultureInfo.InvariantCulture));
+ }
+
+ [Fact]
+ public void Time()
+ {
+ Assert.Equal("09:15:07 PM", CreatePlugin().Time(CultureInfo.InvariantCulture));
+ }
+
+ [Fact]
+ public void Year()
+ {
+ Assert.Equal("2025", CreatePlugin().Year(CultureInfo.InvariantCulture));
+ }
+
+ [Fact]
+ public void Month()
+ {
+ Assert.Equal("June", CreatePlugin().Month(CultureInfo.InvariantCulture));
+ }
+
+ [Fact]
+ public void MonthNumber()
+ {
+ Assert.Equal("06", CreatePlugin().MonthNumber(CultureInfo.InvariantCulture));
}
[Fact]
public void Day()
{
- string expected = DateTime.Now.ToString("dd", CultureInfo.CurrentCulture);
- var plugin = new TimePlugin();
- string result = plugin.Day(CultureInfo.CurrentCulture);
- Assert.Equal(expected, result);
- Assert.True(int.TryParse(result, out _));
+ Assert.Equal("15", CreatePlugin().Day(CultureInfo.InvariantCulture));
+ }
+
+ [Fact]
+ public void DayOfWeek()
+ {
+ Assert.Equal("Sunday", CreatePlugin().DayOfWeek(CultureInfo.InvariantCulture));
+ }
+
+ [Fact]
+ public void Hour()
+ {
+ Assert.Equal("9 PM", CreatePlugin().Hour(CultureInfo.InvariantCulture));
+ }
+
+ [Fact]
+ public void HourNumber()
+ {
+ Assert.Equal("21", CreatePlugin().HourNumber(CultureInfo.InvariantCulture));
+ }
+
+ [Fact]
+ public void Minute()
+ {
+ Assert.Equal("15", CreatePlugin().Minute(CultureInfo.InvariantCulture));
+ }
+
+ [Fact]
+ public void Second()
+ {
+ Assert.Equal("07", CreatePlugin().Second(CultureInfo.InvariantCulture));
+ }
+
+ [Fact]
+ public void TimeZoneOffset()
+ {
+ Assert.Equal("+00:00", CreatePlugin().TimeZoneOffset(CultureInfo.InvariantCulture));
+ }
+
+ [Fact]
+ public void TimeZoneName()
+ {
+ // FixedUtcTimeProvider pins LocalTimeZone to TimeZoneInfo.Utc
+ Assert.Equal(TimeZoneInfo.Utc.DisplayName, CreatePlugin().TimeZoneName());
+ }
+
+ [Fact]
+ public void DaysAgo()
+ {
+ // 2 days before 2025-06-15 is 2025-06-13 (Friday)
+ Assert.Equal("Friday, 13 June 2025", CreatePlugin().DaysAgo(2, CultureInfo.InvariantCulture));
+ }
+
+ [Theory]
+ [MemberData(nameof(DayOfWeekCases))]
+ public void DateMatchingLastDayName(DayOfWeek dayName, string expectedDate)
+ {
+ // Fixed time is Sunday 2025-06-15; walk back to find each day
+ Assert.Equal(expectedDate, CreatePlugin().DateMatchingLastDayName(dayName, CultureInfo.InvariantCulture));
+ }
+
+ public static IEnumerable