From 2c2bfbb4c7a60bbe80af11737eed91c1475b6ceb Mon Sep 17 00:00:00 2001 From: Aditya Kumar Date: Fri, 23 Oct 2026 23:38:28 +0530 Subject: [PATCH] Python: Report repeated native exit codes in persistent PowerShell sessions A persistent PowerShell session reported exit code 0 when a native command failed with the same code as the previous one. The exit-code check compared $LASTEXITCODE by value, which does not change when the code repeats, and $? reflects Invoke-Expression rather than the native command. Compare by reference instead: PowerShell stores a newly boxed int on every native exit, so the variable holds a different object exactly when the command ran a native process. $LASTEXITCODE is still never written. --- .../agent_framework_tools/shell/_session.py | 9 ++++-- .../tools/tests/test_local_shell_tool.py | 30 +++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/python/packages/tools/agent_framework_tools/shell/_session.py b/python/packages/tools/agent_framework_tools/shell/_session.py index f83f1c8e98e..8346aa4b171 100644 --- a/python/packages/tools/agent_framework_tools/shell/_session.py +++ b/python/packages/tools/agent_framework_tools/shell/_session.py @@ -379,7 +379,7 @@ def _build_script(self, command: str, sentinel: str) -> str: # earlier command is still there when a cmdlet-only command # runs. Snapshot it instead of clearing it: the variable stays # readable by the user's own commands, which persistent mode is - # meant to preserve, and a value that differs afterwards can + # meant to preserve, and a different object afterwards can # only have been written by this command. " $__af_last = $LASTEXITCODE;" " try {" @@ -393,7 +393,12 @@ def _build_script(self, command: str, sentinel: str) -> str: # $? has to be read on the very next statement: anything else # in between overwrites it. " $__af_ok = $?;" - " if ($LASTEXITCODE -ne $__af_last) { $__af_rc = $LASTEXITCODE }" + # Compare by reference, not by value: every native exit stores + # a newly boxed int, so a command that fails with the same code + # as the previous one still counts as a change. A value + # comparison would miss it, and $? cannot catch it either, + # because it reflects Invoke-Expression, which succeeded. + " if (-not [object]::ReferenceEquals($LASTEXITCODE, $__af_last)) { $__af_rc = $LASTEXITCODE }" " elseif (-not $__af_ok) { $__af_rc = 1 }" " } catch {" " [Console]::Error.WriteLine($_.ToString());" diff --git a/python/packages/tools/tests/test_local_shell_tool.py b/python/packages/tools/tests/test_local_shell_tool.py index 24ecbaaeb3b..5c591069f88 100644 --- a/python/packages/tools/tests/test_local_shell_tool.py +++ b/python/packages/tools/tests/test_local_shell_tool.py @@ -355,6 +355,36 @@ async def test_persistent_powershell_does_not_inherit_previous_exit_code() -> No ) +@pytest.mark.skipif(_POWERSHELL is None, reason="PowerShell is not installed") +async def test_persistent_powershell_reports_repeated_native_exit_code(tmp_path: os.PathLike[str]) -> None: + """A native command failing with the same code as the previous one must still report it. + + ``$LASTEXITCODE`` keeps the same value in that case, and ``$?`` reflects + ``Invoke-Expression`` rather than the native command, so the second + failure used to be reported as ``exit_code=0``. + Runs wherever PowerShell is installed, not just on Windows. + """ + assert _POWERSHELL is not None + failing = f"& '{sys.executable}' -c 'import sys; sys.exit(3)'" + async with LocalShellTool( + mode="persistent", + shell=[_POWERSHELL, "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", "-"], + approval_mode="never_require", + acknowledge_unsafe=True, + workdir=str(tmp_path), + ) as tool: + for attempt in range(1, 4): + result = await tool.run(failing) + assert result.exit_code == 3, f"attempt {attempt} reported rc {result.exit_code}" + + succeeding = await tool.run("Write-Output ok") + assert succeeding.exit_code == 0 + + readback = await tool.run("Write-Output $LASTEXITCODE") + assert readback.exit_code == 0 + assert readback.stdout.strip() == "3" + + @pytest.mark.skipif(sys.platform != "win32", reason="PowerShell-specific encoding") async def test_persistent_powershell_utf8_roundtrip() -> None: """Non-ASCII output should round-trip without mojibake."""