Co-authored-by: Kim-Adeline Miguel <51720070+kimadeline@users.noreply.github.com> Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai> Co-authored-by: Maxime Dolores <maxime.dolores@ext.mistral.ai> Co-authored-by: Nelson PROIA <144663685+Nelson-PROIA@users.noreply.github.com> Co-authored-by: Paul VEZIA <166131032+le-codeur-rapide@users.noreply.github.com> Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai> Co-authored-by: Quentin <quentin.torroba@mistral.ai> Co-authored-by: Val <102326092+vdeva@users.noreply.github.com> Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
247 lines
9.5 KiB
Python
247 lines
9.5 KiB
Python
from __future__ import annotations
|
||
|
||
import time
|
||
|
||
import pytest
|
||
from textual.widgets import Static
|
||
|
||
from tests.conftest import build_test_agent_loop, build_test_vibe_app
|
||
from tests.mock.utils import mock_llm_chunk
|
||
from tests.stubs.fake_backend import FakeBackend
|
||
from vibe.cli.textual_ui.app import VibeApp
|
||
from vibe.cli.textual_ui.widgets.chat_input.container import ChatInputContainer
|
||
from vibe.cli.textual_ui.widgets.messages import BashOutputMessage, ErrorMessage
|
||
from vibe.core.types import Role
|
||
|
||
|
||
async def _wait_for_bash_output_message(
|
||
vibe_app: VibeApp, pilot, timeout: float = 1.0
|
||
) -> BashOutputMessage:
|
||
deadline = time.monotonic() + timeout
|
||
while time.monotonic() < deadline:
|
||
if message := next(iter(vibe_app.query(BashOutputMessage)), None):
|
||
if not message._pending:
|
||
return message
|
||
await pilot.pause(0.05)
|
||
raise TimeoutError(f"BashOutputMessage did not appear within {timeout}s")
|
||
|
||
|
||
async def _wait_for_pending_bash_message(
|
||
vibe_app: VibeApp, pilot, timeout: float = 1.0
|
||
) -> BashOutputMessage:
|
||
deadline = time.monotonic() + timeout
|
||
while time.monotonic() < deadline:
|
||
if message := next(iter(vibe_app.query(BashOutputMessage)), None):
|
||
return message
|
||
await pilot.pause(0.05)
|
||
raise TimeoutError(f"BashOutputMessage did not appear within {timeout}s")
|
||
|
||
|
||
def assert_no_command_error(vibe_app: VibeApp) -> None:
|
||
errors = list(vibe_app.query(ErrorMessage))
|
||
if not errors:
|
||
return
|
||
|
||
disallowed = {
|
||
"Command failed",
|
||
"Command timed out",
|
||
"No command provided after '!'",
|
||
}
|
||
offending = [
|
||
getattr(err, "_error", "")
|
||
for err in errors
|
||
if getattr(err, "_error", "")
|
||
and any(phrase in getattr(err, "_error", "") for phrase in disallowed)
|
||
]
|
||
assert not offending, f"Unexpected command errors: {offending}"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_ui_reports_no_output(vibe_app: VibeApp) -> None:
|
||
async with vibe_app.run_test() as pilot:
|
||
chat_input = vibe_app.query_one(ChatInputContainer)
|
||
chat_input.value = "!true"
|
||
|
||
await pilot.press("enter")
|
||
message = await _wait_for_bash_output_message(vibe_app, pilot)
|
||
output_widget = message.query_one(".bash-output", Static)
|
||
assert str(output_widget.render()) == "(no output)"
|
||
assert_no_command_error(vibe_app)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_ui_shows_success_in_case_of_zero_code(vibe_app: VibeApp) -> None:
|
||
async with vibe_app.run_test() as pilot:
|
||
chat_input = vibe_app.query_one(ChatInputContainer)
|
||
chat_input.value = "!true"
|
||
|
||
await pilot.press("enter")
|
||
message = await _wait_for_bash_output_message(vibe_app, pilot)
|
||
assert message.has_class("bash-success")
|
||
assert not message.has_class("bash-error")
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_ui_shows_failure_in_case_of_non_zero_code(vibe_app: VibeApp) -> None:
|
||
async with vibe_app.run_test() as pilot:
|
||
chat_input = vibe_app.query_one(ChatInputContainer)
|
||
chat_input.value = "!bash -c 'exit 7'"
|
||
|
||
await pilot.press("enter")
|
||
message = await _wait_for_bash_output_message(vibe_app, pilot)
|
||
assert message.has_class("bash-error")
|
||
assert not message.has_class("bash-success")
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_ui_handles_non_utf8_output(vibe_app: VibeApp) -> None:
|
||
"""Assert the UI accepts decoding a non-UTF8 sequence like `printf '\xf0\x9f\x98'`.
|
||
Whereas `printf '\xf0\x9f\x98\x8b'` prints a smiley face (😋) and would work even without those changes.
|
||
"""
|
||
async with vibe_app.run_test() as pilot:
|
||
chat_input = vibe_app.query_one(ChatInputContainer)
|
||
chat_input.value = "!printf '\\xff\\xfe'"
|
||
|
||
await pilot.press("enter")
|
||
message = await _wait_for_bash_output_message(vibe_app, pilot)
|
||
output_widget = message.query_one(".bash-output", Static)
|
||
# accept both possible encodings, as some shells emit escaped bytes as literal strings
|
||
assert str(output_widget.render()) in {"<EFBFBD><EFBFBD>", "\xff\xfe", r"\xff\xfe"}
|
||
assert_no_command_error(vibe_app)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_ui_handles_utf8_output(vibe_app: VibeApp) -> None:
|
||
async with vibe_app.run_test() as pilot:
|
||
chat_input = vibe_app.query_one(ChatInputContainer)
|
||
chat_input.value = "!echo hello"
|
||
|
||
await pilot.press("enter")
|
||
message = await _wait_for_bash_output_message(vibe_app, pilot)
|
||
output_widget = message.query_one(".bash-output", Static)
|
||
assert str(output_widget.render()) == "hello"
|
||
assert_no_command_error(vibe_app)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_ui_handles_non_utf8_stderr(vibe_app: VibeApp) -> None:
|
||
async with vibe_app.run_test() as pilot:
|
||
chat_input = vibe_app.query_one(ChatInputContainer)
|
||
chat_input.value = "!bash -c \"printf '\\\\xff\\\\xfe' 1>&2\""
|
||
|
||
await pilot.press("enter")
|
||
message = await _wait_for_bash_output_message(vibe_app, pilot)
|
||
output_widget = message.query_one(".bash-output", Static)
|
||
assert str(output_widget.render()) == "<EFBFBD><EFBFBD>"
|
||
assert_no_command_error(vibe_app)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_ui_sends_manual_command_output_to_next_agent_turn() -> None:
|
||
backend = FakeBackend(mock_llm_chunk(content="I saw it."))
|
||
vibe_app = build_test_vibe_app(agent_loop=build_test_agent_loop(backend=backend))
|
||
|
||
async with vibe_app.run_test() as pilot:
|
||
chat_input = vibe_app.query_one(ChatInputContainer)
|
||
chat_input.value = "!echo hello"
|
||
|
||
await pilot.press("enter")
|
||
await _wait_for_bash_output_message(vibe_app, pilot)
|
||
|
||
injected_message = vibe_app.agent_loop.messages[-1]
|
||
assert injected_message.role == Role.user
|
||
assert injected_message.injected is True
|
||
assert injected_message.content is not None
|
||
assert "Manual `!` command result from the user." in injected_message.content
|
||
assert "Command: `echo hello`" in injected_message.content
|
||
assert "Exit code: 0" in injected_message.content
|
||
assert "Stdout:\n```text\nhello\n```" in injected_message.content
|
||
|
||
chat_input.value = "what did the command print?"
|
||
await pilot.press("enter")
|
||
await pilot.app.workers.wait_for_complete()
|
||
|
||
assert len(backend.requests_messages) == 1
|
||
user_messages = [
|
||
msg for msg in backend.requests_messages[0] if msg.role == Role.user
|
||
]
|
||
assert len(user_messages) >= 2
|
||
assert user_messages[-2].content == injected_message.content
|
||
assert user_messages[-2].injected is True
|
||
assert user_messages[-1].content == "what did the command print?"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_ui_shows_command_immediately_in_pending_state(vibe_app: VibeApp) -> None:
|
||
"""The command line should appear before the process finishes."""
|
||
async with vibe_app.run_test() as pilot:
|
||
chat_input = vibe_app.query_one(ChatInputContainer)
|
||
chat_input.value = "!sleep 10"
|
||
|
||
await pilot.press("enter")
|
||
message = await _wait_for_pending_bash_message(vibe_app, pilot)
|
||
assert message._pending is True
|
||
# command line is rendered
|
||
cmd_widget = message.query_one(".bash-command", Static)
|
||
assert str(cmd_widget.render()) == "sleep 10"
|
||
# no output container yet
|
||
assert not list(message.query(".bash-output"))
|
||
|
||
# clean up: cancel the background task
|
||
if vibe_app._bash_task and not vibe_app._bash_task.done():
|
||
vibe_app._bash_task.cancel()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_ui_streams_output_incrementally(vibe_app: VibeApp) -> None:
|
||
"""Output should appear as the command produces it, not all at once."""
|
||
async with vibe_app.run_test() as pilot:
|
||
chat_input = vibe_app.query_one(ChatInputContainer)
|
||
# print lines with a small delay so streaming has a chance to show partial output
|
||
chat_input.value = "!bash -c 'echo first; echo second'"
|
||
|
||
await pilot.press("enter")
|
||
message = await _wait_for_bash_output_message(vibe_app, pilot)
|
||
output_widget = message.query_one(".bash-output", Static)
|
||
rendered = str(output_widget.render())
|
||
assert "first" in rendered
|
||
assert "second" in rendered
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_ui_queues_bash_submitted_while_command_running(
|
||
vibe_app: VibeApp,
|
||
) -> None:
|
||
"""Submitting new bash while a bang command is running should queue, not cancel."""
|
||
async with vibe_app.run_test() as pilot:
|
||
chat_input = vibe_app.query_one(ChatInputContainer)
|
||
chat_input.value = "!sleep 0.5"
|
||
|
||
await pilot.press("enter")
|
||
await _wait_for_pending_bash_message(vibe_app, pilot)
|
||
assert vibe_app._bash_task is not None
|
||
assert not vibe_app._bash_task.done()
|
||
|
||
chat_input.value = "!echo done"
|
||
await pilot.press("enter")
|
||
|
||
# The second command should be queued, not cancelled
|
||
assert len(vibe_app._input_queue) == 1
|
||
|
||
# Wait for both to complete (first runs, drain runs second)
|
||
deadline = time.monotonic() + 5.0
|
||
while time.monotonic() < deadline:
|
||
all_msgs = list(vibe_app.query(BashOutputMessage))
|
||
if (
|
||
len(all_msgs) == 2
|
||
and not all_msgs[0]._pending
|
||
and not all_msgs[1]._pending
|
||
):
|
||
break
|
||
await pilot.pause(0.05)
|
||
|
||
all_msgs = list(vibe_app.query(BashOutputMessage))
|
||
assert len(all_msgs) == 2
|
||
second = all_msgs[1]
|
||
output_widget = second.query_one(".bash-output", Static)
|
||
assert str(output_widget.render()) == "done"
|