chore: import upstream snapshot with attribution
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
This commit is contained in:
+384
@@ -0,0 +1,384 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Tools.Shell.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="ShellEnvironmentProvider"/>. Most assertions go
|
||||
/// through a fake <see cref="ShellExecutor"/> so the tests are
|
||||
/// hermetic and don't depend on the host's installed CLIs.
|
||||
/// </summary>
|
||||
public sealed class ShellEnvironmentProviderTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RefreshAsync_OnPowerShellHost_ReportsPowerShellAsync()
|
||||
{
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
return; // The default-detection path only fires PowerShell on Windows.
|
||||
}
|
||||
|
||||
await using var shell = new LocalShellExecutor(new() { Mode = ShellMode.Stateless });
|
||||
var provider = new ShellEnvironmentProvider(shell, new() { ProbeTools = [] });
|
||||
var snapshot = await provider.RefreshAsync();
|
||||
|
||||
Assert.Equal(ShellFamily.PowerShell, snapshot.Family);
|
||||
Assert.False(string.IsNullOrWhiteSpace(snapshot.WorkingDirectory));
|
||||
// Shell version probe runs `$PSVersionTable.PSVersion` — must be non-null on a real host.
|
||||
Assert.False(string.IsNullOrWhiteSpace(snapshot.ShellVersion));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshAsync_OnPosixHost_ReportsPosixAsync()
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var shell = new LocalShellExecutor(new() { Mode = ShellMode.Stateless });
|
||||
var provider = new ShellEnvironmentProvider(shell, new() { ProbeTools = [] });
|
||||
var snapshot = await provider.RefreshAsync();
|
||||
|
||||
Assert.Equal(ShellFamily.Posix, snapshot.Family);
|
||||
Assert.False(string.IsNullOrWhiteSpace(snapshot.WorkingDirectory));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultInstructionsFormatter_PowerShell_ContainsPowerShellIdioms()
|
||||
{
|
||||
var snapshot = new ShellEnvironmentSnapshot(
|
||||
Family: ShellFamily.PowerShell,
|
||||
OSDescription: "Windows 11",
|
||||
ShellVersion: "7.4.0",
|
||||
WorkingDirectory: @"C:\repo",
|
||||
ToolVersions: new Dictionary<string, string?> { ["git"] = "git 2.46", ["docker"] = null });
|
||||
|
||||
var instructions = ShellEnvironmentProvider.DefaultInstructionsFormatter(snapshot);
|
||||
Assert.Contains("PowerShell 7.4.0", instructions, StringComparison.Ordinal);
|
||||
Assert.Contains("$env:NAME", instructions, StringComparison.Ordinal);
|
||||
Assert.Contains("Set-Location", instructions, StringComparison.Ordinal);
|
||||
Assert.Contains(@"C:\repo", instructions, StringComparison.Ordinal);
|
||||
Assert.Contains("git (git 2.46)", instructions, StringComparison.Ordinal);
|
||||
Assert.Contains("Not installed: docker", instructions, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultInstructionsFormatter_Posix_ContainsPosixIdioms()
|
||||
{
|
||||
var snapshot = new ShellEnvironmentSnapshot(
|
||||
Family: ShellFamily.Posix,
|
||||
OSDescription: "Ubuntu 22.04",
|
||||
ShellVersion: "5.2",
|
||||
WorkingDirectory: "/home/user/repo",
|
||||
ToolVersions: new Dictionary<string, string?> { ["git"] = "git 2.43" });
|
||||
|
||||
var instructions = ShellEnvironmentProvider.DefaultInstructionsFormatter(snapshot);
|
||||
Assert.Contains("POSIX", instructions, StringComparison.Ordinal);
|
||||
Assert.Contains("export NAME=value", instructions, StringComparison.Ordinal);
|
||||
Assert.Contains("/home/user/repo", instructions, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("$env:", instructions, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshAsync_MissingTool_RecordedAsNullAsync()
|
||||
{
|
||||
await using var shell = new LocalShellExecutor(new() { Mode = ShellMode.Stateless });
|
||||
var provider = new ShellEnvironmentProvider(shell, new()
|
||||
{
|
||||
ProbeTools = ["definitely-not-a-real-binary-xyz123"],
|
||||
ProbeTimeout = TimeSpan.FromSeconds(5),
|
||||
});
|
||||
|
||||
var snapshot = await provider.RefreshAsync();
|
||||
Assert.True(snapshot.ToolVersions.ContainsKey("definitely-not-a-real-binary-xyz123"));
|
||||
Assert.Null(snapshot.ToolVersions["definitely-not-a-real-binary-xyz123"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProvideAIContext_CustomFormatter_OverridesDefaultAsync()
|
||||
{
|
||||
var fake = new FakeShellExecutor(
|
||||
new ShellResult("VERSION=1.0\nCWD=/tmp\n", "", 0, TimeSpan.Zero));
|
||||
var options = new ShellEnvironmentProviderOptions
|
||||
{
|
||||
OverrideFamily = ShellFamily.Posix,
|
||||
ProbeTools = [],
|
||||
InstructionsFormatter = _ => "CUSTOM-INSTRUCTIONS",
|
||||
};
|
||||
var provider = new ShellEnvironmentProvider(fake, options);
|
||||
var snapshot = await provider.RefreshAsync();
|
||||
Assert.Equal("/tmp", snapshot.WorkingDirectory);
|
||||
|
||||
// ProvideAIContextAsync is protected; assert the formatter contract directly
|
||||
// against the options instance the test owns.
|
||||
var custom = options.InstructionsFormatter!(snapshot);
|
||||
Assert.Equal("CUSTOM-INSTRUCTIONS", custom);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshAsync_RecomputesSnapshotAsync()
|
||||
{
|
||||
var fake = new FakeShellExecutor(
|
||||
new ShellResult("VERSION=1.0\nCWD=/a\n", "", 0, TimeSpan.Zero));
|
||||
var provider = new ShellEnvironmentProvider(fake, new()
|
||||
{
|
||||
OverrideFamily = ShellFamily.Posix,
|
||||
ProbeTools = [],
|
||||
});
|
||||
|
||||
var first = await provider.RefreshAsync();
|
||||
Assert.Equal("/a", first.WorkingDirectory);
|
||||
|
||||
fake.NextResult = new ShellResult("VERSION=2.0\nCWD=/b\n", "", 0, TimeSpan.Zero);
|
||||
var second = await provider.RefreshAsync();
|
||||
Assert.Equal("/b", second.WorkingDirectory);
|
||||
Assert.Equal("2.0", second.ShellVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshAsync_ReProbesEachCallAsync()
|
||||
{
|
||||
var fake = new FakeShellExecutor(
|
||||
new ShellResult("VERSION=1.0\nCWD=/x\n", "", 0, TimeSpan.Zero));
|
||||
var provider = new ShellEnvironmentProvider(fake, new()
|
||||
{
|
||||
OverrideFamily = ShellFamily.Posix,
|
||||
ProbeTools = [],
|
||||
});
|
||||
|
||||
_ = await provider.RefreshAsync();
|
||||
var probesAfterFirst = fake.RunCount;
|
||||
|
||||
await provider.RefreshAsync();
|
||||
Assert.True(fake.RunCount > probesAfterFirst, "RefreshAsync should re-probe each call");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshAsync_InvalidToolName_RecordedAsNullWithoutInvokingExecutorAsync()
|
||||
{
|
||||
var fake = new FakeShellExecutor(
|
||||
new ShellResult("VERSION=1.0\nCWD=/\n", "", 0, TimeSpan.Zero));
|
||||
var provider = new ShellEnvironmentProvider(fake, new()
|
||||
{
|
||||
OverrideFamily = ShellFamily.Posix,
|
||||
ProbeTools = ["git; rm -rf /", "echo $PATH", "good-tool && bad"],
|
||||
});
|
||||
|
||||
var snapshot = await provider.RefreshAsync();
|
||||
// One probe for shell+CWD; none of the bogus tool names should reach the executor.
|
||||
Assert.Equal(1, fake.RunCount);
|
||||
Assert.Null(snapshot.ToolVersions["git; rm -rf /"]);
|
||||
Assert.Null(snapshot.ToolVersions["echo $PATH"]);
|
||||
Assert.Null(snapshot.ToolVersions["good-tool && bad"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshAsync_DuplicateProbeToolsCaseInsensitive_ProbesOnceAsync()
|
||||
{
|
||||
// ProbeTools is user-supplied. With a case-insensitive backing dictionary,
|
||||
// {"git","GIT"} used to probe twice and let the second insertion silently
|
||||
// overwrite the first. Verify we now skip duplicates.
|
||||
var fake = new ScriptedShellExecutor();
|
||||
fake.Responses.Enqueue(new ShellResult("VERSION=1.0\nCWD=/\n", "", 0, TimeSpan.Zero)); // shell+cwd probe
|
||||
fake.Responses.Enqueue(new ShellResult("git 2.46\n", "", 0, TimeSpan.Zero)); // first git probe
|
||||
// No second probe response queued — if dedup is broken, the test will throw on dequeue.
|
||||
|
||||
var provider = new ShellEnvironmentProvider(fake, new()
|
||||
{
|
||||
OverrideFamily = ShellFamily.Posix,
|
||||
ProbeTools = ["git", "GIT", "Git"],
|
||||
});
|
||||
|
||||
var snapshot = await provider.RefreshAsync();
|
||||
Assert.Single(snapshot.ToolVersions);
|
||||
Assert.Equal("git 2.46", snapshot.ToolVersions["git"]);
|
||||
Assert.Equal("git 2.46", snapshot.ToolVersions["GIT"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshAsync_ToolEmitsVersionToStderr_FallsBackToStderrAsync()
|
||||
{
|
||||
// Some CLIs (e.g. java, older gcc) write `--version` output to stderr.
|
||||
var fake = new ScriptedShellExecutor();
|
||||
fake.Responses.Enqueue(new ShellResult("VERSION=1.0\nCWD=/\n", "", 0, TimeSpan.Zero)); // shell+cwd probe
|
||||
fake.Responses.Enqueue(new ShellResult("", "openjdk 21.0.1 2023-10-17\n", 0, TimeSpan.Zero)); // tool probe
|
||||
|
||||
var provider = new ShellEnvironmentProvider(fake, new()
|
||||
{
|
||||
OverrideFamily = ShellFamily.Posix,
|
||||
ProbeTools = ["java"],
|
||||
});
|
||||
|
||||
var snapshot = await provider.RefreshAsync();
|
||||
Assert.Equal("openjdk 21.0.1 2023-10-17", snapshot.ToolVersions["java"]);
|
||||
}
|
||||
|
||||
private sealed class ScriptedShellExecutor : ShellExecutor
|
||||
{
|
||||
public Queue<ShellResult> Responses { get; } = new();
|
||||
public override Task InitializeAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
public override Task<ShellResult> RunAsync(string command, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(this.Responses.Dequeue());
|
||||
public override AIFunction AsAIFunction(string name = "run_shell", string? description = null, bool requireApproval = true) =>
|
||||
throw new NotSupportedException();
|
||||
public override ValueTask DisposeAsync() => default;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshAsync_CallerCancellation_PropagatesAsync()
|
||||
{
|
||||
var fake = new ThrowingShellExecutor(token =>
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
return new ShellResult("VERSION=1.0\nCWD=/x\n", "", 0, TimeSpan.Zero);
|
||||
});
|
||||
var provider = new ShellEnvironmentProvider(fake, new()
|
||||
{
|
||||
OverrideFamily = ShellFamily.Posix,
|
||||
ProbeTools = [],
|
||||
});
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||
() => provider.RefreshAsync(cts.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshAsync_ProbeTimeout_RecordedAsNullFieldsAsync()
|
||||
{
|
||||
// Executor honors the (linked) probe-timeout token by throwing OCE when it fires.
|
||||
var fake = new ThrowingShellExecutor(token =>
|
||||
{
|
||||
token.WaitHandle.WaitOne(TimeSpan.FromSeconds(5));
|
||||
token.ThrowIfCancellationRequested();
|
||||
return new ShellResult("VERSION=1.0\nCWD=/\n", "", 0, TimeSpan.Zero);
|
||||
});
|
||||
var provider = new ShellEnvironmentProvider(fake, new()
|
||||
{
|
||||
OverrideFamily = ShellFamily.Posix,
|
||||
ProbeTimeout = TimeSpan.FromMilliseconds(50),
|
||||
ProbeTools = ["git"],
|
||||
});
|
||||
|
||||
// Caller-side token stays alive; only the per-probe timeout fires.
|
||||
var snapshot = await provider.RefreshAsync();
|
||||
Assert.Null(snapshot.ShellVersion);
|
||||
Assert.Null(snapshot.ToolVersions["git"]);
|
||||
}
|
||||
|
||||
private sealed class ThrowingShellExecutor : ShellExecutor
|
||||
{
|
||||
private readonly Func<CancellationToken, ShellResult> _factory;
|
||||
public ThrowingShellExecutor(Func<CancellationToken, ShellResult> factory) { this._factory = factory; }
|
||||
public override Task InitializeAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
public override Task<ShellResult> RunAsync(string command, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(this._factory(cancellationToken));
|
||||
public override AIFunction AsAIFunction(string name = "run_shell", string? description = null, bool requireApproval = true) =>
|
||||
throw new NotSupportedException();
|
||||
public override ValueTask DisposeAsync() => default;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProvideAIContextAsync_FirstCallFails_NextCallRetriesAndSucceedsAsync()
|
||||
{
|
||||
// Reproduce the "poisoned _snapshotTask" scenario: the first probe throws
|
||||
// (e.g. caller cancels, or an executor blip), and a subsequent call must
|
||||
// be able to recover instead of returning the cached failure forever.
|
||||
var calls = 0;
|
||||
var fake = new ThrowingShellExecutor(_ =>
|
||||
{
|
||||
calls++;
|
||||
if (calls == 1)
|
||||
{
|
||||
throw new InvalidOperationException("boom");
|
||||
}
|
||||
return new ShellResult("VERSION=2.0\nCWD=/tmp\n", "", 0, TimeSpan.Zero);
|
||||
});
|
||||
var provider = new ShellEnvironmentProvider(fake, new()
|
||||
{
|
||||
OverrideFamily = ShellFamily.Posix,
|
||||
ProbeTools = [],
|
||||
});
|
||||
|
||||
// First call surfaces the executor failure.
|
||||
await Assert.ThrowsAnyAsync<Exception>(() => InvokeProvideAsync(provider));
|
||||
|
||||
// Second call must re-probe and succeed.
|
||||
var ctx = await InvokeProvideAsync(provider);
|
||||
Assert.NotNull(ctx.Instructions);
|
||||
Assert.NotNull(provider.CurrentSnapshot);
|
||||
Assert.Equal("2.0", provider.CurrentSnapshot!.ShellVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProvideAIContextAsync_FirstCallCancelled_NextCallSucceedsAsync()
|
||||
{
|
||||
// Round 6 made caller cancellation propagate. Combined with the cached
|
||||
// _snapshotTask, a single Ctrl-C on the first turn used to permanently
|
||||
// break the provider — verify that round 7's reset clears that.
|
||||
var calls = 0;
|
||||
var fake = new ThrowingShellExecutor(token =>
|
||||
{
|
||||
calls++;
|
||||
if (calls == 1)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
}
|
||||
return new ShellResult("VERSION=3.0\nCWD=/x\n", "", 0, TimeSpan.Zero);
|
||||
});
|
||||
var provider = new ShellEnvironmentProvider(fake, new()
|
||||
{
|
||||
OverrideFamily = ShellFamily.Posix,
|
||||
ProbeTools = [],
|
||||
});
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => InvokeProvideAsync(provider, cts.Token));
|
||||
|
||||
var ctx = await InvokeProvideAsync(provider);
|
||||
Assert.NotNull(ctx.Instructions);
|
||||
Assert.Equal("3.0", provider.CurrentSnapshot!.ShellVersion);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invokes the protected <c>ProvideAIContextAsync</c> via reflection so tests
|
||||
/// can target the cached-task code path directly. <see cref="ShellEnvironmentProvider"/>
|
||||
/// is sealed, so we cannot derive a public passthrough.
|
||||
/// </summary>
|
||||
private static async Task<AIContext> InvokeProvideAsync(ShellEnvironmentProvider provider, CancellationToken ct = default)
|
||||
{
|
||||
var method = typeof(ShellEnvironmentProvider).GetMethod(
|
||||
"ProvideAIContextAsync",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)
|
||||
?? throw new InvalidOperationException("ProvideAIContextAsync not found");
|
||||
var task = (ValueTask<AIContext>)method.Invoke(provider, new object?[] { null, ct })!;
|
||||
return await task.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private sealed class FakeShellExecutor : ShellExecutor
|
||||
{
|
||||
public FakeShellExecutor(ShellResult result) { this.NextResult = result; }
|
||||
public ShellResult NextResult { get; set; }
|
||||
public int RunCount { get; private set; }
|
||||
public override Task InitializeAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
public override Task<ShellResult> RunAsync(string command, CancellationToken cancellationToken = default)
|
||||
{
|
||||
this.RunCount++;
|
||||
return Task.FromResult(this.NextResult);
|
||||
}
|
||||
public override AIFunction AsAIFunction(string name = "run_shell", string? description = null, bool requireApproval = true) =>
|
||||
throw new NotSupportedException();
|
||||
public override ValueTask DisposeAsync() => default;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user