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);
+ }
+}