Co-authored-by: Clément Drouin <clement.drouin@mistral.ai>
Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai>
Co-authored-by: Gauthier Guinet <43207538+Gguinet@users.noreply.github.com>
Co-authored-by: Kim-Adeline Miguel <kimadeline.miguel@mistral.ai>
Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com>
Co-authored-by: Quentin <torroba.q@gmail.com>
Co-authored-by: Simon <80467011+sorgfresser@users.noreply.github.com>
Co-authored-by: Simon Van de Kerckhove <simon.vandekerckhove@mistral.ai>
Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com>
Co-authored-by: angelapopopo <angele.lenglemetz@mistral.ai>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Mathias Gesbert 2026-03-23 18:45:21 +01:00 committed by GitHub
parent 5103019b01
commit eb580209d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
180 changed files with 11136 additions and 1030 deletions

View file

@ -0,0 +1,44 @@
from __future__ import annotations
from vibe.cli.voice_manager.telemetry import TranscriptionTrackingState
class TestTranscriptionTrackingState:
def test_reset_clears_accumulated_state(self) -> None:
state = TranscriptionTrackingState()
state.set_recording_id("req-1")
state.record_text("hello")
state.set_recording_duration(5.0)
state.reset()
assert state.recording_id == ""
assert state.accumulated_transcript_length == 0
assert state.last_recording_duration_ms is None
def test_set_recording_id(self) -> None:
state = TranscriptionTrackingState()
state.set_recording_id("req-abc")
assert state.recording_id == "req-abc"
def test_record_text_accumulates_length(self) -> None:
state = TranscriptionTrackingState()
state.reset()
state.record_text("hello ")
state.record_text("world")
assert state.accumulated_transcript_length == 11
def test_elapsed_ms_returns_positive_value(self) -> None:
state = TranscriptionTrackingState()
state.reset()
assert state.elapsed_ms() >= 0
def test_set_recording_duration_converts_seconds_to_ms(self) -> None:
state = TranscriptionTrackingState()
state.set_recording_duration(2.5)
assert state.last_recording_duration_ms == 2500.0
def test_default_state(self) -> None:
state = TranscriptionTrackingState()
assert state.recording_id == ""
assert state.accumulated_transcript_length == 0
assert state.last_recording_duration_ms is None

View file

@ -1,6 +1,6 @@
from __future__ import annotations
from unittest.mock import patch
from unittest.mock import MagicMock, patch
import pytest
@ -47,12 +47,16 @@ def _make_manager(
*,
voice_mode_enabled: bool = True,
transcribe_client: FakeTranscribeClient | None = None,
telemetry_client: MagicMock | None = None,
) -> tuple[VoiceManager, FakeAudioRecorder, FakeTranscribeClient]:
recorder = FakeAudioRecorder()
client = transcribe_client or FakeTranscribeClient()
config = build_test_vibe_config(voice_mode_enabled=voice_mode_enabled)
manager = VoiceManager(
config_getter=lambda: config, audio_recorder=recorder, transcribe_client=client
config_getter=lambda: config,
audio_recorder=recorder,
transcribe_client=client,
telemetry_client=telemetry_client,
)
return manager, recorder, client
@ -175,7 +179,7 @@ class TestStopRecording:
async def test_stop_recovers_when_no_audio_was_sent(self) -> None:
client = FakeTranscribeClient(
events=[
TranscribeSessionCreated(),
TranscribeSessionCreated(request_id="test-req-id"),
TranscribeError(
message="Cannot flush audio before sending any audio bytes"
),
@ -304,7 +308,7 @@ class TestTranscription:
async def test_text_deltas_notify_listeners(self) -> None:
client = FakeTranscribeClient(
events=[
TranscribeSessionCreated(),
TranscribeSessionCreated(request_id="test-req-id"),
TranscribeTextDelta(text="hello "),
TranscribeTextDelta(text="world"),
TranscribeDone(),
@ -323,7 +327,7 @@ class TestTranscription:
async def test_transcription_error_does_not_crash(self) -> None:
client = FakeTranscribeClient(
events=[
TranscribeSessionCreated(),
TranscribeSessionCreated(request_id="test-req-id"),
TranscribeTextDelta(text="partial"),
TranscribeError(message="something broke"),
TranscribeDone(),
@ -341,7 +345,10 @@ class TestTranscription:
@pytest.mark.asyncio
async def test_cancel_during_transcription(self) -> None:
client = FakeTranscribeClient(
events=[TranscribeSessionCreated(), TranscribeTextDelta(text="hello")]
events=[
TranscribeSessionCreated(request_id="test-req-id"),
TranscribeTextDelta(text="hello"),
]
)
manager, _, _ = _make_manager(transcribe_client=client)
listener = StateListener()
@ -355,7 +362,10 @@ class TestTranscription:
@pytest.mark.asyncio
async def test_session_created_is_silent(self) -> None:
client = FakeTranscribeClient(
events=[TranscribeSessionCreated(), TranscribeDone()]
events=[
TranscribeSessionCreated(request_id="test-req-id"),
TranscribeDone(),
]
)
manager, _, _ = _make_manager(transcribe_client=client)
listener = StateListener()
@ -392,3 +402,178 @@ class TestTranscription:
assert manager.transcribe_state == TranscribeState.IDLE
assert not recorder.is_recording
def _find_telemetry_calls(
mock: MagicMock, event_name: str
) -> list[dict[str, str | int | float | None]]:
"""Return the properties dicts for all calls matching a given event name."""
results: list[dict[str, str | int | float | None]] = []
for call in mock.send_telemetry_event.call_args_list:
if call[0][0] == event_name:
results.append(call[0][1])
return results
class TestTelemetryTracking:
@pytest.mark.asyncio
async def test_start_sends_transcription_start_event(self) -> None:
client = FakeTranscribeClient(
events=[TranscribeSessionCreated(request_id="req-123"), TranscribeDone()]
)
mock_telemetry = MagicMock()
manager, _, _ = _make_manager(
transcribe_client=client, telemetry_client=mock_telemetry
)
manager.start_recording()
await manager.stop_recording()
calls = _find_telemetry_calls(mock_telemetry, "vibe.audio.transcription.start")
assert len(calls) == 1
assert calls[0]["recording_id"] == "req-123"
@pytest.mark.asyncio
async def test_cancel_sends_cancel_event(self) -> None:
mock_telemetry = MagicMock()
manager, _, _ = _make_manager(telemetry_client=mock_telemetry)
manager.start_recording()
manager.cancel_recording()
calls = _find_telemetry_calls(
mock_telemetry, "vibe.audio.transcription.cancel_recording"
)
assert len(calls) == 1
recording_duration_ms = calls[0]["recording_duration_ms"]
assert isinstance(recording_duration_ms, (int, float))
assert recording_duration_ms >= 0
@pytest.mark.asyncio
async def test_done_sends_done_event(self) -> None:
client = FakeTranscribeClient(
events=[
TranscribeSessionCreated(request_id="test-req-id"),
TranscribeTextDelta(text="hello "),
TranscribeTextDelta(text="world"),
TranscribeDone(),
]
)
mock_telemetry = MagicMock()
manager, _, _ = _make_manager(
transcribe_client=client, telemetry_client=mock_telemetry
)
manager.start_recording()
await manager.stop_recording()
calls = _find_telemetry_calls(mock_telemetry, "vibe.audio.transcription.done")
assert len(calls) == 1
assert calls[0]["recording_id"] == "test-req-id"
assert calls[0]["transcript_length"] == len("hello ") + len("world")
transcription_duration_ms = calls[0]["transcription_duration_ms"]
assert isinstance(transcription_duration_ms, (int, float))
assert transcription_duration_ms >= 0
recording_duration_ms = calls[0]["recording_duration_ms"]
assert isinstance(recording_duration_ms, (int, float))
assert recording_duration_ms >= 0
@pytest.mark.asyncio
async def test_error_sends_error_event(self) -> None:
import asyncio
class CrashingTranscribeClient:
def __init__(self, provider=None, model=None) -> None:
pass
async def transcribe(self, audio_stream):
raise RuntimeError("network error")
yield
recorder = FakeAudioRecorder()
config = build_test_vibe_config(voice_mode_enabled=True)
mock_telemetry = MagicMock()
manager = VoiceManager(
config_getter=lambda: config,
audio_recorder=recorder,
transcribe_client=CrashingTranscribeClient(),
telemetry_client=mock_telemetry,
)
manager.start_recording()
await asyncio.sleep(0)
calls = _find_telemetry_calls(mock_telemetry, "vibe.audio.transcription.error")
assert len(calls) == 1
error_message = calls[0]["error_message"]
assert isinstance(error_message, str)
assert "network error" in error_message
transcription_duration_ms = calls[0]["transcription_duration_ms"]
assert isinstance(transcription_duration_ms, (int, float))
assert transcription_duration_ms >= 0
@pytest.mark.asyncio
async def test_no_telemetry_when_client_is_none(self) -> None:
manager, _, _ = _make_manager() # no telemetry_client
manager.start_recording()
manager.cancel_recording()
# No error raised — tracking is silently skipped
@pytest.mark.asyncio
async def test_each_recording_uses_session_request_id(self) -> None:
client = FakeTranscribeClient(
events=[TranscribeSessionCreated(request_id="req-first"), TranscribeDone()]
)
mock_telemetry = MagicMock()
manager, _, _ = _make_manager(
transcribe_client=client, telemetry_client=mock_telemetry
)
manager.start_recording()
await manager.stop_recording()
client.set_events([
TranscribeSessionCreated(request_id="req-second"),
TranscribeDone(),
])
manager.start_recording()
await manager.stop_recording()
calls = _find_telemetry_calls(mock_telemetry, "vibe.audio.transcription.start")
assert len(calls) == 2
assert calls[0]["recording_id"] == "req-first"
assert calls[1]["recording_id"] == "req-second"
@pytest.mark.asyncio
async def test_timeout_sends_error_event(self) -> None:
import asyncio
class HangingTranscribeClient:
def __init__(self, provider=None, model=None) -> None:
pass
async def transcribe(self, audio_stream):
await asyncio.Event().wait()
return
yield
recorder = FakeAudioRecorder()
config = build_test_vibe_config(voice_mode_enabled=True)
mock_telemetry = MagicMock()
manager = VoiceManager(
config_getter=lambda: config,
audio_recorder=recorder,
transcribe_client=HangingTranscribeClient(),
telemetry_client=mock_telemetry,
)
manager.start_recording()
with patch(
"vibe.cli.voice_manager.voice_manager.TRANSCRIPTION_DRAIN_TIMEOUT", 0.01
):
await manager.stop_recording()
calls = _find_telemetry_calls(mock_telemetry, "vibe.audio.transcription.error")
assert len(calls) == 1
error_message = calls[0]["error_message"]
assert isinstance(error_message, str)
assert "timed out" in error_message.lower()