chore: import upstream snapshot with attribution
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
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) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
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
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Microsoft.Agents.AI.LocalCodeAct.Internal;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.LocalCodeAct.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="FileMountHelper"/> covering the capture-limit branches
|
||||
/// (per-file, per-mount, and total) that produce textual omission placeholders
|
||||
/// instead of <see cref="DataContent"/>.
|
||||
/// </summary>
|
||||
public sealed class FileMountHelperTests
|
||||
{
|
||||
[Fact]
|
||||
public void CaptureWrittenFiles_PerFileLimit_ReturnsTextPlaceholder()
|
||||
{
|
||||
var dir = Directory.CreateTempSubdirectory("fmh-perfile-").FullName;
|
||||
try
|
||||
{
|
||||
var mount = FileMountHelper.Normalize(new FileMount(dir, "/output", FileMountMode.ReadWrite));
|
||||
var pre = FileMountHelper.SnapshotWritableMounts(new[] { mount });
|
||||
|
||||
File.WriteAllBytes(Path.Combine(dir, "big.bin"), new byte[2048]);
|
||||
|
||||
// Per-file limit of 1024 bytes — file is 2048 -> should be omitted via TextContent.
|
||||
var limits = new ProcessExecutionLimits { MaxCapturedFileBytes = 1024 };
|
||||
var captured = FileMountHelper.CaptureWrittenFiles(new[] { mount }, pre, limits);
|
||||
|
||||
var text = Assert.Single(captured.OfType<TextContent>());
|
||||
Assert.Contains("/output/big.bin", text.Text);
|
||||
Assert.Contains("per-file capture limit", text.Text);
|
||||
Assert.Empty(captured.OfType<DataContent>());
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CaptureWrittenFiles_PerMountLimit_OmitsSecondFile()
|
||||
{
|
||||
var dir = Directory.CreateTempSubdirectory("fmh-permount-").FullName;
|
||||
try
|
||||
{
|
||||
// WriteBytesLimit caps total bytes captured *for this mount*.
|
||||
var mount = FileMountHelper.Normalize(
|
||||
new FileMount(dir, "/output", FileMountMode.ReadWrite, writeBytesLimit: 600));
|
||||
var pre = FileMountHelper.SnapshotWritableMounts(new[] { mount });
|
||||
|
||||
// Two files of 400 bytes each — first fits, second exceeds the 600-byte per-mount cap.
|
||||
File.WriteAllBytes(Path.Combine(dir, "a.bin"), new byte[400]);
|
||||
File.WriteAllBytes(Path.Combine(dir, "b.bin"), new byte[400]);
|
||||
|
||||
var limits = new ProcessExecutionLimits(); // per-file/total caps high enough not to fire.
|
||||
var captured = FileMountHelper.CaptureWrittenFiles(new[] { mount }, pre, limits);
|
||||
|
||||
Assert.Single(captured.OfType<DataContent>());
|
||||
var text = Assert.Single(captured.OfType<TextContent>());
|
||||
Assert.Contains("per-mount capture limit", text.Text);
|
||||
Assert.Contains("/output/b.bin", text.Text);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CaptureWrittenFiles_TotalLimit_OmitsAcrossMounts()
|
||||
{
|
||||
var dirA = Directory.CreateTempSubdirectory("fmh-totalA-").FullName;
|
||||
var dirB = Directory.CreateTempSubdirectory("fmh-totalB-").FullName;
|
||||
try
|
||||
{
|
||||
var mountA = FileMountHelper.Normalize(new FileMount(dirA, "/a", FileMountMode.ReadWrite));
|
||||
var mountB = FileMountHelper.Normalize(new FileMount(dirB, "/b", FileMountMode.ReadWrite));
|
||||
var mounts = new[] { mountA, mountB };
|
||||
var pre = FileMountHelper.SnapshotWritableMounts(mounts);
|
||||
|
||||
File.WriteAllBytes(Path.Combine(dirA, "a.bin"), new byte[500]);
|
||||
File.WriteAllBytes(Path.Combine(dirB, "b.bin"), new byte[500]);
|
||||
|
||||
// Total capture limit set so the first file fits and the second triggers
|
||||
// the cross-mount total cap.
|
||||
var limits = new ProcessExecutionLimits { MaxTotalCapturedFileBytes = 600 };
|
||||
var captured = FileMountHelper.CaptureWrittenFiles(mounts, pre, limits);
|
||||
|
||||
Assert.Single(captured.OfType<DataContent>());
|
||||
var text = Assert.Single(captured.OfType<TextContent>());
|
||||
Assert.Contains("total capture limit", text.Text);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dirA, recursive: true);
|
||||
Directory.Delete(dirB, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
|
||||
namespace Microsoft.Agents.AI.LocalCodeAct.UnitTests;
|
||||
|
||||
public sealed class FileMountTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_AssignsProperties()
|
||||
{
|
||||
var tempDir = System.IO.Directory.CreateTempSubdirectory("filemount-test-").FullName;
|
||||
try
|
||||
{
|
||||
var mount = new FileMount(tempDir, "/app/data", FileMountMode.ReadWrite, writeBytesLimit: 1024);
|
||||
|
||||
Assert.Equal(tempDir, mount.HostPath);
|
||||
Assert.Equal("/app/data", mount.MountPath);
|
||||
Assert.Equal(FileMountMode.ReadWrite, mount.Mode);
|
||||
Assert.Equal(1024L, mount.WriteBytesLimit);
|
||||
}
|
||||
finally
|
||||
{
|
||||
System.IO.Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_DefaultsAreReadWriteWithNoLimit()
|
||||
{
|
||||
var tempDir = System.IO.Directory.CreateTempSubdirectory("filemount-test-").FullName;
|
||||
try
|
||||
{
|
||||
var mount = new FileMount(tempDir, "/app/data");
|
||||
Assert.Equal(FileMountMode.ReadWrite, mount.Mode);
|
||||
Assert.Null(mount.WriteBytesLimit);
|
||||
}
|
||||
finally
|
||||
{
|
||||
System.IO.Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_RequiresPaths()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new FileMount("", "/app/data"));
|
||||
Assert.Throws<ArgumentException>(() => new FileMount("/host/data", ""));
|
||||
_ = Assert.Throws<ArgumentNullException>(() => new FileMount(null!, "/app/data"));
|
||||
_ = Assert.Throws<ArgumentNullException>(() => new FileMount("/host/data", null!));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Agents.AI.LocalCodeAct.Internal;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.LocalCodeAct.UnitTests;
|
||||
|
||||
public sealed class InstructionBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildContextInstructions_ContainsExecuteCodeName()
|
||||
{
|
||||
var instructions = InstructionBuilder.BuildContextInstructions();
|
||||
Assert.Contains("execute_code", instructions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildExecuteCodeDescription_MentionsToolsWhenProvided()
|
||||
{
|
||||
var tools = new List<AIFunction> { new TestTool("get_weather", "Returns current weather.") };
|
||||
var description = InstructionBuilder.BuildExecuteCodeDescription(tools, new List<FileMount>());
|
||||
|
||||
Assert.Contains("get_weather", description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildExecuteCodeDescription_MentionsMountsWhenProvided()
|
||||
{
|
||||
var mounts = new List<FileMount> { new("/host/data", "/app/data") };
|
||||
var description = InstructionBuilder.BuildExecuteCodeDescription(new List<AIFunction>(), mounts);
|
||||
|
||||
Assert.Contains("/app/data", description);
|
||||
}
|
||||
|
||||
private sealed class TestTool : AIFunction
|
||||
{
|
||||
public TestTool(string name, string description)
|
||||
{
|
||||
this.Name = name;
|
||||
this.Description = description;
|
||||
}
|
||||
|
||||
public override string Name { get; }
|
||||
|
||||
public override string Description { get; }
|
||||
|
||||
protected override System.Threading.Tasks.ValueTask<object?> InvokeCoreAsync(AIFunctionArguments arguments, System.Threading.CancellationToken cancellationToken) =>
|
||||
new((object?)null);
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
|
||||
namespace Microsoft.Agents.AI.LocalCodeAct.UnitTests;
|
||||
|
||||
public sealed class LocalCodeActProviderOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void ProviderConstructor_RequiresPythonExecutablePath()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new LocalCodeActProvider(""));
|
||||
Assert.Throws<ArgumentException>(() => new LocalCodeActProvider(" "));
|
||||
_ = Assert.Throws<ArgumentNullException>(() => new LocalCodeActProvider(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExecuteCodeFunctionConstructor_RequiresPythonExecutablePath()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new LocalExecuteCodeFunction(""));
|
||||
Assert.Throws<ArgumentException>(() => new LocalExecuteCodeFunction(" "));
|
||||
_ = Assert.Throws<ArgumentNullException>(() => new LocalExecuteCodeFunction(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidationDisabled_DefaultsToFalse()
|
||||
{
|
||||
var options = new LocalCodeActProviderOptions();
|
||||
Assert.False(options.ValidationDisabled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.LocalCodeAct.UnitTests;
|
||||
|
||||
public sealed class LocalCodeActProviderTests
|
||||
{
|
||||
private static readonly AIAgent s_mockAgent = new Mock<AIAgent>().Object;
|
||||
|
||||
private static AIContextProvider.InvokingContext NewInvokingContext() =>
|
||||
new(s_mockAgent, session: null, new AIContext());
|
||||
|
||||
private static LocalCodeActProviderOptions Options() =>
|
||||
new()
|
||||
{
|
||||
ValidationDisabled = true, // No subprocess will be launched in these tests
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task ProvideAIContextAsync_ReturnsExecuteCodeToolAndInstructionsAsync()
|
||||
{
|
||||
using var provider = new LocalCodeActProvider("/usr/bin/python3", Options());
|
||||
|
||||
var context = await provider.InvokingAsync(NewInvokingContext());
|
||||
|
||||
Assert.NotNull(context);
|
||||
Assert.NotNull(context!.Tools);
|
||||
var tools = context.Tools!.ToList();
|
||||
Assert.Single(tools);
|
||||
var function = Assert.IsAssignableFrom<AIFunction>(tools[0]);
|
||||
Assert.Equal("execute_code", function.Name);
|
||||
Assert.False(string.IsNullOrWhiteSpace(context.Instructions));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddAndRemoveTools_RoundTrips()
|
||||
{
|
||||
using var provider = new LocalCodeActProvider("/usr/bin/python3", Options());
|
||||
|
||||
var tool = new TestTool("ping");
|
||||
provider.AddTools(tool);
|
||||
|
||||
Assert.Contains(provider.GetTools(), t => t.Name == "ping");
|
||||
|
||||
provider.RemoveTools("ping");
|
||||
Assert.DoesNotContain(provider.GetTools(), t => t.Name == "ping");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddAndRemoveFileMounts_RoundTrips()
|
||||
{
|
||||
using var provider = new LocalCodeActProvider("/usr/bin/python3", Options());
|
||||
|
||||
var tempDir = System.IO.Directory.CreateTempSubdirectory("localcodeact-test-").FullName;
|
||||
try
|
||||
{
|
||||
var mount = new FileMount(tempDir, "/app/data");
|
||||
provider.AddFileMounts(mount);
|
||||
|
||||
Assert.Contains(provider.GetFileMounts(), m => m.MountPath == "/app/data");
|
||||
|
||||
provider.RemoveFileMounts("/app/data");
|
||||
Assert.DoesNotContain(provider.GetFileMounts(), m => m.MountPath == "/app/data");
|
||||
}
|
||||
finally
|
||||
{
|
||||
System.IO.Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClearMethods_EmptyState()
|
||||
{
|
||||
using var provider = new LocalCodeActProvider("/usr/bin/python3", Options());
|
||||
|
||||
var tempDir1 = System.IO.Directory.CreateTempSubdirectory("localcodeact-test-").FullName;
|
||||
var tempDir2 = System.IO.Directory.CreateTempSubdirectory("localcodeact-test-").FullName;
|
||||
try
|
||||
{
|
||||
provider.AddTools(new TestTool("a"), new TestTool("b"));
|
||||
provider.AddFileMounts(new FileMount(tempDir1, "/m/1"), new FileMount(tempDir2, "/m/2"));
|
||||
|
||||
provider.ClearTools();
|
||||
provider.ClearFileMounts();
|
||||
|
||||
Assert.Empty(provider.GetTools());
|
||||
Assert.Empty(provider.GetFileMounts());
|
||||
}
|
||||
finally
|
||||
{
|
||||
System.IO.Directory.Delete(tempDir1, recursive: true);
|
||||
System.IO.Directory.Delete(tempDir2, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestTool : AIFunction
|
||||
{
|
||||
public TestTool(string name)
|
||||
{
|
||||
this.Name = name;
|
||||
}
|
||||
|
||||
public override string Name { get; }
|
||||
|
||||
public override string Description => "test tool";
|
||||
|
||||
protected override ValueTask<object?> InvokeCoreAsync(AIFunctionArguments arguments, System.Threading.CancellationToken cancellationToken) =>
|
||||
new((object?)null);
|
||||
}
|
||||
}
|
||||
+279
@@ -0,0 +1,279 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.LocalCodeAct.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests that launch a real Python subprocess. Skipped automatically when
|
||||
/// no Python interpreter is discoverable on PATH.
|
||||
/// </summary>
|
||||
public sealed class LocalExecuteCodeFunctionIntegrationTests
|
||||
{
|
||||
private static readonly string? s_python = FindPython();
|
||||
|
||||
private static void SkipIfNoPython()
|
||||
{
|
||||
if (s_python is null)
|
||||
{
|
||||
Assert.Skip("No Python interpreter found on PATH; skipping integration test.");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteCode_PrintsAndReturnsResultAsync()
|
||||
{
|
||||
SkipIfNoPython();
|
||||
|
||||
var function = new LocalExecuteCodeFunction(s_python!);
|
||||
|
||||
var args = new AIFunctionArguments
|
||||
{
|
||||
["code"] = "print('hello world')\n1 + 2",
|
||||
};
|
||||
|
||||
var result = await function.InvokeAsync(args, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(result);
|
||||
var combined = GetResultText(result);
|
||||
Assert.Contains("hello world", combined);
|
||||
Assert.Contains("3", combined);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteCode_ValidationBlocksDisallowedImportAsync()
|
||||
{
|
||||
SkipIfNoPython();
|
||||
|
||||
var function = new LocalExecuteCodeFunction(s_python!);
|
||||
|
||||
var args = new AIFunctionArguments
|
||||
{
|
||||
["code"] = "import subprocess",
|
||||
};
|
||||
|
||||
await Assert.ThrowsAsync<CodeValidationException>(async () =>
|
||||
await function.InvokeAsync(args, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteCode_CapturesFilesInWritableMountAsync()
|
||||
{
|
||||
SkipIfNoPython();
|
||||
|
||||
var hostDir = Directory.CreateTempSubdirectory("localcodeact-mount-").FullName;
|
||||
try
|
||||
{
|
||||
var options = new LocalCodeActProviderOptions
|
||||
{
|
||||
FileMounts = new[]
|
||||
{
|
||||
new FileMount(hostDir, "/output", FileMountMode.ReadWrite),
|
||||
},
|
||||
};
|
||||
|
||||
var function = new LocalExecuteCodeFunction(s_python!, options);
|
||||
|
||||
// Use os.path.join via the actual host path - the mount path is descriptive metadata only
|
||||
var escapedPath = hostDir.Replace("\\", "\\\\", StringComparison.Ordinal);
|
||||
var args = new AIFunctionArguments
|
||||
{
|
||||
["code"] = $"from pathlib import Path\nPath(r'{escapedPath}/out.txt').write_text('captured')",
|
||||
};
|
||||
|
||||
var result = await function.InvokeAsync(args, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(result);
|
||||
AssertResultContainsDataContent(result, "/output/out.txt");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(hostDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteCode_UnknownToolNameReturnsErrorToGeneratedCodeAsync()
|
||||
{
|
||||
SkipIfNoPython();
|
||||
|
||||
// No tools are registered, so any call_tool from generated code resolves to
|
||||
// the "Unknown tool" branch in ProcessBridge.HandleToolCallAsync.
|
||||
var function = new LocalExecuteCodeFunction(s_python!);
|
||||
|
||||
var args = new AIFunctionArguments
|
||||
{
|
||||
["code"] = @"
|
||||
try:
|
||||
await call_tool('definitely_not_registered', x=1)
|
||||
print('NO_ERROR')
|
||||
except Exception as exc:
|
||||
print('GOT_ERROR:' + type(exc).__name__ + ':' + str(exc))
|
||||
",
|
||||
};
|
||||
|
||||
var result = await function.InvokeAsync(args, CancellationToken.None);
|
||||
var combined = GetResultText(result);
|
||||
Assert.Contains("GOT_ERROR", combined);
|
||||
Assert.Contains("definitely_not_registered", combined);
|
||||
Assert.DoesNotContain("NO_ERROR", combined);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteCode_ToolThrowingExceptionPropagatesToGeneratedCodeAsync()
|
||||
{
|
||||
SkipIfNoPython();
|
||||
|
||||
// Tool that always throws — exercises ProcessBridge.HandleToolCallAsync exception path
|
||||
// which sends a structured error response back to the subprocess.
|
||||
Func<string, string> faulty = message => throw new InvalidOperationException("intentional: " + message);
|
||||
var faultyTool = AIFunctionFactory.Create(faulty, name: "faulty");
|
||||
|
||||
var options = new LocalCodeActProviderOptions
|
||||
{
|
||||
Tools = new[] { faultyTool },
|
||||
};
|
||||
var function = new LocalExecuteCodeFunction(s_python!, options);
|
||||
|
||||
var args = new AIFunctionArguments
|
||||
{
|
||||
["code"] = @"
|
||||
try:
|
||||
await call_tool('faulty', message='boom')
|
||||
print('NO_ERROR')
|
||||
except Exception as exc:
|
||||
print('GOT_ERROR:' + type(exc).__name__ + ':' + str(exc))
|
||||
",
|
||||
};
|
||||
var result = await function.InvokeAsync(args, CancellationToken.None);
|
||||
var combined = GetResultText(result);
|
||||
Assert.Contains("GOT_ERROR", combined);
|
||||
Assert.Contains("InvalidOperationException", combined);
|
||||
Assert.Contains("intentional: boom", combined);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Validator_TimeoutKillsProcessAndThrowsAsync()
|
||||
{
|
||||
SkipIfNoPython();
|
||||
|
||||
// Custom validator script that ignores stdin and blocks forever so the
|
||||
// parent timeout fires and exercises the timeout catch in CodeValidator.
|
||||
var tempDir = Directory.CreateTempSubdirectory("localcodeact-vtimeout-").FullName;
|
||||
try
|
||||
{
|
||||
var scriptPath = Path.Combine(tempDir, "hang_validator.py");
|
||||
File.WriteAllText(scriptPath, "import time\nwhile True:\n time.sleep(60)\n");
|
||||
|
||||
var validator = new Internal.CodeValidator(
|
||||
s_python!,
|
||||
scriptPath,
|
||||
TimeSpan.FromSeconds(1),
|
||||
allowedImports: null,
|
||||
blockedImports: null,
|
||||
allowedBuiltins: null,
|
||||
blockedBuiltins: null);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<CodeValidationException>(
|
||||
async () => await validator.ValidateAsync("print('x')", CancellationToken.None));
|
||||
Assert.Contains("exceeded", ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetResultText(object? result) =>
|
||||
result switch
|
||||
{
|
||||
IEnumerable<AIContent> contents => string.Join("\n", contents.OfType<TextContent>().Select(t => t.Text)),
|
||||
JsonElement element => element.GetRawText(),
|
||||
_ => result?.ToString() ?? string.Empty,
|
||||
};
|
||||
|
||||
private static void AssertResultContainsDataContent(object? result, string expectedPath)
|
||||
{
|
||||
if (result is IEnumerable<AIContent> contents)
|
||||
{
|
||||
Assert.Contains(contents, c => c is DataContent);
|
||||
return;
|
||||
}
|
||||
|
||||
var json = Assert.IsType<JsonElement>(result).GetRawText();
|
||||
Assert.Contains(expectedPath, json);
|
||||
}
|
||||
|
||||
private static string? FindPython()
|
||||
{
|
||||
var configured = Environment.GetEnvironmentVariable("LOCAL_CODEACT_PYTHON");
|
||||
if (!string.IsNullOrWhiteSpace(configured) && IsUsablePython(configured))
|
||||
{
|
||||
return configured;
|
||||
}
|
||||
|
||||
var executableNames = OperatingSystem.IsWindows()
|
||||
? new[] { "python3.exe", "python.exe" }
|
||||
: new[] { "python3", "python" };
|
||||
|
||||
foreach (var name in executableNames)
|
||||
{
|
||||
var path = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
|
||||
foreach (var dir in path.Split(Path.PathSeparator))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dir))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var candidate = Path.Combine(dir, name);
|
||||
if (File.Exists(candidate) && IsUsablePython(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsUsablePython(string candidate)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var process = Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = candidate,
|
||||
ArgumentList = { "--version" },
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
});
|
||||
if (process is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!process.WaitForExit(milliseconds: 5000))
|
||||
{
|
||||
process.Kill(entireProcessTree: true);
|
||||
return false;
|
||||
}
|
||||
|
||||
return process.ExitCode == 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.LocalCodeAct\Microsoft.Agents.AI.LocalCodeAct.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.LocalCodeAct.UnitTests;
|
||||
|
||||
public sealed class ProcessExecutionLimitsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Defaults_AreReasonable()
|
||||
{
|
||||
var limits = new ProcessExecutionLimits();
|
||||
|
||||
Assert.True(limits.TimeoutSeconds > 0);
|
||||
Assert.True(limits.MaxStdoutBytes > 0);
|
||||
Assert.True(limits.MaxStderrBytes > 0);
|
||||
Assert.True(limits.ValidationTimeoutSeconds > 0);
|
||||
Assert.True(limits.MaxResultBytes > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Properties_AreMutable()
|
||||
{
|
||||
var limits = new ProcessExecutionLimits
|
||||
{
|
||||
TimeoutSeconds = 60,
|
||||
MaxStdoutBytes = 1024,
|
||||
MaxStderrBytes = 512,
|
||||
ValidationTimeoutSeconds = 5,
|
||||
MaxResultBytes = 2048,
|
||||
};
|
||||
|
||||
Assert.Equal(60, limits.TimeoutSeconds);
|
||||
Assert.Equal(1024, limits.MaxStdoutBytes);
|
||||
Assert.Equal(512, limits.MaxStderrBytes);
|
||||
Assert.Equal(5, limits.ValidationTimeoutSeconds);
|
||||
Assert.Equal(2048, limits.MaxResultBytes);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user