From 9d57eb4078f6b62d90c8638a09c727c94720ec4d Mon Sep 17 00:00:00 2001
From: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com>
Date: Tue, 24 Feb 2026 13:00:12 -0800
Subject: [PATCH 1/3] Added shell classes
---
.../Contents/AIContent.cs | 2 +
.../Contents/ShellCallContent.cs | 60 +++++++++
.../Contents/ShellCommandOutput.cs | 41 ++++++
.../Contents/ShellResultContent.cs | 43 ++++++
.../Tools/HostedShellTool.cs | 38 ++++++
.../Tools/ShellTool.cs | 49 +++++++
.../Utilities/AIJsonUtilities.Defaults.cs | 5 +
src/Shared/DiagnosticIds/DiagnosticIds.cs | 1 +
.../Contents/ShellCallContentTests.cs | 115 ++++++++++++++++
.../Contents/ShellCommandOutputTests.cs | 81 +++++++++++
.../Contents/ShellResultContentTests.cs | 126 ++++++++++++++++++
.../Tools/HostedShellToolTests.cs | 38 ++++++
.../Tools/ShellToolTests.cs | 75 +++++++++++
13 files changed, 674 insertions(+)
create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ShellCallContent.cs
create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ShellCommandOutput.cs
create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ShellResultContent.cs
create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedShellTool.cs
create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/ShellTool.cs
create mode 100644 test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/ShellCallContentTests.cs
create mode 100644 test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/ShellCommandOutputTests.cs
create mode 100644 test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/ShellResultContentTests.cs
create mode 100644 test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedShellToolTests.cs
create mode 100644 test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/ShellToolTests.cs
diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs
index af8b19c8d84..9cb56036440 100644
--- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs
+++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs
@@ -32,6 +32,8 @@ namespace Microsoft.Extensions.AI;
// [JsonDerivedType(typeof(McpServerToolApprovalResponseContent), typeDiscriminator: "mcpServerToolApprovalResponse")]
// [JsonDerivedType(typeof(CodeInterpreterToolCallContent), typeDiscriminator: "codeInterpreterToolCall")]
// [JsonDerivedType(typeof(CodeInterpreterToolResultContent), typeDiscriminator: "codeInterpreterToolResult")]
+// [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..134afbc7b4e
--- /dev/null
+++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ShellCallContent.cs
@@ -0,0 +1,60 @@
+// 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 System.Text.Json.Serialization;
+using Microsoft.Shared.DiagnosticIds;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Extensions.AI;
+
+///
+/// Represents a shell tool call request from a service.
+///
+///
+///
+/// This content type is produced by implementations that have native shell tool support.
+/// It extends with shell-specific properties such as the list of commands,
+/// timeout, and output length constraints.
+///
+///
+/// For implementations without native shell support, the standard
+/// is used instead.
+///
+///
+[Experimental(DiagnosticIds.Experiments.AIShell, UrlFormat = DiagnosticIds.UrlFormat)]
+public sealed class ShellCallContent : FunctionCallContent
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The function call ID.
+ /// The tool name.
+ /// The function arguments.
+ [JsonConstructor]
+ public ShellCallContent(string callId, string name, IDictionary? arguments = null)
+ : base(callId, Throw.IfNull(name), arguments)
+ {
+ }
+
+ ///
+ /// Gets or sets the list of commands to execute.
+ ///
+ public IList? Commands { get; set; }
+
+ ///
+ /// Gets or sets the timeout in milliseconds for the shell command execution.
+ ///
+ public int? TimeoutMs { 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..a746310dc20
--- /dev/null
+++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ShellCommandOutput.cs
@@ -0,0 +1,41 @@
+// 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
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public ShellCommandOutput()
+ {
+ }
+
+ ///
+ /// 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..1ce535e3900
--- /dev/null
+++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ShellResultContent.cs
@@ -0,0 +1,43 @@
+// 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 System.Text.Json.Serialization;
+using Microsoft.Shared.DiagnosticIds;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Extensions.AI;
+
+///
+/// Represents the result of a shell tool invocation.
+///
+///
+/// This content type extends with structured shell output data.
+/// It can be produced by subclasses for local execution or by
+/// implementations for hosted shell execution results.
+///
+[Experimental(DiagnosticIds.Experiments.AIShell, UrlFormat = DiagnosticIds.UrlFormat)]
+public sealed class ShellResultContent : FunctionResultContent
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The function call ID for which this is the result.
+ /// The result of the shell tool invocation.
+ [JsonConstructor]
+ public ShellResultContent(string callId, object? result = null)
+ : base(Throw.IfNull(callId), result)
+ {
+ }
+
+ ///
+ /// 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/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/Tools/ShellTool.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/ShellTool.cs
new file mode 100644
index 00000000000..5d7e60d2ea9
--- /dev/null
+++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/ShellTool.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.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.Shared.DiagnosticIds;
+
+namespace Microsoft.Extensions.AI;
+
+/// Represents a shell tool that can execute commands and be described to an AI service.
+///
+///
+/// This is an abstract base class for shell tools that execute commands locally.
+/// Subclasses must implement to provide the actual command execution logic.
+///
+///
+/// implementations backed by a service that has its own notion of a shell tool
+/// can special-case this type, translating it into usage of the service's native shell tool.
+/// For implementations without such special-casing, the tool functions as
+/// a standard that can be invoked via .
+///
+///
+[Experimental(DiagnosticIds.Experiments.AIShell, UrlFormat = DiagnosticIds.UrlFormat)]
+public abstract class ShellTool : AIFunction
+{
+ /// Any additional properties associated with the tool.
+ private IReadOnlyDictionary? _additionalProperties;
+
+ /// Initializes a new instance of the class.
+ protected ShellTool()
+ {
+ }
+
+ /// Initializes a new instance of the class.
+ /// Any additional properties associated with the tool.
+ protected ShellTool(IReadOnlyDictionary? additionalProperties)
+ {
+ _additionalProperties = additionalProperties;
+ }
+
+ ///
+ public override string Name => "local_shell";
+
+ ///
+ public override string Description => "Executes a shell command and returns stdout, stderr, and exit code.";
+
+ ///
+ 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 0f2e4340358..22bf257e58c 100644
--- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs
+++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs
@@ -61,6 +61,8 @@ private static JsonSerializerOptions CreateDefaultOptions()
AddAIContentType(options, typeof(CodeInterpreterToolResultContent), typeDiscriminatorId: "codeInterpreterToolResult", checkBuiltIn: false);
AddAIContentType(options, typeof(ImageGenerationToolCallContent), typeDiscriminatorId: "imageGenerationToolCall", checkBuiltIn: false);
AddAIContentType(options, typeof(ImageGenerationToolResultContent), typeDiscriminatorId: "imageGenerationToolResult", checkBuiltIn: false);
+ AddAIContentType(options, typeof(ShellCallContent), typeDiscriminatorId: "shellCall", checkBuiltIn: false);
+ AddAIContentType(options, typeof(ShellResultContent), typeDiscriminatorId: "shellResult", checkBuiltIn: false);
if (JsonSerializer.IsReflectionEnabledByDefault)
{
@@ -137,6 +139,9 @@ private static JsonSerializerOptions CreateDefaultOptions()
[JsonSerializable(typeof(CodeInterpreterToolResultContent))]
[JsonSerializable(typeof(ImageGenerationToolCallContent))]
[JsonSerializable(typeof(ImageGenerationToolResultContent))]
+ [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 0b11c260d10..3d2bcddd528 100644
--- a/src/Shared/DiagnosticIds/DiagnosticIds.cs
+++ b/src/Shared/DiagnosticIds/DiagnosticIds.cs
@@ -56,6 +56,7 @@ internal static class Experiments
internal const string AIChatReduction = AIExperiments;
internal const string AIResponseContinuations = AIExperiments;
internal const string AICodeInterpreter = AIExperiments;
+ internal const string AIShell = AIExperiments;
internal const string AIRealTime = AIExperiments;
// These diagnostic IDs are defined by the OpenAI package for its experimental APIs.
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..bff9ec3152c
--- /dev/null
+++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/ShellCallContentTests.cs
@@ -0,0 +1,115 @@
+// 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 ShellCallContentTests
+{
+ [Fact]
+ public void Constructor_PropsDefault()
+ {
+ ShellCallContent content = new("call123", "shell");
+ Assert.Equal("call123", content.CallId);
+ Assert.Equal("shell", content.Name);
+ Assert.Null(content.Arguments);
+ Assert.Null(content.Commands);
+ Assert.Null(content.TimeoutMs);
+ Assert.Null(content.MaxOutputLength);
+ Assert.Null(content.Status);
+ }
+
+ [Fact]
+ public void Constructor_WithArguments()
+ {
+ var args = new Dictionary { ["command"] = "ls" };
+ ShellCallContent content = new("call123", "local_shell", args);
+
+ Assert.Equal("call123", content.CallId);
+ Assert.Equal("local_shell", content.Name);
+ Assert.Same(args, content.Arguments);
+ }
+
+ [Fact]
+ public void Properties_Roundtrip()
+ {
+ ShellCallContent content = new("call123", "shell");
+
+ Assert.Null(content.Commands);
+ IList commands = ["ls -la", "pwd"];
+ content.Commands = commands;
+ Assert.Same(commands, content.Commands);
+
+ Assert.Null(content.TimeoutMs);
+ content.TimeoutMs = 120000;
+ Assert.Equal(120000, content.TimeoutMs);
+
+ 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", "shell")
+ {
+ Commands = ["ls -la", "pwd"],
+ TimeoutMs = 60000,
+ 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.Equal("shell", deserializedSut.Name);
+ 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(60000, deserializedSut.TimeoutMs);
+ Assert.Equal(4096, deserializedSut.MaxOutputLength);
+ Assert.Equal("completed", deserializedSut.Status);
+ }
+
+ [Fact]
+ public void Serialization_PolymorphicAsAIContent_Roundtrips()
+ {
+ AIContent content = new ShellCallContent("call123", "shell")
+ {
+ 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.Equal("shell", shellCall.Name);
+ 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..f52d9e5d571
--- /dev/null
+++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/ShellResultContentTests.cs
@@ -0,0 +1,126 @@
+// 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.Result);
+ Assert.Null(content.Output);
+ Assert.Null(content.MaxOutputLength);
+ }
+
+ [Fact]
+ public void Constructor_WithResult()
+ {
+ ShellResultContent content = new("call123", "some result");
+ Assert.Equal("call123", content.CallId);
+ Assert.Equal("some result", content.Result);
+ }
+
+ [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);
+ }
+}
diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/ShellToolTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/ShellToolTests.cs
new file mode 100644
index 00000000000..f6aefc80d06
--- /dev/null
+++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/ShellToolTests.cs
@@ -0,0 +1,75 @@
+// 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.Threading;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace Microsoft.Extensions.AI;
+
+public class ShellToolTests
+{
+ [Fact]
+ public void Constructor_Roundtrips()
+ {
+ var tool = new TestShellTool();
+ Assert.Equal("local_shell", tool.Name);
+ Assert.Equal("Executes a shell command and returns stdout, stderr, and exit code.", 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 TestShellTool(props);
+
+ Assert.Equal("local_shell", tool.Name);
+ Assert.Same(props, tool.AdditionalProperties);
+ }
+
+ [Fact]
+ public async Task InvokeCoreAsync_ReturnsShellResultContent()
+ {
+ var tool = new TestShellTool();
+ var arguments = new AIFunctionArguments { ["command"] = "echo hello" };
+
+ var result = await tool.InvokeAsync(arguments);
+
+ var shellResult = Assert.IsType(result);
+ Assert.Equal("test-call-id", shellResult.CallId);
+ Assert.NotNull(shellResult.Output);
+ Assert.Single(shellResult.Output);
+ Assert.Equal("hello\n", shellResult.Output[0].Stdout);
+ Assert.Equal(0, shellResult.Output[0].ExitCode);
+ }
+
+ private sealed class TestShellTool : ShellTool
+ {
+ public TestShellTool()
+ {
+ }
+
+ public TestShellTool(IReadOnlyDictionary? additionalProperties)
+ : base(additionalProperties)
+ {
+ }
+
+ protected override ValueTask