From 8cf7359c7cb3df94b42153b458124a9343a49e73 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:24:12 +0100 Subject: [PATCH 1/2] Dotnet: reset $LASTEXITCODE per command in persistent PowerShell sessions --- .../ShellSession.cs | 9 ++++-- .../LocalShellExecutorTests.cs | 29 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Tools.Shell/ShellSession.cs b/dotnet/src/Microsoft.Agents.AI.Tools.Shell/ShellSession.cs index 9e85c645436..21ddfa683d5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Tools.Shell/ShellSession.cs +++ b/dotnet/src/Microsoft.Agents.AI.Tools.Shell/ShellSession.cs @@ -573,6 +573,9 @@ private string BuildScript(string command, string sentinel) return "& {" + " $__af_rc = 0;" + + // $LASTEXITCODE persists across commands and cmdlets do not update it. + // Snapshot it so only a value changed by this command is reported. + " $__af_last = $LASTEXITCODE;" + " try {" + $" $__af_cmd = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{encoded}'));" + // Force the user command's success output through the same @@ -588,9 +591,11 @@ private string BuildScript(string command, string sentinel) " [Console]::WriteLine(($_ | Out-String).TrimEnd());" + " }" + " };" + + // Capture the pipeline status before flushing overwrites it. + " $__af_ok = $?;" + " [Console]::Out.Flush();" + - " if ($LASTEXITCODE -ne $null) { $__af_rc = $LASTEXITCODE }" + - " elseif (-not $?) { $__af_rc = 1 }" + + " if ($LASTEXITCODE -ne $__af_last) { $__af_rc = $LASTEXITCODE }" + + " elseif (-not $__af_ok) { $__af_rc = 1 }" + " } catch {" + " [Console]::Error.WriteLine($_.ToString());" + " $__af_rc = 1" + diff --git a/dotnet/tests/Microsoft.Agents.AI.Tools.Shell.UnitTests/LocalShellExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Tools.Shell.UnitTests/LocalShellExecutorTests.cs index 247033fa1d6..d806ccfc2b4 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Tools.Shell.UnitTests/LocalShellExecutorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Tools.Shell.UnitTests/LocalShellExecutorTests.cs @@ -313,6 +313,35 @@ public async Task Persistent_CarriesEnvironment_AcrossCallsAsync() Assert.Contains("persisted-value", read.Stdout, StringComparison.Ordinal); } + [Fact] + public async Task Persistent_PowerShell_DoesNotInheritPreviousExitCodeAsync() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return; + } + + // Arrange + await using var shell = new LocalShellExecutor(new() + { + Mode = ShellMode.Persistent, + Shell = "powershell.exe", + Timeout = TimeSpan.FromSeconds(20), + }); + + // Act + var failing = await shell.RunAsync("cmd /c exit 3"); + var succeeding = await shell.RunAsync("Write-Output ok"); + var readback = await shell.RunAsync("Write-Output $LASTEXITCODE"); + + // Assert + Assert.Equal(3, failing.ExitCode); + Assert.Equal(0, succeeding.ExitCode); + Assert.Contains("ok", succeeding.Stdout, StringComparison.Ordinal); + Assert.Equal(0, readback.ExitCode); + Assert.Equal("3", readback.Stdout.Trim()); + } + [Fact] public async Task Persistent_Timeout_ReturnsExitCode124Async() { From 6716ee3959719c93e0eb2fa8545f940f94a7522b Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:30:59 +0100 Subject: [PATCH 2/2] .NET: reset LASTEXITCODE before each PowerShell command Clear the persistent automatic variable before invoking each command so repeated native failures with the same exit code are reported correctly. Update the Windows regression test to cover repeated failures and reset readback semantics. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/Microsoft.Agents.AI.Tools.Shell/ShellSession.cs | 6 +++--- .../LocalShellExecutorTests.cs | 8 +++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Tools.Shell/ShellSession.cs b/dotnet/src/Microsoft.Agents.AI.Tools.Shell/ShellSession.cs index 21ddfa683d5..4a62bf400b4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Tools.Shell/ShellSession.cs +++ b/dotnet/src/Microsoft.Agents.AI.Tools.Shell/ShellSession.cs @@ -574,8 +574,8 @@ private string BuildScript(string command, string sentinel) "& {" + " $__af_rc = 0;" + // $LASTEXITCODE persists across commands and cmdlets do not update it. - // Snapshot it so only a value changed by this command is reported. - " $__af_last = $LASTEXITCODE;" + + // Clear it so any value after invocation belongs to this command. + " $global:LASTEXITCODE = $null;" + " try {" + $" $__af_cmd = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{encoded}'));" + // Force the user command's success output through the same @@ -594,7 +594,7 @@ private string BuildScript(string command, string sentinel) // Capture the pipeline status before flushing overwrites it. " $__af_ok = $?;" + " [Console]::Out.Flush();" + - " if ($LASTEXITCODE -ne $__af_last) { $__af_rc = $LASTEXITCODE }" + + " if ($LASTEXITCODE -ne $null) { $__af_rc = $LASTEXITCODE }" + " elseif (-not $__af_ok) { $__af_rc = 1 }" + " } catch {" + " [Console]::Error.WriteLine($_.ToString());" + diff --git a/dotnet/tests/Microsoft.Agents.AI.Tools.Shell.UnitTests/LocalShellExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Tools.Shell.UnitTests/LocalShellExecutorTests.cs index d806ccfc2b4..ae6400c295d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Tools.Shell.UnitTests/LocalShellExecutorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Tools.Shell.UnitTests/LocalShellExecutorTests.cs @@ -330,16 +330,18 @@ public async Task Persistent_PowerShell_DoesNotInheritPreviousExitCodeAsync() }); // Act - var failing = await shell.RunAsync("cmd /c exit 3"); + var firstFailure = await shell.RunAsync("cmd /c exit 3"); var succeeding = await shell.RunAsync("Write-Output ok"); + var repeatedFailure = await shell.RunAsync("cmd /c exit 3"); var readback = await shell.RunAsync("Write-Output $LASTEXITCODE"); // Assert - Assert.Equal(3, failing.ExitCode); + Assert.Equal(3, firstFailure.ExitCode); Assert.Equal(0, succeeding.ExitCode); Assert.Contains("ok", succeeding.Stdout, StringComparison.Ordinal); + Assert.Equal(3, repeatedFailure.ExitCode); Assert.Equal(0, readback.ExitCode); - Assert.Equal("3", readback.Stdout.Trim()); + Assert.Empty(readback.Stdout.Trim()); } [Fact]