Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 = $?;"
Expand Down
29 changes: 29 additions & 0 deletions python/packages/tools/tests/test_local_shell_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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."""
Expand Down
Loading