"""Unit tests for terminal tool."""
import asyncio
import pytest
from navi.tools.terminal import TerminalTool
from navi.tools._internal.terminal_manager import TerminalManager, _MAX_TERMINALS_PER_SESSION
class TestTerminalTool:
@pytest.fixture
def tool(self):
return TerminalTool()
async def test_run_echo(self, tool):
result = await tool.execute({"action": "run", "command": "echo hello"})
assert result.success
assert "hello" in result.output
async def test_run_pwd(self, tool):
result = await tool.execute({"action": "run", "command": "pwd"})
assert result.success
assert "/" in result.output
async def test_run_empty_command(self, tool):
result = await tool.execute({"action": "run", "command": " "})
assert not result.success
assert "empty" in result.output.lower()
async def test_run_invalid_command(self, tool):
result = await tool.execute({"action": "run", "command": "this_command_does_not_exist_12345"})
assert not result.success
async def test_missing_action(self, tool):
result = await tool.execute({"command": "echo hi"})
assert not result.success
assert "missing" in result.output.lower()
async def test_unknown_action(self, tool):
result = await tool.execute({"action": "fly", "command": "echo hi"})
assert not result.success
assert "unknown" in result.output.lower()
class TestTerminalToolPersistent:
@pytest.fixture
async def manager(self):
tm = TerminalManager(max_idle_seconds=1)
tm.start()
yield tm
# cleanup
await tm.shutdown()
@pytest.fixture
def tool(self, manager):
return TerminalTool(terminal_manager=manager)
async def test_open_foreground_closes(self, tool, manager):
"""Foreground open should auto-close after gathering output."""
from navi.tools._internal.base import ToolContext
ctx = ToolContext(session_id="s1", stop_event=None, model="test")
result = await tool.execute(
{
"action": "open",
"terminal_name": "t1",
"description": "test terminal",
"command": "echo hello",
},
ctx=ctx,
)
assert result.success
assert result.metadata["name"] == "t1"
assert "hello" in result.output
# Foreground terminal should be auto-closed
result = await tool.execute({"action": "list"}, ctx=ctx)
assert result.success
assert "No active terminals" in result.output
async def test_open_background_list_close(self, tool, manager):
"""Background open should persist until explicit close."""
from navi.tools._internal.base import ToolContext
ctx = ToolContext(session_id="s2", stop_event=None, model="test")
result = await tool.execute(
{
"action": "open",
"terminal_name": "bg1",
"description": "bg test",
"command": "sleep 10",
"background": True,
},
ctx=ctx,
)
assert result.success
assert "background" in result.output.lower()
assert result.metadata["name"] == "bg1"
# list should show it
result = await tool.execute({"action": "list"}, ctx=ctx)
assert result.success
assert "bg1" in result.output
assert "bg test" in result.output
# status should show busy
result = await tool.execute(
{"action": "status", "terminal_name": "bg1"},
ctx=ctx,
)
assert result.success
assert "busy" in result.output.lower()
# close it
result = await tool.execute(
{"action": "close", "terminal_name": "bg1"},
ctx=ctx,
)
assert result.success
result = await tool.execute({"action": "list"}, ctx=ctx)
assert result.success
assert "No active terminals" in result.output
async def test_send_input(self, tool, manager):
from navi.tools._internal.base import ToolContext
ctx = ToolContext(session_id="s3", stop_event=None, model="test")
result = await tool.execute(
{
"action": "open",
"terminal_name": "in1",
"description": "input test",
"command": "cat",
"background": True,
},
ctx=ctx,
)
assert result.success
result = await tool.execute(
{"action": "send_input", "terminal_name": "in1", "input": "hello\n"},
ctx=ctx,
)
assert result.success
# give cat a moment to echo back
await asyncio.sleep(0.2)
result = await tool.execute(
{"action": "status", "terminal_name": "in1"},
ctx=ctx,
)
assert result.success
assert "hello" in result.output
await tool.execute({"action": "close", "terminal_name": "in1"}, ctx=ctx)
async def test_no_manager(self):
tool = TerminalTool(terminal_manager=None)
from navi.tools._internal.base import ToolContext
ctx = ToolContext(session_id="s4", stop_event=None, model="test")
result = await tool.execute(
{"action": "open", "terminal_name": "x", "description": "d", "command": "echo hi"},
ctx=ctx,
)
assert not result.success
assert "manager" in result.output.lower()
async def test_open_missing_name(self, tool):
from navi.tools._internal.base import ToolContext
ctx = ToolContext(session_id="s5", stop_event=None, model="test")
result = await tool.execute(
{"action": "open", "description": "d", "command": "echo hi"},
ctx=ctx,
)
assert not result.success
assert "missing" in result.output.lower()
async def test_max_terminals(self, tool, manager):
"""Opening more than _MAX_TERMINALS_PER_SESSION should fail."""
from navi.tools._internal.base import ToolContext
ctx = ToolContext(session_id="s6", stop_event=None, model="test")
for i in range(_MAX_TERMINALS_PER_SESSION):
result = await tool.execute(
{
"action": "open",
"terminal_name": f"t{i}",
"description": f"term {i}",
"command": "sleep 30",
"background": True,
},
ctx=ctx,
)
assert result.success, f"Failed to open terminal {i}"
# Next one should fail
result = await tool.execute(
{
"action": "open",
"terminal_name": "overflow",
"description": "overflow",
"command": "sleep 30",
"background": True,
},
ctx=ctx,
)
assert not result.success
assert "max" in result.output.lower()
# Cleanup
for i in range(_MAX_TERMINALS_PER_SESSION):
await tool.execute({"action": "close", "terminal_name": f"t{i}"}, ctx=ctx)
class TestTerminalManagerEvents:
"""Lifecycle/stream events reach the registered callback (not a per-tool
sink), so background output keeps flowing after open returns."""
@pytest.fixture
async def manager(self):
tm = TerminalManager(max_idle_seconds=60)
tm.start()
yield tm
await tm.shutdown()
async def test_open_emits_terminal_opened(self, manager):
from navi.core.events import TerminalOpened
events: list = []
async def cb(session_id, event):
events.append((session_id, event))
manager.set_event_callback(cb)
await manager.open(
session_id="se1",
name="t1",
description="d",
command="echo hello",
background=False,
timeout=5,
)
opened = [e for _, e in events if isinstance(e, TerminalOpened)]
assert len(opened) == 1
assert opened[0].terminal_name == "t1"
assert opened[0].description == "d"
assert opened[0].background is False
assert opened[0].pid is not None
async def test_background_streams_output_after_open_returns(self, manager):
"""The fix for gap 3: background output is delivered via the callback
even after the open coroutine returned (no per-tool sink to close)."""
from navi.core.events import TerminalOpened, TerminalOutputDelta
events: list = []
async def cb(session_id, event):
events.append((session_id, event))
manager.set_event_callback(cb)
# A quick producer so the background terminal exits on its own.
await manager.open(
session_id="se2",
name="bg",
description="bg stream",
command="echo streamed-line",
background=True,
timeout=5,
)
# The open call has returned; give the readers time to flush output.
await asyncio.sleep(0.5)
await manager.close("se2", "bg")
deltas = [e for _, e in events if isinstance(e, TerminalOutputDelta)]
assert any("streamed-line" in d.delta for d in deltas)
assert any(isinstance(e, TerminalOpened) for _, e in events)
async def test_close_emits_terminal_closed(self, manager):
from navi.core.events import TerminalClosed
events: list = []
async def cb(session_id, event):
events.append((session_id, event))
manager.set_event_callback(cb)
await manager.open(
session_id="se3",
name="tc",
description="d",
command="sleep 30",
background=True,
timeout=5,
)
await manager.close("se3", "tc")
# close_all/shutdown also closes — wait a tick for the event to land.
await asyncio.sleep(0.1)
closed = [e for _, e in events if isinstance(e, TerminalClosed)]
assert any(c.terminal_name == "tc" for c in closed)
async def test_no_callback_does_not_break_readers(self, manager):
"""Without a registered callback, open/read/close still work (events
silently dropped) — the manager must not depend on a client."""
session = await manager.open(
session_id="se4",
name="nc",
description="d",
command="echo ok",
background=False,
timeout=5,
)
assert session.proc is not None
await manager.close("se4", "nc")