From a4644e3bc3ae573ade46305663ab9f9ef277aea1 Mon Sep 17 00:00:00 2001 From: Aditya Kumar Date: Mon, 5 Oct 2026 04:41:42 +0530 Subject: [PATCH] Python: Fix persistent PowerShell sessions dropping table-formatted output ShellSession._build_script left formatting of the command's output to the host, which only flushes table/list formatting after the wrapping script block returns. By then the finally block has already written the sentinel, so output such as Get-Location, Select-Object or custom objects landed after it and was discarded. Pipe the command through Out-Default inside the try so it is written before the sentinel. The regression test is gated on PowerShell being installed rather than on Windows, so it also runs with pwsh on Linux. --- .../agent_framework_tools/shell/_session.py | 7 ++++- .../tools/tests/test_local_shell_tool.py | 29 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/python/packages/tools/agent_framework_tools/shell/_session.py b/python/packages/tools/agent_framework_tools/shell/_session.py index 7af4771d4b6..bf6f1d4ae1d 100644 --- a/python/packages/tools/agent_framework_tools/shell/_session.py +++ b/python/packages/tools/agent_framework_tools/shell/_session.py @@ -360,7 +360,12 @@ def _build_script(self, command: str, sentinel: str) -> str: " $__af_last = $LASTEXITCODE;" " try {" f" $__af_cmd = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{encoded}'));" - " Invoke-Expression $__af_cmd;" + # Format the output inside the try. Left to the host, pwsh + # formats a script block's output only after the block has + # returned, so the sentinel written in finally would overtake + # anything rendered as a table or list (Get-Location, + # Select-Object, custom objects) and that output would be lost. + " Invoke-Expression $__af_cmd | Out-Default;" # $? has to be read on the very next statement: anything else # in between overwrites it. " $__af_ok = $?;" diff --git a/python/packages/tools/tests/test_local_shell_tool.py b/python/packages/tools/tests/test_local_shell_tool.py index f0e0f56362b..0f2dd7f3483 100644 --- a/python/packages/tools/tests/test_local_shell_tool.py +++ b/python/packages/tools/tests/test_local_shell_tool.py @@ -3,6 +3,7 @@ import asyncio import json import os +import shutil import sys from collections.abc import Awaitable, Mapping, Sequence from typing import Any @@ -24,6 +25,7 @@ _TEST_SHELL = "agent-framework-test-shell" _APPROVED_COMMAND = "printf '%s' approved-value" _ALTERNATE_COMMAND = "printf '%s' alternate-value" +_POWERSHELL = shutil.which("pwsh") or (shutil.which("powershell") if sys.platform == "win32" else None) class _FakeExecProcess: @@ -356,6 +358,33 @@ async def test_persistent_powershell_utf8_roundtrip() -> None: assert "café" in result.stdout +@pytest.mark.skipif(_POWERSHELL is None, reason="PowerShell is not installed") +async def test_persistent_powershell_returns_formatted_object_output(tmp_path: os.PathLike[str]) -> None: + """Output that pwsh renders as a table must arrive before the sentinel. + + The host formats a script block's output only after the block returns, + which is after the sentinel has been written, so this output used to be + dropped, along with any plain string written after the first object. + Runs wherever PowerShell is installed, not just on Windows. + """ + assert _POWERSHELL is not None + async with LocalShellTool( + mode="persistent", + shell=[_POWERSHELL, "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", "-"], + approval_mode="never_require", + acknowledge_unsafe=True, + workdir=str(tmp_path), + ) as tool: + result = await tool.run("[pscustomobject]@{ Marker = 'af-object' }; Write-Output 'af-trailing'") + assert result.exit_code == 0 + assert "af-object" in result.stdout + assert "af-trailing" in result.stdout + + selected = await tool.run(f"Get-Item -LiteralPath '{tmp_path}' | Select-Object Name") + assert selected.exit_code == 0 + assert os.path.basename(str(tmp_path)) in selected.stdout + + async def test_concurrent_first_calls_do_not_spawn_two_sessions() -> None: """Regression: startup must be serialised so two concurrent first callers don't each spawn their own subprocess."""