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>
190 lines
5.6 KiB
Python
190 lines
5.6 KiB
Python
from __future__ import annotations
|
|
|
|
from collections.abc import Callable
|
|
import io
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import time
|
|
|
|
import pexpect
|
|
import pytest
|
|
|
|
from tests.e2e.common import (
|
|
SpawnedVibeProcessFixture,
|
|
ansi_tolerant_pattern,
|
|
poll_until,
|
|
send_ctrl_c_until_quit_confirmation,
|
|
strip_ansi,
|
|
wait_for_main_screen,
|
|
wait_for_request_count,
|
|
)
|
|
from tests.e2e.mock_server import StreamingMockServer
|
|
from vibe.core.utils.io import read_safe
|
|
|
|
|
|
def _usage_by_run_factory(
|
|
request_index: int, _payload: object
|
|
) -> list[dict[str, object]]:
|
|
return [
|
|
StreamingMockServer.build_chunk(
|
|
created=123,
|
|
delta={"role": "assistant", "content": f"Reply {request_index + 1}"},
|
|
finish_reason=None,
|
|
),
|
|
StreamingMockServer.build_chunk(
|
|
created=124,
|
|
delta={},
|
|
finish_reason="stop",
|
|
usage=(
|
|
{"prompt_tokens": 11, "completion_tokens": 7}
|
|
if request_index == 0
|
|
else {"prompt_tokens": 2, "completion_tokens": 1}
|
|
),
|
|
),
|
|
]
|
|
|
|
|
|
def _saved_session_has_usage(
|
|
vibe_home: Path, expected_prompt_tokens: int, expected_completion_tokens: int
|
|
) -> bool:
|
|
session_log_dir = vibe_home / "logs" / "session"
|
|
if not session_log_dir.exists():
|
|
return False
|
|
|
|
for metadata_path in session_log_dir.glob("session_*/meta.json"):
|
|
try:
|
|
metadata = json.loads(read_safe(metadata_path).text)
|
|
except (OSError, json.JSONDecodeError):
|
|
continue
|
|
|
|
stats = metadata.get("stats", {})
|
|
if not isinstance(stats, dict):
|
|
continue
|
|
if (
|
|
stats.get("session_prompt_tokens") == expected_prompt_tokens
|
|
and stats.get("session_completion_tokens") == expected_completion_tokens
|
|
):
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def _wait_for_saved_session_usage(
|
|
expected_prompt_tokens: int, expected_completion_tokens: int
|
|
) -> None:
|
|
vibe_home = Path(os.environ["VIBE_HOME"])
|
|
poll_until(
|
|
lambda: _saved_session_has_usage(
|
|
vibe_home, expected_prompt_tokens, expected_completion_tokens
|
|
),
|
|
timeout=10,
|
|
message=(
|
|
"Timed out waiting for saved session usage "
|
|
f"input={expected_prompt_tokens} output={expected_completion_tokens}."
|
|
),
|
|
)
|
|
|
|
|
|
def _finish_turn(
|
|
child: pexpect.spawn,
|
|
captured: io.StringIO,
|
|
expected_reply: str,
|
|
expected_request_count: int,
|
|
request_count_getter: Callable[[], int],
|
|
) -> None:
|
|
wait_for_request_count(
|
|
request_count_getter, expected_count=expected_request_count, timeout=10
|
|
)
|
|
child.expect(ansi_tolerant_pattern(expected_reply), timeout=10)
|
|
|
|
start = time.monotonic()
|
|
last_change = start
|
|
last_size = len(captured.getvalue())
|
|
|
|
while time.monotonic() - start < 5:
|
|
try:
|
|
child.expect(r"\S", timeout=0.05)
|
|
except pexpect.TIMEOUT:
|
|
pass
|
|
|
|
current_size = len(captured.getvalue())
|
|
if current_size != last_size:
|
|
last_size = current_size
|
|
last_change = time.monotonic()
|
|
continue
|
|
|
|
if time.monotonic() - last_change >= 0.3:
|
|
return
|
|
|
|
rendered_tail = strip_ansi(captured.getvalue())[-1200:]
|
|
raise AssertionError(
|
|
f"Timed out waiting for the turn to finish.\n\nRendered tail:\n{rendered_tail}"
|
|
)
|
|
|
|
|
|
@pytest.mark.timeout(30)
|
|
@pytest.mark.parametrize(
|
|
"streaming_mock_server",
|
|
[pytest.param(_usage_by_run_factory, id="fresh-usage-after-resume")],
|
|
indirect=True,
|
|
)
|
|
def test_resumed_session_prints_only_fresh_token_usage_on_exit(
|
|
streaming_mock_server: StreamingMockServer,
|
|
setup_e2e_env: None,
|
|
e2e_workdir: Path,
|
|
spawned_vibe_process: SpawnedVibeProcessFixture,
|
|
) -> None:
|
|
with spawned_vibe_process(e2e_workdir) as (child, captured):
|
|
wait_for_main_screen(child, timeout=15)
|
|
child.send("First run")
|
|
child.send("\r")
|
|
|
|
_finish_turn(
|
|
child,
|
|
captured,
|
|
expected_reply="Reply 1",
|
|
expected_request_count=1,
|
|
request_count_getter=lambda: len(streaming_mock_server.requests),
|
|
)
|
|
_wait_for_saved_session_usage(
|
|
expected_prompt_tokens=11, expected_completion_tokens=7
|
|
)
|
|
|
|
send_ctrl_c_until_quit_confirmation(child, captured, timeout=5)
|
|
child.expect(pexpect.EOF, timeout=10)
|
|
|
|
first_output = strip_ansi(captured.getvalue())
|
|
resume_match = re.search(r"Or: vibe --resume ([0-9a-f-]+)", first_output)
|
|
assert resume_match is not None
|
|
session_id = resume_match.group(1)
|
|
assert (
|
|
"Total tokens used this session: input=11 output=7 (total=18)" in first_output
|
|
)
|
|
|
|
with spawned_vibe_process(e2e_workdir, extra_args=["--resume", session_id]) as (
|
|
resumed_child,
|
|
resumed_captured,
|
|
):
|
|
wait_for_main_screen(resumed_child, timeout=15)
|
|
resumed_child.send("Second run")
|
|
resumed_child.send("\r")
|
|
|
|
_finish_turn(
|
|
resumed_child,
|
|
resumed_captured,
|
|
expected_reply="Reply 2",
|
|
expected_request_count=2,
|
|
request_count_getter=lambda: len(streaming_mock_server.requests),
|
|
)
|
|
_wait_for_saved_session_usage(
|
|
expected_prompt_tokens=2, expected_completion_tokens=1
|
|
)
|
|
|
|
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())
|
|
|
|
assert "Total tokens used this session: input=2 output=1 (total=3)" in second_output
|