Co-authored-by: Alexis Tacnet <alexis@mistral.ai>
Co-authored-by: Clément Drouin <clement.drouin@mistral.ai>
Co-authored-by: Guillaume LE GOFF <guillaume.lgf@gmail.com>
Co-authored-by: Lucas Marandat <31749711+lucasmrdt@users.noreply.github.com>
Co-authored-by: Maxime Dolores <maxime.dolores@ext.mistral.ai>
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: p.vezia <166131032+le-codeur-rapide@users.noreply.github.com>
Co-authored-by: Hiba Chaabnia <Hiba-Chaabnia@users.noreply.github.com>
Co-authored-by: Nikhil Bhima <nikhilbhima@users.noreply.github.com>
Co-authored-by: Nkipohcs <Nkipohcs@users.noreply.github.com>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Mathias Gesbert 2026-06-04 18:26:35 +02:00 committed by GitHub
parent ad0d5c9520
commit 3f8487f761
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
197 changed files with 10819 additions and 2830 deletions

View file

@ -0,0 +1,71 @@
from __future__ import annotations
import hashlib
from pathlib import Path
import pytest
from vibe.core.session.image_snapshot import ImageSnapshotError, snapshot_image
PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16
def test_snapshot_image_copies_to_attachments_dir(tmp_path: Path) -> None:
src = tmp_path / "screenshot.png"
src.write_bytes(PNG_BYTES)
session_dir = tmp_path / "session"
session_dir.mkdir()
att = snapshot_image(src, alias="screenshot.png", session_dir=session_dir)
digest = hashlib.sha1(PNG_BYTES, usedforsecurity=False).hexdigest()
assert att.path == (session_dir / "attachments" / f"{digest}.png").resolve()
assert att.alias == "screenshot.png"
assert att.mime_type == "image/png"
assert att.path.read_bytes() == PNG_BYTES
def test_snapshot_image_is_idempotent_on_same_bytes(tmp_path: Path) -> None:
src_a = tmp_path / "a.png"
src_b = tmp_path / "b.png"
src_a.write_bytes(PNG_BYTES)
src_b.write_bytes(PNG_BYTES)
session_dir = tmp_path / "session"
att_a = snapshot_image(src_a, alias="a.png", session_dir=session_dir)
att_b = snapshot_image(src_b, alias="b.png", session_dir=session_dir)
assert att_a.path == att_b.path
assert sum(1 for _ in (session_dir / "attachments").iterdir()) == 1
def test_snapshot_image_returns_source_when_session_dir_is_none(tmp_path: Path) -> None:
src = tmp_path / "screenshot.png"
src.write_bytes(PNG_BYTES)
att = snapshot_image(src, alias="screenshot.png", session_dir=None)
assert att.path == src.resolve()
assert att.alias == "screenshot.png"
def test_snapshot_image_rejects_non_image_extension(tmp_path: Path) -> None:
src = tmp_path / "readme.txt"
src.write_bytes(b"hi")
with pytest.raises(ImageSnapshotError):
snapshot_image(src, alias="readme.txt", session_dir=None)
def test_snapshot_image_rejects_missing_file(tmp_path: Path) -> None:
with pytest.raises(ImageSnapshotError):
snapshot_image(tmp_path / "missing.png", alias="missing.png", session_dir=None)
def test_snapshot_image_normalizes_jpg_to_jpeg_mime(tmp_path: Path) -> None:
src = tmp_path / "photo.jpg"
src.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 8)
att = snapshot_image(src, alias="photo.jpg", session_dir=None)
assert att.mime_type == "image/jpeg"

View file

@ -80,6 +80,36 @@ def test_record_ignores_empty_session_id(
assert last_session_pointer.load(session_logging) is None
def test_clear_matching_removes_matching_pointers_only(
session_logging: SessionLoggingConfig,
) -> None:
pointer_dir = Path(session_logging.save_dir) / last_session_pointer.POINTER_DIR_NAME
pointer_dir.mkdir()
(pointer_dir / "ttys001").write_text("deleted-session\n", encoding="utf-8")
(pointer_dir / "ttys002").write_text("other-session\n", encoding="utf-8")
(pointer_dir / "ttys003").write_text("deleted-session\n", encoding="utf-8")
(pointer_dir / "nested").mkdir()
last_session_pointer.clear_matching(session_logging, "deleted-session")
assert not (pointer_dir / "ttys001").exists()
assert (pointer_dir / "ttys002").read_text(encoding="utf-8") == "other-session\n"
assert not (pointer_dir / "ttys003").exists()
assert (pointer_dir / "nested").is_dir()
def test_clear_matching_skips_when_logging_disabled(tmp_path: Path) -> None:
disabled = SessionLoggingConfig(save_dir=str(tmp_path), enabled=False)
pointer_dir = tmp_path / last_session_pointer.POINTER_DIR_NAME
pointer_dir.mkdir()
pointer_path = pointer_dir / "ttys001"
pointer_path.write_text("deleted-session\n", encoding="utf-8")
last_session_pointer.clear_matching(disabled, "deleted-session")
assert pointer_path.exists()
def test_current_tty_key_returns_none_when_ttyname_is_unavailable(
monkeypatch: pytest.MonkeyPatch,
) -> None:

View file

@ -344,13 +344,13 @@ class TestProjectPromptsDirsAdditionalDirs:
class TestProjectRootsNestedDedup:
def test_nested_add_dirs_collapse(self, tmp_path: Path) -> None:
def test_nested_add_dirs_preserved(self, tmp_path: Path) -> None:
outer = (tmp_path / "outer").resolve()
inner = outer / "inner"
inner.mkdir(parents=True)
mgr = HarnessFilesManager(sources=("user",), _additional_dirs=(outer, inner))
assert mgr.project_roots == [outer]
assert mgr.project_roots == [outer, inner]
def test_add_dir_containing_cwd_keeps_both(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
@ -380,10 +380,13 @@ class TestProjectRootsNestedDedup:
outer = (tmp_path / "outer").resolve()
inner = outer / "inner"
inner.mkdir(parents=True)
skills_dir = inner / ".vibe" / "skills"
skills_dir.mkdir(parents=True)
monkeypatch.chdir(outer)
trusted_folders_manager.trust_for_session(outer)
mgr = HarnessFilesManager(
sources=("user", "project"), _additional_dirs=(inner,)
)
assert mgr.project_roots == [outer]
assert mgr.project_roots == [outer, inner]
assert skills_dir in mgr.project_skills_dirs

View file

@ -20,7 +20,7 @@ class TestAgentProfile:
"""Test that EXPLORE agent has expected enabled tools."""
enabled_tools = EXPLORE.overrides.get("enabled_tools", [])
assert "grep" in enabled_tools
assert "read_file" in enabled_tools
assert "read" in enabled_tools
def test_builtin_agents_contains_explore(self) -> None:
"""Test that BUILTIN_AGENTS includes explore."""

View file

@ -13,6 +13,7 @@ from vibe.core.config.layer import (
TrustNotResolvedError,
UntrustedLayerError,
)
from vibe.core.config.patch import ConfigPatch
class StubLayer(ConfigLayer[BaseModel]):
@ -401,7 +402,7 @@ async def test_get_fingerprint_not_implemented() -> None:
async def test_apply_not_implemented() -> None:
layer = StubLayer()
with pytest.raises(NotImplementedError):
await layer.apply({"op": "set"})
await layer.apply(ConfigPatch(fingerprint="fp-1"))
@pytest.mark.asyncio

View file

@ -7,6 +7,7 @@ import pytest
from vibe.core.config.layer import ConfigLayer, RawConfig
from vibe.core.config.orchestrator import ConfigOrchestrator
from vibe.core.config.patch import ConfigPatch
from vibe.core.config.schema import ConfigSchema, WithReplaceMerge
@ -76,7 +77,7 @@ async def test_origin_of_missing_key_returns_none() -> None:
async def test_apply_patch_raises_not_implemented() -> None:
orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[])
with pytest.raises(NotImplementedError, match="M2"):
await orch.apply_patch({})
await orch.apply_patch(ConfigPatch(fingerprint="fp-1"))
@pytest.mark.asyncio

View file

@ -78,7 +78,7 @@ class TestProjectToolsDirs:
mgr = HarnessFilesManager(sources=("user", "project"))
assert mgr.project_tools_dirs == []
def test_finds_tools_dirs_recursively(
def test_does_not_find_nested_tools_dirs(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
@ -86,19 +86,6 @@ class TestProjectToolsDirs:
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
(tmp_path / "sub" / ".vibe" / "tools").mkdir(parents=True)
mgr = HarnessFilesManager(sources=("user", "project"))
assert mgr.project_tools_dirs == [
tmp_path / ".vibe" / "tools",
tmp_path / "sub" / ".vibe" / "tools",
]
def test_does_not_descend_into_ignored_dirs(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True)
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
(tmp_path / ".git" / ".vibe" / "tools").mkdir(parents=True)
mgr = HarnessFilesManager(sources=("user", "project"))
assert mgr.project_tools_dirs == [tmp_path / ".vibe" / "tools"]
@ -147,7 +134,7 @@ class TestProjectAgentsDirs:
mgr = HarnessFilesManager(sources=("user", "project"))
assert mgr.project_agents_dirs == []
def test_finds_agents_dirs_recursively(
def test_does_not_find_nested_agents_dirs(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
@ -155,19 +142,6 @@ class TestProjectAgentsDirs:
(tmp_path / ".vibe" / "agents").mkdir(parents=True)
(tmp_path / "sub" / "deep" / ".vibe" / "agents").mkdir(parents=True)
mgr = HarnessFilesManager(sources=("user", "project"))
assert mgr.project_agents_dirs == [
tmp_path / ".vibe" / "agents",
tmp_path / "sub" / "deep" / ".vibe" / "agents",
]
def test_does_not_descend_into_ignored_dirs(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True)
(tmp_path / ".vibe" / "agents").mkdir(parents=True)
(tmp_path / "__pycache__" / ".vibe" / "agents").mkdir(parents=True)
mgr = HarnessFilesManager(sources=("user", "project"))
assert mgr.project_agents_dirs == [tmp_path / ".vibe" / "agents"]
@ -544,7 +518,7 @@ class TestProjectSkillsDirs:
mgr = HarnessFilesManager(sources=("user", "project"))
assert mgr.project_skills_dirs == []
def test_finds_skills_dirs_recursively_in_trusted_folder(
def test_does_not_find_nested_skills_dirs(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
@ -553,20 +527,6 @@ class TestProjectSkillsDirs:
(tmp_path / "sub" / ".agents" / "skills").mkdir(parents=True)
(tmp_path / "sub" / "deep" / ".vibe" / "skills").mkdir(parents=True)
mgr = HarnessFilesManager(sources=("user", "project"))
assert mgr.project_skills_dirs == [
tmp_path / ".vibe" / "skills",
tmp_path / "sub" / ".agents" / "skills",
tmp_path / "sub" / "deep" / ".vibe" / "skills",
]
def test_does_not_descend_into_ignored_dirs(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True)
(tmp_path / ".vibe" / "skills").mkdir(parents=True)
(tmp_path / "node_modules" / ".vibe" / "skills").mkdir(parents=True)
mgr = HarnessFilesManager(sources=("user", "project"))
assert mgr.project_skills_dirs == [tmp_path / ".vibe" / "skills"]

View file

@ -309,6 +309,32 @@ class TestSetThinking:
assert result["models"][0].get("thinking") is None
assert result["models"][1]["thinking"] == "max"
def test_preserves_supports_images_when_materializing_defaults(
self, config_dir: Path
) -> None:
config_file = config_dir / "config.toml"
data = {"active_model": "mistral-medium-3.5"}
with config_file.open("wb") as f:
tomli_w.dump(data, f)
cfg = VibeConfig.load()
cfg.set_thinking("low")
reloaded = VibeConfig.load()
active = reloaded.get_active_model()
assert active.alias == "mistral-medium-3.5"
assert active.thinking == "low"
assert active.supports_images is True
with config_file.open("rb") as f:
result = tomllib.load(f)
active_entry = result["models"][0]
assert active_entry["supports_images"] is True
assert "temperature" not in active_entry
assert "input_price" not in active_entry
assert "output_price" not in active_entry
assert "auto_compact_threshold" not in active_entry
assert "supports_images" not in result["models"][1]
class TestMigrateLeavesFindInBashAllowlist:
def test_keeps_find_in_config_file(
@ -659,6 +685,62 @@ class TestMigrateMistralVibeCliLatestDefaults:
assert result["models"][0]["output_price"] == 7.5
assert result["models"][0]["thinking"] == "high"
def test_backfills_supports_images_on_existing_mistral_medium_entry(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("VIBE_HOME", str(tmp_path))
config_file = tmp_path / "config.toml"
data = {
"active_model": "mistral-medium-3.5",
"models": [
{
"name": "mistral-vibe-cli-latest",
"provider": "mistral",
"alias": "mistral-medium-3.5",
"temperature": 1.0,
"input_price": 1.5,
"output_price": 7.5,
"thinking": "high",
}
],
}
with config_file.open("wb") as f:
tomli_w.dump(data, f)
reset_harness_files_manager()
init_harness_files_manager("user")
VibeConfig._migrate()
with config_file.open("rb") as f:
result = tomllib.load(f)
assert result["models"][0]["supports_images"] is True
def test_preserves_explicit_supports_images_false(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("VIBE_HOME", str(tmp_path))
config_file = tmp_path / "config.toml"
data = {
"models": [
{
"name": "mistral-vibe-cli-latest",
"provider": "mistral",
"alias": "mistral-medium-3.5",
"supports_images": False,
}
]
}
with config_file.open("wb") as f:
tomli_w.dump(data, f)
reset_harness_files_manager()
init_harness_files_manager("user")
VibeConfig._migrate()
with config_file.open("rb") as f:
result = tomllib.load(f)
assert result["models"][0]["supports_images"] is False
class TestAutoCompactThresholdFallback:
def test_model_without_explicit_threshold_inherits_global(self) -> None:
@ -1320,3 +1402,105 @@ class TestIsActiveModelMistral:
active_model="llama-local",
)
assert cfg.is_active_model_mistral() is False
class TestMigrateRenamedTools:
def test_renames_read_file_and_search_replace_keys(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("VIBE_HOME", str(tmp_path))
config_file = tmp_path / "config.toml"
data = {
"tools": {
"read_file": {
"permission": "always",
"allowlist": ["src/**"],
"max_read_bytes": 64000,
},
"search_replace": {
"allowlist": ["src/**"],
"max_content_size": 100000,
"create_backup": True,
},
}
}
with config_file.open("wb") as f:
tomli_w.dump(data, f)
reset_harness_files_manager()
init_harness_files_manager("user")
VibeConfig._migrate()
with config_file.open("rb") as f:
result = tomllib.load(f)
tools = result["tools"]
assert "read_file" not in tools
assert "search_replace" not in tools
assert tools["read"] == {
"permission": "always",
"allowlist": ["src/**"],
"max_read_bytes": 64000,
}
# Common options carry over; edit-incompatible options are dropped.
assert tools["edit"] == {"allowlist": ["src/**"]}
def test_prefers_existing_new_key_and_drops_legacy(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("VIBE_HOME", str(tmp_path))
config_file = tmp_path / "config.toml"
data = {
"tools": {
"read_file": {"permission": "always"},
"read": {"permission": "ask"},
}
}
with config_file.open("wb") as f:
tomli_w.dump(data, f)
reset_harness_files_manager()
init_harness_files_manager("user")
VibeConfig._migrate()
with config_file.open("rb") as f:
result = tomllib.load(f)
assert "read_file" not in result["tools"]
assert result["tools"]["read"] == {"permission": "ask"}
def test_renames_entries_in_enabled_and_disabled_lists(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("VIBE_HOME", str(tmp_path))
config_file = tmp_path / "config.toml"
data = {
"enabled_tools": ["read_file", "grep"],
"disabled_tools": ["search_replace"],
}
with config_file.open("wb") as f:
tomli_w.dump(data, f)
reset_harness_files_manager()
init_harness_files_manager("user")
VibeConfig._migrate()
with config_file.open("rb") as f:
result = tomllib.load(f)
assert result["enabled_tools"] == ["read", "grep"]
assert result["disabled_tools"] == ["edit"]
def test_noop_when_no_legacy_tool_names(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("VIBE_HOME", str(tmp_path))
config_file = tmp_path / "config.toml"
data = {"tools": {"read": {"permission": "always"}}}
with config_file.open("wb") as f:
tomli_w.dump(data, f)
reset_harness_files_manager()
init_harness_files_manager("user")
VibeConfig._migrate()
with config_file.open("rb") as f:
result = tomllib.load(f)
assert result["tools"] == {"read": {"permission": "always"}}

View file

@ -0,0 +1,86 @@
from __future__ import annotations
import os
from pathlib import Path
import stat
import pytest
from tests.mock.utils import collect_result
from vibe.core.tools.base import BaseToolState, ToolError
from vibe.core.tools.builtins.edit import Edit, EditArgs, EditConfig
@pytest.mark.asyncio
async def test_edit_rewrites_with_detected_encoding(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
path = tmp_path / "utf16.txt"
original = "line one café\nline two été\n"
path.write_bytes(original.encode("utf-16"))
tool = Edit(config_getter=lambda: EditConfig(), state=BaseToolState())
await collect_result(
tool.run(
EditArgs(
file_path=str(path),
old_string="line one café",
new_string="LINE ONE CAFÉ",
)
)
)
assert path.read_bytes().startswith(b"\xff\xfe")
assert path.read_text(encoding="utf-16") == "LINE ONE CAFÉ\nline two été\n"
@pytest.mark.asyncio
async def test_edit_preserves_file_mode(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
path = tmp_path / "script.sh"
path.write_text("#!/bin/sh\necho old\n")
os.chmod(path, 0o755)
tool = Edit(config_getter=lambda: EditConfig(), state=BaseToolState())
await collect_result(
tool.run(EditArgs(file_path=str(path), old_string="old", new_string="new"))
)
assert stat.S_IMODE(path.stat().st_mode) == 0o755
@pytest.mark.asyncio
@pytest.mark.parametrize("newline", ["\r\n", "\r", "\n"])
async def test_edit_preserves_line_endings(
newline: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
path = tmp_path / "f.txt"
original = newline.join(["alpha", "beta", "gamma"])
path.write_bytes(original.encode("utf-8"))
tool = Edit(config_getter=lambda: EditConfig(), state=BaseToolState())
await collect_result(
tool.run(EditArgs(file_path=str(path), old_string="beta", new_string="BETA"))
)
assert path.read_bytes() == newline.join(["alpha", "BETA", "gamma"]).encode("utf-8")
@pytest.mark.asyncio
async def test_edit_binary_file_raises_tool_error(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
path = tmp_path / "blob.bin"
# PNG header + some non-text payload.
path.write_bytes(b"\x89PNG\r\n\x1a\n" + bytes(range(256)) * 4)
tool = Edit(config_getter=lambda: EditConfig(), state=BaseToolState())
with pytest.raises(ToolError, match="not valid text"):
await collect_result(
tool.run(EditArgs(file_path=str(path), old_string="PNG", new_string="JPG"))
)

View file

@ -0,0 +1,48 @@
from __future__ import annotations
import os
from unittest.mock import patch
import pytest
from vibe.core.config.layers.environment import EnvironmentLayer
from vibe.core.config.vibe_schema import VibeConfigSchema
@pytest.mark.asyncio
async def test_reads_env_vars() -> None:
env = {
"MISTRAL_API_KEY": "test-key",
"VIBE_ACTIVE_MODEL": "mistral-large",
"VIBE_VIM_KEYBINDINGS": "true",
"VIBE_ENABLE_TELEMETRY": "0",
"VIBE_UNKNOWN_VAR": "ignored",
"VIBE_SESSION_LOGGING__ENABLED": "false",
"VIBE_SESSION_LOGGING__SESSION_PREFIX": "mysession",
"VIBE_API_TIMEOUT": ".12",
}
with patch.dict(os.environ, env, clear=True):
layer = EnvironmentLayer(schema=VibeConfigSchema)
data = await layer.load()
assert data.model_dump() == {
"active_model": "mistral-large",
"vim_keybindings": True,
"enable_telemetry": False,
"session_logging": {"enabled": False, "session_prefix": "mysession"},
"api_timeout": 0.12,
}
assert layer.name == "environment"
@pytest.mark.asyncio
async def test_no_vars_set_returns_empty() -> None:
with patch.dict(os.environ, {}, clear=True):
data = await EnvironmentLayer(schema=VibeConfigSchema).load()
assert data.model_dump() == {}
@pytest.mark.asyncio
async def test_always_trusted() -> None:
assert await EnvironmentLayer(schema=VibeConfigSchema).resolve_trust() is True

View file

@ -0,0 +1,51 @@
from __future__ import annotations
from pathlib import Path
import pytest
from vibe.core.types import ImageAttachment, LLMMessage, Role
@pytest.fixture()
def image_a(tmp_path: Path) -> ImageAttachment:
p = tmp_path / "a.png"
p.write_bytes(b"\x89PNG")
return ImageAttachment(path=p, alias="a.png", mime_type="image/png")
@pytest.fixture()
def image_b(tmp_path: Path) -> ImageAttachment:
p = tmp_path / "b.png"
p.write_bytes(b"\x89PNG")
return ImageAttachment(path=p, alias="b.png", mime_type="image/png")
def _msg(content: str, images: list[ImageAttachment] | None = None) -> LLMMessage:
return LLMMessage(role=Role.assistant, content=content, images=images)
def test_merge_prefers_self_images_when_present(
image_a: ImageAttachment, image_b: ImageAttachment
) -> None:
merged = _msg("hi", images=[image_a]) + _msg(" there", images=[image_b])
assert merged.images == [image_a]
def test_merge_falls_back_to_other_when_self_is_none(image_b: ImageAttachment) -> None:
merged = _msg("hi") + _msg(" there", images=[image_b])
assert merged.images == [image_b]
def test_merge_preserves_explicit_empty_self_images_over_other(
image_b: ImageAttachment,
) -> None:
# An explicit `[]` (intentional clearing) must NOT silently inherit
# `other.images`. Truthy/falsy-based merging would have flipped this.
merged = _msg("hi", images=[]) + _msg(" there", images=[image_b])
assert merged.images == []
def test_merge_yields_none_when_both_sides_are_none() -> None:
merged = _msg("hi") + _msg(" there")
assert merged.images is None

View file

@ -0,0 +1,114 @@
from __future__ import annotations
from pathlib import Path
from vibe.core.paths._local_config_files import LocalConfigDirs, find_local_config_dirs
class TestSubdirs:
def test_finds_config_at_root(self, tmp_path: Path) -> None:
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
result = find_local_config_dirs(tmp_path)
assert tmp_path.resolve() / ".vibe" / "tools" in result.tools
def test_does_not_descend_into_subdirectories(self, tmp_path: Path) -> None:
(tmp_path / "sub" / ".vibe" / "tools").mkdir(parents=True)
(tmp_path / "a" / "b" / ".vibe" / "skills").mkdir(parents=True)
result = find_local_config_dirs(tmp_path)
assert result.tools == ()
assert result.skills == ()
assert result.agents == ()
assert result.config_dirs == ()
def test_finds_agents_skills_at_root(self, tmp_path: Path) -> None:
(tmp_path / ".agents" / "skills").mkdir(parents=True)
result = find_local_config_dirs(tmp_path)
assert tmp_path.resolve() / ".agents" / "skills" in result.skills
def test_finds_all_config_types_at_root(self, tmp_path: Path) -> None:
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
(tmp_path / ".vibe" / "skills").mkdir(parents=True)
(tmp_path / ".vibe" / "agents").mkdir(parents=True)
(tmp_path / ".agents" / "skills").mkdir(parents=True)
result = find_local_config_dirs(tmp_path)
resolved = tmp_path.resolve()
assert resolved / ".vibe" / "tools" in result.tools
assert resolved / ".vibe" / "skills" in result.skills
assert resolved / ".vibe" / "agents" in result.agents
assert resolved / ".agents" / "skills" in result.skills
class TestConfigDirs:
def test_finds_vibe_with_tools(self, tmp_path: Path) -> None:
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
result = find_local_config_dirs(tmp_path)
assert tmp_path.resolve() / ".vibe" in result.config_dirs
def test_finds_vibe_with_skills(self, tmp_path: Path) -> None:
(tmp_path / ".vibe" / "skills").mkdir(parents=True)
result = find_local_config_dirs(tmp_path)
assert tmp_path.resolve() / ".vibe" in result.config_dirs
def test_finds_agents_with_skills(self, tmp_path: Path) -> None:
(tmp_path / ".agents" / "skills").mkdir(parents=True)
result = find_local_config_dirs(tmp_path)
assert tmp_path.resolve() / ".agents" in result.config_dirs
def test_ignores_empty_vibe_dir(self, tmp_path: Path) -> None:
(tmp_path / ".vibe").mkdir()
result = find_local_config_dirs(tmp_path)
assert result.config_dirs == ()
def test_ignores_empty_agents_dir(self, tmp_path: Path) -> None:
(tmp_path / ".agents").mkdir()
result = find_local_config_dirs(tmp_path)
assert result.config_dirs == ()
def test_returns_empty_when_empty(self, tmp_path: Path) -> None:
result = find_local_config_dirs(tmp_path)
assert result.config_dirs == ()
def test_finds_vibe_with_prompts(self, tmp_path: Path) -> None:
(tmp_path / ".vibe" / "prompts").mkdir(parents=True)
result = find_local_config_dirs(tmp_path)
assert tmp_path.resolve() / ".vibe" in result.config_dirs
def test_finds_vibe_with_config_toml(self, tmp_path: Path) -> None:
(tmp_path / ".vibe").mkdir()
(tmp_path / ".vibe" / "config.toml").write_text("")
result = find_local_config_dirs(tmp_path)
assert tmp_path.resolve() / ".vibe" in result.config_dirs
def test_finds_vibe_and_agents_at_same_root(self, tmp_path: Path) -> None:
(tmp_path / ".vibe" / "skills").mkdir(parents=True)
(tmp_path / ".agents" / "skills").mkdir(parents=True)
result = find_local_config_dirs(tmp_path)
resolved = tmp_path.resolve()
assert resolved / ".vibe" in result.config_dirs
assert resolved / ".agents" in result.config_dirs
class TestLocalConfigDirsOr:
def test_or_concatenates_each_field(self) -> None:
a = LocalConfigDirs(
config_dirs=(Path("/a/.vibe"),),
tools=(Path("/a/.vibe/tools"),),
skills=(Path("/a/.vibe/skills"),),
agents=(Path("/a/.vibe/agents"),),
)
b = LocalConfigDirs(
config_dirs=(Path("/b/.vibe"),),
tools=(Path("/b/.vibe/tools"),),
skills=(Path("/b/.vibe/skills"),),
agents=(Path("/b/.vibe/agents"),),
)
merged = a | b
assert merged.config_dirs == (Path("/a/.vibe"), Path("/b/.vibe"))
assert merged.tools == (Path("/a/.vibe/tools"), Path("/b/.vibe/tools"))
assert merged.skills == (Path("/a/.vibe/skills"), Path("/b/.vibe/skills"))
assert merged.agents == (Path("/a/.vibe/agents"), Path("/b/.vibe/agents"))
def test_or_with_empty_is_identity(self) -> None:
a = LocalConfigDirs(tools=(Path("/a/.vibe/tools"),))
assert (a | LocalConfigDirs()) == a
assert (LocalConfigDirs() | a) == a

View file

@ -1,195 +0,0 @@
from __future__ import annotations
from pathlib import Path
from vibe.core.paths._local_config_walk import (
_MAX_DIRS,
WALK_MAX_DEPTH,
ConfigWalkResult,
walk_local_config_dirs,
)
class TestWalkTools:
def test_finds_config_at_root(self, tmp_path: Path) -> None:
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
result = walk_local_config_dirs(tmp_path)
assert tmp_path.resolve() / ".vibe" / "tools" in result.tools
def test_finds_config_within_depth_limit(self, tmp_path: Path) -> None:
nested = tmp_path
for i in range(WALK_MAX_DEPTH):
nested = nested / f"level{i}"
(nested / ".vibe" / "skills").mkdir(parents=True)
result = walk_local_config_dirs(tmp_path)
assert nested.resolve() / ".vibe" / "skills" in result.skills
def test_does_not_find_config_beyond_depth_limit(self, tmp_path: Path) -> None:
nested = tmp_path
for i in range(WALK_MAX_DEPTH + 1):
nested = nested / f"level{i}"
(nested / ".vibe" / "tools").mkdir(parents=True)
result = walk_local_config_dirs(tmp_path)
assert not result.tools
assert not result.skills
assert not result.agents
def test_respects_dir_count_limit(self, tmp_path: Path) -> None:
for i in range(_MAX_DIRS + 10):
(tmp_path / f"dir{i:05d}").mkdir()
(tmp_path / "zzz_last" / ".vibe" / "tools").mkdir(parents=True)
result = walk_local_config_dirs(tmp_path)
assert isinstance(result.tools, tuple)
def test_skips_ignored_directories(self, tmp_path: Path) -> None:
(tmp_path / "node_modules" / ".vibe" / "tools").mkdir(parents=True)
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
result = walk_local_config_dirs(tmp_path)
assert result.tools == (tmp_path.resolve() / ".vibe" / "tools",)
def test_skips_dot_directories(self, tmp_path: Path) -> None:
(tmp_path / ".hidden" / ".vibe" / "tools").mkdir(parents=True)
result = walk_local_config_dirs(tmp_path)
assert not result.tools
def test_preserves_alphabetical_ordering(self, tmp_path: Path) -> None:
(tmp_path / "bbb" / ".vibe" / "tools").mkdir(parents=True)
(tmp_path / "aaa" / ".vibe" / "tools").mkdir(parents=True)
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
result = walk_local_config_dirs(tmp_path)
resolved = tmp_path.resolve()
assert result.tools == (
resolved / ".vibe" / "tools",
resolved / "aaa" / ".vibe" / "tools",
resolved / "bbb" / ".vibe" / "tools",
)
def test_finds_agents_skills(self, tmp_path: Path) -> None:
(tmp_path / ".agents" / "skills").mkdir(parents=True)
result = walk_local_config_dirs(tmp_path)
assert tmp_path.resolve() / ".agents" / "skills" in result.skills
def test_finds_all_config_types(self, tmp_path: Path) -> None:
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
(tmp_path / ".vibe" / "skills").mkdir(parents=True)
(tmp_path / ".vibe" / "agents").mkdir(parents=True)
(tmp_path / ".agents" / "skills").mkdir(parents=True)
result = walk_local_config_dirs(tmp_path)
resolved = tmp_path.resolve()
assert resolved / ".vibe" / "tools" in result.tools
assert resolved / ".vibe" / "skills" in result.skills
assert resolved / ".vibe" / "agents" in result.agents
assert resolved / ".agents" / "skills" in result.skills
class TestWalkConfigDirs:
def test_finds_vibe_with_tools(self, tmp_path: Path) -> None:
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
result = walk_local_config_dirs(tmp_path)
assert tmp_path.resolve() / ".vibe" in result.config_dirs
def test_finds_vibe_with_skills(self, tmp_path: Path) -> None:
(tmp_path / ".vibe" / "skills").mkdir(parents=True)
result = walk_local_config_dirs(tmp_path)
assert tmp_path.resolve() / ".vibe" in result.config_dirs
def test_finds_agents_with_skills(self, tmp_path: Path) -> None:
(tmp_path / ".agents" / "skills").mkdir(parents=True)
result = walk_local_config_dirs(tmp_path)
assert tmp_path.resolve() / ".agents" in result.config_dirs
def test_ignores_empty_vibe_dir(self, tmp_path: Path) -> None:
(tmp_path / ".vibe").mkdir()
result = walk_local_config_dirs(tmp_path)
assert result.config_dirs == ()
def test_ignores_empty_agents_dir(self, tmp_path: Path) -> None:
(tmp_path / ".agents").mkdir()
result = walk_local_config_dirs(tmp_path)
assert result.config_dirs == ()
def test_returns_empty_when_empty(self, tmp_path: Path) -> None:
result = walk_local_config_dirs(tmp_path)
assert result.config_dirs == ()
def test_finds_shallow_nested(self, tmp_path: Path) -> None:
(tmp_path / "sub" / ".vibe" / "skills").mkdir(parents=True)
result = walk_local_config_dirs(tmp_path)
assert tmp_path.resolve() / "sub" / ".vibe" in result.config_dirs
def test_finds_at_depth_2(self, tmp_path: Path) -> None:
(tmp_path / "a" / "b" / ".agents" / "skills").mkdir(parents=True)
result = walk_local_config_dirs(tmp_path)
assert tmp_path.resolve() / "a" / "b" / ".agents" in result.config_dirs
def test_returns_empty_beyond_default_depth(self, tmp_path: Path) -> None:
(tmp_path / "a" / "b" / "c" / "d" / "e" / ".vibe" / "tools").mkdir(parents=True)
result = walk_local_config_dirs(tmp_path)
assert result.config_dirs == ()
def test_custom_depth(self, tmp_path: Path) -> None:
(tmp_path / "a" / "b" / "c" / "d" / "e" / ".vibe" / "tools").mkdir(parents=True)
result = walk_local_config_dirs(tmp_path, max_depth=5)
assert (
tmp_path.resolve() / "a" / "b" / "c" / "d" / "e" / ".vibe"
in result.config_dirs
)
def test_finds_match_among_many_dirs(self, tmp_path: Path) -> None:
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
for i in range(100):
(tmp_path / f"dir{i}").mkdir()
result = walk_local_config_dirs(tmp_path)
assert tmp_path.resolve() / ".vibe" in result.config_dirs
def test_skips_ignored_directories(self, tmp_path: Path) -> None:
(tmp_path / "node_modules" / ".vibe" / "skills").mkdir(parents=True)
result = walk_local_config_dirs(tmp_path)
assert result.config_dirs == ()
def test_finds_vibe_with_prompts(self, tmp_path: Path) -> None:
(tmp_path / ".vibe" / "prompts").mkdir(parents=True)
result = walk_local_config_dirs(tmp_path)
assert tmp_path.resolve() / ".vibe" in result.config_dirs
def test_finds_vibe_with_config_toml(self, tmp_path: Path) -> None:
(tmp_path / ".vibe").mkdir()
(tmp_path / ".vibe" / "config.toml").write_text("")
result = walk_local_config_dirs(tmp_path)
assert tmp_path.resolve() / ".vibe" in result.config_dirs
def test_finds_multiple_config_dirs(self, tmp_path: Path) -> None:
(tmp_path / ".vibe" / "skills").mkdir(parents=True)
(tmp_path / ".agents" / "skills").mkdir(parents=True)
(tmp_path / "sub" / ".vibe" / "tools").mkdir(parents=True)
result = walk_local_config_dirs(tmp_path)
resolved = tmp_path.resolve()
assert resolved / ".vibe" in result.config_dirs
assert resolved / ".agents" in result.config_dirs
assert resolved / "sub" / ".vibe" in result.config_dirs
class TestConfigWalkResultOr:
def test_or_concatenates_each_field(self) -> None:
a = ConfigWalkResult(
config_dirs=(Path("/a/.vibe"),),
tools=(Path("/a/.vibe/tools"),),
skills=(Path("/a/.vibe/skills"),),
agents=(Path("/a/.vibe/agents"),),
)
b = ConfigWalkResult(
config_dirs=(Path("/b/.vibe"),),
tools=(Path("/b/.vibe/tools"),),
skills=(Path("/b/.vibe/skills"),),
agents=(Path("/b/.vibe/agents"),),
)
merged = a | b
assert merged.config_dirs == (Path("/a/.vibe"), Path("/b/.vibe"))
assert merged.tools == (Path("/a/.vibe/tools"), Path("/b/.vibe/tools"))
assert merged.skills == (Path("/a/.vibe/skills"), Path("/b/.vibe/skills"))
assert merged.agents == (Path("/a/.vibe/agents"), Path("/b/.vibe/agents"))
def test_or_with_empty_is_identity(self) -> None:
a = ConfigWalkResult(tools=(Path("/a/.vibe/tools"),))
assert (a | ConfigWalkResult()) == a
assert (ConfigWalkResult() | a) == a

View file

@ -3,6 +3,7 @@ from __future__ import annotations
import pytest
from vibe.core.config.layers.overrides import OverridesLayer
from vibe.core.config.patch import ConfigPatch
@pytest.mark.asyncio
@ -23,7 +24,7 @@ async def test_always_trusted() -> None:
async def test_apply_raises_not_implemented() -> None:
layer = OverridesLayer(data={})
with pytest.raises(NotImplementedError, match="M2"):
await layer.apply({"op": "set"})
await layer.apply(ConfigPatch(fingerprint="fp-1"))
@pytest.mark.asyncio

View file

@ -6,6 +6,7 @@ import pytest
from vibe.core.config.layer import UntrustedLayerError
from vibe.core.config.layers.project import ProjectConfigLayer
from vibe.core.config.patch import ConfigPatch
from vibe.core.paths._vibe_home import GlobalPath
from vibe.core.trusted_folders import trusted_folders_manager
@ -66,7 +67,7 @@ async def test_default_name(tmp_working_directory: Path) -> None:
async def test_apply_raises_not_implemented(tmp_working_directory: Path) -> None:
layer = ProjectConfigLayer(path=tmp_working_directory)
with pytest.raises(NotImplementedError, match="M2"):
await layer.apply({"op": "set"})
await layer.apply(ConfigPatch(fingerprint="fp-1"))
@pytest.mark.asyncio

View file

@ -413,7 +413,6 @@ def test_working_task_promoted_to_real_tool_call_does_not_create_duplicate_row()
"output": {
"path": "/workspace/hello.py",
"bytes_written": 22,
"file_existed": False,
"content": 'print("Hello, World!")',
},
},
@ -456,7 +455,6 @@ def test_idle_boundary_waits_for_open_tool_results() -> None:
"output": {
"path": "/workspace/hello_world.js",
"bytes_written": 29,
"file_existed": False,
"content": "console.log('Hello, World!');",
},
},
@ -731,7 +729,7 @@ def test_working_completed_with_tool_call_id_emits_error_result() -> None:
"working",
{
"title": "Executing write_file",
"content": "Error: File exists. Set overwrite=True.",
"content": "Error: Permission denied.",
"toolUIState": {
"type": "file",
"toolCallId": "call-write-err",

View file

@ -1,9 +1,15 @@
from __future__ import annotations
from collections.abc import AsyncGenerator
import httpx
import pytest
from vibe.core.utils.retry import _is_retryable_http_error
from vibe.core.utils.retry import (
_is_retryable_http_error,
async_generator_retry,
async_retry,
)
def _make_http_status_error(status_code: int) -> httpx.HTTPStatusError:
@ -15,6 +21,10 @@ def _make_http_status_error(status_code: int) -> httpx.HTTPStatusError:
)
def _make_request(url: str = "https://example.com") -> httpx.Request:
return httpx.Request("POST", url)
class TestIsRetryableHttpError:
@pytest.mark.parametrize("code", [408, 409, 425, 429, 500, 502, 503, 504, 529])
def test_retryable_codes(self, code: int) -> None:
@ -24,8 +34,127 @@ class TestIsRetryableHttpError:
def test_non_retryable_codes(self, code: int) -> None:
assert _is_retryable_http_error(_make_http_status_error(code)) is False
@pytest.mark.parametrize(
"exc",
[
httpx.ConnectTimeout("connect timed out", request=_make_request()),
httpx.ReadTimeout("read timed out", request=_make_request()),
httpx.WriteTimeout("write timed out", request=_make_request()),
httpx.PoolTimeout("pool timed out", request=_make_request()),
httpx.ConnectError("connection refused", request=_make_request()),
httpx.ReadError("read failed", request=_make_request()),
httpx.WriteError("write failed", request=_make_request()),
httpx.RemoteProtocolError("server disconnected", request=_make_request()),
],
)
def test_retryable_network_errors(self, exc: Exception) -> None:
assert _is_retryable_http_error(exc) is True
def test_non_retryable_request_error(self) -> None:
assert _is_retryable_http_error(httpx.InvalidURL("bad url")) is False
def test_non_http_error_returns_false(self) -> None:
assert _is_retryable_http_error(ValueError("not http")) is False
def test_generic_exception_returns_false(self) -> None:
assert _is_retryable_http_error(RuntimeError("boom")) is False
class TestAsyncRetry:
@pytest.mark.asyncio
async def test_retries_network_error_then_succeeds(self) -> None:
attempts = 0
@async_retry(tries=3, delay_seconds=0.0, backoff_factor=1.0)
async def call() -> str:
nonlocal attempts
attempts += 1
if attempts < 2:
raise httpx.ConnectTimeout("timeout", request=_make_request())
return "ok"
result = await call()
assert result == "ok"
assert attempts == 2
@pytest.mark.asyncio
async def test_does_not_retry_non_retryable(self) -> None:
attempts = 0
@async_retry(tries=3, delay_seconds=0.0, backoff_factor=1.0)
async def call() -> str:
nonlocal attempts
attempts += 1
raise ValueError("nope")
with pytest.raises(ValueError):
await call()
assert attempts == 1
@pytest.mark.asyncio
async def test_exhausts_retries(self) -> None:
attempts = 0
@async_retry(tries=3, delay_seconds=0.0, backoff_factor=1.0)
async def call() -> str:
nonlocal attempts
attempts += 1
raise httpx.ReadTimeout("timeout", request=_make_request())
with pytest.raises(httpx.ReadTimeout):
await call()
assert attempts == 3
class TestAsyncGeneratorRetry:
@pytest.mark.asyncio
async def test_retries_before_first_yield(self) -> None:
attempts = 0
@async_generator_retry(tries=3, delay_seconds=0.0, backoff_factor=1.0)
async def gen() -> AsyncGenerator[int]:
nonlocal attempts
attempts += 1
if attempts < 2:
raise httpx.ConnectError("connect failed", request=_make_request())
yield 1
yield 2
items = [item async for item in gen()]
assert items == [1, 2]
assert attempts == 2
@pytest.mark.asyncio
async def test_does_not_retry_after_first_yield(self) -> None:
attempts = 0
@async_generator_retry(tries=3, delay_seconds=0.0, backoff_factor=1.0)
async def gen() -> AsyncGenerator[int]:
nonlocal attempts
attempts += 1
yield 1
raise httpx.ReadError("midstream", request=_make_request())
items: list[int] = []
with pytest.raises(httpx.ReadError):
async for item in gen():
items.append(item)
assert items == [1]
assert attempts == 1
@pytest.mark.asyncio
async def test_does_not_retry_non_retryable_before_yield(self) -> None:
attempts = 0
@async_generator_retry(tries=3, delay_seconds=0.0, backoff_factor=1.0)
async def gen() -> AsyncGenerator[int]:
nonlocal attempts
attempts += 1
raise ValueError("nope")
yield 0 # pragma: no cover
with pytest.raises(ValueError):
async for _ in gen():
pass
assert attempts == 1

View file

@ -25,23 +25,24 @@ async def _act_and_collect(agent_loop, prompt: str) -> list[BaseEvent]:
def _write_file_tool_call(
path: str, content: str, *, call_id: str = "call_1", overwrite: bool = False
path: str, content: str, *, call_id: str = "call_1"
) -> ToolCall:
args = json.dumps({"path": path, "content": content, "overwrite": overwrite})
args = json.dumps({"path": path, "content": content})
return ToolCall(
id=call_id, index=0, function=FunctionCall(name="write_file", arguments=args)
)
def _search_replace_tool_call(
file_path: str, search: str, replace: str, *, call_id: str = "call_1"
def _edit_tool_call(
file_path: str, old_string: str, new_string: str, *, call_id: str = "call_1"
) -> ToolCall:
content = f"<<<<<<< SEARCH\n{search}\n=======\n{replace}\n>>>>>>> REPLACE"
args = json.dumps({"file_path": file_path, "content": content})
args = json.dumps({
"file_path": file_path,
"old_string": old_string,
"new_string": new_string,
})
return ToolCall(
id=call_id,
index=0,
function=FunctionCall(name="search_replace", arguments=args),
id=call_id, index=0, function=FunctionCall(name="edit", arguments=args)
)
@ -54,10 +55,10 @@ def _bash_tool_call(command: str, *, call_id: str = "call_1") -> ToolCall:
def _make_agent_loop(backend: FakeBackend):
config = build_test_vibe_config(
enabled_tools=["write_file", "search_replace", "bash"],
enabled_tools=["write_file", "edit", "bash"],
tools={
"write_file": {"permission": "always"},
"search_replace": {"permission": "always"},
"edit": {"permission": "always"},
"bash": {"permission": "always"},
},
system_prompt_id="tests",
@ -98,10 +99,10 @@ class TestRewindIntegration:
assert not target.exists()
async def test_search_replace_rewind_restores_previous_version(
async def test_edit_rewind_restores_previous_version(
self, tmp_working_directory: Path
) -> None:
"""Edit a pre-existing file with search_replace, rewind restores original."""
"""Edit a pre-existing file with edit, rewind restores original."""
target = tmp_working_directory / "config.yaml"
target.write_text("key: original\n", encoding="utf-8")
@ -110,9 +111,7 @@ class TestRewindIntegration:
mock_llm_chunk(
content="Updating config.",
tool_calls=[
_search_replace_tool_call(
str(target), "key: original", "key: modified"
)
_edit_tool_call(str(target), "key: original", "key: modified")
],
)
],
@ -129,11 +128,11 @@ class TestRewindIntegration:
assert target.read_text() == "key: original\n"
async def test_write_then_search_replace_rewind_to_middle(
async def test_write_then_edit_rewind_to_middle(
self, tmp_working_directory: Path
) -> None:
"""Turn 1 creates a file with write_file, turn 2 patches it with
search_replace. Rewind to turn 2 restores the turn 1 version.
edit. Rewind to turn 2 restores the turn 1 version.
"""
target = tmp_working_directory / "app.py"
@ -148,12 +147,12 @@ class TestRewindIntegration:
)
],
[mock_llm_chunk(content="Created.")],
# Turn 2: patch with search_replace
# Turn 2: patch with edit
[
mock_llm_chunk(
content="Updating.",
tool_calls=[
_search_replace_tool_call(
_edit_tool_call(
str(target),
" pass",
' print("hello")',
@ -222,9 +221,7 @@ class TestRewindIntegration:
mock_llm_chunk(
content="v2.",
tool_calls=[
_write_file_tool_call(
str(target), "v2", call_id="call_2", overwrite=True
)
_edit_tool_call(str(target), "v1", "v2", call_id="call_2")
],
)
],
@ -234,9 +231,7 @@ class TestRewindIntegration:
mock_llm_chunk(
content="v2bis.",
tool_calls=[
_write_file_tool_call(
str(target), "v2bis", call_id="call_3", overwrite=True
)
_edit_tool_call(str(target), "v1", "v2bis", call_id="call_3")
],
)
],

View file

@ -1,34 +0,0 @@
from __future__ import annotations
from pathlib import Path
import pytest
from tests.mock.utils import collect_result
from vibe.core.tools.base import BaseToolState
from vibe.core.tools.builtins.search_replace import (
SearchReplace,
SearchReplaceArgs,
SearchReplaceConfig,
)
@pytest.mark.asyncio
async def test_search_replace_rewrites_with_detected_encoding(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
path = tmp_path / "utf16.txt"
original = "line one café\nline two été\n"
path.write_bytes(original.encode("utf-16"))
tool = SearchReplace(
config_getter=lambda: SearchReplaceConfig(), state=BaseToolState()
)
patch = "<<<<<<< SEARCH\nline one café\n=======\nLINE ONE CAFÉ\n>>>>>>> REPLACE"
await collect_result(
tool.run(SearchReplaceArgs(file_path=str(path), content=patch))
)
assert path.read_bytes().startswith(b"\xff\xfe")
assert path.read_text(encoding="utf-16") == "LINE ONE CAFÉ\nline two été\n"

View file

@ -28,9 +28,7 @@ def _make_resolved_tool_call(
tool_name: str, args_dict: dict[str, Any]
) -> ResolvedToolCall:
if tool_name == "write_file":
validated = WriteFileArgs(
path="foo.txt", content="x", overwrite=args_dict.get("overwrite", False)
)
validated = WriteFileArgs(path="foo.txt", content="x")
cls: type[BaseTool] = WriteFile
else:
validated = FakeToolArgs()
@ -169,12 +167,12 @@ class TestTelemetryClient:
assert telemetry_events[0]["properties"]["message_id"] == "msg-123"
def test_send_tool_call_finished_nb_files_created_write_file_new(
def test_send_tool_call_finished_nb_files_created_write_file(
self, telemetry_events: list[dict[str, Any]]
) -> None:
config = build_test_vibe_config(enable_telemetry=True)
client = TelemetryClient(config_getter=lambda: config)
tool_call = _make_resolved_tool_call("write_file", {"overwrite": False})
tool_call = _make_resolved_tool_call("write_file", {})
client.send_tool_call_finished(
tool_call=tool_call,
@ -182,31 +180,12 @@ class TestTelemetryClient:
decision=None,
agent_profile_name="default",
model="mistral-large",
result={"file_existed": False},
result={},
)
assert telemetry_events[0]["properties"]["nb_files_created"] == 1
assert telemetry_events[0]["properties"]["nb_files_modified"] == 0
def test_send_tool_call_finished_nb_files_modified_write_file_overwrite(
self, telemetry_events: list[dict[str, Any]]
) -> None:
config = build_test_vibe_config(enable_telemetry=True)
client = TelemetryClient(config_getter=lambda: config)
tool_call = _make_resolved_tool_call("write_file", {"overwrite": True})
client.send_tool_call_finished(
tool_call=tool_call,
status="success",
decision=None,
agent_profile_name="default",
model="mistral-large",
result={"file_existed": True},
)
assert telemetry_events[0]["properties"]["nb_files_created"] == 0
assert telemetry_events[0]["properties"]["nb_files_modified"] == 1
def test_send_tool_call_finished_decision_none(
self, telemetry_events: list[dict[str, Any]]
) -> None:

View file

@ -10,6 +10,8 @@ import tomli_w
from vibe.core.paths import AGENTS_MD_FILENAME, TRUSTED_FOLDERS_FILE
from vibe.core.trusted_folders import (
TrustedFoldersManager,
find_git_repo_ancestor,
find_repo_trustable_files_for_cwd,
find_trustable_files,
has_agents_md_file,
)
@ -291,15 +293,46 @@ class TestFindTrustRoot:
# child should find parent (closest), not tmp_path
assert manager.find_trust_root(child) == parent.resolve()
def test_ignores_untrusted_ancestors(self, tmp_path: Path) -> None:
def test_returns_none_when_closer_ancestor_is_untrusted(
self, tmp_path: Path
) -> None:
parent = tmp_path / "parent"
child = parent / "child"
child.mkdir(parents=True)
manager = TrustedFoldersManager()
manager.add_untrusted(parent)
manager.add_trusted(tmp_path)
# find_trust_root skips untrusted, finds tmp_path
assert manager.find_trust_root(child) == tmp_path.resolve()
# The closer untrusted ancestor blocks the higher trusted one.
assert manager.find_trust_root(child) is None
def test_returns_none_when_path_itself_is_untrusted(self, tmp_path: Path) -> None:
manager = TrustedFoldersManager()
manager.add_untrusted(tmp_path)
assert manager.find_trust_root(tmp_path) is None
class TestIsExplicitlyUntrusted:
def test_returns_true_for_path_in_untrusted_list(self, tmp_path: Path) -> None:
manager = TrustedFoldersManager()
manager.add_untrusted(tmp_path)
assert manager.is_explicitly_untrusted(tmp_path) is True
def test_returns_false_for_unknown_path(self, tmp_path: Path) -> None:
manager = TrustedFoldersManager()
assert manager.is_explicitly_untrusted(tmp_path) is False
def test_does_not_walk_ancestors(self, tmp_path: Path) -> None:
child = tmp_path / "child"
child.mkdir()
manager = TrustedFoldersManager()
manager.add_untrusted(tmp_path)
# parent is untrusted, but child itself is not in the list
assert manager.is_explicitly_untrusted(child) is False
def test_returns_false_for_trusted_path(self, tmp_path: Path) -> None:
manager = TrustedFoldersManager()
manager.add_trusted(tmp_path)
assert manager.is_explicitly_untrusted(tmp_path) is False
class TestHasAgentsMdFile:
@ -351,37 +384,132 @@ class TestFindTrustableFiles:
(tmp_path / "other.txt").write_text("", encoding="utf-8")
assert find_trustable_files(tmp_path) == []
def test_detects_vibe_config_in_subfolder(self, tmp_path: Path) -> None:
def test_ignores_vibe_config_in_subfolder(self, tmp_path: Path) -> None:
(tmp_path / "sub" / ".vibe" / "skills").mkdir(parents=True)
result = find_trustable_files(tmp_path)
assert "sub/.vibe/" in result
assert find_trustable_files(tmp_path) == []
def test_detects_agents_skills_in_subfolder(self, tmp_path: Path) -> None:
def test_ignores_agents_skills_in_subfolder(self, tmp_path: Path) -> None:
(tmp_path / "deep" / "nested" / ".agents" / "skills").mkdir(parents=True)
result = find_trustable_files(tmp_path)
assert "deep/nested/.agents/" in result
assert find_trustable_files(tmp_path) == []
def test_returns_empty_when_config_only_inside_ignored_dir(
self, tmp_path: Path
) -> None:
def test_returns_empty_when_config_only_in_subfolder(self, tmp_path: Path) -> None:
(tmp_path / "node_modules" / ".vibe" / "skills").mkdir(parents=True)
assert find_trustable_files(tmp_path) == []
def test_detects_nested_vibe_dir(self, tmp_path: Path) -> None:
(tmp_path / "pkg" / ".vibe" / "tools").mkdir(parents=True)
result = find_trustable_files(tmp_path)
assert "pkg/.vibe/" in result
def test_detects_multiple_files(self, tmp_path: Path) -> None:
def test_detects_multiple_files_at_root(self, tmp_path: Path) -> None:
(tmp_path / ".vibe" / "skills").mkdir(parents=True)
(tmp_path / ".agents" / "skills").mkdir(parents=True)
(tmp_path / "AGENTS.md").write_text("# Agent", encoding="utf-8")
(tmp_path / "sub" / ".agents" / "skills").mkdir(parents=True)
result = find_trustable_files(tmp_path)
assert ".vibe/" in result
assert ".agents/" in result
assert "AGENTS.md" in result
assert "sub/.agents/" in result
def test_no_duplicates_for_root_vibe_dir(self, tmp_path: Path) -> None:
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
result = find_trustable_files(tmp_path)
assert result.count(".vibe/") == 1
def _make_git_repo(path: Path) -> None:
git_dir = path / ".git"
git_dir.mkdir()
(git_dir / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8")
class TestFindRepoTrustableFilesForCwd:
def test_returns_empty_when_repo_root_is_none(self, tmp_path: Path) -> None:
assert find_repo_trustable_files_for_cwd(tmp_path, None) == []
def test_returns_empty_when_repo_root_is_not_ancestor(self, tmp_path: Path) -> None:
cwd = tmp_path / "cwd"
cwd.mkdir()
repo = tmp_path / "repo"
repo.mkdir()
_make_git_repo(repo)
assert find_repo_trustable_files_for_cwd(cwd, repo) == []
def test_includes_root_trustable_files(self, tmp_path: Path) -> None:
repo = tmp_path / "repo"
repo.mkdir()
_make_git_repo(repo)
(repo / ".vibe" / "skills").mkdir(parents=True)
(repo / ".agents" / "skills").mkdir(parents=True)
(repo / "AGENTS.md").write_text("# Agent", encoding="utf-8")
cwd = repo / "src" / "pkg"
cwd.mkdir(parents=True)
assert find_repo_trustable_files_for_cwd(cwd, repo) == [
".agents/",
".vibe/",
"AGENTS.md",
]
def test_includes_agents_md_between_cwd_and_repo_root(self, tmp_path: Path) -> None:
repo = tmp_path / "repo"
repo.mkdir()
_make_git_repo(repo)
cwd = repo / "src" / "pkg" / "deep"
cwd.mkdir(parents=True)
(repo / "src" / "AGENTS.md").write_text("# Source", encoding="utf-8")
(repo / "src" / "pkg" / "AGENTS.md").write_text("# Package", encoding="utf-8")
(cwd / "AGENTS.md").write_text("# Cwd", encoding="utf-8")
assert find_repo_trustable_files_for_cwd(cwd, repo) == [
"src/AGENTS.md",
"src/pkg/AGENTS.md",
]
class TestFindGitRepoAncestor:
def test_returns_path_when_directly_contains_git(self, tmp_path: Path) -> None:
_make_git_repo(tmp_path)
assert find_git_repo_ancestor(tmp_path) == tmp_path.resolve()
def test_ignores_git_file_pointer(self, tmp_path: Path) -> None:
(tmp_path / ".git").write_text("gitdir: /elsewhere", encoding="utf-8")
# Not a directory, so not treated as a repo root.
assert find_git_repo_ancestor(tmp_path) is None
def test_ignores_empty_git_directory(self, tmp_path: Path) -> None:
(tmp_path / ".git").mkdir()
# Missing HEAD, so not a real repo.
assert find_git_repo_ancestor(tmp_path) is None
def test_returns_closest_ancestor_with_git(self, tmp_path: Path) -> None:
_make_git_repo(tmp_path)
nested = tmp_path / "a" / "b" / "c"
nested.mkdir(parents=True)
assert find_git_repo_ancestor(nested) == tmp_path.resolve()
def test_returns_innermost_when_multiple_git_repos(self, tmp_path: Path) -> None:
_make_git_repo(tmp_path)
inner = tmp_path / "sub"
inner.mkdir()
_make_git_repo(inner)
child = inner / "deep"
child.mkdir()
assert find_git_repo_ancestor(child) == inner.resolve()
def test_returns_none_when_no_git_anywhere(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(Path, "home", classmethod(lambda _cls: tmp_path.parent))
assert find_git_repo_ancestor(tmp_path / "a") is None
def test_excludes_home_directory(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
home = tmp_path / "home"
home.mkdir()
_make_git_repo(home)
monkeypatch.setattr(Path, "home", classmethod(lambda _cls: home))
sub = home / "project"
sub.mkdir()
assert find_git_repo_ancestor(sub) is None
def test_terminates_at_filesystem_root_without_git(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.setattr(Path, "home", classmethod(lambda _cls: tmp_path / "nope"))
assert find_git_repo_ancestor(tmp_path) is None

View file

@ -6,6 +6,7 @@ import pytest
from vibe.core.config.layer import LayerImplementationError
from vibe.core.config.layers.user import UserConfigLayer
from vibe.core.config.patch import ConfigPatch
@pytest.mark.asyncio
@ -43,7 +44,7 @@ async def test_apply_raises_not_implemented(tmp_working_directory: Path) -> None
path = tmp_working_directory / "config.toml"
layer = UserConfigLayer(path=path, name="user-toml")
with pytest.raises(NotImplementedError, match="M2"):
await layer.apply({"op": "set"})
await layer.apply(ConfigPatch(fingerprint="fp-1"))
@pytest.mark.asyncio

View file

@ -1,12 +1,21 @@
from __future__ import annotations
import asyncio
from pathlib import Path
import pytest
from vibe.core.utils import compact_complete_display, get_server_url_from_api_base
import vibe.core.utils.io as io_utils
from vibe.core.utils.io import decode_safe, read_safe, read_safe_async
from vibe.core.utils.io import (
_FILE_WRITE_LOCKS,
decode_safe,
file_write_lock,
read_lines_safe,
read_lines_safe_async,
read_safe,
read_safe_async,
)
@pytest.mark.parametrize(
@ -201,3 +210,127 @@ class TestReadSafeAsync:
assert (await read_safe_async(f, raise_on_error=False)).text == "maf<EFBFBD>\n"
with pytest.raises(UnicodeDecodeError):
await read_safe_async(f, raise_on_error=True)
class TestReadLinesSafe:
def test_small_file_fully_read(self, tmp_path: Path) -> None:
f = tmp_path / "f.txt"
f.write_text("a\nb\nc\n", encoding="utf-8")
got = read_lines_safe(f, limit=100, max_bytes=1024)
assert got.lines == ["a", "b", "c"]
assert got.total_lines == 3
assert got.was_truncated is False
def test_no_trailing_newline(self, tmp_path: Path) -> None:
f = tmp_path / "f.txt"
f.write_text("a\nb", encoding="utf-8")
got = read_lines_safe(f, limit=100, max_bytes=1024)
assert got.lines == ["a", "b"]
assert got.total_lines == 2
assert got.was_truncated is False
def test_truncates_at_limit(self, tmp_path: Path) -> None:
f = tmp_path / "f.txt"
f.write_text("".join(f"line {i}\n" for i in range(1, 101)), encoding="utf-8")
got = read_lines_safe(f, limit=10, max_bytes=1024)
assert got.lines == [f"line {i}" for i in range(1, 11)]
assert got.total_lines is None
assert got.was_truncated is True
def test_offset_skips_leading_lines(self, tmp_path: Path) -> None:
f = tmp_path / "f.txt"
f.write_text("".join(f"line {i}\n" for i in range(1, 11)), encoding="utf-8")
got = read_lines_safe(f, start_line=3, limit=2, max_bytes=1024)
assert got.lines == ["line 3", "line 4"]
assert got.was_truncated is True
def test_offset_past_eof_reports_total(self, tmp_path: Path) -> None:
f = tmp_path / "f.txt"
f.write_text("a\nb\n", encoding="utf-8")
got = read_lines_safe(f, start_line=100, limit=10, max_bytes=1024)
assert got.lines == []
assert got.total_lines == 2
assert got.was_truncated is False
def test_does_not_load_whole_file(self, tmp_path: Path) -> None:
f = tmp_path / "big.txt"
f.write_text("".join(f"line {i}\n" for i in range(1_000_000)), encoding="utf-8")
got = read_lines_safe(f, limit=5, max_bytes=1024)
assert got.lines == [f"line {i}" for i in range(5)]
assert got.total_lines is None
assert got.was_truncated is True
def test_oversized_single_line_returns_partial(self, tmp_path: Path) -> None:
f = tmp_path / "f.txt"
f.write_text("x" * 5000 + "\n", encoding="utf-8")
got = read_lines_safe(f, limit=10, max_bytes=1024)
assert got.lines == ["x" * 1024]
assert got.was_truncated is True
def test_cumulative_byte_budget_truncates(self, tmp_path: Path) -> None:
f = tmp_path / "f.txt"
f.write_text("".join("x" * 200 + "\n" for _ in range(50)), encoding="utf-8")
got = read_lines_safe(f, limit=50, max_bytes=1024)
assert 0 < len(got.lines) < 50
assert got.total_lines is None
assert got.was_truncated is True
def test_oversized_unselected_line_is_skipped(self, tmp_path: Path) -> None:
f = tmp_path / "f.txt"
f.write_text("x" * 5000 + "\nkept\n", encoding="utf-8")
got = read_lines_safe(f, start_line=2, limit=10, max_bytes=1024)
assert got.lines == ["kept"]
@pytest.mark.parametrize("encoding", ["utf-16-le", "utf-16-be", "utf-16"])
def test_utf16_is_decoded(self, tmp_path: Path, encoding: str) -> None:
f = tmp_path / "u16.txt"
f.write_bytes("héllo\nwörld\n".encode(encoding))
got = read_lines_safe(f, limit=10, max_bytes=4096)
assert got.lines[-1] == "wörld"
# A leading BOM may remain as U+FEFF on the first line.
assert got.lines[0].endswith("héllo")
@pytest.mark.asyncio
async def test_async_matches_sync(self, tmp_path: Path) -> None:
f = tmp_path / "f.txt"
f.write_text("a\nb\nc\n", encoding="utf-8")
got = await read_lines_safe_async(f, limit=2, max_bytes=1024)
assert got.lines == ["a", "b"]
assert got.was_truncated is True
class TestFileWriteLock:
@pytest.mark.asyncio
async def test_same_lock_for_different_path_spellings(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
path = tmp_path / "f.txt"
path.touch()
_FILE_WRITE_LOCKS.clear()
order: list[str] = []
held = asyncio.Event()
release = asyncio.Event()
async def first() -> None:
async with file_write_lock(path):
order.append("first-acquired")
held.set()
await release.wait()
order.append("first-released")
async def second() -> None:
await held.wait()
# Same file, different spelling — must contend on the same lock.
async with file_write_lock(Path("f.txt")):
order.append("second-acquired")
t1 = asyncio.create_task(first())
t2 = asyncio.create_task(second())
await held.wait()
await asyncio.sleep(0)
assert order == ["first-acquired"]
release.set()
await asyncio.gather(t1, t2)
assert order == ["first-acquired", "first-released", "second-acquired"]

View file

@ -0,0 +1,62 @@
from __future__ import annotations
from pathlib import Path
import pytest
from vibe.core.config._settings import VibeConfig
from vibe.core.config.vibe_schema import VibeConfigSchema
def test_vibe_config_schema_covers_all_vibe_config_fields() -> None:
legacy_fields = set(VibeConfig.model_fields.keys())
schema_fields = set(VibeConfigSchema.model_fields.keys())
missing = legacy_fields - schema_fields
assert not missing, (
f"VibeConfigSchema is missing {len(missing)} field(s) that exist in VibeConfig: "
f"{sorted(missing)}. "
f"When you add a new field to VibeConfig, also add it to VibeConfigSchema "
f"(vibe/core/config/vibe_schema.py) with the appropriate merge annotation."
)
@pytest.mark.asyncio
async def test_full_toml_to_vibe_config_schema(tmp_path: Path) -> None:
toml_path = tmp_path / "config.toml"
toml_path.write_text(
"""\
vim_keybindings = true
api_timeout = 300.0
active_model = "codestral"
disabled_tools = ["bash"]
default_agent = "plan"
enabled_skills = ["search"]
enable_otel = true
[[models]]
alias = "codestral"
name = "codestral-latest"
provider = "mistral"
"""
)
from vibe.core.config.layers.user import UserConfigLayer
from vibe.core.config.orchestrator import ConfigOrchestrator
class VibeConfig(VibeConfigSchema):
pass
layer = UserConfigLayer(path=toml_path, name="user-toml")
orchestrator = await ConfigOrchestrator[VibeConfig].create(
schema=VibeConfig, layers=[layer]
)
config = orchestrator.config
assert config.vim_keybindings is True
assert config.api_timeout == 300.0
assert config.active_model == "codestral"
assert config.models[0].alias == "codestral"
assert "bash" in config.disabled_tools
assert config.default_agent == "plan"
assert "search" in config.enabled_skills
assert config.enable_otel is True

View file

@ -0,0 +1,230 @@
from __future__ import annotations
import asyncio
from pathlib import Path
import pytest
from tests.mock.utils import collect_result
from vibe.core.tools.base import BaseToolState, ToolError
from vibe.core.tools.builtins.edit import Edit, EditArgs, EditConfig, EditResult
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay
from vibe.core.types import ToolResultEvent
def _make_edit() -> Edit:
return Edit(config_getter=lambda: EditConfig(), state=BaseToolState())
def _write(tmp_path: Path, name: str, content: str) -> Path:
p = tmp_path / name
p.write_text(content)
return p
@pytest.mark.asyncio
async def test_exact_match_replaces(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
_write(tmp_path, "f.txt", "hello world\n")
edit = _make_edit()
result = await collect_result(
edit.run(EditArgs(file_path="f.txt", old_string="hello", new_string="goodbye"))
)
assert (tmp_path / "f.txt").read_text() == "goodbye world\n"
assert result.file == str(tmp_path / "f.txt")
assert result.message == "The file has been updated successfully."
@pytest.mark.asyncio
async def test_replace_all(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
_write(tmp_path, "f.txt", "aaa bbb aaa\n")
edit = _make_edit()
result = await collect_result(
edit.run(
EditArgs(
file_path="f.txt", old_string="aaa", new_string="ccc", replace_all=True
)
)
)
assert (tmp_path / "f.txt").read_text() == "ccc bbb ccc\n"
assert result.message == (
"The file has been updated. All occurrences were successfully replaced"
)
@pytest.mark.asyncio
async def test_not_found_raises(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
_write(tmp_path, "f.txt", "hello world\n")
edit = _make_edit()
with pytest.raises(ToolError, match="String to replace not found in file"):
await collect_result(
edit.run(EditArgs(file_path="f.txt", old_string="missing", new_string="x"))
)
@pytest.mark.asyncio
async def test_multiple_matches_without_replace_all_raises(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
_write(tmp_path, "f.txt", "aaa bbb aaa\n")
edit = _make_edit()
with pytest.raises(ToolError, match="Found 2 matches"):
await collect_result(
edit.run(EditArgs(file_path="f.txt", old_string="aaa", new_string="ccc"))
)
@pytest.mark.asyncio
async def test_old_equals_new_raises(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
_write(tmp_path, "f.txt", "hello\n")
edit = _make_edit()
with pytest.raises(ToolError, match="No changes to make"):
await collect_result(
edit.run(
EditArgs(file_path="f.txt", old_string="hello", new_string="hello")
)
)
@pytest.mark.asyncio
async def test_empty_old_string_raises(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
_write(tmp_path, "f.txt", "hello\n")
edit = _make_edit()
with pytest.raises(ToolError, match="old_string cannot be empty"):
await collect_result(
edit.run(EditArgs(file_path="f.txt", old_string="", new_string="x"))
)
@pytest.mark.asyncio
async def test_file_not_found_raises(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
edit = _make_edit()
with pytest.raises(ToolError, match="File does not exist"):
await collect_result(
edit.run(
EditArgs(
file_path="/nonexistent/file.py", old_string="x", new_string="y"
)
)
)
@pytest.mark.asyncio
async def test_empty_file_path_raises(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
edit = _make_edit()
with pytest.raises(ToolError, match="File path cannot be empty"):
await collect_result(
edit.run(EditArgs(file_path="", old_string="x", new_string="y"))
)
@pytest.mark.asyncio
async def test_deletion_removes_exact_string(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
_write(tmp_path, "f.txt", "line1\nline2\nline3\n")
edit = _make_edit()
await collect_result(
edit.run(EditArgs(file_path="f.txt", old_string="line2\n", new_string=""))
)
assert (tmp_path / "f.txt").read_text() == "line1\nline3\n"
@pytest.mark.asyncio
async def test_parallel_edits_same_file_all_land(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
_write(tmp_path, "f.txt", "A\nB\nC\nD\n")
edit = _make_edit()
await asyncio.gather(
collect_result(
edit.run(EditArgs(file_path="f.txt", old_string="A", new_string="A1"))
),
collect_result(
edit.run(EditArgs(file_path="f.txt", old_string="B", new_string="B1"))
),
collect_result(
edit.run(EditArgs(file_path="f.txt", old_string="C", new_string="C1"))
),
collect_result(
edit.run(EditArgs(file_path="f.txt", old_string="D", new_string="D1"))
),
)
assert (tmp_path / "f.txt").read_text() == "A1\nB1\nC1\nD1\n"
@pytest.mark.asyncio
async def test_relative_path_resolved_from_cwd(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
(tmp_path / "sub").mkdir(exist_ok=True)
_write(tmp_path / "sub", "f.txt", "old")
edit = _make_edit()
result = await collect_result(
edit.run(EditArgs(file_path="sub/f.txt", old_string="old", new_string="new"))
)
assert (tmp_path / "sub" / "f.txt").read_text() == "new"
assert result.file == str(tmp_path / "sub" / "f.txt")
def test_format_call_display() -> None:
args = EditArgs(file_path="/abs/foo.py", old_string="old", new_string="new")
display = Edit.format_call_display(args)
assert isinstance(display, ToolCallDisplay)
assert display.summary == "Editing foo.py"
def test_get_result_display() -> None:
result = EditResult(
file="/path/to/foo.py",
message="The file has been updated successfully.",
old_string="old",
new_string="new",
)
event = ToolResultEvent(
tool_call_id="test", tool_name="edit", tool_class=None, result=result
)
display = Edit.get_result_display(event)
assert isinstance(display, ToolResultDisplay)
assert display.success is True
assert "foo.py" in display.message

View file

@ -0,0 +1,355 @@
from __future__ import annotations
from collections.abc import Iterator
from pathlib import Path
import pytest
from tests.mock.utils import collect_result
from vibe.core.config.harness_files import (
init_harness_files_manager,
reset_harness_files_manager,
)
from vibe.core.tools.base import ToolError
from vibe.core.tools.builtins.read import (
DEFAULT_LINE_LIMIT,
MAX_BYTES,
Read,
ReadArgs,
ReadConfig,
ReadResult,
ReadState,
_add_line_numbers,
)
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay
from vibe.core.trusted_folders import trusted_folders_manager
from vibe.core.types import ToolResultEvent
from vibe.core.utils import VIBE_WARNING_TAG
def _make_read() -> Read:
return Read(config_getter=lambda: ReadConfig(), state=ReadState())
@pytest.mark.asyncio
async def test_reads_entire_small_file(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
(tmp_path / "hello.txt").write_text("line one\nline two\n", encoding="utf-8")
tool = _make_read()
result = await collect_result(
tool.run(ReadArgs(file_path=str(tmp_path / "hello.txt")))
)
assert result.num_lines == 2
assert result.total_lines == 2
assert result.start_line == 1
assert "line one" in result.content
assert "line two" in result.content
@pytest.mark.asyncio
async def test_reads_with_offset_and_limit(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
content = "".join(f"line {i}\n" for i in range(1, 11))
(tmp_path / "f.txt").write_text(content, encoding="utf-8")
tool = _make_read()
result = await collect_result(
tool.run(ReadArgs(file_path=str(tmp_path / "f.txt"), offset=3, limit=2))
)
assert result.num_lines == 2
assert result.start_line == 3
# Bounded read stops at the limit, so the true total is unknown.
assert result.total_lines is None
assert result.was_truncated is True
assert "line 3" in result.content
assert "line 4" in result.content
assert "line 2" not in result.content
assert "line 5" not in result.content
@pytest.mark.asyncio
async def test_empty_file_returns_warning(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
(tmp_path / "empty.txt").write_text("", encoding="utf-8")
tool = _make_read()
result = await collect_result(
tool.run(ReadArgs(file_path=str(tmp_path / "empty.txt")))
)
assert result.num_lines == 0
assert VIBE_WARNING_TAG in result.content
assert "empty" in result.content
@pytest.mark.asyncio
async def test_offset_beyond_file_returns_warning(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
(tmp_path / "short.txt").write_text("one\ntwo\n", encoding="utf-8")
tool = _make_read()
result = await collect_result(
tool.run(ReadArgs(file_path=str(tmp_path / "short.txt"), offset=100))
)
assert result.num_lines == 0
assert VIBE_WARNING_TAG in result.content
assert "shorter" in result.content
@pytest.mark.asyncio
async def test_exceeds_max_bytes_raises(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
# Create file that generates output exceeding MAX_BYTES
big_line = "x" * 200 + "\n"
lines_needed = (MAX_BYTES // len(big_line)) + 100
(tmp_path / "big.txt").write_text(big_line * lines_needed, encoding="utf-8")
tool = _make_read()
with pytest.raises(ToolError, match="exceeds maximum allowed size"):
await collect_result(tool.run(ReadArgs(file_path=str(tmp_path / "big.txt"))))
@pytest.mark.asyncio
async def test_truncated_when_more_lines_than_limit(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
content = "".join(f"line {i}\n" for i in range(1, 101))
(tmp_path / "f.txt").write_text(content, encoding="utf-8")
tool = _make_read()
result = await collect_result(
tool.run(ReadArgs(file_path=str(tmp_path / "f.txt"), limit=10))
)
assert result.num_lines == 10
assert result.was_truncated is True
assert result.total_lines is None
@pytest.mark.asyncio
async def test_single_oversized_line_raises(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
(tmp_path / "wide.txt").write_text("x" * (MAX_BYTES + 10), encoding="utf-8")
tool = _make_read()
with pytest.raises(ToolError, match="exceeds maximum allowed size"):
await collect_result(tool.run(ReadArgs(file_path=str(tmp_path / "wide.txt"))))
@pytest.mark.asyncio
async def test_file_not_found_raises(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
tool = _make_read()
with pytest.raises(ToolError, match="File not found"):
await collect_result(tool.run(ReadArgs(file_path=str(tmp_path / "nope.txt"))))
@pytest.mark.asyncio
async def test_empty_path_raises(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
tool = _make_read()
with pytest.raises(ToolError, match="file_path cannot be empty"):
await collect_result(tool.run(ReadArgs(file_path="")))
@pytest.mark.asyncio
async def test_directory_raises(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
(tmp_path / "adir").mkdir()
tool = _make_read()
with pytest.raises(ToolError, match="directory"):
await collect_result(tool.run(ReadArgs(file_path=str(tmp_path / "adir"))))
@pytest.mark.asyncio
async def test_relative_path_resolved(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
(tmp_path / "sub").mkdir()
(tmp_path / "sub" / "f.txt").write_text("ok\n", encoding="utf-8")
tool = _make_read()
result = await collect_result(tool.run(ReadArgs(file_path="sub/f.txt")))
assert result.num_lines == 1
assert str(tmp_path / "sub" / "f.txt") == result.file_path
def test_line_number_format() -> None:
formatted = _add_line_numbers(["hello", "world"], start=1)
lines = formatted.split("\n")
assert lines[0] == " 1\u2192hello"
assert lines[1] == " 2\u2192world"
def test_default_limit_is_2000() -> None:
assert DEFAULT_LINE_LIMIT == 2000
def test_format_call_display() -> None:
args = ReadArgs(file_path="/some/file.py")
display = Read.format_call_display(args)
assert isinstance(display, ToolCallDisplay)
assert "file.py" in display.summary
def test_format_call_display_with_offset_limit() -> None:
args = ReadArgs(file_path="/some/file.py", offset=10, limit=50)
display = Read.format_call_display(args)
assert "from line 10" in display.summary
assert "limit 50" in display.summary
def test_get_result_display() -> None:
result = ReadResult(
file_path="/path/to/foo.py",
content="...",
num_lines=10,
start_line=1,
total_lines=10,
)
event = ToolResultEvent(
tool_call_id="test", tool_name="read", tool_class=None, result=result
)
display = Read.get_result_display(event)
assert isinstance(display, ToolResultDisplay)
assert display.success is True
assert "10 lines" in display.message
assert "foo.py" in display.message
def test_get_result_display_truncated() -> None:
result = ReadResult(
file_path="/path/to/foo.py",
content="...",
num_lines=10,
start_line=1,
total_lines=100,
)
event = ToolResultEvent(
tool_call_id="test", tool_name="read", tool_class=None, result=result
)
display = Read.get_result_display(event)
assert "truncated" in display.message
def test_get_result_display_truncated_via_flag() -> None:
result = ReadResult(
file_path="/path/to/foo.py",
content="...",
num_lines=10,
start_line=1,
total_lines=None,
was_truncated=True,
)
event = ToolResultEvent(
tool_call_id="test", tool_name="read", tool_class=None, result=result
)
display = Read.get_result_display(event)
assert "truncated" in display.message
@pytest.fixture()
def _setup_manager(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True)
monkeypatch.setattr(
trusted_folders_manager, "find_trust_root", lambda _: tmp_path.resolve()
)
reset_harness_files_manager()
init_harness_files_manager("user", "project")
yield
reset_harness_files_manager()
@pytest.mark.usefixtures("_setup_manager")
def test_agents_md_injection(tmp_path: Path) -> None:
sub = tmp_path / "sub"
sub.mkdir()
(sub / "AGENTS.md").write_text("# Sub instructions", encoding="utf-8")
target = sub / "file.py"
target.write_text("hello", encoding="utf-8")
tool = _make_read()
result = ReadResult(
file_path=str(target), content="hello", num_lines=1, start_line=1, total_lines=1
)
annotation = tool.get_result_extra(result)
assert annotation is not None
assert VIBE_WARNING_TAG in annotation
assert "# Sub instructions" in annotation
@pytest.mark.usefixtures("_setup_manager")
def test_agents_md_deduplicates(tmp_path: Path) -> None:
sub = tmp_path / "sub"
sub.mkdir()
(sub / "AGENTS.md").write_text("# Sub", encoding="utf-8")
(sub / "a.py").write_text("a", encoding="utf-8")
(sub / "b.py").write_text("b", encoding="utf-8")
tool = _make_read()
r1 = ReadResult(
file_path=str(sub / "a.py"),
content="a",
num_lines=1,
start_line=1,
total_lines=1,
)
assert tool.get_result_extra(r1) is not None
r2 = ReadResult(
file_path=str(sub / "b.py"),
content="b",
num_lines=1,
start_line=1,
total_lines=1,
)
assert tool.get_result_extra(r2) is None
def test_agents_md_returns_none_when_not_initialized(tmp_path: Path) -> None:
reset_harness_files_manager()
tool = _make_read()
result = ReadResult(
file_path=str(tmp_path / "file.py"),
content="",
num_lines=0,
start_line=1,
total_lines=0,
)
assert tool.get_result_extra(result) is None
reset_harness_files_manager()

View file

@ -1,217 +0,0 @@
from __future__ import annotations
from collections.abc import Iterator
from pathlib import Path
import pytest
from tests.mock.utils import collect_result
from vibe.core.config.harness_files import (
init_harness_files_manager,
reset_harness_files_manager,
)
from vibe.core.tools.builtins.read_file import (
ReadFile,
ReadFileArgs,
ReadFileResult,
ReadFileState,
ReadFileToolConfig,
)
from vibe.core.trusted_folders import trusted_folders_manager
from vibe.core.utils import VIBE_WARNING_TAG
@pytest.fixture()
def _setup_manager(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
"""Initialize harness files manager for tests, reset after."""
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True)
monkeypatch.setattr(
trusted_folders_manager, "find_trust_root", lambda _: tmp_path.resolve()
)
reset_harness_files_manager()
init_harness_files_manager("user", "project")
yield
reset_harness_files_manager()
def _make_read_file() -> ReadFile:
return ReadFile(config_getter=lambda: ReadFileToolConfig(), state=ReadFileState())
class TestReadFileExecution:
@pytest.mark.asyncio
async def test_run_with_large_offset_still_reads_lines(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
test_file = tmp_path / "large_file.txt"
test_file.write_text(
"".join(f"line {i}\n" for i in range(200)), encoding="utf-8"
)
tool = ReadFile(
config_getter=lambda: ReadFileToolConfig(max_read_bytes=64),
state=ReadFileState(),
)
result = await collect_result(
tool.run(ReadFileArgs(path=str(test_file), offset=50, limit=2))
)
assert result.content == "line 50\nline 51\n"
assert result.lines_read == 2
assert result.was_truncated
@pytest.mark.asyncio
async def test_run_marks_truncated_when_max_read_bytes_exceeded(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
test_file = tmp_path / "big.txt"
test_file.write_text(
"".join(f"line {i}\n" for i in range(100)), encoding="utf-8"
)
tool = ReadFile(
config_getter=lambda: ReadFileToolConfig(max_read_bytes=20),
state=ReadFileState(),
)
result = await collect_result(tool.run(ReadFileArgs(path=str(test_file))))
assert result.was_truncated
assert result.lines_read > 0
@pytest.mark.asyncio
async def test_run_not_truncated_when_eof_reached(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
test_file = tmp_path / "small.txt"
test_file.write_text("a\nb\nc\n", encoding="utf-8")
tool = ReadFile(
config_getter=lambda: ReadFileToolConfig(max_read_bytes=1024),
state=ReadFileState(),
)
result = await collect_result(tool.run(ReadFileArgs(path=str(test_file))))
assert not result.was_truncated
assert result.lines_read == 3
@pytest.mark.asyncio
async def test_run_not_truncated_when_limit_matches_remaining_lines(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
test_file = tmp_path / "exact.txt"
test_file.write_text("a\nb\nc\n", encoding="utf-8")
tool = ReadFile(
config_getter=lambda: ReadFileToolConfig(max_read_bytes=1024),
state=ReadFileState(),
)
result = await collect_result(
tool.run(ReadFileArgs(path=str(test_file), limit=10))
)
assert not result.was_truncated
assert result.lines_read == 3
class TestGetResultExtra:
@pytest.mark.usefixtures("_setup_manager")
def test_returns_none_when_no_agents_md(self, tmp_path: Path) -> None:
sub = tmp_path / "sub"
sub.mkdir()
target = sub / "file.py"
target.write_text("hello", encoding="utf-8")
tool = _make_read_file()
result = ReadFileResult(
path=str(target), content="hello", lines_read=1, was_truncated=False
)
assert tool.get_result_extra(result) is None
@pytest.mark.usefixtures("_setup_manager")
def test_returns_tagged_content_when_agents_md_found(self, tmp_path: Path) -> None:
sub = tmp_path / "sub"
sub.mkdir()
(sub / "AGENTS.md").write_text("# Sub instructions", encoding="utf-8")
target = sub / "file.py"
target.write_text("hello", encoding="utf-8")
tool = _make_read_file()
result = ReadFileResult(
path=str(target), content="hello", lines_read=1, was_truncated=False
)
annotation = tool.get_result_extra(result)
assert annotation is not None
assert f"<{VIBE_WARNING_TAG}>" in annotation
assert f"</{VIBE_WARNING_TAG}>" in annotation
assert "# Sub instructions" in annotation
assert "project instructions for this directory" in annotation
@pytest.mark.usefixtures("_setup_manager")
def test_deduplicates_across_calls(self, tmp_path: Path) -> None:
sub = tmp_path / "sub"
sub.mkdir()
(sub / "AGENTS.md").write_text("# Sub", encoding="utf-8")
file1 = sub / "a.py"
file2 = sub / "b.py"
file1.write_text("a", encoding="utf-8")
file2.write_text("b", encoding="utf-8")
tool = _make_read_file()
result1 = ReadFileResult(
path=str(file1), content="a", lines_read=1, was_truncated=False
)
assert tool.get_result_extra(result1) is not None
# Second call for a different file in the same dir → no duplicate
result2 = ReadFileResult(
path=str(file2), content="b", lines_read=1, was_truncated=False
)
assert tool.get_result_extra(result2) is None
@pytest.mark.usefixtures("_setup_manager")
def test_injects_new_dir_after_dedup(self, tmp_path: Path) -> None:
sub_a = tmp_path / "a"
sub_b = tmp_path / "b"
sub_a.mkdir()
sub_b.mkdir()
(sub_a / "AGENTS.md").write_text("# A", encoding="utf-8")
(sub_b / "AGENTS.md").write_text("# B", encoding="utf-8")
file_a = sub_a / "f.py"
file_b = sub_b / "f.py"
file_a.write_text("", encoding="utf-8")
file_b.write_text("", encoding="utf-8")
tool = _make_read_file()
r1 = ReadFileResult(
path=str(file_a), content="", lines_read=0, was_truncated=False
)
ann1 = tool.get_result_extra(r1)
assert ann1 is not None
assert "# A" in ann1
# Different subdirectory → should inject its AGENTS.md
r2 = ReadFileResult(
path=str(file_b), content="", lines_read=0, was_truncated=False
)
ann2 = tool.get_result_extra(r2)
assert ann2 is not None
assert "# B" in ann2
def test_returns_none_when_manager_not_initialized(self, tmp_path: Path) -> None:
reset_harness_files_manager()
tool = _make_read_file()
result = ReadFileResult(
path=str(tmp_path / "file.py"),
content="",
lines_read=0,
was_truncated=False,
)
assert tool.get_result_extra(result) is None
reset_harness_files_manager()