v2.16.0 (#798)
Co-authored-by: Hdandria <henri.dandria@mistral.ai> Co-authored-by: Liam Lyons <65613603+lyons-liam@users.noreply.github.com> Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai> Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai> Co-authored-by: Quentin <quentin.torroba@mistral.ai> Co-authored-by: allansimon-mistral <allan.simon@ext.mistral.ai> Co-authored-by: angelapopopo <angele.lenglemetz@mistral.ai> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
parent
cafb6d4147
commit
c2cb612ac1
67 changed files with 2704 additions and 197 deletions
|
|
@ -12,13 +12,16 @@ from vibe.core.experiments.session import (
|
|||
hydrate_experiments_from_session,
|
||||
initialize_experiments,
|
||||
)
|
||||
from vibe.core.telemetry.types import TerminalEmulator
|
||||
|
||||
|
||||
class _StubClient(RemoteEvalClient):
|
||||
def __init__(self, response: EvalResponse | None) -> None:
|
||||
self._response = response
|
||||
self.attributes: ExperimentAttributes | None = None
|
||||
|
||||
async def evaluate(self, attributes: ExperimentAttributes) -> EvalResponse | None:
|
||||
self.attributes = attributes
|
||||
return self._response
|
||||
|
||||
async def aclose(self) -> None:
|
||||
|
|
@ -168,6 +171,36 @@ async def test_initialize_returns_true_and_persists_when_remote_eval_succeeds(
|
|||
persist.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_uses_provided_terminal_emulator(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"vibe.core.experiments.session.get_mistral_provider_and_api_key",
|
||||
lambda _config: (MagicMock(), "fake-key"),
|
||||
)
|
||||
persist = AsyncMock()
|
||||
session_logger = MagicMock()
|
||||
session_logger.persist_experiments = persist
|
||||
response = EvalResponse.model_validate({
|
||||
"features": {"vibe_cli_system_prompt": {"defaultValue": "cli"}}
|
||||
})
|
||||
client = _StubClient(response)
|
||||
manager = ExperimentManager(client=client)
|
||||
|
||||
result = await initialize_experiments(
|
||||
config=_make_config(),
|
||||
manager=manager,
|
||||
session_logger=session_logger,
|
||||
entrypoint_metadata=None,
|
||||
terminal_emulator=TerminalEmulator.VSCODE,
|
||||
)
|
||||
|
||||
assert result is True
|
||||
assert client.attributes is not None
|
||||
assert client.attributes.terminal_emulator is TerminalEmulator.VSCODE
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hydrate_returns_false_when_telemetry_disabled() -> None:
|
||||
session_logger = MagicMock()
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ class TestIterSSEEvents:
|
|||
payload = _valid_event_payload()
|
||||
lines = [f"data: {json.dumps(payload)}"]
|
||||
response = AsyncMock()
|
||||
response.aiter_lines = _async_line_iter(lines)
|
||||
response.aiter_bytes = _async_byte_iter(lines)
|
||||
|
||||
events = [e async for e in client._iter_sse_events(response)]
|
||||
assert len(events) == 1
|
||||
|
|
@ -79,7 +79,7 @@ class TestIterSSEEvents:
|
|||
payload = _valid_event_payload()
|
||||
lines = ["", ": this is a comment", f"data: {json.dumps(payload)}", ""]
|
||||
response = AsyncMock()
|
||||
response.aiter_lines = _async_line_iter(lines)
|
||||
response.aiter_bytes = _async_byte_iter(lines)
|
||||
|
||||
events = [e async for e in client._iter_sse_events(response)]
|
||||
assert len(events) == 1
|
||||
|
|
@ -90,7 +90,7 @@ class TestIterSSEEvents:
|
|||
payload = {"error": "server broke"}
|
||||
lines = ["event: error", f"data: {json.dumps(payload)}"]
|
||||
response = AsyncMock()
|
||||
response.aiter_lines = _async_line_iter(lines)
|
||||
response.aiter_bytes = _async_byte_iter(lines)
|
||||
|
||||
with pytest.raises(WorkflowsException) as exc_info:
|
||||
_ = [e async for e in client._iter_sse_events(response)]
|
||||
|
|
@ -106,7 +106,7 @@ class TestIterSSEEvents:
|
|||
f"data: {json.dumps(payload)}",
|
||||
]
|
||||
response = AsyncMock()
|
||||
response.aiter_lines = _async_line_iter(lines)
|
||||
response.aiter_bytes = _async_byte_iter(lines)
|
||||
|
||||
with patch.object(
|
||||
client, "_parse_sse_data", wraps=client._parse_sse_data
|
||||
|
|
@ -122,7 +122,7 @@ class TestIterSSEEvents:
|
|||
payload = _valid_event_payload()
|
||||
lines = ["id: 123", "retry: 5000", f"data: {json.dumps(payload)}"]
|
||||
response = AsyncMock()
|
||||
response.aiter_lines = _async_line_iter(lines)
|
||||
response.aiter_bytes = _async_byte_iter(lines)
|
||||
|
||||
events = [e async for e in client._iter_sse_events(response)]
|
||||
assert len(events) == 1
|
||||
|
|
@ -133,7 +133,7 @@ class TestIterSSEEvents:
|
|||
payload = _valid_event_payload()
|
||||
lines = ["data: {not valid json}", f"data: {json.dumps(payload)}"]
|
||||
response = AsyncMock()
|
||||
response.aiter_lines = _async_line_iter(lines)
|
||||
response.aiter_bytes = _async_byte_iter(lines)
|
||||
|
||||
with patch("vibe.core.nuage.client.logger") as mock_logger:
|
||||
events = [e async for e in client._iter_sse_events(response)]
|
||||
|
|
@ -159,7 +159,7 @@ class TestStreamEvents:
|
|||
|
||||
mock_response = AsyncMock()
|
||||
mock_response.raise_for_status = lambda: None
|
||||
mock_response.aiter_lines = _async_line_iter(lines)
|
||||
mock_response.aiter_bytes = _async_byte_iter(lines)
|
||||
_setup_mock_client(client, mock_response)
|
||||
|
||||
params = StreamEventsQueryParams(workflow_exec_id="wf-1", start_seq=0)
|
||||
|
|
@ -173,7 +173,7 @@ class TestStreamEvents:
|
|||
|
||||
mock_response = AsyncMock()
|
||||
mock_response.raise_for_status = lambda: None
|
||||
mock_response.aiter_lines = _async_line_iter([
|
||||
mock_response.aiter_bytes = _async_byte_iter([
|
||||
"event: error",
|
||||
'data: {"error": "stream error"}',
|
||||
])
|
||||
|
|
@ -240,9 +240,9 @@ class TestGetWorkflowRuns:
|
|||
assert call_params["user_id"] == "user-123"
|
||||
|
||||
|
||||
def _async_line_iter(lines: list[str]):
|
||||
def _async_byte_iter(lines: list[str]):
|
||||
async def _iter():
|
||||
for line in lines:
|
||||
yield line
|
||||
yield f"{line}\n".encode()
|
||||
|
||||
return _iter
|
||||
|
|
|
|||
|
|
@ -17,7 +17,10 @@ def _make_payload_summary() -> PayloadSummary:
|
|||
|
||||
|
||||
def _make_error(
|
||||
*, status: int | None, headers: dict[str, str] | None = None
|
||||
*,
|
||||
status: int | None,
|
||||
headers: dict[str, str] | None = None,
|
||||
body_text: str = "body",
|
||||
) -> BackendError:
|
||||
return BackendError(
|
||||
provider="test-provider",
|
||||
|
|
@ -25,7 +28,7 @@ def _make_error(
|
|||
status=status,
|
||||
reason="some reason",
|
||||
headers=headers or {},
|
||||
body_text="body",
|
||||
body_text=body_text,
|
||||
parsed_error=None,
|
||||
model="test-model",
|
||||
payload_summary=_make_payload_summary(),
|
||||
|
|
@ -72,3 +75,49 @@ class TestBackendErrorFmt:
|
|||
msg = str(err)
|
||||
assert str(code) in msg
|
||||
assert "LLM backend error" in msg
|
||||
|
||||
|
||||
class TestBackendErrorIsContextTooLong:
|
||||
@pytest.mark.parametrize(
|
||||
("status", "body_text"),
|
||||
[
|
||||
(400, "context too long"),
|
||||
(400, "prompt is too long"),
|
||||
# orchestral_runtime wraps context errors as 422
|
||||
(422, '{"error":{"type":"model_context_exceeded"}}'),
|
||||
(422, '{"error":{"type":"prompt_too_long"}}'),
|
||||
],
|
||||
)
|
||||
def test_true(self, status: int, body_text: str) -> None:
|
||||
err = _make_error(status=status, body_text=body_text)
|
||||
assert err.is_context_too_long
|
||||
|
||||
def test_false_on_unrelated_status(self) -> None:
|
||||
err = _make_error(status=500, body_text="context too long")
|
||||
assert not err.is_context_too_long
|
||||
|
||||
def test_false_on_max_tokens(self) -> None:
|
||||
# max-tokens truncation must not be misread as context-too-long
|
||||
err = _make_error(status=422, body_text="max_tokens_exceeded")
|
||||
assert not err.is_context_too_long
|
||||
|
||||
|
||||
class TestBackendErrorIsResponseTooLong:
|
||||
@pytest.mark.parametrize(
|
||||
"body_text",
|
||||
[
|
||||
'{"error":{"type":"max_tokens_exceeded"}}',
|
||||
"Generation truncated: finish_reason=length",
|
||||
],
|
||||
)
|
||||
def test_true_on_422_with_substring(self, body_text: str) -> None:
|
||||
err = _make_error(status=422, body_text=body_text)
|
||||
assert err.is_response_too_long
|
||||
|
||||
def test_false_when_status_not_422(self) -> None:
|
||||
err = _make_error(status=400, body_text="max_tokens_exceeded")
|
||||
assert not err.is_response_too_long
|
||||
|
||||
def test_false_when_substring_missing(self) -> None:
|
||||
err = _make_error(status=422, body_text="some unrelated error")
|
||||
assert not err.is_response_too_long
|
||||
|
|
|
|||
75
tests/core/test_sse.py
Normal file
75
tests/core/test_sse.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from vibe.core.utils.sse import iter_sse_lines
|
||||
|
||||
|
||||
class _ChunkedStream(httpx.AsyncByteStream):
|
||||
def __init__(self, chunks: list[bytes]) -> None:
|
||||
self._chunks = chunks
|
||||
|
||||
async def __aiter__(self) -> AsyncIterator[bytes]:
|
||||
for chunk in self._chunks:
|
||||
yield chunk
|
||||
|
||||
|
||||
async def _collect(chunks: list[bytes]) -> list[str]:
|
||||
response = httpx.Response(
|
||||
status_code=200,
|
||||
stream=_ChunkedStream(chunks),
|
||||
request=httpx.Request("POST", "https://example.com"),
|
||||
)
|
||||
return [line async for line in iter_sse_lines(response)]
|
||||
|
||||
|
||||
class TestIterSseLines:
|
||||
@pytest.mark.asyncio
|
||||
async def test_splits_on_lf_and_crlf(self) -> None:
|
||||
lines = await _collect([b"data: a\r\ndata: b\n\ndata: c\n"])
|
||||
assert lines == ["data: a", "data: b", "", "data: c"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("separator", ["\u2028", "\u2029", "\x85", "\x0b", "\x0c"])
|
||||
async def test_keeps_unicode_line_breaks_within_line(self, separator: str) -> None:
|
||||
payload = f'data: {{"arguments": "{separator}value"}}\n'
|
||||
lines = await _collect([payload.encode()])
|
||||
assert lines == [payload.removesuffix("\n")]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_buffers_partial_lines_across_chunks(self) -> None:
|
||||
lines = await _collect([b"data: one", b"two\ndata: three", b"four\n"])
|
||||
assert lines == ["data: onetwo", "data: threefour"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multibyte_char_split_across_chunks(self) -> None:
|
||||
encoded = "data: café\n".encode()
|
||||
lines = await _collect([encoded[:9], encoded[9:]])
|
||||
assert lines == ["data: café"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_yields_trailing_line_without_newline(self) -> None:
|
||||
lines = await _collect([b"data: a\ndata: b"])
|
||||
assert lines == ["data: a", "data: b"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_crlf_split_across_chunks(self) -> None:
|
||||
lines = await _collect([b"data: a\r", b"\ndata: b\n"])
|
||||
assert lines == ["data: a", "data: b"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_splits_on_lone_cr(self) -> None:
|
||||
lines = await _collect([b"data: a\rdata: b\r"])
|
||||
assert lines == ["data: a", "data: b"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_stream(self) -> None:
|
||||
assert await _collect([]) == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_replaces_undecodable_bytes(self) -> None:
|
||||
lines = await _collect([b"data: a\xff b\n"])
|
||||
assert lines == ["data: a<> b"]
|
||||
|
|
@ -21,6 +21,7 @@ from vibe.core.telemetry.types import (
|
|||
AttachmentKind,
|
||||
EntrypointMetadata,
|
||||
TelemetryRequestMetadata,
|
||||
TerminalEmulator,
|
||||
)
|
||||
from vibe.core.tools.base import BaseTool, ToolPermission
|
||||
from vibe.core.types import Backend
|
||||
|
|
@ -557,7 +558,7 @@ class TestTelemetryClient:
|
|||
entrypoint="cli",
|
||||
client_name="vscode",
|
||||
client_version="1.96.0",
|
||||
terminal_emulator="vscode",
|
||||
terminal_emulator=TerminalEmulator.VSCODE,
|
||||
)
|
||||
|
||||
assert len(telemetry_events) == 1
|
||||
|
|
|
|||
|
|
@ -13,11 +13,13 @@ import httpx
|
|||
import pytest
|
||||
import zstandard
|
||||
|
||||
from tests.conftest import build_test_vibe_config
|
||||
from vibe.core.teleport.errors import (
|
||||
ServiceTeleportError,
|
||||
ServiceTeleportNotSupportedError,
|
||||
)
|
||||
from vibe.core.teleport.git import GitRepoInfo
|
||||
from vibe.core.teleport.nuage import DEFAULT_NUAGE_PROJECT_NAME
|
||||
from vibe.core.teleport.teleport import TeleportService
|
||||
from vibe.core.teleport.types import (
|
||||
TeleportCheckingGitEvent,
|
||||
|
|
@ -157,6 +159,49 @@ class TestTeleportServiceIsSupported:
|
|||
|
||||
|
||||
class TestTeleportServiceExecute:
|
||||
def test_build_nuage_request_uses_configured_project_name(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
service = _make_service(
|
||||
tmp_path,
|
||||
vibe_config=build_test_vibe_config(vibe_code_project_name=" Zed "),
|
||||
)
|
||||
|
||||
request = service._build_nuage_request(
|
||||
prompt="test prompt",
|
||||
git_info=GitRepoInfo(
|
||||
remote_url="https://github.com/owner/repo",
|
||||
owner="owner",
|
||||
repo="repo",
|
||||
branch="main",
|
||||
commit="abc123",
|
||||
diff="",
|
||||
),
|
||||
)
|
||||
|
||||
assert request.project_name == "Zed"
|
||||
|
||||
def test_build_nuage_request_uses_default_project_name_for_blank_config(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
service = _make_service(
|
||||
tmp_path, vibe_config=build_test_vibe_config(vibe_code_project_name=" ")
|
||||
)
|
||||
|
||||
request = service._build_nuage_request(
|
||||
prompt="test prompt",
|
||||
git_info=GitRepoInfo(
|
||||
remote_url="https://github.com/owner/repo",
|
||||
owner="owner",
|
||||
repo="repo",
|
||||
branch="main",
|
||||
commit="abc123",
|
||||
diff="",
|
||||
),
|
||||
)
|
||||
|
||||
assert request.project_name == DEFAULT_NUAGE_PROJECT_NAME
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_happy_path(self, tmp_path: Path) -> None:
|
||||
seen_body: dict[str, object] | None = None
|
||||
|
|
|
|||
29
tests/core/test_text.py
Normal file
29
tests/core/test_text.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.core.utils.text import snippet_start_line
|
||||
|
||||
|
||||
class TestSnippetStartLine:
|
||||
def test_finds_line_number(self) -> None:
|
||||
assert snippet_start_line("a\nb\nc\nd\n", "c") == 3
|
||||
|
||||
def test_first_line(self) -> None:
|
||||
assert snippet_start_line("hello\nworld", "hello") == 1
|
||||
|
||||
def test_multiline_snippet(self) -> None:
|
||||
assert snippet_start_line("a\nb\nc", "\nb\n") == 2
|
||||
|
||||
def test_first_occurrence_when_repeated(self) -> None:
|
||||
assert snippet_start_line("x\nx\nx", "x") == 1
|
||||
|
||||
def test_leading_newline_anchors_first_content_line(self) -> None:
|
||||
assert snippet_start_line("bar\nx\nbar", "\nbar") == 3
|
||||
|
||||
def test_returns_none_when_exact_snippet_absent(self) -> None:
|
||||
assert snippet_start_line("a\nb\nfoo", "foo\n") is None
|
||||
|
||||
def test_not_found(self) -> None:
|
||||
assert snippet_start_line("hello\nworld", "missing") is None
|
||||
|
||||
def test_blank_snippet(self) -> None:
|
||||
assert snippet_start_line("hello", "\n") is None
|
||||
|
|
@ -228,3 +228,46 @@ def test_get_result_display() -> None:
|
|||
assert isinstance(display, ToolResultDisplay)
|
||||
assert display.success is True
|
||||
assert "foo.py" in display.message
|
||||
|
||||
|
||||
def test_ui_start_line_not_part_of_model_contract() -> None:
|
||||
result = EditResult(file="/x", message="m", old_string="a", new_string="b")
|
||||
result._ui_start_line = 42
|
||||
|
||||
assert result.ui_start_line == 42
|
||||
assert "ui_start_line" not in result.model_dump()
|
||||
assert "_ui_start_line" not in result.model_dump()
|
||||
assert "ui_start_line" not in result.model_dump_json()
|
||||
assert "ui_start_line" not in EditResult.model_fields
|
||||
assert "ui_start_line" not in EditResult.model_json_schema().get("properties", {})
|
||||
assert "ui_start_line" not in dict(result)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ui_start_line_computed_at_edit_site(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
_write(tmp_path, "f.txt", "alpha\nbeta\ngamma\ndelta\n")
|
||||
edit = _make_edit()
|
||||
|
||||
result = await collect_result(
|
||||
edit.run(EditArgs(file_path="f.txt", old_string="gamma", new_string="GAMMA"))
|
||||
)
|
||||
|
||||
assert result.ui_start_line == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ui_start_line_set_for_pure_deletion(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
_write(tmp_path, "f.txt", "keep1\nkeep2\nremove\nkeep3\n")
|
||||
edit = _make_edit()
|
||||
|
||||
result = await collect_result(
|
||||
edit.run(EditArgs(file_path="f.txt", old_string="remove\n", new_string=""))
|
||||
)
|
||||
|
||||
assert result.ui_start_line == 3
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue