Co-authored-by: Antoine <33425718+anth2o@users.noreply.github.com>
Co-authored-by: Bastien <bastien.baret@gmail.com>
Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai>
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: Michel Thomazo <51709227+michelTho@users.noreply.github.com>
Co-authored-by: Nelson PROIA <144663685+Nelson-PROIA@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: Robin Gullo <robin.gullo@mistral.ai>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Clément Drouin 2026-04-28 17:44:07 +02:00 committed by GitHub
parent a83c81ecf5
commit 632ea8c032
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
253 changed files with 13965 additions and 2525 deletions

View file

@ -49,15 +49,23 @@ def strip_ansi(text: str) -> str:
return re.sub(r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07]*\x07", "", text)
def poll_until(predicate: Callable[[], bool], timeout: float, message: str) -> None:
start = time.monotonic()
while time.monotonic() - start < timeout:
if predicate():
return
time.sleep(0.05)
raise AssertionError(message)
def wait_for_request_count(
request_count_getter: Callable[[], int], expected_count: int, timeout: float
) -> None:
start = time.monotonic()
while time.monotonic() - start < timeout:
if request_count_getter() >= expected_count:
return
time.sleep(0.05)
raise AssertionError(f"Timed out waiting for {expected_count} backend request(s).")
poll_until(
lambda: request_count_getter() >= expected_count,
timeout,
f"Timed out waiting for {expected_count} backend request(s).",
)
def wait_for_main_screen(child: pexpect.spawn, timeout: float = 20.0) -> None:
@ -84,3 +92,24 @@ def wait_for_rendered_text(
raise AssertionError(
f"Timed out waiting for rendered text: {needle!r}\n\nRendered tail:\n{rendered_tail}"
)
def send_ctrl_c_until_quit_confirmation(
child: pexpect.spawn, captured: io.StringIO, timeout: float = 3
) -> None:
"""Send Ctrl+C and wait for quit confirmation prompt. Retries if first Ctrl+C interrupts."""
start = time.monotonic()
while time.monotonic() - start < timeout:
child.sendcontrol("c")
try:
child.expect(ansi_tolerant_pattern("Press Ctrl+C again to quit"), timeout=2)
# Confirmation prompt appeared, send second Ctrl+C
child.sendcontrol("c")
return
except pexpect.TIMEOUT:
# First Ctrl+C may have interrupted something, try again
continue
rendered_tail = strip_ansi(captured.getvalue())[-1200:]
raise AssertionError(
f"Timed out waiting for quit confirmation prompt.\n\nRendered tail:\n{rendered_tail}"
)

View file

@ -0,0 +1,87 @@
from __future__ import annotations
import json
import os
from pathlib import Path
import tomllib
import pexpect
import pytest
import tomli_w
from tests.e2e.common import (
SpawnedVibeProcessFixture,
poll_until,
send_ctrl_c_until_quit_confirmation,
wait_for_main_screen,
wait_for_rendered_text,
wait_for_request_count,
)
from tests.e2e.mock_server import StreamingMockServer
def _enable_hooks(vibe_home: Path, invocation_path: Path) -> None:
config_path = vibe_home / "config.toml"
config = tomllib.loads(config_path.read_text(encoding="utf-8"))
config["enable_experimental_hooks"] = True
config_path.write_bytes(tomli_w.dumps(config).encode())
script = vibe_home / "_record_hook.py"
script.write_text(
"import json, sys\n"
"from pathlib import Path\n"
f"Path({str(invocation_path)!r}).write_text("
"json.dumps(json.load(sys.stdin)), encoding='utf-8')\n",
encoding="utf-8",
)
with (vibe_home / "hooks.toml").open("wb") as f:
tomli_w.dump(
{
"hooks": [
{
"name": "record-invocation",
"type": "post_agent_turn",
"command": f"uv run python {script}",
}
]
},
f,
)
@pytest.mark.timeout(20)
def test_spawn_cli_runs_configured_hook_after_turn(
streaming_mock_server: StreamingMockServer,
setup_e2e_env: None,
e2e_workdir: Path,
spawned_vibe_process: SpawnedVibeProcessFixture,
) -> None:
vibe_home = Path(os.environ["VIBE_HOME"])
invocation_path = vibe_home / "hook-invocation.json"
_enable_hooks(vibe_home, invocation_path)
with spawned_vibe_process(e2e_workdir) as (child, captured):
wait_for_main_screen(child, timeout=15)
child.send("Run the configured hook")
child.send("\r")
wait_for_request_count(
lambda: len(streaming_mock_server.requests), expected_count=1, timeout=10
)
wait_for_rendered_text(
child, captured, needle="Hello from mock server", timeout=10
)
poll_until(
invocation_path.is_file,
timeout=10,
message=f"Timed out waiting for hook output file: {invocation_path}",
)
send_ctrl_c_until_quit_confirmation(child, captured, timeout=5)
child.expect(pexpect.EOF, timeout=10)
assert len(streaming_mock_server.requests) == 1
invocation = json.loads(invocation_path.read_text(encoding="utf-8"))
assert invocation["hook_event_name"] == "post_agent_turn"
assert isinstance(invocation["cwd"], str) and invocation["cwd"]
assert isinstance(invocation["session_id"], str) and invocation["session_id"]

View file

@ -12,6 +12,7 @@ import pytest
from tests.e2e.common import (
SpawnedVibeProcessFixture,
ansi_tolerant_pattern,
send_ctrl_c_until_quit_confirmation,
strip_ansi,
wait_for_main_screen,
wait_for_request_count,
@ -103,7 +104,7 @@ def test_resumed_session_prints_only_fresh_token_usage_on_exit(
request_count_getter=lambda: len(streaming_mock_server.requests),
)
child.sendcontrol("c")
send_ctrl_c_until_quit_confirmation(child, captured, timeout=5)
child.expect(pexpect.EOF, timeout=10)
first_output = strip_ansi(captured.getvalue())
@ -130,7 +131,7 @@ def test_resumed_session_prints_only_fresh_token_usage_on_exit(
request_count_getter=lambda: len(streaming_mock_server.requests),
)
resumed_child.sendcontrol("c")
send_ctrl_c_until_quit_confirmation(resumed_child, resumed_captured, timeout=5)
resumed_child.expect(pexpect.EOF, timeout=10)
second_output = strip_ansi(resumed_captured.getvalue())

View file

@ -8,6 +8,7 @@ import pytest
from tests.e2e.common import (
SpawnedVibeProcessFixture,
ansi_tolerant_pattern,
send_ctrl_c_until_quit_confirmation,
wait_for_main_screen,
wait_for_request_count,
)
@ -31,7 +32,7 @@ def test_spawn_cli_to_send_and_receive_message(
)
child.expect(ansi_tolerant_pattern("Hello from mock server"), timeout=10)
child.sendcontrol("c")
send_ctrl_c_until_quit_confirmation(child, captured, timeout=5)
child.expect(pexpect.EOF, timeout=10)
output = captured.getvalue()

View file

@ -7,6 +7,7 @@ import pytest
from tests.e2e.common import (
SpawnedVibeProcessFixture,
send_ctrl_c_until_quit_confirmation,
wait_for_main_screen,
wait_for_rendered_text,
wait_for_request_count,
@ -83,5 +84,5 @@ def test_spawn_cli_asks_bash_permission_and_shows_tool_output_after_approval(
child.send("\r")
wait_for_rendered_text(child, captured, needle=PREDICTABLE_OUTPUT, timeout=10)
child.sendcontrol("c")
send_ctrl_c_until_quit_confirmation(child, captured, timeout=5)
child.expect(pexpect.EOF, timeout=10)