Newer
Older
navi-1 / tests / unit / tools / test_terminal.py
"""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")