diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs index 42bb93a7051..c092313d311 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs @@ -38,6 +38,8 @@ namespace Microsoft.Extensions.AI; // [JsonDerivedType(typeof(ImageGenerationToolResultContent), typeDiscriminator: "imageGenerationToolResult")] // [JsonDerivedType(typeof(WebSearchToolCallContent), typeDiscriminator: "webSearchToolCall")] // [JsonDerivedType(typeof(WebSearchToolResultContent), typeDiscriminator: "webSearchToolResult")] +// [JsonDerivedType(typeof(ShellCallContent), typeDiscriminator: "shellCall")] +// [JsonDerivedType(typeof(ShellResultContent), typeDiscriminator: "shellResult")] public class AIContent { diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ShellCallContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ShellCallContent.cs new file mode 100644 index 00000000000..60587150880 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ShellCallContent.cs @@ -0,0 +1,49 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents a shell tool call invocation by a hosted service. +/// +/// +/// This content type is produced by implementations that have native shell tool support. +/// It is informational only and represents the call itself, not the result. +/// +[Experimental(DiagnosticIds.Experiments.AIShell, UrlFormat = DiagnosticIds.UrlFormat)] +public sealed class ShellCallContent : ToolCallContent +{ + /// + /// Initializes a new instance of the class. + /// + /// The tool call ID. + public ShellCallContent(string callId) + : base(callId) + { + } + + /// + /// Gets or sets the list of commands to execute. + /// + public IList? Commands { get; set; } + + /// + /// Gets or sets the timeout for the shell command execution. + /// + public TimeSpan? Timeout { get; set; } + + /// + /// Gets or sets the maximum output length in characters. + /// + public int? MaxOutputLength { get; set; } + + /// + /// Gets or sets the status of the shell call. + /// + public string? Status { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ShellCommandOutput.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ShellCommandOutput.cs new file mode 100644 index 00000000000..dffcf8bcd9c --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ShellCommandOutput.cs @@ -0,0 +1,34 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents the output of a single shell command execution. +/// +[Experimental(DiagnosticIds.Experiments.AIShell, UrlFormat = DiagnosticIds.UrlFormat)] +public class ShellCommandOutput : AIContent +{ + /// + /// Gets or sets the standard output of the command. + /// + public string? Stdout { get; set; } + + /// + /// Gets or sets the standard error output of the command. + /// + public string? Stderr { get; set; } + + /// + /// Gets or sets the exit code of the command, or if the command timed out. + /// + public int? ExitCode { get; set; } + + /// + /// Gets or sets a value indicating whether the command execution timed out. + /// + public bool TimedOut { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ShellResultContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ShellResultContent.cs new file mode 100644 index 00000000000..00280a87b1f --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ShellResultContent.cs @@ -0,0 +1,34 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents the result of a shell tool invocation by a hosted service. +/// +[Experimental(DiagnosticIds.Experiments.AIShell, UrlFormat = DiagnosticIds.UrlFormat)] +public sealed class ShellResultContent : ToolResultContent +{ + /// + /// Initializes a new instance of the class. + /// + /// The tool call ID. + public ShellResultContent(string callId) + : base(callId) + { + } + + /// + /// Gets or sets the output of the shell command execution. + /// + public IList? Output { get; set; } + + /// + /// Gets or sets the maximum output length in characters. + /// + public int? MaxOutputLength { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ToolCallContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ToolCallContent.cs index ecdba681b0d..7b2e8039924 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ToolCallContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ToolCallContent.cs @@ -22,6 +22,7 @@ namespace Microsoft.Extensions.AI; // [JsonDerivedType(typeof(CodeInterpreterToolCallContent), "codeInterpreterToolCall")] // [JsonDerivedType(typeof(ImageGenerationToolCallContent), "imageGenerationToolCall")] // [JsonDerivedType(typeof(WebSearchToolCallContent), "webSearchToolCall")] +// [JsonDerivedType(typeof(ShellCallContent), "shellCall")] public class ToolCallContent : AIContent { /// diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ToolResultContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ToolResultContent.cs index 63a99476bb7..aeeb21fa189 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ToolResultContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ToolResultContent.cs @@ -22,6 +22,7 @@ namespace Microsoft.Extensions.AI; // [JsonDerivedType(typeof(CodeInterpreterToolResultContent), "codeInterpreterToolResult")] // [JsonDerivedType(typeof(ImageGenerationToolResultContent), "imageGenerationToolResult")] // [JsonDerivedType(typeof(WebSearchToolResultContent), "webSearchToolResult")] +// [JsonDerivedType(typeof(ShellResultContent), "shellResult")] public class ToolResultContent : AIContent { /// diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedShellTool.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedShellTool.cs new file mode 100644 index 00000000000..415bdeb15cf --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedShellTool.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Extensions.AI; + +/// Represents a hosted tool that can be specified to an AI service to enable it to execute shell commands. +/// +/// This tool does not itself implement shell command execution. It is a marker that can be used to inform a service +/// that the service is allowed to execute shell commands if the service is capable of doing so. +/// +[Experimental(DiagnosticIds.Experiments.AIShell, UrlFormat = DiagnosticIds.UrlFormat)] +public class HostedShellTool : AITool +{ + /// Any additional properties associated with the tool. + private IReadOnlyDictionary? _additionalProperties; + + /// Initializes a new instance of the class. + public HostedShellTool() + { + } + + /// Initializes a new instance of the class. + /// Any additional properties associated with the tool. + public HostedShellTool(IReadOnlyDictionary? additionalProperties) + { + _additionalProperties = additionalProperties; + } + + /// + public override string Name => "shell"; + + /// + public override IReadOnlyDictionary AdditionalProperties => _additionalProperties ?? base.AdditionalProperties; +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs index 419e8d86e7e..38c02989133 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs @@ -57,14 +57,18 @@ private static JsonSerializerOptions CreateDefaultOptions() AddAIContentType(options, typeof(AIContent), typeof(ImageGenerationToolResultContent), typeDiscriminatorId: "imageGenerationToolResult", checkBuiltIn: false); AddAIContentType(options, typeof(AIContent), typeof(WebSearchToolCallContent), typeDiscriminatorId: "webSearchToolCall", checkBuiltIn: false); AddAIContentType(options, typeof(AIContent), typeof(WebSearchToolResultContent), typeDiscriminatorId: "webSearchToolResult", checkBuiltIn: false); + AddAIContentType(options, typeof(AIContent), typeof(ShellCallContent), typeDiscriminatorId: "shellCall", checkBuiltIn: false); + AddAIContentType(options, typeof(AIContent), typeof(ShellResultContent), typeDiscriminatorId: "shellResult", checkBuiltIn: false); // Also register the experimental types as derived types of ToolCallContent/ToolResultContent. AddAIContentType(options, typeof(ToolCallContent), typeof(CodeInterpreterToolCallContent), "codeInterpreterToolCall", checkBuiltIn: false); AddAIContentType(options, typeof(ToolCallContent), typeof(ImageGenerationToolCallContent), "imageGenerationToolCall", checkBuiltIn: false); AddAIContentType(options, typeof(ToolCallContent), typeof(WebSearchToolCallContent), "webSearchToolCall", checkBuiltIn: false); + AddAIContentType(options, typeof(ToolCallContent), typeof(ShellCallContent), "shellCall", checkBuiltIn: false); AddAIContentType(options, typeof(ToolResultContent), typeof(CodeInterpreterToolResultContent), "codeInterpreterToolResult", checkBuiltIn: false); AddAIContentType(options, typeof(ToolResultContent), typeof(ImageGenerationToolResultContent), "imageGenerationToolResult", checkBuiltIn: false); AddAIContentType(options, typeof(ToolResultContent), typeof(WebSearchToolResultContent), "webSearchToolResult", checkBuiltIn: false); + AddAIContentType(options, typeof(ToolResultContent), typeof(ShellResultContent), "shellResult", checkBuiltIn: false); if (JsonSerializer.IsReflectionEnabledByDefault) { @@ -135,6 +139,9 @@ private static JsonSerializerOptions CreateDefaultOptions() [JsonSerializable(typeof(ImageGenerationToolResultContent))] [JsonSerializable(typeof(WebSearchToolCallContent))] [JsonSerializable(typeof(WebSearchToolResultContent))] + [JsonSerializable(typeof(ShellCallContent))] + [JsonSerializable(typeof(ShellResultContent))] + [JsonSerializable(typeof(ShellCommandOutput))] [JsonSerializable(typeof(ResponseContinuationToken))] // IEmbeddingGenerator diff --git a/src/Shared/DiagnosticIds/DiagnosticIds.cs b/src/Shared/DiagnosticIds/DiagnosticIds.cs index 92dd69462c9..c651a1097d9 100644 --- a/src/Shared/DiagnosticIds/DiagnosticIds.cs +++ b/src/Shared/DiagnosticIds/DiagnosticIds.cs @@ -57,6 +57,7 @@ internal static class Experiments internal const string AIResponseContinuations = AIExperiments; internal const string AICodeInterpreter = AIExperiments; internal const string AIWebSearch = AIExperiments; + internal const string AIShell = AIExperiments; internal const string AIRealTime = AIExperiments; internal const string AIFiles = AIExperiments; diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/ShellCallContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/ShellCallContentTests.cs new file mode 100644 index 00000000000..c79d99a6501 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/ShellCallContentTests.cs @@ -0,0 +1,101 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class ShellCallContentTests +{ + [Fact] + public void Constructor_PropsDefault() + { + ShellCallContent content = new("call123"); + Assert.Equal("call123", content.CallId); + Assert.Null(content.Commands); + Assert.Null(content.Timeout); + Assert.Null(content.MaxOutputLength); + Assert.Null(content.Status); + } + + [Fact] + public void Properties_Roundtrip() + { + ShellCallContent content = new("call123"); + + Assert.Null(content.Commands); + IList commands = ["ls -la", "pwd"]; + content.Commands = commands; + Assert.Same(commands, content.Commands); + + Assert.Null(content.Timeout); + content.Timeout = TimeSpan.FromMinutes(2); + Assert.Equal(TimeSpan.FromMinutes(2), content.Timeout); + + Assert.Null(content.MaxOutputLength); + content.MaxOutputLength = 4096; + Assert.Equal(4096, content.MaxOutputLength); + + Assert.Null(content.Status); + content.Status = "completed"; + Assert.Equal("completed", content.Status); + + Assert.Null(content.RawRepresentation); + object raw = new(); + content.RawRepresentation = raw; + Assert.Same(raw, content.RawRepresentation); + + Assert.Null(content.AdditionalProperties); + AdditionalPropertiesDictionary props = new() { { "key", "value" } }; + content.AdditionalProperties = props; + Assert.Same(props, content.AdditionalProperties); + } + + [Fact] + public void Serialization_Roundtrips() + { + ShellCallContent content = new("call123") + { + Commands = ["ls -la", "pwd"], + Timeout = TimeSpan.FromSeconds(60), + MaxOutputLength = 4096, + Status = "completed", + }; + + var json = JsonSerializer.Serialize(content, AIJsonUtilities.DefaultOptions); + var deserializedSut = JsonSerializer.Deserialize(json, AIJsonUtilities.DefaultOptions); + + Assert.NotNull(deserializedSut); + Assert.Equal("call123", deserializedSut.CallId); + Assert.NotNull(deserializedSut.Commands); + Assert.Equal(2, deserializedSut.Commands.Count); + Assert.Equal("ls -la", deserializedSut.Commands[0]); + Assert.Equal("pwd", deserializedSut.Commands[1]); + Assert.Equal(TimeSpan.FromSeconds(60), deserializedSut.Timeout); + Assert.Equal(4096, deserializedSut.MaxOutputLength); + Assert.Equal("completed", deserializedSut.Status); + } + + [Fact] + public void Serialization_PolymorphicAsAIContent_Roundtrips() + { + AIContent content = new ShellCallContent("call123") + { + Commands = ["echo hello"], + }; + + var json = JsonSerializer.Serialize(content, AIJsonUtilities.DefaultOptions); + Assert.Contains("\"$type\"", json); + Assert.Contains("\"shellCall\"", json); + + var deserializedSut = JsonSerializer.Deserialize(json, AIJsonUtilities.DefaultOptions); + var shellCall = Assert.IsType(deserializedSut); + Assert.Equal("call123", shellCall.CallId); + Assert.NotNull(shellCall.Commands); + Assert.Single(shellCall.Commands); + Assert.Equal("echo hello", shellCall.Commands[0]); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/ShellCommandOutputTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/ShellCommandOutputTests.cs new file mode 100644 index 00000000000..4c5bd0b356e --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/ShellCommandOutputTests.cs @@ -0,0 +1,81 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class ShellCommandOutputTests +{ + [Fact] + public void Constructor_PropsDefault() + { + ShellCommandOutput output = new(); + Assert.Null(output.Stdout); + Assert.Null(output.Stderr); + Assert.Null(output.ExitCode); + Assert.False(output.TimedOut); + } + + [Fact] + public void Properties_Roundtrip() + { + ShellCommandOutput output = new(); + + Assert.Null(output.Stdout); + output.Stdout = "hello"; + Assert.Equal("hello", output.Stdout); + + Assert.Null(output.Stderr); + output.Stderr = "error message"; + Assert.Equal("error message", output.Stderr); + + Assert.Null(output.ExitCode); + output.ExitCode = 42; + Assert.Equal(42, output.ExitCode); + + Assert.False(output.TimedOut); + output.TimedOut = true; + Assert.True(output.TimedOut); + } + + [Fact] + public void Serialization_Roundtrips() + { + ShellCommandOutput output = new() + { + Stdout = "hello world", + Stderr = "warning: something", + ExitCode = 0, + TimedOut = false, + }; + + var json = JsonSerializer.Serialize(output, AIJsonUtilities.DefaultOptions); + var deserializedSut = JsonSerializer.Deserialize(json, AIJsonUtilities.DefaultOptions); + + Assert.NotNull(deserializedSut); + Assert.Equal("hello world", deserializedSut.Stdout); + Assert.Equal("warning: something", deserializedSut.Stderr); + Assert.Equal(0, deserializedSut.ExitCode); + Assert.False(deserializedSut.TimedOut); + } + + [Fact] + public void Serialization_TimedOut_Roundtrips() + { + ShellCommandOutput output = new() + { + Stdout = "partial output", + TimedOut = true, + }; + + var json = JsonSerializer.Serialize(output, AIJsonUtilities.DefaultOptions); + var deserializedSut = JsonSerializer.Deserialize(json, AIJsonUtilities.DefaultOptions); + + Assert.NotNull(deserializedSut); + Assert.Equal("partial output", deserializedSut.Stdout); + Assert.Null(deserializedSut.ExitCode); + Assert.True(deserializedSut.TimedOut); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/ShellResultContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/ShellResultContentTests.cs new file mode 100644 index 00000000000..7fd43c95442 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/ShellResultContentTests.cs @@ -0,0 +1,117 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Text.Json; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class ShellResultContentTests +{ + [Fact] + public void Constructor_PropsDefault() + { + ShellResultContent content = new("call123"); + Assert.Equal("call123", content.CallId); + Assert.Null(content.Output); + Assert.Null(content.MaxOutputLength); + } + + [Fact] + public void Properties_Roundtrip() + { + ShellResultContent content = new("call123"); + + Assert.Null(content.Output); + IList output = + [ + new ShellCommandOutput { Stdout = "hello", ExitCode = 0 } + ]; + content.Output = output; + Assert.Same(output, content.Output); + + Assert.Null(content.MaxOutputLength); + content.MaxOutputLength = 4096; + Assert.Equal(4096, content.MaxOutputLength); + + Assert.Null(content.RawRepresentation); + object raw = new(); + content.RawRepresentation = raw; + Assert.Same(raw, content.RawRepresentation); + + Assert.Null(content.AdditionalProperties); + AdditionalPropertiesDictionary props = new() { { "key", "value" } }; + content.AdditionalProperties = props; + Assert.Same(props, content.AdditionalProperties); + } + + [Fact] + public void Output_SupportsMultipleItems() + { + ShellResultContent content = new("call123") + { + Output = + [ + new ShellCommandOutput { Stdout = "output1", ExitCode = 0 }, + new ShellCommandOutput { Stderr = "error", ExitCode = 1 }, + new ShellCommandOutput { TimedOut = true }, + ] + }; + + Assert.NotNull(content.Output); + Assert.Equal(3, content.Output.Count); + Assert.Equal("output1", content.Output[0].Stdout); + Assert.Equal(0, content.Output[0].ExitCode); + Assert.Equal("error", content.Output[1].Stderr); + Assert.Equal(1, content.Output[1].ExitCode); + Assert.True(content.Output[2].TimedOut); + } + + [Fact] + public void Serialization_Roundtrips() + { + ShellResultContent content = new("call123") + { + Output = + [ + new ShellCommandOutput { Stdout = "hello\n", ExitCode = 0 }, + ], + MaxOutputLength = 4096, + }; + + var json = JsonSerializer.Serialize(content, AIJsonUtilities.DefaultOptions); + var deserializedSut = JsonSerializer.Deserialize(json, AIJsonUtilities.DefaultOptions); + + Assert.NotNull(deserializedSut); + Assert.Equal("call123", deserializedSut.CallId); + Assert.NotNull(deserializedSut.Output); + Assert.Single(deserializedSut.Output); + Assert.Equal("hello\n", deserializedSut.Output[0].Stdout); + Assert.Equal(0, deserializedSut.Output[0].ExitCode); + Assert.Equal(4096, deserializedSut.MaxOutputLength); + } + + [Fact] + public void Serialization_PolymorphicAsAIContent_Roundtrips() + { + AIContent content = new ShellResultContent("call123") + { + Output = + [ + new ShellCommandOutput { Stdout = "done", ExitCode = 0 }, + ], + }; + + var json = JsonSerializer.Serialize(content, AIJsonUtilities.DefaultOptions); + Assert.Contains("\"$type\"", json); + Assert.Contains("\"shellResult\"", json); + + var deserializedSut = JsonSerializer.Deserialize(json, AIJsonUtilities.DefaultOptions); + var shellResult = Assert.IsType(deserializedSut); + Assert.Equal("call123", shellResult.CallId); + Assert.NotNull(shellResult.Output); + Assert.Single(shellResult.Output); + Assert.Equal("done", shellResult.Output[0].Stdout); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedShellToolTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedShellToolTests.cs new file mode 100644 index 00000000000..a6815fcb08e --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedShellToolTests.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class HostedShellToolTests +{ + [Fact] + public void Constructor_Roundtrips() + { + var tool = new HostedShellTool(); + Assert.Equal("shell", tool.Name); + Assert.Empty(tool.Description); + Assert.Empty(tool.AdditionalProperties); + Assert.Equal(tool.Name, tool.ToString()); + } + + [Fact] + public void Constructor_AdditionalProperties_Roundtrips() + { + var props = new Dictionary { ["key"] = "value" }; + var tool = new HostedShellTool(props); + + Assert.Equal("shell", tool.Name); + Assert.Same(props, tool.AdditionalProperties); + } + + [Fact] + public void Constructor_NullAdditionalProperties_UsesEmpty() + { + var tool = new HostedShellTool(null); + + Assert.Empty(tool.AdditionalProperties); + } +}