From 56ea20953532baaa7f0bbc274ba63e15fd16d66f Mon Sep 17 00:00:00 2001 From: Lee Miller Date: Wed, 26 Apr 2023 15:04:26 -0700 Subject: [PATCH] Updates and tests for plan serialization and deserialization (#620) ### Motivation and Context The change improves the serialization and deserialization of Plan objects. ### Description Allows deserialization of Plans without a context. Adds unit tests for the Plan and ContextVariables classes, which cover various scenarios and edge cases of serializing and deserializing plans and context variables. Details: - Updates the custom converter for the ContextVariables class, which serializes and deserializes the key-value pairs as an array of objects, instead of a dictionary. This improves the efficiency and readability of the JSON format. - Add a new file PlanSerializationTests.cs to the SemanticKernel.UnitTests project, which contains unit tests for the Plan class. The tests verify that a plan can be serialized and deserialized correctly, and that the plan state is updated correctly after each step execution. The tests also check for invalid JSON strings. - Add a new file ContextVariablesConverterTests.cs to the SemanticKernel.UnitTests project, which contains unit tests for the ContextVariablesConverter class. The tests verify that the converter can correctly serialize and deserialize context variables to and from JSON, and handle null, empty, and invalid inputs. --- .vscode/tasks.json | 22 + .../Extensions.UnitTests.csproj | 4 + .../IntegrationTests/Planning/PlanTests.cs | 91 +++ .../ContextVariablesConverterTests.cs | 186 ++++++ .../Planning/PlanSerializationTests.cs | 538 ++++++++++++++++++ .../ContextVariablesConverter.cs | 16 +- dotnet/src/SemanticKernel/Planning/Plan.cs | 11 +- 7 files changed, 859 insertions(+), 9 deletions(-) create mode 100644 dotnet/src/SemanticKernel.UnitTests/Orchestration/ContextVariablesConverterTests.cs create mode 100644 dotnet/src/SemanticKernel.UnitTests/Planning/PlanSerializationTests.cs diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 02ec385253..815042b6be 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -137,6 +137,28 @@ "cwd": "${workspaceFolder}/dotnet/src/SemanticKernel.UnitTests/" } }, + { + "label": "Test - Extensions (Code Coverage)", + "command": "dotnet", + "type": "process", + "args": [ + "test", + "--collect", + "XPlat Code Coverage;Format=lcov", + "--filter", + "${input:filter}", + "Extensions.UnitTests.csproj" + ], + "problemMatcher": "$msCompile", + "group": "test", + "presentation": { + "reveal": "always", + "panel": "shared" + }, + "options": { + "cwd": "${workspaceFolder}/dotnet/src/Extensions/Extensions.UnitTests/" + } + }, { "label": "Test - Semantic-Kernel Integration (Code Coverage)", "command": "dotnet", diff --git a/dotnet/src/Extensions/Extensions.UnitTests/Extensions.UnitTests.csproj b/dotnet/src/Extensions/Extensions.UnitTests/Extensions.UnitTests.csproj index c40d26b48a..a3eb070896 100644 --- a/dotnet/src/Extensions/Extensions.UnitTests/Extensions.UnitTests.csproj +++ b/dotnet/src/Extensions/Extensions.UnitTests/Extensions.UnitTests.csproj @@ -18,6 +18,10 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + diff --git a/dotnet/src/IntegrationTests/Planning/PlanTests.cs b/dotnet/src/IntegrationTests/Planning/PlanTests.cs index 96adc68f7b..62a4460cc6 100644 --- a/dotnet/src/IntegrationTests/Planning/PlanTests.cs +++ b/dotnet/src/IntegrationTests/Planning/PlanTests.cs @@ -157,6 +157,40 @@ public sealed class PlanTests : IDisposable Assert.Equal($"Sent email to: {email}. Body: {expectedBody}".Trim(), plan.State.ToString()); } + [Theory] + [InlineData(null, "Write a poem or joke and send it in an e-mail to Kai.", null)] + [InlineData("", "Write a poem or joke and send it in an e-mail to Kai.", "")] + [InlineData("Hello World!", "Write a poem or joke and send it in an e-mail to Kai.", "some_email@email.com")] + public async Task CanExecuteRunPlanSimpleManualStateNoVariableAsync(string input, string goal, string email) + { + // Arrange + IKernel target = this.InitializeKernel(); + var emailSkill = target.ImportSkill(new EmailSkillFake()); + + // Create the input mapping from parent (plan) plan state to child plan (sendEmailPlan) state. + var cv = new ContextVariables(); + cv.Set("email_address", string.Empty); + var sendEmailPlan = new Plan(emailSkill["SendEmailAsync"]) + { + NamedParameters = cv, + }; + + var plan = new Plan(goal); + plan.AddSteps(sendEmailPlan); + plan.State.Set("email_address", email); // manually prepare the state + + // Act + var result = await target.StepAsync(input, plan); + + // Assert + var expectedBody = string.IsNullOrEmpty(input) ? goal : input; + Assert.Single(result.Steps); + Assert.Equal(1, result.NextStepIndex); + Assert.False(result.HasNextStep); + Assert.Equal(goal, plan.Description); + Assert.Equal($"Sent email to: {email}. Body: {expectedBody}".Trim(), plan.State.ToString()); + } + [Theory] [InlineData(null, "Write a poem or joke and send it in an e-mail to Kai.", null)] [InlineData("", "Write a poem or joke and send it in an e-mail to Kai.", "")] @@ -314,6 +348,63 @@ public sealed class PlanTests : IDisposable Assert.True(expectedBody.Length < result.Result.Length); } + [Theory] + [InlineData("Summarize an input, translate to french, and e-mail to Kai", "This is a story about a dog.", "French", "Kai", "Kai@example.com")] + public async Task CanExecuteRunSequentialOnDeserializedPlanAsync(string goal, string inputToSummarize, string inputLanguage, string inputName, + string expectedEmail) + { + // Arrange + IKernel target = this.InitializeKernel(); + var summarizeSkill = TestHelpers.GetSkills(target, "SummarizeSkill"); + var writerSkill = TestHelpers.GetSkills(target, "WriterSkill"); + var emailSkill = target.ImportSkill(new EmailSkillFake()); + + var expectedBody = $"Sent email to: {expectedEmail}. Body:".Trim(); + + var summarizePlan = new Plan(summarizeSkill["Summarize"]); + + var cv = new ContextVariables(); + cv.Set("language", inputLanguage); + var outputs = new ContextVariables(); + outputs.Set("TRANSLATED_SUMMARY", string.Empty); + + var translatePlan = new Plan(writerSkill["Translate"]) + { + NamedParameters = cv, + NamedOutputs = outputs, + }; + + cv = new ContextVariables(); + cv.Update(inputName); + outputs = new ContextVariables(); + outputs.Set("TheEmailFromState", string.Empty); + var getEmailPlan = new Plan(emailSkill["GetEmailAddressAsync"]) + { + NamedParameters = cv, + NamedOutputs = outputs, + }; + + cv = new ContextVariables(); + cv.Set("email_address", "$TheEmailFromState"); + cv.Set("input", "$TRANSLATED_SUMMARY"); + var sendEmailPlan = new Plan(emailSkill["SendEmailAsync"]) + { + NamedParameters = cv + }; + + var plan = new Plan(goal); + plan.AddSteps(summarizePlan, translatePlan, getEmailPlan, sendEmailPlan); + + // Act + var serializedPlan = plan.ToJson(); + var deserializedPlan = Plan.FromJson(serializedPlan, target.CreateNewContext()); + var result = await target.RunAsync(inputToSummarize, deserializedPlan); + + // Assert + Assert.Contains(expectedBody, result.Result, StringComparison.OrdinalIgnoreCase); + Assert.True(expectedBody.Length < result.Result.Length); + } + [Theory] [InlineData("Summarize an input, translate to french, and e-mail to Kai", "This is a story about a dog.", "French", "kai@email.com")] public async Task CanExecuteRunSequentialFunctionsAsync(string goal, string inputToSummarize, string inputLanguage, string expectedEmail) diff --git a/dotnet/src/SemanticKernel.UnitTests/Orchestration/ContextVariablesConverterTests.cs b/dotnet/src/SemanticKernel.UnitTests/Orchestration/ContextVariablesConverterTests.cs new file mode 100644 index 0000000000..05f1e7ec8c --- /dev/null +++ b/dotnet/src/SemanticKernel.UnitTests/Orchestration/ContextVariablesConverterTests.cs @@ -0,0 +1,186 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using Microsoft.SemanticKernel.Orchestration; +using Xunit; + +namespace SemanticKernel.UnitTests.Orchestration; + +/// +/// Unit tests of . +/// +public class ContextVariablesConverterTests +{ + [Fact] + public void ReadFromJsonSucceeds() + { + // Arrange + string json = /*lang=json,strict*/ @"[{""Key"":""a"", ""Value"":""b""}]"; + var options = new JsonSerializerOptions(); + options.Converters.Add(new ContextVariablesConverter()); + + // Act + var result = JsonSerializer.Deserialize(json, options); + + // Assert + Assert.Equal("b", result!["a"]); + Assert.Equal(string.Empty, result!["INPUT"]); + } + + [Fact] + public void ReadFromJsonWrongTypeThrows() + { + // Arrange + string json = /*lang=json,strict*/ @"[{""Key"":""a"", ""Value"":""b""}]"; + var options = new JsonSerializerOptions(); + options.Converters.Add(new ContextVariablesConverter()); + + // Act and Assert + Assert.Throws(() => JsonSerializer.Deserialize>(json, options)); + } + + [Fact] + public void ReadFromJsonSucceedsWithInput() + { + // Arrange + string json = /*lang=json,strict*/ @"[{""Key"":""INPUT"", ""Value"":""c""}, {""Key"":""a"", ""Value"":""b""}]"; + var options = new JsonSerializerOptions(); + options.Converters.Add(new ContextVariablesConverter()); + + // Act + var result = JsonSerializer.Deserialize(json, options); + + // Assert + Assert.Equal("b", result!["a"]); + Assert.Equal("c", result!["INPUT"]); + } + + // input value + // params key/value + [Theory] + [InlineData(null, new[] { "a", "b" }, new[] + { + /*lang=json,strict*/ @"{""Key"":""INPUT"",""Value"":""""}", /*lang=json,strict*/ @"{""Key"":""a"",""Value"":""b""}" + })] + [InlineData("", new[] { "a", "b" }, new[] + { + /*lang=json,strict*/ @"{""Key"":""INPUT"",""Value"":""""}", /*lang=json,strict*/ @"{""Key"":""a"",""Value"":""b""}" + })] + [InlineData("c", new[] { "a", "b" }, new[] + { + /*lang=json,strict*/ @"{""Key"":""INPUT"",""Value"":""c""}", /*lang=json,strict*/ @"{""Key"":""a"",""Value"":""b""}" + })] + [InlineData("c", new[] { "a", "b", "d", "e" }, new[] + { + /*lang=json,strict*/ @"{""Key"":""INPUT"",""Value"":""c""}", /*lang=json,strict*/ @"{""Key"":""a"",""Value"":""b""}", /*lang=json,strict*/ + @"{""Key"":""d"",""Value"":""e""}" + })] + public void WriteToJsonSuceeds(string inputValue, IList contextToSet, IList expectedJson) + { + // Arrange + var options = new JsonSerializerOptions(); + options.Converters.Add(new ContextVariablesConverter()); + var context = new ContextVariables(); + if (inputValue != null) + { + context.Update(inputValue); + } + + for (int i = 0; i < contextToSet.Count; i += 2) + { + context.Set(contextToSet[i], contextToSet[i + 1]); + } + + // Act + string result = JsonSerializer.Serialize(context, options); + + // Assert + foreach (string key in expectedJson) + { + Assert.Contains(key, result, StringComparison.Ordinal); + } + } + + [Fact] + public void WriteToJsonSucceedsAfterClearing() + { + // Arrange + var options = new JsonSerializerOptions(); + options.Converters.Add(new ContextVariablesConverter()); + var context = new ContextVariables(); + context.Set("a", "b"); + context.Set("INPUT", "c"); + context.Set("d", "e"); + context.Set("f", "ThingToBeCleared"); + context.Set("f", null); + context.Set("g", string.Empty); + + // Act + string result = JsonSerializer.Serialize(context, options); + + // Assert + Assert.Contains( /*lang=json,strict*/ @"{""Key"":""INPUT"",""Value"":""c""}", result, StringComparison.Ordinal); + Assert.Contains( /*lang=json,strict*/ @"{""Key"":""a"",""Value"":""b""}", result, StringComparison.Ordinal); + Assert.Contains( /*lang=json,strict*/ @"{""Key"":""d"",""Value"":""e""}", result, StringComparison.Ordinal); + Assert.DoesNotContain(@"""Key"":""f""", result, StringComparison.Ordinal); + Assert.Contains( /*lang=json,strict*/ @"{""Key"":""g"",""Value"":""""}", result, StringComparison.Ordinal); + } + + // Error Tests + [Fact] + public void ReadFromJsonReturnsNullWithNull() + { + // Arrange + string json = /*lang=json,strict*/ @"null"; + var options = new JsonSerializerOptions(); + options.Converters.Add(new ContextVariablesConverter()); + + // Act + var result = JsonSerializer.Deserialize(json, options); + + // Assert + Assert.Null(result); + } + + [Fact] + public void ReadFromJsonReturnsDefaultWithEmpty() + { + // Arrange + string json = /*lang=json,strict*/ @"[]"; + var options = new JsonSerializerOptions(); + options.Converters.Add(new ContextVariablesConverter()); + + // Act + var result = JsonSerializer.Deserialize(json, options); + + // Assert + Assert.NotNull(result); + Assert.Equal(string.Empty, result!["INPUT"]); + } + + [Fact] + public void ReadFromJsonThrowsWithInvalidJson() + { + // Arrange + string json = /*lang=json,strict*/ @"[{""Key"":""a"", ""Value"":""b"""; + var options = new JsonSerializerOptions(); + options.Converters.Add(new ContextVariablesConverter()); + + // Act & Assert + Assert.Throws(() => JsonSerializer.Deserialize(json, options)); + } + + [Fact] + public void ReadFromJsonThrowsWithInvalidJson2() + { + // Arrange + string json = /*lang=json,strict*/ @"[{""Keys"":""a"", ""Value"":""b""}]"; + var options = new JsonSerializerOptions(); + options.Converters.Add(new ContextVariablesConverter()); + + // Act & Assert + Assert.Throws(() => JsonSerializer.Deserialize(json, options)); + } +} diff --git a/dotnet/src/SemanticKernel.UnitTests/Planning/PlanSerializationTests.cs b/dotnet/src/SemanticKernel.UnitTests/Planning/PlanSerializationTests.cs new file mode 100644 index 0000000000..776cd0cc54 --- /dev/null +++ b/dotnet/src/SemanticKernel.UnitTests/Planning/PlanSerializationTests.cs @@ -0,0 +1,538 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.AI.TextCompletion; +using Microsoft.SemanticKernel.Memory; +using Microsoft.SemanticKernel.Orchestration; +using Microsoft.SemanticKernel.Planning; +using Microsoft.SemanticKernel.SkillDefinition; +using Moq; +using Xunit; + +namespace SemanticKernel.UnitTests.Planning; + +public sealed class PlanSerializationTests +{ + [Fact] + public void CanSerializePlan() + { + // Arrange + var goal = "Write a poem or joke and send it in an e-mail to Kai."; + var expectedSteps = "\"steps\":[]"; + var plan = new Plan(goal); + + // Act + var serializedPlan = plan.ToJson(); + + // Assert + Assert.NotNull(serializedPlan); + Assert.NotEmpty(serializedPlan); + Assert.Contains(goal, serializedPlan, StringComparison.OrdinalIgnoreCase); + Assert.Contains(expectedSteps, serializedPlan, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void CanSerializePlanWithGoalAndSteps() + { + // Arrange + var goal = "Write a poem or joke and send it in an e-mail to Kai."; + var expectedSteps = "\"steps\":[{"; + var plan = new Plan(goal, new Mock().Object, new Mock().Object); + + // Act + var serializedPlan = plan.ToJson(); + + // Assert + Assert.NotNull(serializedPlan); + Assert.NotEmpty(serializedPlan); + Assert.Contains(goal, serializedPlan, StringComparison.OrdinalIgnoreCase); + Assert.Contains(expectedSteps, serializedPlan, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void CanSerializePlanWithGoalAndSubPlans() + { + // Arrange + var goal = "Write a poem or joke and send it in an e-mail to Kai."; + var expectedSteps = "\"steps\":[{"; + var plan = new Plan(goal, new Plan("Write a poem or joke"), new Plan("Send it in an e-mail to Kai")); + + // Act + var serializedPlan = plan.ToJson(); + + // Assert + Assert.NotNull(serializedPlan); + Assert.NotEmpty(serializedPlan); + Assert.Contains($"\"description\":\"{goal}\"", serializedPlan, StringComparison.OrdinalIgnoreCase); + Assert.Contains($"\"description\":\"Write a poem or joke\"", serializedPlan, StringComparison.OrdinalIgnoreCase); + Assert.Contains($"\"description\":\"Send it in an e-mail to Kai\"", serializedPlan, StringComparison.OrdinalIgnoreCase); + Assert.Contains(expectedSteps, serializedPlan, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void CanSerializePlanWithPlanStep() + { + // Arrange + var goal = "Write a poem or joke and send it in an e-mail to Kai."; + var stepOutput = "Output: The input was: "; + var expectedSteps = "\"steps\":[{"; + var plan = new Plan(goal); + + // Arrange Mocks + var kernel = new Mock(); + var log = new Mock(); + var memory = new Mock(); + var skills = new Mock(); + + var returnContext = new SKContext( + new ContextVariables(stepOutput), + memory.Object, + skills.Object, + log.Object + ); + + var mockFunction = new Mock(); + mockFunction.Setup(x => x.InvokeAsync(It.IsAny(), null, null, null)) + .Callback((c, s, l, ct) => + returnContext.Variables.Update(returnContext.Variables.Input + c.Variables.Input)) + .Returns(() => Task.FromResult(returnContext)); + + plan.AddSteps(new Plan(mockFunction.Object)); + + // Act + var serializedPlan = plan.ToJson(); + + // Assert + Assert.NotNull(serializedPlan); + Assert.NotEmpty(serializedPlan); + Assert.Contains(goal, serializedPlan, StringComparison.OrdinalIgnoreCase); + Assert.Contains(expectedSteps, serializedPlan, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void CanSerializePlanWithFunctionStep() + { + // Arrange + var goal = "Write a poem or joke and send it in an e-mail to Kai."; + var stepOutput = "Output: The input was: "; + var expectedSteps = "\"steps\":[{"; + var plan = new Plan(goal); + + // Arrange + var kernel = new Mock(); + var log = new Mock(); + var memory = new Mock(); + var skills = new Mock(); + + var returnContext = new SKContext( + new ContextVariables(stepOutput), + memory.Object, + skills.Object, + log.Object + ); + + var mockFunction = new Mock(); + mockFunction.Setup(x => x.InvokeAsync(It.IsAny(), null, null, null)) + .Callback((c, s, l, ct) => + returnContext.Variables.Update(returnContext.Variables.Input + c.Variables.Input)) + .Returns(() => Task.FromResult(returnContext)); + + plan.AddSteps(mockFunction.Object); + + // Act + var serializedPlan = plan.ToJson(); + + // Assert + Assert.NotNull(serializedPlan); + Assert.NotEmpty(serializedPlan); + Assert.Contains(goal, serializedPlan, StringComparison.OrdinalIgnoreCase); + Assert.Contains(expectedSteps, serializedPlan, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void CanSerializePlanWithFunctionSteps() + { + // Arrange + var goal = "Write a poem or joke and send it in an e-mail to Kai."; + var stepOutput = "Output: The input was: "; + var expectedSteps = "\"steps\":[{"; + var plan = new Plan(goal); + + // Arrange + var kernel = new Mock(); + var log = new Mock(); + var memory = new Mock(); + var skills = new Mock(); + + var returnContext = new SKContext( + new ContextVariables(stepOutput), + memory.Object, + skills.Object, + log.Object + ); + + var mockFunction = new Mock(); + mockFunction.Setup(x => x.InvokeAsync(It.IsAny(), null, null, null)) + .Callback((c, s, l, ct) => + returnContext.Variables.Update(returnContext.Variables.Input + c.Variables.Input)) + .Returns(() => Task.FromResult(returnContext)); + + plan.AddSteps(mockFunction.Object, mockFunction.Object); + + // Act + var serializedPlan = plan.ToJson(); + + // Assert + Assert.NotNull(serializedPlan); + Assert.NotEmpty(serializedPlan); + Assert.Contains(goal, serializedPlan, StringComparison.OrdinalIgnoreCase); + Assert.Contains(expectedSteps, serializedPlan, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void CanSerializePlanWithStepsAndFunction() + { + // Arrange + var goal = "Write a poem or joke and send it in an e-mail to Kai."; + var stepOutput = "Output: The input was: "; + var expectedSteps = "\"steps\":[{"; + var plan = new Plan(goal); + + // Arrange + var kernel = new Mock(); + var log = new Mock(); + var memory = new Mock(); + var skills = new Mock(); + + var returnContext = new SKContext( + new ContextVariables(stepOutput), + memory.Object, + skills.Object, + log.Object + ); + + var mockFunction = new Mock(); + mockFunction.Setup(x => x.InvokeAsync(It.IsAny(), null, null, null)) + .Callback((c, s, l, ct) => + returnContext.Variables.Update(returnContext.Variables.Input + c.Variables.Input)) + .Returns(() => Task.FromResult(returnContext)); + + plan.AddSteps(new Plan(mockFunction.Object), mockFunction.Object); + + // Act + var serializedPlan = plan.ToJson(); + + // Assert + Assert.NotNull(serializedPlan); + Assert.NotEmpty(serializedPlan); + Assert.Contains(goal, serializedPlan, StringComparison.OrdinalIgnoreCase); + Assert.Contains(expectedSteps, serializedPlan, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void CanSerializePlanWithSteps() + { + // Arrange + var goal = "Write a poem or joke and send it in an e-mail to Kai."; + var stepOutput = "Output: The input was: "; + var plan = new Plan(goal); + + // Arrange + var kernel = new Mock(); + var log = new Mock(); + var memory = new Mock(); + var skills = new Mock(); + + var returnContext = new SKContext( + new ContextVariables(stepOutput), + memory.Object, + skills.Object, + log.Object + ); + + var mockFunction = new Mock(); + mockFunction.Setup(x => x.InvokeAsync(It.IsAny(), null, null, null)) + .Callback((c, s, l, ct) => + returnContext.Variables.Update(returnContext.Variables.Input + c.Variables.Input)) + .Returns(() => Task.FromResult(returnContext)); + + plan.AddSteps(new Plan(mockFunction.Object), new Plan(mockFunction.Object)); + + // Act + var serializedPlan = plan.ToJson(); + + // Assert + Assert.NotNull(serializedPlan); + Assert.NotEmpty(serializedPlan); + } + + [Fact] + public async Task CanStepAndSerializePlanWithStepsAsync() + { + // Arrange + var goal = "Write a poem or joke and send it in an e-mail to Kai."; + var planInput = "Some input"; + var stepOutput = "Output: The input was: "; + var plan = new Plan(goal); + + // Arrange + var kernel = new Mock(); + var log = new Mock(); + var memory = new Mock(); + var skills = new Mock(); + + var returnContext = new SKContext( + new ContextVariables(stepOutput), + memory.Object, + skills.Object, + log.Object + ); + + var mockFunction = new Mock(); + mockFunction.Setup(x => x.InvokeAsync(It.IsAny(), null, null, null)) + .Callback((c, s, l, ct) => + returnContext.Variables.Update(returnContext.Variables.Input + c.Variables.Input)) + .Returns(() => Task.FromResult(returnContext)); + + plan.AddSteps(mockFunction.Object, mockFunction.Object); + + var serializedPlan1 = plan.ToJson(); + + // Assert + Assert.NotNull(serializedPlan1); + Assert.NotEmpty(serializedPlan1); + Assert.Contains("\"next_step_index\":0", serializedPlan1, StringComparison.OrdinalIgnoreCase); + + var result = await kernel.Object.StepAsync(planInput, plan); + + // Act + var serializedPlan2 = plan.ToJson(); + + // Assert + Assert.NotNull(serializedPlan2); + Assert.NotEmpty(serializedPlan2); + Assert.NotEqual(serializedPlan1, serializedPlan2); + Assert.Contains("\"next_step_index\":1", serializedPlan2, StringComparison.OrdinalIgnoreCase); + + result = await kernel.Object.StepAsync(result); + var serializedPlan3 = plan.ToJson(); + + // Assert + Assert.NotNull(serializedPlan3); + Assert.NotEmpty(serializedPlan3); + Assert.NotEqual(serializedPlan1, serializedPlan3); + Assert.NotEqual(serializedPlan2, serializedPlan3); + Assert.Contains("\"next_step_index\":2", serializedPlan3, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task CanStepAndSerializePlanWithStepsAndContextAsync() + { + // Arrange + var goal = "Write a poem or joke and send it in an e-mail to Kai."; + var planInput = "Some input"; + var stepOutput = "Output: The input was: "; + var plan = new Plan(goal); + + // Arrange + var kernel = new Mock(); + var log = new Mock(); + var memory = new Mock(); + var skills = new Mock(); + + var returnContext = new SKContext( + new ContextVariables(stepOutput), + memory.Object, + skills.Object, + log.Object + ); + + var mockFunction = new Mock(); + mockFunction.Setup(x => x.InvokeAsync(It.IsAny(), null, null, null)) + .Callback((c, s, l, ct) => + { + c.Variables.Get("variables", out var v); + returnContext.Variables.Update(returnContext.Variables.Input + c.Variables.Input + v); + }) + .Returns(() => Task.FromResult(returnContext)); + mockFunction.Setup(x => x.Describe()).Returns(new FunctionView() + { + Parameters = new List() + { + new ParameterView() { Name = "variables" } + } + }); + + plan.AddSteps(mockFunction.Object, mockFunction.Object); + + var cv = new ContextVariables(planInput); + cv.Set("variables", "foo"); + plan = await kernel.Object.StepAsync(cv, plan); + + // Act + var serializedPlan1 = plan.ToJson(); + + // Assert + Assert.NotNull(serializedPlan1); + Assert.NotEmpty(serializedPlan1); + Assert.Contains("\"next_step_index\":1", serializedPlan1, StringComparison.OrdinalIgnoreCase); + + // Act + cv.Set("variables", "bar"); + cv.Update(string.Empty); + plan = await kernel.Object.StepAsync(cv, plan); + + // Assert + Assert.NotNull(plan); + Assert.Equal($"{stepOutput}{planInput}foo{stepOutput}{planInput}foobar", plan.State.ToString()); + mockFunction.Verify(x => x.InvokeAsync(It.IsAny(), null, null, null), Times.Exactly(2)); + + // Act + var serializedPlan2 = plan.ToJson(); + + // Assert + Assert.NotNull(serializedPlan2); + Assert.NotEmpty(serializedPlan2); + Assert.NotEqual(serializedPlan1, serializedPlan2); + Assert.Contains("\"next_step_index\":2", serializedPlan2, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task CanStepAndSerializeAndDeserializePlanWithStepsAndContextAsync() + { + // Arrange + var goal = "Write a poem or joke and send it in an e-mail to Kai."; + var planInput = "Some input"; + var stepOutput = "Output: The input was: "; + var plan = new Plan(goal); + + // Arrange + var kernel = new Mock(); + var log = new Mock(); + var memory = new Mock(); + var skills = new Mock(); + + var returnContext = new SKContext( + new ContextVariables(stepOutput), + memory.Object, + skills.Object, + log.Object + ); + + var mockFunction = new Mock(); + mockFunction.Setup(x => x.InvokeAsync(It.IsAny(), null, null, null)) + .Callback((c, s, l, ct) => + { + c.Variables.Get("variables", out var v); + returnContext.Variables.Update(returnContext.Variables.Input + c.Variables.Input + v); + }) + .Returns(() => Task.FromResult(returnContext)); + mockFunction.Setup(x => x.Describe()).Returns(new FunctionView() + { + Parameters = new List() + { + new ParameterView() { Name = "variables" } + } + }); + + skills.Setup(x => x.HasNativeFunction(It.IsAny(), It.IsAny())).Returns(true); + skills.Setup(x => x.GetNativeFunction(It.IsAny(), It.IsAny())).Returns(mockFunction.Object); + + plan.AddSteps(mockFunction.Object, mockFunction.Object); + + var serializedPlan = plan.ToJson(); + + var cv = new ContextVariables(planInput); + cv.Set("variables", "foo"); + plan = await kernel.Object.StepAsync(cv, plan); + + // Act + var serializedPlan1 = plan.ToJson(); + + // Assert + Assert.NotNull(serializedPlan1); + Assert.NotEmpty(serializedPlan1); + Assert.NotEqual(serializedPlan, serializedPlan1); + Assert.Contains("\"next_step_index\":1", serializedPlan1, StringComparison.OrdinalIgnoreCase); + + // Act + cv.Set("variables", "bar"); + cv.Update(string.Empty); + var nextContext = new SKContext( + new ContextVariables(), + memory.Object, + skills.Object, + log.Object + ); + plan = Plan.FromJson(serializedPlan1, nextContext); + plan = await kernel.Object.StepAsync(cv, plan); + + // Assert + Assert.NotNull(plan); + Assert.Equal($"{stepOutput}{planInput}foo{stepOutput}{planInput}foobar", plan.State.ToString()); + mockFunction.Verify(x => x.InvokeAsync(It.IsAny(), null, null, null), Times.Exactly(2)); + + // Act + var serializedPlan2 = plan.ToJson(); + + // Assert + Assert.NotNull(serializedPlan2); + Assert.NotEmpty(serializedPlan2); + Assert.NotEqual(serializedPlan1, serializedPlan2); + Assert.Contains("\"next_step_index\":2", serializedPlan2, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void CanDeserializePlan() + { + // Arrange + var goal = "Write a poem or joke and send it in an e-mail to Kai."; + var stepOutput = "Output: The input was: "; + var plan = new Plan(goal); + + // Arrange + var kernel = new Mock(); + var log = new Mock(); + var memory = new Mock(); + var skills = new Mock(); + + var returnContext = new SKContext( + new ContextVariables(stepOutput), + memory.Object, + skills.Object, + log.Object + ); + + var mockFunction = new Mock(); + mockFunction.Setup(x => x.InvokeAsync(It.IsAny(), null, null, null)) + .Callback((c, s, l, ct) => + returnContext.Variables.Update(returnContext.Variables.Input + c.Variables.Input)) + .Returns(() => Task.FromResult(returnContext)); + + plan.AddSteps(new Plan("Step1", mockFunction.Object), mockFunction.Object); + + // Act + var serializedPlan = plan.ToJson(); + var deserializedPlan = Plan.FromJson(serializedPlan, returnContext); + + // Assert + Assert.NotNull(deserializedPlan); + Assert.Equal(goal, deserializedPlan.Description); + + Assert.Equal(string.Join(",", plan.NamedOutputs.Select(kv => $"{kv.Key}:{kv.Value}")), + string.Join(",", deserializedPlan.NamedOutputs.Select(kv => $"{kv.Key}:{kv.Value}"))); + Assert.Equal(string.Join(",", plan.NamedParameters.Select(kv => $"{kv.Key}:{kv.Value}")), + string.Join(",", deserializedPlan.NamedParameters.Select(kv => $"{kv.Key}:{kv.Value}"))); + Assert.Equal(string.Join(",", plan.State.Select(kv => $"{kv.Key}:{kv.Value}")), + string.Join(",", deserializedPlan.State.Select(kv => $"{kv.Key}:{kv.Value}"))); + + Assert.Equal(plan.Steps[0].Name, deserializedPlan.Steps[0].Name); + Assert.Equal(plan.Steps[1].Name, deserializedPlan.Steps[1].Name); + } +} diff --git a/dotnet/src/SemanticKernel/Orchestration/ContextVariablesConverter.cs b/dotnet/src/SemanticKernel/Orchestration/ContextVariablesConverter.cs index f60e58abae..ca03d1047d 100644 --- a/dotnet/src/SemanticKernel/Orchestration/ContextVariablesConverter.cs +++ b/dotnet/src/SemanticKernel/Orchestration/ContextVariablesConverter.cs @@ -16,12 +16,11 @@ public class ContextVariablesConverter : JsonConverter /// Read the JSON and convert to ContextVariables /// /// The JSON reader. - /// The type to convert from. + /// The type to convert. /// The JSON serializer options. /// The deserialized . public override ContextVariables Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - // Needs Test var keyValuePairs = JsonSerializer.Deserialize>>(ref reader, options); var context = new ContextVariables(); @@ -35,7 +34,16 @@ public class ContextVariablesConverter : JsonConverter public override void Write(Utf8JsonWriter writer, ContextVariables value, JsonSerializerOptions options) { - // Needs Test - JsonSerializer.Serialize(writer, value, options); + writer.WriteStartArray(); + + foreach (var kvp in value) + { + writer.WriteStartObject(); + writer.WriteString("Key", kvp.Key); + writer.WriteString("Value", kvp.Value); + writer.WriteEndObject(); + } + + writer.WriteEndArray(); } } diff --git a/dotnet/src/SemanticKernel/Planning/Plan.cs b/dotnet/src/SemanticKernel/Planning/Plan.cs index 7e03210d7a..b29a573491 100644 --- a/dotnet/src/SemanticKernel/Planning/Plan.cs +++ b/dotnet/src/SemanticKernel/Planning/Plan.cs @@ -159,12 +159,15 @@ public sealed class Plan : ISKFunction /// JSON string representation of a Plan /// The context to use for function registrations. /// An instance of a Plan object. - public static Plan FromJson(string json, SKContext context) + /// If Context is not supplied, plan will not be able to execute. + public static Plan FromJson(string json, SKContext? context = null) { - // Needs Test var plan = JsonSerializer.Deserialize(json, new JsonSerializerOptions() { IncludeFields = true }) ?? new Plan(string.Empty); - plan = SetRegisteredFunctions(plan, context); + if (context != null) + { + plan = SetRegisteredFunctions(plan, context); + } return plan; } @@ -174,7 +177,6 @@ public sealed class Plan : ISKFunction /// public string ToJson() { - // Needs Test return JsonSerializer.Serialize(this); } @@ -449,7 +451,6 @@ public sealed class Plan : ISKFunction } else if (this.State.Get(param.Name, out value) && !string.IsNullOrEmpty(value)) { - // Needs test stepVariables.Set(param.Name, value); } }