diff --git a/.gitignore b/.gitignore index 096b9ac..0c1ec63 100644 --- a/.gitignore +++ b/.gitignore @@ -203,3 +203,6 @@ tests/playground/* # Profiler HTML/TXT reports (generated by vibe.cli.profiler) *-profile.html *-profile.txt + +# Snapshot test report (generated by pytest-textual-snapshot) +snapshot_report.html diff --git a/AGENTS.md b/AGENTS.md index 1f3bce3..edba197 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,7 +66,7 @@ Always go through `uv` — never invoke bare `python` or `pip`. ## Logging & errors - Use `from vibe.core.logger import logger` — stdlib `logging` with `StructuredLogFormatter`, not `structlog`. -- Configure via env: `LOG_LEVEL` (default `WARNING`), `DEBUG_MODE`, `LOG_MAX_BYTES`. Logs land in `~/.vibe/logs/vibe.log`. +- Configure via env: `LOG_LEVEL` (default `WARNING`), `LOG_MAX_BYTES`. Logs land in `~/.vibe/logs/vibe.log`. - Pass variables as keyword args, not interpolated into the message: prefer `logger.error("Failed to fetch", url=url)` over `logger.error(f"Failed to fetch {url}")`. - Define module-local exception hierarchies. Always chain with `raise NewError(...) from e`. Rich exceptions expose a `_fmt()` helper for human-readable output. diff --git a/CHANGELOG.md b/CHANGELOG.md index 3839799..f6ba2bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.9.5] - 2026-05-06 + +### Added + +- `/loop` command to run a prompt or slash command on a recurring interval +- `default_agent` config setting +- Telemetry instrumentation for the `teleport` command +- Logging environment variables documented in `--help` + +### Changed + +- `enable_telemetry` now takes precedence over `enable_otel` + +### Fixed + +- Non-retryable errors raised by sub-activities now correctly stop the retry when the agent loop runs inside a Temporal activity (deepens the 2.9.4 fix to walk the exception cause chain) +- Compacted session IDs displayed correctly +- Reload the history file before writing so parallel instances don't clobber each other +- `read_file` flagged as truncated when the limit is reached +- CLI loader left-alignment +- Default scroll sensitivity in the TUI restored +- Loosened the "no git commit" constraint +- Ensure `enable_telemetry` takes precedence over `enable_otel` + + ## [2.9.4] - 2026-05-05 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index aac0ce3..620b539 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -80,7 +80,7 @@ Logs are written to `~/.vibe/logs/vibe.log` by default. Control logging via envi |----------|-------------|---------| | `LOG_LEVEL` | Logging level (`DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`) | `WARNING` | | `LOG_MAX_BYTES` | Max log file size in bytes before rotation | `10485760` (10 MB) | -| `DEBUG_MODE` | When `true`, forces `DEBUG` level | - | +| `DEBUG_MODE` | When `true`, forces `DEBUG` logging (and attaches `debugpy` on `localhost:5678` under `vibe-acp`) | - | Example: diff --git a/README.md b/README.md index 9a21110..37f1b3e 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,22 @@ Use the `--agent` flag to select a different agent: vibe --agent plan ``` +To change the default agent used when `--agent` is not passed, set +`default_agent` in your `config.toml`: + +```toml +default_agent = "plan" +``` + +Valid values are `default`, `plan`, `accept-edits`, `auto-approve`, +`lean` (only when listed in `installed_agents`), or the name of any +custom agent file in `~/.vibe/agents/` or the project's `.vibe/agents/` +directory. Subagents such as `explore` are not accepted. + +> Note: `default_agent` only applies to interactive sessions. In +> programmatic mode (`-p` / `--prompt`), Vibe falls back to `auto-approve` +> when `--agent` is not provided, so `default_agent` is ignored. + ### Subagents and Task Delegation Vibe supports subagents for delegating tasks. Subagents run independently and can perform specialized work without user interaction, preventing the context from being overloaded. diff --git a/distribution/zed/extension.toml b/distribution/zed/extension.toml index 962ee3d..c849971 100644 --- a/distribution/zed/extension.toml +++ b/distribution/zed/extension.toml @@ -1,7 +1,7 @@ id = "mistral-vibe" name = "Mistral Vibe" description = "Mistral's open-source coding assistant" -version = "2.9.4" +version = "2.9.5" schema_version = 1 authors = ["Mistral AI"] repository = "https://github.com/mistralai/mistral-vibe" @@ -11,21 +11,21 @@ name = "Mistral Vibe" icon = "./icons/mistral_vibe.svg" [agent_servers.mistral-vibe.targets.darwin-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.4/vibe-acp-darwin-aarch64-2.9.4.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.5/vibe-acp-darwin-aarch64-2.9.5.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.darwin-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.4/vibe-acp-darwin-x86_64-2.9.4.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.5/vibe-acp-darwin-x86_64-2.9.5.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.linux-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.4/vibe-acp-linux-aarch64-2.9.4.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.5/vibe-acp-linux-aarch64-2.9.5.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.linux-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.4/vibe-acp-linux-x86_64-2.9.4.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.5/vibe-acp-linux-x86_64-2.9.5.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.windows-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.4/vibe-acp-windows-x86_64-2.9.4.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.5/vibe-acp-windows-x86_64-2.9.5.zip" cmd = "./vibe-acp.exe" diff --git a/pyproject.toml b/pyproject.toml index f8adcf2..6901e92 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mistral-vibe" -version = "2.9.4" +version = "2.9.5" description = "Minimal CLI coding agent by Mistral" readme = "README.md" requires-python = ">=3.12" diff --git a/tests/acp/test_acp_entrypoint_smoke.py b/tests/acp/test_acp_entrypoint_smoke.py index 6826e24..e6a11cc 100644 --- a/tests/acp/test_acp_entrypoint_smoke.py +++ b/tests/acp/test_acp_entrypoint_smoke.py @@ -101,6 +101,11 @@ def _build_env(vibe_home_dir: Path, *, include_api_key: bool) -> dict[str, str]: env["PYTHONUNBUFFERED"] = "1" env["VIBE_HOME"] = str(vibe_home_dir) + vibe_home_dir.mkdir(parents=True, exist_ok=True) + config_file = vibe_home_dir / "config.toml" + if not config_file.exists(): + config_file.write_text("enable_telemetry = false\n") + if include_api_key: env["MISTRAL_API_KEY"] = "mock" else: diff --git a/tests/acp/test_close_session.py b/tests/acp/test_close_session.py index cabcb0f..acd60a6 100644 --- a/tests/acp/test_close_session.py +++ b/tests/acp/test_close_session.py @@ -14,7 +14,7 @@ from vibe.acp.acp_agent_loop import VibeAcpAgentLoop class TestCloseSession: @pytest.mark.asyncio async def test_close_session_removes_session_and_closes_resources( - self, acp_agent_loop: VibeAcpAgentLoop + self, acp_agent_loop: VibeAcpAgentLoop, telemetry_events: list[dict[str, Any]] ) -> None: session_response = await acp_agent_loop.new_session(cwd=".", mcp_servers=[]) session = acp_agent_loop.sessions[session_response.session_id] @@ -30,6 +30,17 @@ class TestCloseSession: assert session_response.session_id not in acp_agent_loop.sessions backend_close.assert_awaited_once() telemetry_close.assert_awaited_once() + session_closed_events = [ + event + for event in telemetry_events + if event["event_name"] == "vibe.session_closed" + ] + assert len(session_closed_events) == 1 + assert ( + session_closed_events[0]["properties"]["session_id"] + == session_response.session_id + ) + assert session_closed_events[0]["properties"]["agent_entrypoint"] == "acp" @pytest.mark.asyncio async def test_close_session_cancels_active_prompt( @@ -69,6 +80,23 @@ class TestCloseSession: assert bg_task.cancelled() + @pytest.mark.asyncio + async def test_emit_session_closed_for_active_sessions( + self, acp_agent_loop: VibeAcpAgentLoop, telemetry_events: list[dict[str, Any]] + ) -> None: + session1 = await acp_agent_loop.new_session(cwd=".", mcp_servers=[]) + session2 = await acp_agent_loop.new_session(cwd=".", mcp_servers=[]) + + await acp_agent_loop.emit_session_closed_for_active_sessions() + + session_closed_events = [ + e for e in telemetry_events if e["event_name"] == "vibe.session_closed" + ] + emitted_ids = {e["properties"]["session_id"] for e in session_closed_events} + assert len(session_closed_events) == 2 + assert session1.session_id in emitted_ids + assert session2.session_id in emitted_ids + @pytest.mark.asyncio async def test_close_session_rejects_new_background_tasks( self, acp_agent_loop: VibeAcpAgentLoop diff --git a/tests/acp/test_compact_session_updates.py b/tests/acp/test_compact_session_updates.py index 0410068..42ba529 100644 --- a/tests/acp/test_compact_session_updates.py +++ b/tests/acp/test_compact_session_updates.py @@ -12,6 +12,7 @@ from tests.stubs.fake_backend import FakeBackend from tests.stubs.fake_client import FakeClient from vibe.acp.acp_agent_loop import VibeAcpAgentLoop from vibe.core.agent_loop import AgentLoop +from vibe.core.session.session_id import shorten_session_id @pytest.fixture @@ -79,3 +80,10 @@ class TestCompactEventHandling: assert compact_end.status == "completed" assert compact_start.tool_call_id == compact_end.tool_call_id + assert compact_end.content is not None + compact_end_text = compact_end.content[0].content + assert isinstance(compact_end_text, TextContentBlock) + assert shorten_session_id(session_response.session_id) in compact_end_text.text + assert ( + shorten_session_id(session.agent_loop.session_id) in compact_end_text.text + ) diff --git a/tests/acp/test_initialize.py b/tests/acp/test_initialize.py index f8d7b16..7f9b337 100644 --- a/tests/acp/test_initialize.py +++ b/tests/acp/test_initialize.py @@ -34,7 +34,7 @@ class TestACPInitialize: ), ) assert response.agent_info == Implementation( - name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.9.4" + name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.9.5" ) assert response.auth_methods == [] @@ -62,7 +62,7 @@ class TestACPInitialize: ), ) assert response.agent_info == Implementation( - name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.9.4" + name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.9.5" ) assert response.auth_methods is not None diff --git a/tests/acp/test_multi_session.py b/tests/acp/test_multi_session.py index 9cfad26..8cf95c4 100644 --- a/tests/acp/test_multi_session.py +++ b/tests/acp/test_multi_session.py @@ -92,20 +92,26 @@ class TestMultiSessionCore: ) assert user_message1 is not None assert user_message1.content == "Prompt for session 1" - assistant_message1 = next( - (msg for msg in session1.agent_loop.messages if msg.role == Role.assistant), - None, - ) - assert assistant_message1 is not None - assert assistant_message1.content == "Response 1" user_message2 = next( (msg for msg in session2.agent_loop.messages if msg.role == Role.user), None ) assert user_message2 is not None assert user_message2.content == "Prompt for session 2" + + # Backend stream order is non-deterministic under asyncio.gather, so + # assert that both sessions received distinct responses from the + # expected set rather than pinning a specific assignment. + assistant_message1 = next( + (msg for msg in session1.agent_loop.messages if msg.role == Role.assistant), + None, + ) assistant_message2 = next( (msg for msg in session2.agent_loop.messages if msg.role == Role.assistant), None, ) + assert assistant_message1 is not None assert assistant_message2 is not None - assert assistant_message2.content == "Response 2" + assert {assistant_message1.content, assistant_message2.content} == { + "Response 1", + "Response 2", + } diff --git a/tests/autocompletion/test_slash_command_controller.py b/tests/autocompletion/test_slash_command_controller.py index 8feeb53..2bd0984 100644 --- a/tests/autocompletion/test_slash_command_controller.py +++ b/tests/autocompletion/test_slash_command_controller.py @@ -199,6 +199,28 @@ def test_callable_entries_updates_completions_dynamically() -> None: assert suggestions[0].description == "Summarize the conversation" +def test_tab_on_slash_command_with_args_replaces_only_head() -> None: + controller, view = make_controller() + text = "/compact some args" + controller.on_text_changed(text, cursor_index=len(text)) + + result = controller.on_key(key_event("tab"), text=text, cursor_index=len(text)) + + assert result is CompletionResult.HANDLED + assert view.replacements == [Replacement(0, 8, "/compact")] + + +def test_enter_on_slash_command_with_args_submits_with_head_only_replacement() -> None: + controller, view = make_controller() + text = "/compact some args" + controller.on_text_changed(text, cursor_index=len(text)) + + result = controller.on_key(key_event("enter"), text=text, cursor_index=len(text)) + + assert result is CompletionResult.SUBMIT + assert view.replacements == [Replacement(0, 8, "/compact")] + + def test_callable_entries_reflects_enabled_disabled_skills() -> None: """Test that skill enable/disable changes are reflected in completions. diff --git a/tests/autocompletion/test_ui_chat_autocompletion.py b/tests/autocompletion/test_ui_chat_autocompletion.py index 693292d..6bf5dfc 100644 --- a/tests/autocompletion/test_ui_chat_autocompletion.py +++ b/tests/autocompletion/test_ui_chat_autocompletion.py @@ -42,7 +42,7 @@ async def test_popup_hides_when_input_cleared(vibe_app: VibeApp) -> None: @pytest.mark.asyncio -async def test_pressing_tab_writes_selected_command_and_hides_popup( +async def test_pressing_tab_writes_selected_command_and_leaves_popup_visible( vibe_app: VibeApp, ) -> None: async with vibe_app.run_test() as pilot: @@ -53,7 +53,7 @@ async def test_pressing_tab_writes_selected_command_and_hides_popup( await pilot.press("tab") assert chat_input.value == "/config" - assert popup.styles.display == "none" + assert popup.styles.display == "block" def ensure_selected_command(popup: CompletionPopup, expected_alias: str) -> None: diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index 372542c..dba193d 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -133,3 +133,13 @@ class TestCommandRegistry: assert result is not None _, cmd, _ = result assert cmd.handler == "_show_data_retention" + + def test_loop_command_registration(self) -> None: + registry = CommandRegistry() + assert registry.get_command_name("/loop") == "loop" + result = registry.parse_command("/loop 30s ping") + assert result is not None + cmd_name, cmd, cmd_args = result + assert cmd_name == "loop" + assert cmd.handler == "_loop_command" + assert cmd_args == "30s ping" diff --git a/tests/cli/test_compact_message.py b/tests/cli/test_compact_message.py new file mode 100644 index 0000000..0183f0a --- /dev/null +++ b/tests/cli/test_compact_message.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from unittest.mock import MagicMock + +from vibe.cli.textual_ui.widgets.compact import CompactMessage +from vibe.core.session.session_id import shorten_session_id + + +class TestCompactMessage: + def test_get_content_includes_session_ids_after_compaction(self) -> None: + message = CompactMessage() + message.post_message = MagicMock() + + message.set_complete( + old_tokens=177_017, + new_tokens=23_263, + old_session_id="11111111-1111-1111-1111-111111111111", + new_session_id="22222222-2222-2222-2222-222222222222", + ) + + assert message.get_content() == ( + "Compaction complete: 177,017 → 23,263 tokens (-87.%)\n" + "session: " + f"{shorten_session_id('11111111-1111-1111-1111-111111111111')} " + "(before compaction) → " + f"{shorten_session_id('22222222-2222-2222-2222-222222222222')} " + "(after compaction)" + ) diff --git a/tests/cli/test_initial_agent_name.py b/tests/cli/test_initial_agent_name.py new file mode 100644 index 0000000..037678d --- /dev/null +++ b/tests/cli/test_initial_agent_name.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import argparse + +from vibe.cli.cli import get_initial_agent_name +from vibe.core.agents.models import BuiltinAgentName +from vibe.core.config import VibeConfig + + +def _make_args(*, agent: str | None, prompt: str | None) -> argparse.Namespace: + return argparse.Namespace(agent=agent, prompt=prompt) + + +def test_uses_args_agent_when_provided() -> None: + config = VibeConfig.model_construct(default_agent=BuiltinAgentName.PLAN) + args = _make_args(agent="accept-edits", prompt=None) + + assert get_initial_agent_name(args, config) == "accept-edits" + + +def test_falls_back_to_config_default_agent_when_args_agent_is_none() -> None: + config = VibeConfig.model_construct(default_agent=BuiltinAgentName.PLAN) + args = _make_args(agent=None, prompt=None) + + assert get_initial_agent_name(args, config) == BuiltinAgentName.PLAN + + +def test_defaults_to_default_when_unset_in_config_and_args() -> None: + config = VibeConfig.model_construct() + args = _make_args(agent=None, prompt=None) + + assert get_initial_agent_name(args, config) == BuiltinAgentName.DEFAULT + + +def test_programmatic_mode_promotes_default_to_auto_approve() -> None: + config = VibeConfig.model_construct(default_agent=BuiltinAgentName.DEFAULT) + args = _make_args(agent=None, prompt="hello") + + assert get_initial_agent_name(args, config) == BuiltinAgentName.AUTO_APPROVE + + +def test_programmatic_mode_ignores_config_default_agent_when_args_agent_is_none() -> ( + None +): + config = VibeConfig.model_construct(default_agent=BuiltinAgentName.PLAN) + args = _make_args(agent=None, prompt="hello") + + assert get_initial_agent_name(args, config) == BuiltinAgentName.AUTO_APPROVE + + +def test_programmatic_mode_keeps_explicit_agent_arg() -> None: + config = VibeConfig.model_construct(default_agent=BuiltinAgentName.DEFAULT) + args = _make_args(agent="accept-edits", prompt="hello") + + assert get_initial_agent_name(args, config) == "accept-edits" diff --git a/tests/cli/test_ui_teleport_availability.py b/tests/cli/test_ui_teleport_availability.py index 45bb0d2..6a8aa28 100644 --- a/tests/cli/test_ui_teleport_availability.py +++ b/tests/cli/test_ui_teleport_availability.py @@ -1,6 +1,7 @@ from __future__ import annotations import time +from typing import Any from unittest.mock import AsyncMock, patch import pytest @@ -10,7 +11,7 @@ from tests.conftest import build_test_vibe_app, build_test_vibe_config from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIPlanType, WhoAmIResponse from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig -from vibe.core.types import Backend +from vibe.core.types import Backend, LLMMessage, Role def _chat_plan_gateway(*, prompt_switching_to_pro_plan: bool) -> FakeWhoAmIGateway: @@ -36,6 +37,16 @@ async def _wait_until(pause, predicate, timeout: float = 2.0) -> None: raise AssertionError("Condition was not met within the timeout") +def _teleport_failed_events( + telemetry_events: list[dict[str, Any]], +) -> list[dict[str, Any]]: + return [ + event + for event in telemetry_events + if event["event_name"] == "vibe.teleport_failed" + ] + + @pytest.mark.asyncio async def test_teleport_command_visible_for_paid_chat_users() -> None: app = build_test_vibe_app( @@ -56,6 +67,81 @@ async def test_teleport_command_visible_for_paid_chat_users() -> None: assert "&" in input_widget.mode_characters +@pytest.mark.asyncio +async def test_teleport_command_without_history_sends_early_failure_telemetry( + telemetry_events: list[dict[str, Any]], +) -> None: + app = build_test_vibe_app( + config=_vibe_code_enabled_config(), + plan_offer_gateway=_chat_plan_gateway(prompt_switching_to_pro_plan=False), + ) + + async with app.run_test() as pilot: + await _wait_until( + pilot.pause, + lambda: app.commands.get_command_name("/teleport") == "teleport", + ) + + await app.on_chat_input_container_submitted( + ChatInputContainer.Submitted("/teleport") + ) + + assert _teleport_failed_events(telemetry_events) == [ + { + "event_name": "vibe.teleport_failed", + "properties": { + "stage": "no_history", + "error_class": "TeleportNoHistoryError", + "push_required": False, + "github_auth_required": False, + "nb_session_messages": 0, + "session_id": app.agent_loop.session_id, + }, + } + ] + + +@pytest.mark.asyncio +async def test_teleport_command_in_remote_session_sends_early_failure_telemetry( + telemetry_events: list[dict[str, Any]], +) -> None: + app = build_test_vibe_app( + config=_vibe_code_enabled_config(), + plan_offer_gateway=_chat_plan_gateway(prompt_switching_to_pro_plan=False), + ) + app.agent_loop.messages.append(LLMMessage(role=Role.user, content="hello")) + app.agent_loop.messages.append(LLMMessage(role=Role.assistant, content="hi")) + + async with app.run_test() as pilot: + await _wait_until( + pilot.pause, + lambda: app.commands.get_command_name("/teleport") == "teleport", + ) + + await app._remote_manager.attach(session_id="remote-session", config=app.config) + await app.on_chat_input_container_submitted( + ChatInputContainer.Submitted("/teleport") + ) + await _wait_until( + pilot.pause, lambda: len(_teleport_failed_events(telemetry_events)) == 1 + ) + await app._remote_manager.detach() + + assert _teleport_failed_events(telemetry_events) == [ + { + "event_name": "vibe.teleport_failed", + "properties": { + "stage": "remote_session", + "error_class": "TeleportRemoteSessionError", + "push_required": False, + "github_auth_required": False, + "nb_session_messages": 2, + "session_id": app.agent_loop.session_id, + }, + } + ] + + @pytest.mark.asyncio async def test_teleport_command_hidden_when_current_key_is_not_eligible() -> None: app = build_test_vibe_app( diff --git a/tests/conftest.py b/tests/conftest.py index 56112e8..c79c034 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -53,6 +53,7 @@ def get_base_config() -> dict[str, Any]: } ], "enable_auto_update": False, + "enable_telemetry": False, } @@ -174,7 +175,8 @@ def telemetry_events(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]: *, correlation_id: str | None = None, ) -> None: - event: dict[str, Any] = {"event_name": event_name, "properties": properties} + merged = self.build_client_event_metadata() | properties + event: dict[str, Any] = {"event_name": event_name, "properties": merged} if correlation_id is not None: event["correlation_id"] = correlation_id events.append(event) diff --git a/tests/core/test_agents.py b/tests/core/test_agents.py index 37c86ce..b2a0442 100644 --- a/tests/core/test_agents.py +++ b/tests/core/test_agents.py @@ -104,3 +104,19 @@ class TestAgentManager: ) manager = AgentManager(lambda: config, initial_agent="plan") assert manager.active_profile.name == "plan" + + def test_initial_agent_raises_when_agent_is_disabled(self) -> None: + config = build_test_vibe_config( + include_project_context=False, + include_prompt_detail=False, + disabled_agents=["plan"], + ) + with pytest.raises(ValueError, match="not available"): + AgentManager(lambda: config, initial_agent="plan") + + def test_initial_agent_raises_when_agent_does_not_exist(self) -> None: + config = build_test_vibe_config( + include_project_context=False, include_prompt_detail=False + ) + with pytest.raises(ValueError, match="not found"): + AgentManager(lambda: config, initial_agent="nonexistent-agent") diff --git a/tests/core/test_loop.py b/tests/core/test_loop.py new file mode 100644 index 0000000..91eb317 --- /dev/null +++ b/tests/core/test_loop.py @@ -0,0 +1,304 @@ +from __future__ import annotations + +import math +from typing import cast + +import pytest + +from vibe.core.loop import ( + MAX_LOOPS_PER_SESSION, + MIN_INTERVAL_SECONDS, + LoopError, + LoopErrorResult, + LoopListResult, + LoopManager, + LoopOkResult, + ScheduledLoop, + format_duration, + parse_interval, +) +from vibe.core.session.session_logger import SessionLogger + + +class FakeMetadata: + def __init__(self) -> None: + self.loops: list[ScheduledLoop] = [] + + +class FakeSessionLogger: + def __init__(self) -> None: + self.session_metadata = FakeMetadata() + self.persisted: list[list[ScheduledLoop]] = [] + + async def persist_loops(self) -> None: + self.persisted.append([*self.session_metadata.loops]) + + +class RaisingSessionLogger: + def __init__(self) -> None: + self.session_metadata = FakeMetadata() + + async def persist_loops(self) -> None: + raise RuntimeError("disk on fire") + + +def _build_manager() -> tuple[LoopManager, FakeSessionLogger]: + fake = FakeSessionLogger() + return LoopManager(cast(SessionLogger, fake)), fake + + +class TestParseInterval: + def test_parses_supported_units(self) -> None: + assert parse_interval("30s") == 30 + assert parse_interval("5m") == 300 + assert parse_interval("2h") == 7200 + assert parse_interval("1d") == 86400 + + def test_parses_case_insensitively(self) -> None: + assert parse_interval("30S") == 30 + assert parse_interval("2H") == 7200 + + def test_strips_whitespace(self) -> None: + assert parse_interval(" 30s ") == 30 + + @pytest.mark.parametrize("bad", ["", "5", "5x", "-5m", "5 m", "1.5m", "abc"]) + def test_rejects_invalid_inputs(self, bad: str) -> None: + with pytest.raises(LoopError): + parse_interval(bad) + + def test_rejects_below_minimum(self) -> None: + with pytest.raises(LoopError, match=f"at least {MIN_INTERVAL_SECONDS}"): + parse_interval("29s") + + +class TestFormatInterval: + @pytest.mark.parametrize("text", ["30s", "5m", "2h", "1d"]) + def test_round_trip_with_parse(self, text: str) -> None: + assert format_duration(parse_interval(text)) == text + + def test_formats_non_round_values(self) -> None: + assert format_duration(90) == "1m30s" + assert format_duration(180) == "3m" + assert format_duration(7200) == "2h" + assert format_duration(259200) == "3d" + + def test_formats_short_version(self) -> None: + assert format_duration(90, short=True) == "1m" + assert format_duration(180, short=True) == "3m" + assert format_duration(190, short=True) == "3m" + assert format_duration(8000, short=True) == "2h" + assert format_duration(260000, short=True) == "3d" + + +class TestLoopManagerHandleCommand: + @pytest.mark.asyncio + async def test_empty_returns_loops_list(self) -> None: + manager, fake = _build_manager() + result = await manager.handle_command("") + assert isinstance(result, LoopListResult) + assert result.loops == [] + assert fake.persisted == [] + + @pytest.mark.asyncio + @pytest.mark.parametrize("verb", ["list", "ls"]) + async def test_list_alias_returns_loops_list(self, verb: str) -> None: + manager, fake = _build_manager() + result = await manager.handle_command(verb) + assert isinstance(result, LoopListResult) + assert result.loops == [] + assert fake.persisted == [] + + @pytest.mark.asyncio + async def test_add_success_persists_and_returns_id(self) -> None: + manager, fake = _build_manager() + result = await manager.handle_command("1m hello world") + assert isinstance(result, LoopOkResult) + assert "every 1m" in result.message + assert "hello world" in result.message + assert len(manager.loops) == 1 + loop = manager.loops[0] + assert loop.interval_seconds == 60 + assert loop.prompt == "hello world" + assert result.message.count(loop.id) >= 1 + assert len(fake.persisted) == 1 + assert fake.persisted[0][0].id == loop.id + + @pytest.mark.asyncio + async def test_add_bad_interval_returns_error_no_persist(self) -> None: + manager, fake = _build_manager() + result = await manager.handle_command("nope hello") + assert isinstance(result, LoopErrorResult) + assert "Invalid interval" in result.message + assert "Usage:" not in result.message + assert manager.loops == [] + assert fake.persisted == [] + + @pytest.mark.asyncio + async def test_add_empty_prompt_returns_error_no_persist(self) -> None: + manager, fake = _build_manager() + result = await manager.handle_command("30s ") + assert isinstance(result, LoopErrorResult) + assert "Missing prompt" in result.message + assert manager.loops == [] + assert fake.persisted == [] + + @pytest.mark.asyncio + async def test_add_prompt_starting_with_slash_returns_error(self) -> None: + manager, fake = _build_manager() + result = await manager.handle_command("30s /config") + assert isinstance(result, LoopErrorResult) + assert "cannot start with '/'" in result.message + assert manager.loops == [] + assert fake.persisted == [] + + @pytest.mark.asyncio + async def test_over_limit_returns_error(self) -> None: + manager, _ = _build_manager() + for i in range(MAX_LOOPS_PER_SESSION): + result = await manager.handle_command(f"30s prompt {i}") + assert isinstance(result, LoopOkResult) + result = await manager.handle_command("30s overflow") + assert isinstance(result, LoopErrorResult) + assert "limit" in result.message.lower() + assert len(manager.loops) == MAX_LOOPS_PER_SESSION + + @pytest.mark.asyncio + async def test_cancel_missing_target_is_error_no_persist(self) -> None: + manager, fake = _build_manager() + result = await manager.handle_command("cancel") + assert isinstance(result, LoopErrorResult) + assert "Missing loop id" in result.message + assert fake.persisted == [] + + @pytest.mark.asyncio + async def test_cancel_unknown_id_is_error_no_persist(self) -> None: + manager, fake = _build_manager() + await manager.handle_command("30s ping") + fake.persisted.clear() + result = await manager.handle_command("cancel deadbeef") + assert isinstance(result, LoopErrorResult) + assert "deadbeef" in result.message + assert fake.persisted == [] + + @pytest.mark.asyncio + async def test_cancel_known_id_persists(self) -> None: + manager, fake = _build_manager() + await manager.handle_command("30s ping") + loop_id = manager.loops[0].id + fake.persisted.clear() + result = await manager.handle_command(f"cancel {loop_id}") + assert isinstance(result, LoopOkResult) + assert loop_id in result.message + assert "ping" in result.message + assert manager.loops == [] + assert fake.persisted == [[]] + + @pytest.mark.asyncio + async def test_cancel_all_persists(self) -> None: + manager, fake = _build_manager() + await manager.handle_command("30s a") + await manager.handle_command("30s b") + fake.persisted.clear() + result = await manager.handle_command("cancel all") + assert isinstance(result, LoopOkResult) + assert "2 scheduled loop(s)" in result.message + assert manager.loops == [] + assert fake.persisted == [[]] + + @pytest.mark.asyncio + @pytest.mark.parametrize("verb", ["rm", "stop", "delete"]) + async def test_cancel_alias_verbs_work(self, verb: str) -> None: + manager, _ = _build_manager() + await manager.handle_command("30s ping") + loop_id = manager.loops[0].id + result = await manager.handle_command(f"{verb} {loop_id}") + assert isinstance(result, LoopOkResult) + assert manager.loops == [] + + +class TestLoopManagerPopDue: + @pytest.mark.asyncio + async def test_returns_none_when_empty(self) -> None: + manager, fake = _build_manager() + assert await manager.pop_due() is None + assert fake.persisted == [] + + @pytest.mark.asyncio + async def test_returns_none_when_nothing_due(self) -> None: + manager, fake = _build_manager() + await manager.handle_command("30s ping") + fake.persisted.clear() + result = await manager.pop_due(now=0.0) + assert result is None + assert fake.persisted == [] + + @pytest.mark.asyncio + async def test_returns_due_loop_advances_and_persists(self) -> None: + manager, fake = _build_manager() + await manager.handle_command("30s ping") + fake.persisted.clear() + loop = manager.loops[0] + original_next = loop.next_fire_at + due = await manager.pop_due(now=original_next + 100.0) + assert due is not None + assert due.id == loop.id + assert manager.loops[0].next_fire_at == original_next + 100.0 + 30.0 + assert len(fake.persisted) == 1 + + +class TestLoopManagerNextDueIn: + def test_inf_when_empty(self) -> None: + manager, _ = _build_manager() + assert manager.next_due_in() == math.inf + + @pytest.mark.asyncio + async def test_correct_delta_when_populated(self) -> None: + manager, _ = _build_manager() + await manager.handle_command("30s ping") + loop = manager.loops[0] + delta = manager.next_due_in(now=loop.next_fire_at - 7.0) + assert delta == pytest.approx(7.0) + + @pytest.mark.asyncio + async def test_zero_when_overdue(self) -> None: + manager, _ = _build_manager() + await manager.handle_command("30s ping") + loop = manager.loops[0] + delta = manager.next_due_in(now=loop.next_fire_at + 100.0) + assert delta == 0.0 + + +class TestLoopManagerRestore: + def test_replaces_in_memory_list_without_persist(self) -> None: + manager, fake = _build_manager() + loops = [ + ScheduledLoop( + id="aabbccdd", + interval_seconds=30, + prompt="x", + next_fire_at=1.0, + created_at=0.0, + ), + ScheduledLoop( + id="11223344", + interval_seconds=60, + prompt="y", + next_fire_at=2.0, + created_at=0.0, + ), + ] + manager.restore(loops) + assert [loop.id for loop in manager.loops] == ["aabbccdd", "11223344"] + assert fake.persisted == [] + + +class TestLoopManagerPersisterErrors: + @pytest.mark.asyncio + async def test_persister_exception_does_not_propagate(self) -> None: + manager = LoopManager(cast(SessionLogger, RaisingSessionLogger())) + result = await manager.handle_command("30s ping") + assert isinstance(result, LoopOkResult) + assert len(manager.loops) == 1 + loop = manager.loops[0] + due = await manager.pop_due(now=loop.next_fire_at + 1.0) + assert due is not None diff --git a/tests/core/test_telemetry_send.py b/tests/core/test_telemetry_send.py index 7a2683b..779faf5 100644 --- a/tests/core/test_telemetry_send.py +++ b/tests/core/test_telemetry_send.py @@ -362,6 +362,48 @@ class TestTelemetryClient: assert telemetry_events[1]["properties"]["command"] == "my_skill" assert telemetry_events[1]["properties"]["command_type"] == "skill" + def test_send_teleport_completed_payload( + self, telemetry_events: list[dict[str, Any]] + ) -> None: + config = build_test_vibe_config(enable_telemetry=True) + client = TelemetryClient(config_getter=lambda: config) + + client.send_teleport_completed( + push_required=True, github_auth_required=False, nb_session_messages=4 + ) + + assert len(telemetry_events) == 1 + assert telemetry_events[0]["event_name"] == "vibe.teleport_completed" + assert telemetry_events[0]["properties"] == { + "push_required": True, + "github_auth_required": False, + "nb_session_messages": 4, + } + + def test_send_teleport_failed_payload( + self, telemetry_events: list[dict[str, Any]] + ) -> None: + config = build_test_vibe_config(enable_telemetry=True) + client = TelemetryClient(config_getter=lambda: config) + + client.send_teleport_failed( + stage="push", + error_class="ServiceTeleportError", + push_required=True, + github_auth_required=False, + nb_session_messages=4, + ) + + assert len(telemetry_events) == 1 + assert telemetry_events[0]["event_name"] == "vibe.teleport_failed" + assert telemetry_events[0]["properties"] == { + "stage": "push", + "error_class": "ServiceTeleportError", + "push_required": True, + "github_auth_required": False, + "nb_session_messages": 4, + } + def test_send_new_session_payload( self, telemetry_events: list[dict[str, Any]] ) -> None: @@ -393,6 +435,55 @@ class TestTelemetryClient: assert properties["terminal_emulator"] == "vscode" assert "version" in properties + @pytest.mark.asyncio + async def test_send_session_closed_payload( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr( + TelemetryClient, "send_telemetry_event", _original_send_telemetry_event + ) + config = build_test_vibe_config(enable_telemetry=True) + env_key = config.get_active_provider().api_key_env_var + monkeypatch.setenv(env_key, "sk-test") + client = TelemetryClient( + config_getter=lambda: config, + session_id_getter=lambda: "current-session", + parent_session_id_getter=lambda: "current-parent-session", + entrypoint_metadata_getter=lambda: EntrypointMetadata( + agent_entrypoint="cli", + agent_version="1.0.0", + client_name="vibe_cli", + client_version="1.0.0", + ), + ) + mock_post = AsyncMock(return_value=MagicMock(status_code=204)) + client._client = MagicMock() + client._client.post = mock_post + client._client.aclose = AsyncMock() + + client.send_session_closed() + await client.aclose() + + mock_post.assert_called_once_with( + "https://api.mistral.ai/v1/datalake/events", + json={ + "event": "vibe.session_closed", + "properties": { + "agent_entrypoint": "cli", + "agent_version": "1.0.0", + "client_name": "vibe_cli", + "client_version": "1.0.0", + "session_id": "current-session", + "parent_session_id": "current-parent-session", + }, + }, + headers={ + "Content-Type": "application/json", + "Authorization": "Bearer sk-test", + "User-Agent": get_user_agent(Backend.MISTRAL), + }, + ) + def test_build_base_metadata_includes_entrypoint_and_session(self) -> None: metadata = build_base_metadata( entrypoint_metadata=EntrypointMetadata( diff --git a/tests/core/test_teleport_telemetry.py b/tests/core/test_teleport_telemetry.py new file mode 100644 index 0000000..9eecd36 --- /dev/null +++ b/tests/core/test_teleport_telemetry.py @@ -0,0 +1,231 @@ +from __future__ import annotations + +import asyncio +from collections.abc import AsyncGenerator +from typing import Any, cast + +import pytest + +from vibe.core.agent_loop import AgentLoop, TeleportError +from vibe.core.teleport.errors import ServiceTeleportError +from vibe.core.teleport.teleport import TeleportService +from vibe.core.teleport.types import ( + TeleportAuthRequiredEvent, + TeleportCheckingGitEvent, + TeleportCompleteEvent, + TeleportFetchingUrlEvent, + TeleportPushingEvent, + TeleportPushRequiredEvent, + TeleportPushResponseEvent, + TeleportStartingWorkflowEvent, + TeleportWaitingForGitHubEvent, +) +from vibe.core.types import LLMMessage, Role + + +def _set_teleport_service(agent_loop: AgentLoop, service: object) -> None: + agent_loop._teleport_service = cast(TeleportService, service) + + +class TestTeleportAgentLoopTelemetry: + @pytest.mark.asyncio + async def test_teleport_to_vibe_code_sends_completed_success( + self, agent_loop: AgentLoop, telemetry_events: list[dict[str, Any]] + ) -> None: + class FakeTeleportService: + async def __aenter__(self) -> FakeTeleportService: + return self + + async def __aexit__(self, *_args: object) -> None: + return None + + async def execute( + self, *_args: object, **_kwargs: object + ) -> AsyncGenerator[object, object]: + yield TeleportCheckingGitEvent() + yield TeleportPushRequiredEvent() + yield TeleportPushingEvent() + yield TeleportStartingWorkflowEvent() + yield TeleportWaitingForGitHubEvent() + yield TeleportAuthRequiredEvent(oauth_url="https://github.com/auth") + yield TeleportFetchingUrlEvent() + yield TeleportCompleteEvent(url="https://chat.example.com/123") + + agent_loop.messages.append(LLMMessage(role=Role.user, content="hello")) + _set_teleport_service(agent_loop, FakeTeleportService()) + + gen = agent_loop.teleport_to_vibe_code(None) + response = None + events = [] + while True: + try: + event = await gen.asend(response) + except StopAsyncIteration: + break + events.append(event) + response = ( + TeleportPushResponseEvent(approved=True) + if isinstance(event, TeleportPushRequiredEvent) + else None + ) + + assert isinstance(events[-1], TeleportCompleteEvent) + assert telemetry_events[-1]["event_name"] == "vibe.teleport_completed" + assert telemetry_events[-1]["properties"] == { + "push_required": True, + "github_auth_required": True, + "nb_session_messages": 1, + "session_id": agent_loop.session_id, + } + + @pytest.mark.asyncio + async def test_teleport_to_vibe_code_sends_failed_stage( + self, agent_loop: AgentLoop, telemetry_events: list[dict[str, Any]] + ) -> None: + class FakeTeleportService: + async def __aenter__(self) -> FakeTeleportService: + return self + + async def __aexit__(self, *_args: object) -> None: + return None + + async def execute( + self, *_args: object, **_kwargs: object + ) -> AsyncGenerator[object, object]: + yield TeleportCheckingGitEvent() + yield TeleportStartingWorkflowEvent() + raise ServiceTeleportError("Workflow api-key-123 could not be started.") + + agent_loop.messages.append(LLMMessage(role=Role.user, content="hello")) + _set_teleport_service(agent_loop, FakeTeleportService()) + + gen = agent_loop.teleport_to_vibe_code(None) + with pytest.raises(TeleportError, match="api-key-123"): + async for _ in gen: + pass + + assert telemetry_events[-1]["event_name"] == "vibe.teleport_failed" + assert telemetry_events[-1]["properties"] == { + "stage": "workflow_start", + "error_class": "ServiceTeleportError", + "push_required": False, + "github_auth_required": False, + "nb_session_messages": 1, + "session_id": agent_loop.session_id, + } + assert "api-key-123" not in str(telemetry_events[-1]["properties"]) + + @pytest.mark.asyncio + async def test_teleport_to_vibe_code_sends_failed_cancelled( + self, agent_loop: AgentLoop, telemetry_events: list[dict[str, Any]] + ) -> None: + class FakeTeleportService: + async def __aenter__(self) -> FakeTeleportService: + return self + + async def __aexit__(self, *_args: object) -> None: + return None + + async def execute( + self, *_args: object, **_kwargs: object + ) -> AsyncGenerator[object, object]: + yield TeleportCheckingGitEvent() + response = yield TeleportPushRequiredEvent() + if ( + not isinstance(response, TeleportPushResponseEvent) + or not response.approved + ): + raise ServiceTeleportError( + "Teleport cancelled: changes not pushed." + ) + + agent_loop.messages.append(LLMMessage(role=Role.user, content="hello")) + _set_teleport_service(agent_loop, FakeTeleportService()) + + gen = agent_loop.teleport_to_vibe_code(None) + assert isinstance(await gen.asend(None), TeleportCheckingGitEvent) + assert isinstance(await gen.asend(None), TeleportPushRequiredEvent) + + with pytest.raises(TeleportError, match="Teleport cancelled"): + await gen.asend(TeleportPushResponseEvent(approved=False)) + + assert telemetry_events[-1]["event_name"] == "vibe.teleport_failed" + assert telemetry_events[-1]["properties"] == { + "stage": "cancelled", + "error_class": "ServiceTeleportError", + "push_required": True, + "github_auth_required": False, + "nb_session_messages": 1, + "session_id": agent_loop.session_id, + } + + @pytest.mark.asyncio + async def test_teleport_to_vibe_code_sends_failed_when_task_cancelled( + self, agent_loop: AgentLoop, telemetry_events: list[dict[str, Any]] + ) -> None: + class FakeTeleportService: + async def __aenter__(self) -> FakeTeleportService: + return self + + async def __aexit__(self, *_args: object) -> None: + return None + + async def execute( + self, *_args: object, **_kwargs: object + ) -> AsyncGenerator[object, object]: + yield TeleportCheckingGitEvent() + raise asyncio.CancelledError + + agent_loop.messages.append(LLMMessage(role=Role.user, content="hello")) + _set_teleport_service(agent_loop, FakeTeleportService()) + + gen = agent_loop.teleport_to_vibe_code(None) + assert isinstance(await gen.asend(None), TeleportCheckingGitEvent) + + with pytest.raises(asyncio.CancelledError): + await gen.asend(None) + + assert telemetry_events[-1]["event_name"] == "vibe.teleport_failed" + assert telemetry_events[-1]["properties"] == { + "stage": "cancelled", + "error_class": "CancelledError", + "push_required": False, + "github_auth_required": False, + "nb_session_messages": 1, + "session_id": agent_loop.session_id, + } + + @pytest.mark.asyncio + async def test_teleport_to_vibe_code_sends_failed_when_consumer_closes_generator( + self, agent_loop: AgentLoop, telemetry_events: list[dict[str, Any]] + ) -> None: + class FakeTeleportService: + async def __aenter__(self) -> FakeTeleportService: + return self + + async def __aexit__(self, *_args: object) -> None: + return None + + async def execute( + self, *_args: object, **_kwargs: object + ) -> AsyncGenerator[object, object]: + yield TeleportCheckingGitEvent() + yield TeleportPushRequiredEvent() + + agent_loop.messages.append(LLMMessage(role=Role.user, content="hello")) + _set_teleport_service(agent_loop, FakeTeleportService()) + + gen = agent_loop.teleport_to_vibe_code(None) + assert isinstance(await gen.asend(None), TeleportCheckingGitEvent) + + await gen.aclose() + + assert telemetry_events[-1]["event_name"] == "vibe.teleport_failed" + assert telemetry_events[-1]["properties"] == { + "stage": "cancelled", + "error_class": "CancelledError", + "push_required": False, + "github_auth_required": False, + "nb_session_messages": 1, + "session_id": agent_loop.session_id, + } diff --git a/tests/core/test_utils.py b/tests/core/test_utils.py index 6904da2..f50bbe9 100644 --- a/tests/core/test_utils.py +++ b/tests/core/test_utils.py @@ -4,7 +4,7 @@ from pathlib import Path import pytest -from vibe.core.utils import get_server_url_from_api_base +from vibe.core.utils import compact_reduction_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 @@ -23,6 +23,24 @@ def test_get_server_url_from_api_base(api_base, expected): assert get_server_url_from_api_base(api_base) == expected +class TestCompactReductionDisplay: + def test_includes_session_ids_when_available(self) -> None: + assert compact_reduction_display( + 177_017, + 23_263, + old_session_id="11111111-1111-1111-1111-111111111111", + new_session_id="22222222-2222-2222-2222-222222222222", + ) == ( + "Compaction complete: 177,017 → 23,263 tokens (-87.%)\n" + "session: 11111111 (before compaction) → 22222222 (after compaction)" + ) + + def test_returns_base_message_without_session_ids(self) -> None: + assert compact_reduction_display(177_017, 23_263) == ( + "Compaction complete: 177,017 → 23,263 tokens (-87.%)" + ) + + class TestReadSafe: def test_reads_utf8(self, tmp_path: Path) -> None: f = tmp_path / "hello.txt" diff --git a/tests/core/tools/builtins/test_read_file.py b/tests/core/tools/builtins/test_read_file.py index 200865e..1ab5964 100644 --- a/tests/core/tools/builtins/test_read_file.py +++ b/tests/core/tools/builtins/test_read_file.py @@ -60,7 +60,62 @@ class TestReadFileExecution: 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: diff --git a/tests/session/test_resume_sessions.py b/tests/session/test_resume_sessions.py index c86a155..464ee34 100644 --- a/tests/session/test_resume_sessions.py +++ b/tests/session/test_resume_sessions.py @@ -5,33 +5,41 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from vibe.core.session.resume_sessions import ( - SHORT_SESSION_ID_LEN, list_remote_resume_sessions, short_session_id, ) +from vibe.core.session.session_id import shorten_session_id + + +class TestShortenSessionId: + def test_shortens_to_first_8_chars(self) -> None: + sid = "abcdef1234567890" + assert shorten_session_id(sid) == "abcdef12" + + def test_from_end_shortens_to_last_8_chars(self) -> None: + sid = "abcdef1234567890" + assert shorten_session_id(sid, from_end=True) == "34567890" + + def test_returns_full_id_when_shorter_than_limit(self) -> None: + sid = "abc" + assert shorten_session_id(sid) == "abc" + assert shorten_session_id(sid, from_end=True) == "abc" class TestShortSessionId: - def test_local_shortens_to_first_chars(self) -> None: + def test_local_delegates_to_shorten(self) -> None: sid = "abcdef1234567890" - result = short_session_id(sid) - assert result == sid[:SHORT_SESSION_ID_LEN] - assert len(result) == SHORT_SESSION_ID_LEN + assert short_session_id(sid) == shorten_session_id(sid) def test_local_is_default(self) -> None: sid = "abcdef1234567890" assert short_session_id(sid) == short_session_id(sid, source="local") - def test_remote_shortens_to_last_chars(self) -> None: + def test_remote_delegates_to_shorten_from_end(self) -> None: sid = "abcdef1234567890" - result = short_session_id(sid, source="remote") - assert result == sid[-SHORT_SESSION_ID_LEN:] - assert len(result) == SHORT_SESSION_ID_LEN - - def test_returns_full_id_when_shorter_than_limit(self) -> None: - sid = "abc" - assert short_session_id(sid) == "abc" - assert short_session_id(sid, source="remote") == "abc" + assert short_session_id(sid, source="remote") == shorten_session_id( + sid, from_end=True + ) def test_empty_string(self) -> None: assert short_session_id("") == "" diff --git a/tests/session/test_session_logger.py b/tests/session/test_session_logger.py index 73cf099..bc13a99 100644 --- a/tests/session/test_session_logger.py +++ b/tests/session/test_session_logger.py @@ -11,6 +11,7 @@ import pytest from tests.conftest import build_test_vibe_config from vibe.core.agents.models import AgentProfile, AgentSafety from vibe.core.config import SessionLoggingConfig, VibeConfig +from vibe.core.loop import ScheduledLoop from vibe.core.session.session_logger import SessionLogger from vibe.core.tools.manager import ToolManager from vibe.core.types import AgentStats, LLMMessage, Role, SessionMetadata @@ -868,3 +869,136 @@ class TestSessionLoggerCleanupTmpFiles: logger.maybe_cleanup_tmp_files() assert cleanup_spy.call_count == 2 + + +class TestPersistLoops: + @pytest.mark.asyncio + async def test_writes_into_existing_metadata( + self, + session_config: SessionLoggingConfig, + mock_vibe_config: VibeConfig, + mock_tool_manager: ToolManager, + mock_agent_profile: AgentProfile, + ) -> None: + logger = SessionLogger(session_config, "test-session-loops") + await logger.save_interaction( + messages=[ + LLMMessage(role=Role.system, content="System prompt"), + LLMMessage(role=Role.user, content="Hello"), + LLMMessage(role=Role.assistant, content="Hi there!"), + ], + stats=AgentStats(steps=1), + base_config=mock_vibe_config, + tool_manager=mock_tool_manager, + agent_profile=mock_agent_profile, + ) + + assert logger.session_metadata is not None + logger.session_metadata.loops = [ + ScheduledLoop( + id="aabbccdd", + interval_seconds=30, + prompt="ping", + next_fire_at=12345.0, + created_at=12000.0, + ) + ] + await logger.persist_loops() + + assert logger.session_dir is not None + with open(logger.session_dir / "meta.json") as f: + metadata = json.load(f) + + assert metadata["session_id"] == "test-session-loops" + assert metadata["total_messages"] == 2 + assert metadata["loops"] == [ + { + "id": "aabbccdd", + "interval_seconds": 30, + "prompt": "ping", + "next_fire_at": 12345.0, + "created_at": 12000.0, + } + ] + + @pytest.mark.asyncio + async def test_noop_when_metadata_file_missing( + self, session_config: SessionLoggingConfig + ) -> None: + logger = SessionLogger(session_config, "no-meta-session") + assert logger.session_dir is not None + # No save_interaction was called -> meta.json does not exist + assert not (logger.session_dir / "meta.json").exists() + + assert logger.session_metadata is not None + logger.session_metadata.loops = [ + ScheduledLoop( + id="aabbccdd", + interval_seconds=30, + prompt="ping", + next_fire_at=1.0, + created_at=0.0, + ) + ] + await logger.persist_loops() + + assert not (logger.session_dir / "meta.json").exists() + + @pytest.mark.asyncio + async def test_noop_when_logging_disabled( + self, disabled_session_config: SessionLoggingConfig + ) -> None: + logger = SessionLogger(disabled_session_config, "ignored") + # Should not raise even though session_metadata is None + await logger.persist_loops() + + @pytest.mark.asyncio + async def test_subsequent_save_interaction_preserves_loops( + self, + session_config: SessionLoggingConfig, + mock_vibe_config: VibeConfig, + mock_tool_manager: ToolManager, + mock_agent_profile: AgentProfile, + ) -> None: + logger = SessionLogger(session_config, "loops-vs-save") + messages = [ + LLMMessage(role=Role.system, content="System prompt"), + LLMMessage(role=Role.user, content="Hello"), + LLMMessage(role=Role.assistant, content="Hi there!"), + ] + await logger.save_interaction( + messages=messages, + stats=AgentStats(steps=1), + base_config=mock_vibe_config, + tool_manager=mock_tool_manager, + agent_profile=mock_agent_profile, + ) + + assert logger.session_metadata is not None + logger.session_metadata.loops = [ + ScheduledLoop( + id="aabbccdd", + interval_seconds=30, + prompt="ping", + next_fire_at=12345.0, + created_at=12000.0, + ) + ] + await logger.persist_loops() + + # A subsequent save (e.g. user sends another message) must not + # overwrite the on-disk loops with the stale in-memory value. + more_messages = [*messages, LLMMessage(role=Role.user, content="Again")] + await logger.save_interaction( + messages=more_messages, + stats=AgentStats(steps=2), + base_config=mock_vibe_config, + tool_manager=mock_tool_manager, + agent_profile=mock_agent_profile, + ) + + assert logger.session_dir is not None + with open(logger.session_dir / "meta.json") as f: + metadata = json.load(f) + assert len(metadata["loops"]) == 1 + assert metadata["loops"][0]["id"] == "aabbccdd" diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_narrator_flow/test_snapshot_narrator_speaking.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_narrator_flow/test_snapshot_narrator_speaking.svg index db23c22..1c9c256 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_narrator_flow/test_snapshot_narrator_speaking.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_narrator_flow/test_snapshot_narrator_speaking.svg @@ -191,7 +191,7 @@ Hello! I can help you. -▂▅▇ speaking Esc/Ctrl+C to stop +▂▅▇ speaking Esc/Ctrl+C to stop ┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐ > diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_narrator_flow/test_snapshot_narrator_summarizing.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_narrator_flow/test_snapshot_narrator_summarizing.svg index 89bacf3..10b48b6 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_narrator_flow/test_snapshot_narrator_summarizing.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_narrator_flow/test_snapshot_narrator_summarizing.svg @@ -191,7 +191,7 @@ Hello! I can help you. - summarizing Esc/Ctrl+C to stop + summarizing Esc/Ctrl+C to stop ┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐ > diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_command_hidden_for_non_pro_account.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_command_hidden_for_non_pro_account.svg index 1b28fc3..844042b 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_command_hidden_for_non_pro_account.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_command_hidden_for_non_pro_account.svg @@ -33,13 +33,12 @@ } .terminal-r1 { fill: #c5c8c6 } -.terminal-r2 { fill: #68a0b3 } -.terminal-r3 { fill: #4b4e55 } -.terminal-r4 { fill: #9a9b99 } -.terminal-r5 { fill: #608ab1;font-weight: bold } +.terminal-r2 { fill: #9a9b99 } +.terminal-r3 { fill: #608ab1;font-weight: bold } +.terminal-r4 { fill: #98a84b;font-weight: bold } +.terminal-r5 { fill: #4b4e55 } .terminal-r6 { fill: #292929 } -.terminal-r7 { fill: #98a84b;font-weight: bold } -.terminal-r8 { fill: #ff8205;font-weight: bold } +.terminal-r7 { fill: #ff8205;font-weight: bold } @@ -197,56 +196,56 @@ - + -   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - -Keyboard Shortcuts -• Enter Submit message -• Ctrl+J / Shift+Enter Insert newline -• Escape Interrupt agent or close dialogs -• Ctrl+C Quit (or clear input if text present) -• Ctrl+G Edit input in external editor -• Ctrl+O Toggle tool output view -• Shift+Tab Cycle through agents (default, plan, ...) -• Alt+↑↓ / Ctrl+P/N Rewind to previous/next message - -Special Features - -• !<command> Execute bash command directly -• @path/to/file/ Autocompletes file paths - -Commands - -• /help: Show help message -• /config: Edit config settings -• /model: Select active model -• /thinking: Select thinking level -• /reload: Reload configuration, agent instructions, and skills from disk -• /clear: Clear conversation history -• /copy: Copy the last agent message to the clipboard -• /log: Show path to current interaction log file -• /debug: Toggle debug console -• /compact: Compact conversation history by summarizing. Optionally pass instructions to guide the summary -• /exit: Exit the application -• /status: Display agent statistics -• /proxy-setup: Configure proxy and SSL certificate settings -• /continue/resume: Browse and resume past sessions -• /rename: Rename the current session -• /connectors/mcp: Display available MCP servers and connectors. Pass a name to list its tools -• /voice: Configure voice settings -• /leanstall: Install the Lean 4 agent (leanstral) -• /unleanstall: Uninstall the Lean 4 agent -• /rewind: Rewind to a previous message -• /data-retention: Show data retention information + +Keyboard Shortcuts +• Enter Submit message +• Ctrl+J / Shift+Enter Insert newline +• Escape Interrupt agent or close dialogs +• Ctrl+C Quit (or clear input if text present) +• Ctrl+G Edit input in external editor +• Ctrl+O Toggle tool output view +• Shift+Tab Cycle through agents (default, plan, ...) +• Alt+↑↓ / Ctrl+P/N Rewind to previous/next message + +Special Features + +• !<command> Execute bash command directly +• @path/to/file/ Autocompletes file paths + +Commands + +• /help: Show help message +• /config: Edit config settings +• /model: Select active model +• /thinking: Select thinking level +• /reload: Reload configuration, agent instructions, and skills from disk +• /clear: Clear conversation history +• /copy: Copy the last agent message to the clipboard +• /log: Show path to current interaction log file +• /debug: Toggle debug console +• /compact: Compact conversation history by summarizing. Optionally pass instructions to guide the summary +• /exit: Exit the application +• /status: Display agent statistics +• /proxy-setup: Configure proxy and SSL certificate settings +• /continue/resume: Browse and resume past sessions +• /rename: Rename the current session +• /connectors/mcp: Display available MCP servers and connectors. Pass a name to list its tools +• /voice: Configure voice settings +• /leanstall: Install the Lean 4 agent (leanstral) +• /unleanstall: Uninstall the Lean 4 agent +• /rewind: Rewind to a previous message +• /loop: Schedule a recurring prompt. Use /loop <interval> <prompt>/loop list, or /loop cancel <id|all> +• /data-retention: Show data retention information -┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐ -> - - -└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐ +> + + +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_command_visible_for_pro_account.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_command_visible_for_pro_account.svg index 53f48b7..64512d0 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_command_visible_for_pro_account.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_command_visible_for_pro_account.svg @@ -196,47 +196,47 @@ - + - -Keyboard Shortcuts -• Enter Submit message -• Ctrl+J / Shift+Enter Insert newline -• Escape Interrupt agent or close dialogs -• Ctrl+C Quit (or clear input if text present) -• Ctrl+G Edit input in external editor -• Ctrl+O Toggle tool output view -• Shift+Tab Cycle through agents (default, plan, ...) -• Alt+↑↓ / Ctrl+P/N Rewind to previous/next message - -Special Features - -• !<command> Execute bash command directly -• @path/to/file/ Autocompletes file paths - -Commands - -• /help: Show help message -• /config: Edit config settings -• /model: Select active model -• /thinking: Select thinking level -• /reload: Reload configuration, agent instructions, and skills from disk -• /clear: Clear conversation history -• /copy: Copy the last agent message to the clipboard -• /log: Show path to current interaction log file -• /debug: Toggle debug console -• /compact: Compact conversation history by summarizing. Optionally pass instructions to guide the summary -• /exit: Exit the application -• /status: Display agent statistics -• /teleport: Teleport session to Vibe Code -• /proxy-setup: Configure proxy and SSL certificate settings -• /continue/resume: Browse and resume past sessions -• /rename: Rename the current session -• /connectors/mcp: Display available MCP servers and connectors. Pass a name to list its tools -• /voice: Configure voice settings -• /leanstall: Install the Lean 4 agent (leanstral) -• /unleanstall: Uninstall the Lean 4 agent -• /rewind: Rewind to a previous message + Keyboard Shortcuts +• Enter Submit message +• Ctrl+J / Shift+Enter Insert newline +• Escape Interrupt agent or close dialogs +• Ctrl+C Quit (or clear input if text present) +• Ctrl+G Edit input in external editor +• Ctrl+O Toggle tool output view +• Shift+Tab Cycle through agents (default, plan, ...) +• Alt+↑↓ / Ctrl+P/N Rewind to previous/next message + +Special Features + +• !<command> Execute bash command directly +• @path/to/file/ Autocompletes file paths + +Commands + +• /help: Show help message +• /config: Edit config settings +• /model: Select active model +• /thinking: Select thinking level +• /reload: Reload configuration, agent instructions, and skills from disk +• /clear: Clear conversation history +• /copy: Copy the last agent message to the clipboard +• /log: Show path to current interaction log file +• /debug: Toggle debug console +• /compact: Compact conversation history by summarizing. Optionally pass instructions to guide the summary +• /exit: Exit the application +• /status: Display agent statistics +• /teleport: Teleport session to Vibe Code +• /proxy-setup: Configure proxy and SSL certificate settings +• /continue/resume: Browse and resume past sessions +• /rename: Rename the current session +• /connectors/mcp: Display available MCP servers and connectors. Pass a name to list its tools +• /voice: Configure voice settings +• /leanstall: Install the Lean 4 agent (leanstral) +• /unleanstall: Uninstall the Lean 4 agent +• /rewind: Rewind to a previous message +• /loop: Schedule a recurring prompt. Use /loop <interval> <prompt>/loop list, or /loop cancel <id|all> • /data-retention: Show data retention information diff --git a/tests/snapshots/conftest.py b/tests/snapshots/conftest.py index adee63c..fe59cff 100644 --- a/tests/snapshots/conftest.py +++ b/tests/snapshots/conftest.py @@ -11,6 +11,11 @@ def _pin_timezone(monkeypatch: pytest.MonkeyPatch) -> None: time.tzset() +@pytest.fixture(autouse=True) +def _pin_snapshot_colors(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("NO_COLOR", raising=False) + + @pytest.fixture(autouse=True) def _pin_banner_version(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( diff --git a/tests/snapshots/test_ui_snapshot_feedback_bar.py b/tests/snapshots/test_ui_snapshot_feedback_bar.py index f055be3..371b6d7 100644 --- a/tests/snapshots/test_ui_snapshot_feedback_bar.py +++ b/tests/snapshots/test_ui_snapshot_feedback_bar.py @@ -9,6 +9,7 @@ from tests.mock.utils import mock_llm_chunk from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp from tests.snapshots.snap_compare import SnapCompare from tests.stubs.fake_backend import FakeBackend +from vibe.core.telemetry.send import TelemetryClient @pytest.fixture(autouse=True) @@ -20,6 +21,7 @@ def _enable_feedback_bar(monkeypatch: pytest.MonkeyPatch) -> None: "vibe.cli.textual_ui.widgets.feedback_bar_manager.MIN_USER_MESSAGES_FOR_FEEDBACK", 1, ) + monkeypatch.setattr(TelemetryClient, "is_active", lambda self: True) class FeedbackBarSnapshotApp(BaseSnapshotTestApp): diff --git a/tests/test_agent_observer_streaming.py b/tests/test_agent_observer_streaming.py index 32784b3..f5f21f8 100644 --- a/tests/test_agent_observer_streaming.py +++ b/tests/test_agent_observer_streaming.py @@ -581,6 +581,60 @@ async def test_non_retryable_passes_through_non_streaming(observer_capture) -> N assert agent.session_logger.save_interaction.await_count == 1 +def _wrap_with_cause(message: str, cause: BaseException) -> RuntimeError: + # Mirrors how Temporal raises ``ActivityError`` on the workflow side with + # the original ``ApplicationError`` chained as ``__cause__`` — except we + # don't import temporalio. The wrap-site check has to traverse the chain + # to find ``non_retryable`` regardless of how deep it is. + error = RuntimeError(message) + error.__cause__ = cause + return error + + +@pytest.mark.asyncio +async def test_non_retryable_via_cause_chain_streaming(observer_capture) -> None: + observed, observer = observer_capture + wrapped = _wrap_with_cause( + "Activity task failed", _NonRetryableError("auth failed") + ) + backend = FakeBackend(exception_to_raise=wrapped) + agent = build_test_agent_loop( + config=make_config(), + backend=backend, + message_observer=observer, + enable_streaming=True, + ) + agent.session_logger.save_interaction = AsyncMock(return_value=None) + + with pytest.raises(RuntimeError, match="Activity task failed"): + [_ async for _ in agent.act("Trigger non-retryable via cause chain")] + + assert [role for role, _ in observed] == [Role.system, Role.user] + assert agent.session_logger.save_interaction.await_count == 1 + + +@pytest.mark.asyncio +async def test_non_retryable_via_cause_chain_non_streaming(observer_capture) -> None: + observed, observer = observer_capture + wrapped = _wrap_with_cause( + "Activity task failed", _NonRetryableError("auth failed") + ) + backend = FakeBackend(exception_to_raise=wrapped) + agent = build_test_agent_loop( + config=make_config(), + backend=backend, + message_observer=observer, + enable_streaming=False, + ) + agent.session_logger.save_interaction = AsyncMock(return_value=None) + + with pytest.raises(RuntimeError, match="Activity task failed"): + [_ async for _ in agent.act("Trigger non-retryable via cause chain")] + + assert [role for role, _ in observed] == [Role.system, Role.user] + assert agent.session_logger.save_interaction.await_count == 1 + + def _snapshot_events(events: list) -> list[tuple[str, str]]: return [ (type(e).__name__, e.content) diff --git a/tests/test_cli_programmatic_preload.py b/tests/test_cli_programmatic_preload.py index e0c186a..4a0ed41 100644 --- a/tests/test_cli_programmatic_preload.py +++ b/tests/test_cli_programmatic_preload.py @@ -80,8 +80,15 @@ def test_run_programmatic_preload_streaming_is_batched( new_session = [ e for e in telemetry_events if e.get("event_name") == "vibe.new_session" ] + assert len(new_session) == 0 + session_closed = [ + e for e in telemetry_events if e.get("event_name") == "vibe.session_closed" + ] + assert len(session_closed) == 1 + assert session_closed[0]["properties"]["agent_entrypoint"] == "programmatic" + assert ( spy.emitted[0][1] == "You are Vibe, a super useful programming assistant." ) diff --git a/tests/test_history_manager.py b/tests/test_history_manager.py index 836af4e..1fed16a 100644 --- a/tests/test_history_manager.py +++ b/tests/test_history_manager.py @@ -3,6 +3,8 @@ from __future__ import annotations import json from pathlib import Path +import pytest + from vibe.cli.history_manager import HistoryManager @@ -74,6 +76,45 @@ def test_history_manager_stores_slash_prefixed_entries(tmp_path: Path) -> None: assert reloaded.get_previous(current_input="") is None +def test_history_manager_keeps_entries_when_reload_read_fails( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + history_file = tmp_path / "history.jsonl" + manager = HistoryManager(history_file) + manager.add("first") + + def raise_os_error(*args: object, **kwargs: object) -> None: + raise OSError + + monkeypatch.setattr("vibe.cli.history_manager.read_safe", raise_os_error) + + manager.add("second") + + assert manager.get_previous(current_input="") == "second" + assert manager.get_previous(current_input="") == "first" + + +def test_history_manager_resets_navigation_when_add_is_duplicate( + tmp_path: Path, +) -> None: + history_file = tmp_path / "history.jsonl" + manager = HistoryManager(history_file, max_entries=2) + manager.add("a") + manager.add("b") + + other = HistoryManager(history_file, max_entries=10) + other.add("c") + other.add("d") + other.add("e") + + assert manager.get_previous(current_input="") == "b" + manager.add("e") + + assert manager.get_previous(current_input="") == "e" + assert manager.get_previous(current_input="") == "d" + assert manager.get_previous(current_input="") is None + + def test_history_manager_allows_navigation_round_trip(tmp_path: Path) -> None: history_file = tmp_path / "history.jsonl" manager = HistoryManager(history_file) diff --git a/tests/test_tracing.py b/tests/test_tracing.py index 40df145..5dd3efb 100644 --- a/tests/test_tracing.py +++ b/tests/test_tracing.py @@ -48,19 +48,28 @@ def _otel_provider(monkeypatch: pytest.MonkeyPatch): class TestSetupTracing: def test_noop_when_disabled(self) -> None: - config = MagicMock(enable_otel=False) + config = MagicMock(enable_telemetry=True, enable_otel=False) + with patch("vibe.core.tracing.trace.set_tracer_provider") as mock_set: + setup_tracing(config) + mock_set.assert_not_called() + + def test_noop_when_telemetry_disabled(self) -> None: + config = MagicMock(enable_telemetry=False, enable_otel=True) with patch("vibe.core.tracing.trace.set_tracer_provider") as mock_set: setup_tracing(config) mock_set.assert_not_called() def test_noop_when_exporter_config_is_none(self) -> None: - config = MagicMock(enable_otel=True, otel_span_exporter_config=None) + config = MagicMock( + enable_telemetry=True, enable_otel=True, otel_span_exporter_config=None + ) with patch("vibe.core.tracing.trace.set_tracer_provider") as mock_set: setup_tracing(config) mock_set.assert_not_called() def test_configures_provider_from_exporter_config(self) -> None: config = MagicMock( + enable_telemetry=True, enable_otel=True, otel_span_exporter_config=OtelSpanExporterConfig( endpoint="https://customer.mistral.ai/telemetry/v1/traces", @@ -85,6 +94,7 @@ class TestSetupTracing: def test_custom_endpoint_has_no_auth_headers(self) -> None: config = MagicMock( + enable_telemetry=True, enable_otel=True, otel_span_exporter_config=OtelSpanExporterConfig( endpoint="https://my-collector:4318/v1/traces" diff --git a/uv.lock b/uv.lock index e884f38..87267d0 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.12" [options] -exclude-newer = "2026-04-28T08:28:58.401523Z" +exclude-newer = "2026-04-29T14:28:22.645142Z" exclude-newer-span = "P7D" [options.exclude-newer-package] @@ -820,7 +820,7 @@ wheels = [ [[package]] name = "mistral-vibe" -version = "2.9.4" +version = "2.9.5" source = { editable = "." } dependencies = [ { name = "agent-client-protocol" }, diff --git a/vibe/__init__.py b/vibe/__init__.py index a916b91..9d4b414 100644 --- a/vibe/__init__.py +++ b/vibe/__init__.py @@ -3,4 +3,4 @@ from __future__ import annotations from pathlib import Path VIBE_ROOT = Path(__file__).parent -__version__ = "2.9.4" +__version__ = "2.9.5" diff --git a/vibe/acp/acp_agent_loop.py b/vibe/acp/acp_agent_loop.py index a881c8b..7376d7c 100644 --- a/vibe/acp/acp_agent_loop.py +++ b/vibe/acp/acp_agent_loop.py @@ -7,6 +7,7 @@ import inspect import logging import os from pathlib import Path +import signal import sys from typing import Any, cast, override from uuid import uuid4 @@ -994,11 +995,22 @@ class VibeAcpAgentLoop(AcpAgent): session = self._get_session(session_id) self.sessions.pop(session_id, None) + session.agent_loop.emit_session_closed_telemetry() await session.close() await self._close_agent_loop(session.agent_loop) return CloseSessionResponse() + async def emit_session_closed_for_active_sessions(self) -> None: + agent_loops = [session.agent_loop for session in self.sessions.values()] + for agent_loop in agent_loops: + agent_loop.telemetry_client._client = None + agent_loop.emit_session_closed_telemetry() + await asyncio.gather( + *(agent_loop.telemetry_client.aclose() for agent_loop in agent_loops), + return_exceptions=True, + ) + async def _close_agent_loop(self, agent_loop: AgentLoop) -> None: deferred_init_thread = agent_loop._deferred_init_thread if deferred_init_thread is not None and deferred_init_thread.is_alive(): @@ -1248,6 +1260,7 @@ class VibeAcpAgentLoop(AcpAgent): tool_call_id = str(uuid4()) old_tokens = session.agent_loop.stats.context_tokens + old_session_id = session.agent_loop.session_id parts = text_prompt.strip().split(None, 1) cmd_args = parts[1] if len(parts) > 1 else "" @@ -1268,6 +1281,8 @@ class VibeAcpAgentLoop(AcpAgent): old_context_tokens=old_tokens or 0, new_context_tokens=new_tokens or 0, summary_length=0, + old_session_id=old_session_id, + new_session_id=session.agent_loop.session_id, tool_call_id=tool_call_id, ) await self.client.session_update( @@ -1419,19 +1434,48 @@ class VibeAcpAgentLoop(AcpAgent): return await self._command_reply(session, DATA_RETENTION_MESSAGE, message_id) +SESSION_CLOSED_FLUSH_TIMEOUT_SECONDS = 1.0 + + def run_acp_server() -> None: + agent = VibeAcpAgentLoop() + install_sigterm_flush = TelemetryClient(config_getter=VibeConfig.load).is_active() + received_sigterm = False + previous_sigterm_handler = signal.getsignal(signal.SIGTERM) + + def _handle_sigterm(_signum: int, _frame: Any) -> None: + nonlocal received_sigterm + received_sigterm = True + raise KeyboardInterrupt + + if install_sigterm_flush: + signal.signal(signal.SIGTERM, _handle_sigterm) try: asyncio.run( run_agent( - agent=VibeAcpAgentLoop(), + agent=agent, use_unstable_protocol=True, observers=[acp_message_observer], ) ) except KeyboardInterrupt: + if received_sigterm: + signal.signal(signal.SIGTERM, previous_sigterm_handler) + try: + asyncio.run( + asyncio.wait_for( + agent.emit_session_closed_for_active_sessions(), + timeout=SESSION_CLOSED_FLUSH_TIMEOUT_SECONDS, + ) + ) + except (TimeoutError, Exception): + pass # This is expected when the server is terminated pass except Exception as e: # Log any unexpected errors print(f"ACP Agent Server error: {e}", file=sys.stderr) raise + finally: + if install_sigterm_flush: + signal.signal(signal.SIGTERM, previous_sigterm_handler) diff --git a/vibe/acp/entrypoint.py b/vibe/acp/entrypoint.py index 772b8bd..a362b24 100644 --- a/vibe/acp/entrypoint.py +++ b/vibe/acp/entrypoint.py @@ -60,6 +60,7 @@ def bootstrap_config_files() -> None: raise +# When DEBUG_MODE=true, attaches debugpy on localhost:5678. def handle_debug_mode() -> None: if os.environ.get("DEBUG_MODE") != "true": return diff --git a/vibe/acp/utils.py b/vibe/acp/utils.py index 57884ee..7f8c3da 100644 --- a/vibe/acp/utils.py +++ b/vibe/acp/utils.py @@ -231,7 +231,10 @@ def create_compact_end_session_update(event: CompactEndEvent) -> ToolCallProgres type="text", text=( compact_reduction_display( - event.old_context_tokens, event.new_context_tokens + event.old_context_tokens, + event.new_context_tokens, + old_session_id=event.old_session_id, + new_session_id=event.new_session_id, ) ), ), diff --git a/vibe/cli/autocompletion/slash_command.py b/vibe/cli/autocompletion/slash_command.py index 612e5a0..45bc723 100644 --- a/vibe/cli/autocompletion/slash_command.py +++ b/vibe/cli/autocompletion/slash_command.py @@ -32,7 +32,7 @@ class SlashCommandController: return suggestions = self._completer.get_completion_items(text, cursor_index) - if suggestions and all(alias != text for alias, _ in suggestions): + if suggestions: self._suggestions = suggestions self._selected_index = 0 self._view.render_completion_suggestions( diff --git a/vibe/cli/cli.py b/vibe/cli/cli.py index 2600850..d00df80 100644 --- a/vibe/cli/cli.py +++ b/vibe/cli/cli.py @@ -42,10 +42,10 @@ def _build_cli_entrypoint_metadata() -> EntrypointMetadata: ) -def get_initial_agent_name(args: argparse.Namespace) -> str: - if args.prompt is not None and args.agent == BuiltinAgentName.DEFAULT: +def get_initial_agent_name(args: argparse.Namespace, config: VibeConfig) -> str: + if args.prompt is not None and args.agent is None: return BuiltinAgentName.AUTO_APPROVE - return args.agent + return args.agent or config.default_agent def get_prompt_from_stdin() -> str | None: @@ -196,9 +196,9 @@ def run_cli(args: argparse.Namespace) -> None: sys.exit(0) try: - initial_agent_name = get_initial_agent_name(args) is_interactive = args.prompt is None config = load_config_or_exit(interactive=is_interactive) + initial_agent_name = get_initial_agent_name(args, config) hook_config_result = load_hooks_from_fs(config) setup_tracing(config) @@ -243,18 +243,22 @@ def run_cli(args: argparse.Namespace) -> None: except TeleportError as e: print(f"Teleport error: {e}", file=sys.stderr) sys.exit(1) - except RuntimeError as e: + except (RuntimeError, ValueError) as e: print(f"Error: {e}", file=sys.stderr) sys.exit(1) else: - agent_loop = AgentLoop( - config, - agent_name=initial_agent_name, - enable_streaming=True, - entrypoint_metadata=_build_cli_entrypoint_metadata(), - defer_heavy_init=True, - hook_config_result=hook_config_result, - ) + try: + agent_loop = AgentLoop( + config, + agent_name=initial_agent_name, + enable_streaming=True, + entrypoint_metadata=_build_cli_entrypoint_metadata(), + defer_heavy_init=True, + hook_config_result=hook_config_result, + ) + except ValueError as e: + rprint(f"[red]Error:[/] {e}") + sys.exit(1) if loaded_session: _resume_previous_session(agent_loop, *loaded_session) diff --git a/vibe/cli/commands.py b/vibe/cli/commands.py index ca43689..1d310b3 100644 --- a/vibe/cli/commands.py +++ b/vibe/cli/commands.py @@ -161,6 +161,14 @@ class CommandRegistry: description="Rewind to a previous message", handler="_start_rewind_mode", ), + "loop": Command( + aliases=frozenset(["/loop"]), + description=( + "Schedule a recurring prompt. " + "Use `/loop `, `/loop list`, or `/loop cancel `" + ), + handler="_loop_command", + ), "data-retention": Command( aliases=frozenset(["/data-retention"]), description="Show data retention information", diff --git a/vibe/cli/entrypoint.py b/vibe/cli/entrypoint.py index 6a16085..f506405 100644 --- a/vibe/cli/entrypoint.py +++ b/vibe/cli/entrypoint.py @@ -8,7 +8,6 @@ import sys from rich import print as rprint from vibe import __version__ -from vibe.core.agents.models import BuiltinAgentName from vibe.core.config.harness_files import init_harness_files_manager from vibe.core.trusted_folders import find_trustable_files, trusted_folders_manager from vibe.setup.trusted_folders.trust_folder_dialog import ( @@ -18,7 +17,18 @@ from vibe.setup.trusted_folders.trust_folder_dialog import ( def parse_arguments() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Run the Mistral Vibe interactive CLI") + parser = argparse.ArgumentParser( + description="Run the Mistral Vibe interactive CLI", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=( + "Environment variables:\n" + " VIBE_HOME Override the Vibe home directory (default: ~/.vibe)\n" + " LOG_LEVEL Logging level: DEBUG, INFO, WARNING (default), ERROR, CRITICAL.\n" + " Logs are written to $VIBE_HOME/logs/vibe.log.\n" + " LOG_MAX_BYTES Max size of vibe.log before rotation (default: 10485760).\n" + " VIBE_* Override any config field (e.g. VIBE_ACTIVE_MODEL=local)." + ), + ) parser.add_argument( "-v", "--version", action="version", version=f"%(prog)s {__version__}" ) @@ -72,9 +82,12 @@ def parse_arguments() -> argparse.Namespace: parser.add_argument( "--agent", metavar="NAME", - default=BuiltinAgentName.DEFAULT, + default=None, help="Agent to use (builtin: default, plan, accept-edits, auto-approve, " - "or custom from ~/.vibe/agents/NAME.toml)", + "or custom from ~/.vibe/agents/NAME.toml). In interactive mode, " + "defaults to the 'default_agent' config setting. In programmatic " + "mode (-p/--prompt), defaults to auto-approve and 'default_agent' " + "is ignored.", ) parser.add_argument("--setup", action="store_true", help="Setup API key and exit") parser.add_argument( diff --git a/vibe/cli/history_manager.py b/vibe/cli/history_manager.py index b181b4c..ce68832 100644 --- a/vibe/cli/history_manager.py +++ b/vibe/cli/history_manager.py @@ -1,7 +1,9 @@ from __future__ import annotations import json +import os from pathlib import Path +import tempfile from vibe.core.utils.io import read_safe @@ -22,12 +24,10 @@ class HistoryManager: try: text = read_safe(self.history_file).text except OSError: - self._entries = [] return entries = [] for raw_line in text.splitlines(): - raw_line = raw_line.rstrip("\n\r") if not raw_line: continue try: @@ -36,13 +36,24 @@ class HistoryManager: entry = raw_line entries.append(entry if isinstance(entry, str) else str(entry)) self._entries = entries[-self.max_entries :] + self.reset_navigation() def _save_history(self) -> None: try: self.history_file.parent.mkdir(parents=True, exist_ok=True) - with self.history_file.open("w", encoding="utf-8") as f: - for entry in self._entries: - f.write(json.dumps(entry, ensure_ascii=False) + "\n") + fd, tmp_path = tempfile.mkstemp( + prefix=f".{self.history_file.name}.", + suffix=".tmp", + dir=self.history_file.parent, + ) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + for entry in self._entries: + f.write(json.dumps(entry, ensure_ascii=False) + "\n") + os.replace(tmp_path, self.history_file) + except OSError: + Path(tmp_path).unlink(missing_ok=True) + raise except OSError: pass @@ -51,6 +62,8 @@ class HistoryManager: if not text: return + self._load_history() + if self._entries and self._entries[-1] == text: return @@ -60,7 +73,6 @@ class HistoryManager: self._entries = self._entries[-self.max_entries :] self._save_history() - self.reset_navigation() def get_previous(self, current_input: str) -> str | None: if not self._entries: diff --git a/vibe/cli/textual_ui/app.py b/vibe/cli/textual_ui/app.py index cb5b812..27bd585 100644 --- a/vibe/cli/textual_ui/app.py +++ b/vibe/cli/textual_ui/app.py @@ -51,6 +51,7 @@ from vibe.cli.textual_ui.notifications import ( ) from vibe.cli.textual_ui.quit_manager import QuitManager from vibe.cli.textual_ui.remote import RemoteSessionManager, is_progress_event +from vibe.cli.textual_ui.scheduled_loop_runner import ScheduledLoopRunner from vibe.cli.textual_ui.session_exit import print_session_resume_message from vibe.cli.textual_ui.widgets.approval_app import ApprovalApp from vibe.cli.textual_ui.widgets.banner.banner import Banner @@ -140,6 +141,7 @@ from vibe.core.session.resume_sessions import ( from vibe.core.session.saved_sessions import update_saved_session_title_at_path from vibe.core.session.session_loader import SessionLoader from vibe.core.skills.manager import SkillManager +from vibe.core.teleport.telemetry import send_teleport_early_failure_telemetry from vibe.core.teleport.types import ( TeleportAuthCompleteEvent, TeleportAuthRequiredEvent, @@ -339,7 +341,6 @@ class VibeApp(App): # noqa: PLR0904 **kwargs: Any, ) -> None: super().__init__(**kwargs) - self.scroll_sensitivity_y = 1.0 self.agent_loop = agent_loop self._plan_info: PlanInfo | None = None self._voice_manager: VoiceManagerPort = ( @@ -397,7 +398,17 @@ class VibeApp(App): # noqa: PLR0904 self._rewind_mode = False self._rewind_highlighted_widget: UserMessage | None = None self._fatal_init_error = False + self._force_quit_task: asyncio.Task[None] | None = None self.commands = self._build_command_registry() + self._loop_runner = ScheduledLoopRunner( + self.agent_loop.session_logger, + can_fire=lambda: ( + not self._agent_running and self._current_bottom_app == BottomApp.Input + ), + fire=self._handle_user_message, + mount=self._mount_and_scroll, + tools_collapsed=lambda: self._tools_collapsed, + ) def _configure_startup_options(self, startup: StartupOptions | None) -> None: opts = startup or StartupOptions() @@ -504,6 +515,8 @@ class VibeApp(App): # noqa: PLR0904 await self._resolve_plan() await self._show_dangerous_directory_warning() await self._resume_history_from_messages() + self._loop_runner.restore_from_session() + self._loop_runner.start() await self._check_and_show_whats_new() self._schedule_update_notification() if not self._is_resuming_session: @@ -1407,6 +1420,12 @@ class VibeApp(App): # noqa: PLR0904 if show_message: await self._mount_and_scroll(UserMessage("/teleport")) if not has_history: + send_teleport_early_failure_telemetry( + self.agent_loop.telemetry_client, + stage="no_history", + error_class="TeleportNoHistoryError", + nb_session_messages=len(self.agent_loop.messages[1:]), + ) await self._mount_and_scroll( ErrorMessage( "No conversation history to teleport.", @@ -1429,6 +1448,12 @@ class VibeApp(App): # noqa: PLR0904 await self._mount_and_scroll(teleport_msg) if self._remote_manager.is_active: + send_teleport_early_failure_telemetry( + self.agent_loop.telemetry_client, + stage="remote_session", + error_class="TeleportRemoteSessionError", + nb_session_messages=len(self.agent_loop.messages[1:]), + ) await loading.remove() await self._mount_and_scroll( ErrorMessage( @@ -1818,6 +1843,8 @@ class VibeApp(App): # noqa: PLR0904 f"Session `{short_session_id(session.session_id)}` not found." ) + self._emit_session_closed_for_active_session() + loaded_messages, metadata = SessionLoader.load_session(session_path) if self._chat_input_container: self._chat_input_container.set_custom_border(None) @@ -1846,6 +1873,7 @@ class VibeApp(App): # noqa: PLR0904 if self.event_handler: self.event_handler.is_remote = False await self._resume_history_from_messages() + self._loop_runner.restore_from_session() await self._mount_and_scroll( UserCommandMessage( f"Resumed session `{short_session_id(session.session_id)}`" @@ -1856,6 +1884,8 @@ class VibeApp(App): # noqa: PLR0904 await self._remote_manager.attach( session_id=session.session_id, config=self.config ) + self._emit_session_closed_for_active_session() + self.agent_loop.session_id = session.session_id self._refresh_profile_widgets() if self._chat_input_container: self._chat_input_container.set_custom_border(None) @@ -2018,6 +2048,10 @@ class VibeApp(App): # noqa: PLR0904 ) ) + async def _loop_command(self, cmd_args: str = "", **kwargs: Any) -> None: + widget = await self._loop_runner.handle_command(cmd_args) + await self._mount_and_scroll(widget) + async def _compact_history(self, cmd_args: str = "", **kwargs: Any) -> None: if self._agent_running: await self._mount_and_scroll( @@ -2041,22 +2075,32 @@ class VibeApp(App): # noqa: PLR0904 return old_tokens = self.agent_loop.stats.context_tokens + old_session_id = self.agent_loop.session_id compact_msg = CompactMessage() self.event_handler.current_compact = compact_msg await self._mount_and_scroll(compact_msg) self._agent_task = asyncio.create_task( - self._run_compact(compact_msg, old_tokens, cmd_args.strip()) + self._run_compact(compact_msg, old_tokens, old_session_id, cmd_args.strip()) ) async def _run_compact( - self, compact_msg: CompactMessage, old_tokens: int, extra_instructions: str = "" + self, + compact_msg: CompactMessage, + old_tokens: int, + old_session_id: str, + extra_instructions: str = "", ) -> None: self._agent_running = True try: await self.agent_loop.compact(extra_instructions=extra_instructions) new_tokens = self.agent_loop.stats.context_tokens - compact_msg.set_complete(old_tokens=old_tokens, new_tokens=new_tokens) + compact_msg.set_complete( + old_tokens=old_tokens, + new_tokens=new_tokens, + old_session_id=old_session_id, + new_session_id=self.agent_loop.session_id, + ) except asyncio.CancelledError: compact_msg.set_error("Compaction interrupted") @@ -2085,9 +2129,16 @@ class VibeApp(App): # noqa: PLR0904 return short_session_id(self.agent_loop.session_logger.session_id) async def _exit_app(self, **kwargs: Any) -> None: + self._emit_session_closed_for_active_session() + await self._loop_runner.stop() self._log_reader.shutdown() await self._narrator_manager.close() - self.exit(result=self._get_session_resume_info()) + try: + await self.agent_loop.telemetry_client.aclose() + except Exception as exc: + logger.error("Failed to close telemetry client during exit", exc_info=exc) + finally: + self.exit(result=self._get_session_resume_info()) def _make_default_voice_manager(self) -> VoiceManager: try: @@ -2746,7 +2797,16 @@ class VibeApp(App): # noqa: PLR0904 return self._quit_manager.request_confirmation("Ctrl+D") + def _emit_session_closed_for_active_session(self) -> None: + self.agent_loop.emit_session_closed_telemetry() + def _force_quit(self) -> None: + if self._force_quit_task is not None and not self._force_quit_task.done(): + return + self._force_quit_task = asyncio.create_task(self._force_quit_async()) + + async def _force_quit_async(self) -> None: + self._emit_session_closed_for_active_session() if self._agent_task and not self._agent_task.done(): self._agent_task.cancel() if self._bash_task and not self._bash_task.done(): @@ -2755,7 +2815,14 @@ class VibeApp(App): # noqa: PLR0904 self._log_reader.shutdown() self._narrator_manager.cancel() - self.exit(result=self._get_session_resume_info()) + try: + await self.agent_loop.telemetry_client.aclose() + except Exception as exc: + logger.error( + "Failed to close telemetry client during force quit", exc_info=exc + ) + finally: + self.exit(result=self._get_session_resume_info()) def action_scroll_chat_up(self) -> None: try: diff --git a/vibe/cli/textual_ui/app.tcss b/vibe/cli/textual_ui/app.tcss index 6a2b5d6..7feeb67 100644 --- a/vibe/cli/textual_ui/app.tcss +++ b/vibe/cli/textual_ui/app.tcss @@ -980,7 +980,7 @@ NarratorStatus { height: auto; background: transparent; padding: 0; - margin: 0 0 0 1; + margin: 0; } #banner-container { diff --git a/vibe/cli/textual_ui/handlers/event_handler.py b/vibe/cli/textual_ui/handlers/event_handler.py index b612bae..857dab1 100644 --- a/vibe/cli/textual_ui/handlers/event_handler.py +++ b/vibe/cli/textual_ui/handlers/event_handler.py @@ -217,7 +217,10 @@ class EventHandler: async def _handle_compact_end(self, event: CompactEndEvent) -> None: if self.current_compact: self.current_compact.set_complete( - old_tokens=event.old_context_tokens, new_tokens=event.new_context_tokens + old_tokens=event.old_context_tokens, + new_tokens=event.new_context_tokens, + old_session_id=event.old_session_id, + new_session_id=event.new_session_id, ) self.current_compact = None diff --git a/vibe/cli/textual_ui/scheduled_loop_runner.py b/vibe/cli/textual_ui/scheduled_loop_runner.py new file mode 100644 index 0000000..314d3d3 --- /dev/null +++ b/vibe/cli/textual_ui/scheduled_loop_runner.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +import time + +from textual.widget import Widget + +from vibe.cli.textual_ui.widgets.messages import ErrorMessage, UserCommandMessage +from vibe.core.logger import logger +from vibe.core.loop import ( + USAGE_HINT, + LoopErrorResult, + LoopListResult, + LoopManager, + LoopOkResult, + ScheduledLoop, + format_duration, +) +from vibe.core.session.session_logger import SessionLogger + + +def _format_loop_list(loops: list[ScheduledLoop]) -> str: + if not loops: + return "No scheduled loops." + now = time.time() + rows = ["| Prompt | Next in | Every | ID |", "|--------|------|-------|----|"] + for loop in loops: + remaining = format_duration(max(0, int(loop.next_fire_at - now)), short=True) + interval = format_duration(loop.interval_seconds) + prompt = loop.prompt.replace("|", "\\|").replace("\n", " ") + rows.append(f"| {prompt} | {remaining} | {interval} | `{loop.id}` |") + return "\n".join(rows) + + +class ScheduledLoopRunner: + def __init__( + self, + session_logger: SessionLogger, + *, + can_fire: Callable[[], bool], + fire: Callable[[str], Awaitable[None]], + mount: Callable[[Widget], Awaitable[None]], + tools_collapsed: Callable[[], bool], + ) -> None: + self._session_logger = session_logger + self._manager = LoopManager(session_logger) + self._can_fire = can_fire + self._fire = fire + self._mount = mount + self._tools_collapsed = tools_collapsed + self._task: asyncio.Task[None] | None = None + + def restore_from_session(self) -> None: + metadata = self._session_logger.session_metadata + self._manager.restore(list(metadata.loops) if metadata is not None else []) + + def start(self) -> None: + if self._task is None or self._task.done(): + self._task = asyncio.create_task(self._poll()) + + async def stop(self) -> None: + if self._task is None: + return + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + + async def handle_command(self, cmd_args: str) -> Widget: + result = await self._manager.handle_command(cmd_args) + match result: + case LoopListResult(loops=loops): + return UserCommandMessage(_format_loop_list(loops)) + case LoopErrorResult(message=message): + return ErrorMessage( + f"{message}\n{USAGE_HINT}", collapsed=self._tools_collapsed() + ) + case LoopOkResult(message=message): + return UserCommandMessage(message) + + async def _poll(self) -> None: + while True: + try: + sleep_for = min(self._manager.next_due_in(), 1.0) + await asyncio.sleep(max(0.05, sleep_for)) + if not self._can_fire(): + continue + due = await self._manager.pop_due() + if due is None: + continue + await self._fire(due.prompt) + await self._mount(UserCommandMessage(f"Loop `{due.id}` fired")) + except asyncio.CancelledError: + return + except Exception as e: + logger.error("Error polling scheduled loops", exc_info=e) diff --git a/vibe/cli/textual_ui/widgets/compact.py b/vibe/cli/textual_ui/widgets/compact.py index f232b0f..8d9fc9d 100644 --- a/vibe/cli/textual_ui/widgets/compact.py +++ b/vibe/cli/textual_ui/widgets/compact.py @@ -17,6 +17,8 @@ class CompactMessage(StatusMessage): self.add_class("compact-message") self.old_tokens: int | None = None self.new_tokens: int | None = None + self.old_session_id: str | None = None + self.new_session_id: str | None = None self.error_message: str | None = None def get_content(self) -> str: @@ -26,13 +28,25 @@ class CompactMessage(StatusMessage): if self.error_message: return f"Error: {self.error_message}" - return compact_reduction_display(self.old_tokens, self.new_tokens) + return compact_reduction_display( + self.old_tokens, + self.new_tokens, + old_session_id=self.old_session_id, + new_session_id=self.new_session_id, + ) def set_complete( - self, old_tokens: int | None = None, new_tokens: int | None = None + self, + old_tokens: int | None = None, + new_tokens: int | None = None, + *, + old_session_id: str | None = None, + new_session_id: str | None = None, ) -> None: self.old_tokens = old_tokens self.new_tokens = new_tokens + self.old_session_id = old_session_id + self.new_session_id = new_session_id self.stop_spinning(success=True) self.post_message(self.Completed(self)) diff --git a/vibe/core/agent_loop.py b/vibe/core/agent_loop.py index 16bf1cd..4f2e082 100644 --- a/vibe/core/agent_loop.py +++ b/vibe/core/agent_loop.py @@ -66,6 +66,9 @@ from vibe.core.telemetry.types import ( TelemetryCallType, TelemetryRequestMetadata, ) +from vibe.core.teleport.errors import ServiceTeleportError +from vibe.core.teleport.telemetry import TeleportTelemetryTracker +from vibe.core.teleport.types import TeleportCompleteEvent from vibe.core.tools.base import ( BaseTool, InvokeContext, @@ -174,9 +177,18 @@ def _is_context_too_long_error(e: Exception) -> bool: def _is_non_retryable_error(e: BaseException) -> bool: # Detect Temporal-style ``non_retryable`` flag without importing temporalio. - # Wrapping such an exception in a plain RuntimeError strips the flag, so - # Temporal's activity retry policy will retry the call until exhaustion. - return bool(getattr(e, "non_retryable", False)) + # Walks ``__cause__`` so an ``ActivityError`` whose cause is a non-retryable + # ``ApplicationError`` is detected too — that's what callers driving the + # agent loop from a Temporal activity will see when a sub-activity has + # already failed terminally. + seen: set[int] = set() + current: BaseException | None = e + while current is not None and id(current) not in seen: + if getattr(current, "non_retryable", False): + return True + seen.add(id(current)) + current = current.__cause__ + return False def requires_init(fn: Callable[..., Any]) -> Callable[..., Any]: @@ -504,6 +516,9 @@ class AgentLoop: def emit_ready_telemetry(self, init_duration_ms: int) -> None: self.telemetry_client.send_ready(init_duration_ms=init_duration_ms) + def emit_session_closed_telemetry(self) -> None: + self.telemetry_client.send_session_closed() + def _create_connector_registry(self) -> ConnectorRegistry | None: if not connectors_enabled(): return None @@ -592,16 +607,25 @@ class AgentLoop: async def teleport_to_vibe_code( self, prompt: str | None ) -> AsyncGenerator[TeleportYieldEvent, TeleportPushResponseEvent | None]: - from vibe.core.teleport.errors import ServiceTeleportError from vibe.core.teleport.nuage import TeleportSession + session_messages = [ + msg.model_dump(exclude_none=True) for msg in self.messages[1:] + ] + telemetry_tracker = TeleportTelemetryTracker( + telemetry_client=self.telemetry_client, + nb_session_messages=len(session_messages), + stage="no_history" + if prompt is None and not session_messages + else "git_check", + ) session = TeleportSession( metadata={ "agent": self.agent_profile.name, "model": self.config.active_model, "stats": self.stats.model_dump(), }, - messages=[msg.model_dump(exclude_none=True) for msg in self.messages[1:]], + messages=session_messages, ) try: async with self.teleport_service: @@ -610,12 +634,23 @@ class AgentLoop: while True: try: event = await gen.asend(response) + telemetry_tracker.record_event(event) + if isinstance(event, TeleportCompleteEvent): + telemetry_tracker.send_success() response = yield event except StopAsyncIteration: break except ServiceTeleportError as e: + telemetry_tracker.record_service_error(e) raise TeleportError(str(e)) from e + except (asyncio.CancelledError, GeneratorExit): + telemetry_tracker.record_cancelled() + raise + except Exception as e: + telemetry_tracker.record_unexpected_error(e) + raise finally: + telemetry_tracker.send_failure_if_needed() self._teleport_service = None def _setup_middleware(self) -> None: @@ -709,6 +744,8 @@ class AgentLoop: old_context_tokens=old_tokens, new_context_tokens=new_tokens, summary_length=len(summary), + old_session_id=old_session_id, + new_session_id=self.session_id, ) case MiddlewareAction.CONTINUE: @@ -1395,8 +1432,9 @@ class AgentLoop: responded_ids: set[str] = set() j = i + 1 while j < len(self.messages) and self.messages[j].role == "tool": - if self.messages[j].tool_call_id: - responded_ids.add(self.messages[j].tool_call_id) + tool_call_id = self.messages[j].tool_call_id + if tool_call_id is not None: + responded_ids.add(tool_call_id) j += 1 if len(responded_ids) < expected_responses: @@ -1431,6 +1469,7 @@ class AgentLoop: def _reset_session(self, keep_parent: bool = True) -> None: old_session_id = self.session_id + self.emit_session_closed_telemetry() suffix = extract_suffix(self.session_id) self.session_id = generate_session_id(suffix=suffix) parent_session_id = old_session_id if keep_parent else None diff --git a/vibe/core/agents/manager.py b/vibe/core/agents/manager.py index cfcdf4e..c82ea2b 100644 --- a/vibe/core/agents/manager.py +++ b/vibe/core/agents/manager.py @@ -40,18 +40,22 @@ class AgentManager: " ".join(str(p) for p in self._search_paths), ) - profile = self._available.get(initial_agent) - if ( - not allow_subagent - and profile is not None - and profile.agent_type != AgentType.AGENT - ): + available = self.available_agents + profile = available.get(initial_agent) + if profile is None: + if initial_agent in self._available: + raise ValueError( + f"Agent '{initial_agent}' is not available. " + f"It may be disabled, not installed, or excluded by your config." + ) + raise ValueError(f"Agent '{initial_agent}' not found.") + if not allow_subagent and profile.agent_type != AgentType.AGENT: raise ValueError( f"Agent '{initial_agent}' is a {profile.agent_type} and cannot be used" f" as the primary agent. Only agents of type 'agent' can be selected" f" with --agent." ) - self.active_profile = profile or self._available[BuiltinAgentName.DEFAULT] + self.active_profile = profile self._cached_config: VibeConfig | None = None @property diff --git a/vibe/core/autocompletion/completers.py b/vibe/core/autocompletion/completers.py index 73b10f1..486571b 100644 --- a/vibe/core/autocompletion/completers.py +++ b/vibe/core/autocompletion/completers.py @@ -64,13 +64,16 @@ class CommandCompleter(Completer): descriptions[alias] = description return list(descriptions.keys()), descriptions + def _head_word(self, text: str, cursor_pos: int) -> str: + head = text.split(" ", 1)[0] + return head[1 : min(cursor_pos, len(head))].lower() + def get_completions(self, text: str, cursor_pos: int) -> list[str]: if not text.startswith("/"): return [] aliases, _ = self._build_lookup() - word = text[1:cursor_pos].lower() - search_str = "/" + word + search_str = "/" + self._head_word(text, cursor_pos) return [alias for alias in aliases if alias.lower().startswith(search_str)] def get_completion_items(self, text: str, cursor_pos: int) -> list[tuple[str, str]]: @@ -78,8 +81,7 @@ class CommandCompleter(Completer): return [] aliases, descriptions = self._build_lookup() - word = text[1:cursor_pos].lower() - search_str = "/" + word + search_str = "/" + self._head_word(text, cursor_pos) items = [ (alias, descriptions.get(alias, "")) for alias in aliases @@ -90,9 +92,11 @@ class CommandCompleter(Completer): def get_replacement_range( self, text: str, cursor_pos: int ) -> tuple[int, int] | None: - if text.startswith("/"): - return (0, cursor_pos) - return None + if not text.startswith("/"): + return None + first_space = text.find(" ") + end = cursor_pos if first_space == -1 else min(cursor_pos, first_space) + return (0, end) class PathCompleter(Completer): diff --git a/vibe/core/config/_settings.py b/vibe/core/config/_settings.py index 3afadc6..d83ae6e 100644 --- a/vibe/core/config/_settings.py +++ b/vibe/core/config/_settings.py @@ -25,6 +25,7 @@ from pydantic_settings import ( ) import tomli_w +from vibe.core.agents.models import BuiltinAgentName from vibe.core.config.harness_files import get_harness_files_manager from vibe.core.logger import logger from vibe.core.paths import GLOBAL_ENV_FILE, SESSION_LOG_DIR @@ -610,6 +611,15 @@ class VibeConfig(BaseSettings): "A list of opt-in builtin agent names that have been explicitly installed." ), ) + default_agent: str = Field( + default=BuiltinAgentName.DEFAULT, + description=( + "Agent profile to use when no --agent flag is passed in interactive " + "mode. Builtin: default, plan, accept-edits, auto-approve. " + "Ignored in programmatic mode (-p/--prompt), which falls back to " + "auto-approve when --agent is not provided." + ), + ) skill_paths: list[Path] = Field( default_factory=list, description=( diff --git a/vibe/core/logger.py b/vibe/core/logger.py index 38307d1..e1d77eb 100644 --- a/vibe/core/logger.py +++ b/vibe/core/logger.py @@ -45,6 +45,8 @@ def apply_logging_config(target_logger: logging.Logger) -> None: max_bytes = int(os.environ.get("LOG_MAX_BYTES", 10 * 1024 * 1024)) + # DEBUG_MODE is the debugpy switch (see vibe/acp/entrypoint.py); + # it also forces DEBUG-level logging here. if os.environ.get("DEBUG_MODE") == "true": log_level_str = "DEBUG" else: diff --git a/vibe/core/loop.py b/vibe/core/loop.py new file mode 100644 index 0000000..43fdda1 --- /dev/null +++ b/vibe/core/loop.py @@ -0,0 +1,250 @@ +from __future__ import annotations + +from enum import StrEnum, auto +import math +import re +import secrets +import time +from typing import TYPE_CHECKING + +from pydantic import BaseModel, ConfigDict + +from vibe.core.logger import logger +from vibe.core.types import ScheduledLoop + +if TYPE_CHECKING: + from vibe.core.session.session_logger import SessionLogger + +__all__ = [ + "MAX_LOOPS_PER_SESSION", + "MIN_INTERVAL_SECONDS", + "USAGE_HINT", + "IntervalUnit", + "LoopCommandResult", + "LoopError", + "LoopErrorResult", + "LoopListResult", + "LoopManager", + "LoopOkResult", + "format_duration", + "parse_interval", +] + + +MIN_INTERVAL_SECONDS = 30 +MAX_LOOPS_PER_SESSION = 50 +USAGE_HINT = """\ +Usage: + /loop + /loop list + /loop cancel +""" + +_INTERVAL_RE = re.compile(r"^(\d+)([smhd])$") +_CANCEL_VERBS = frozenset({"cancel", "rm", "stop", "delete"}) +_LIST_VERBS = frozenset({"list", "ls"}) + + +class LoopError(Exception): + pass + + +class IntervalUnit(StrEnum): + SECOND = auto() + MINUTE = auto() + HOUR = auto() + DAY = auto() + + @property + def seconds(self) -> int: + match self: + case IntervalUnit.SECOND: + return 1 + case IntervalUnit.MINUTE: + return 60 + case IntervalUnit.HOUR: + return 3600 + case IntervalUnit.DAY: + return 86400 + + @property + def suffix(self) -> str: + match self: + case IntervalUnit.SECOND: + return "s" + case IntervalUnit.MINUTE: + return "m" + case IntervalUnit.HOUR: + return "h" + case IntervalUnit.DAY: + return "d" + + @classmethod + def from_suffix(cls, ch: str) -> IntervalUnit: + match ch: + case "s": + return cls.SECOND + case "m": + return cls.MINUTE + case "h": + return cls.HOUR + case "d": + return cls.DAY + case _: + raise LoopError(f"Unknown interval unit `{ch}`.") + + +def parse_interval(text: str) -> int: + if not text: + raise LoopError("Missing interval.") + match = _INTERVAL_RE.match(text.strip().lower()) + if match is None: + raise LoopError( + f"Invalid interval `{text}`. " + "Expected: (e.g., 30s, 5m, 2h, 1d)." + ) + value = int(match.group(1)) + seconds = value * IntervalUnit.from_suffix(match.group(2)).seconds + if seconds < MIN_INTERVAL_SECONDS: + raise LoopError(f"Interval must be at least {MIN_INTERVAL_SECONDS}s.") + return seconds + + +def format_duration(seconds: int, short: bool = False) -> str: + parts = [] + for unit in ( + IntervalUnit.DAY, + IntervalUnit.HOUR, + IntervalUnit.MINUTE, + IntervalUnit.SECOND, + ): + value = seconds // unit.seconds + if value > 0: + parts.append(f"{value}{unit.suffix}") + seconds %= unit.seconds + if not parts: + parts = ["0s"] + if short: + return parts[0] + return "".join(parts) + + +class _LoopResultBase(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + +class LoopListResult(_LoopResultBase): + loops: list[ScheduledLoop] + + +class LoopOkResult(_LoopResultBase): + message: str + + +class LoopErrorResult(_LoopResultBase): + message: str + + +LoopCommandResult = LoopListResult | LoopOkResult | LoopErrorResult + + +class LoopManager: + def __init__(self, session_logger: SessionLogger) -> None: + self._session_logger = session_logger + self._loops: list[ScheduledLoop] = [] + + @property + def loops(self) -> list[ScheduledLoop]: + return list(self._loops) + + def restore(self, loops: list[ScheduledLoop]) -> None: + self._loops = list(loops) + + def next_due_in(self, now: float | None = None) -> float: + if not self._loops: + return math.inf + ts = now if now is not None else time.time() + return max(0.0, min(loop.next_fire_at for loop in self._loops) - ts) + + async def pop_due(self, now: float | None = None) -> ScheduledLoop | None: + if not self._loops: + return None + ts = now if now is not None else time.time() + due_loops = [loop for loop in self._loops if loop.next_fire_at <= ts] + if not due_loops: + return None + due = min(due_loops, key=lambda loop: loop.next_fire_at) + due.next_fire_at = ts + due.interval_seconds + await self._persist() + return due + + async def handle_command(self, args: str) -> LoopCommandResult: + text = args.strip() + if not text: + return self._list_result() + verb, _, rest = text.partition(" ") + verb_lower = verb.lower() + if verb_lower in _LIST_VERBS: + return self._list_result() + if verb_lower in _CANCEL_VERBS: + return await self._cancel(rest.strip()) + return await self._add(verb, rest) + + def _list_result(self) -> LoopListResult: + return LoopListResult(loops=list(self._loops)) + + async def _add( + self, interval_text: str, prompt: str + ) -> LoopOkResult | LoopErrorResult: + try: + seconds = parse_interval(interval_text) + except LoopError as e: + return LoopErrorResult(message=str(e)) + prompt = prompt.strip() + if not prompt: + return LoopErrorResult(message="Missing prompt.") + if prompt.startswith("/"): + return LoopErrorResult(message="Prompt cannot start with '/'.") + if len(self._loops) >= MAX_LOOPS_PER_SESSION: + return LoopErrorResult( + message=f"Loop limit reached ({MAX_LOOPS_PER_SESSION} per session)." + ) + now = time.time() + loop = ScheduledLoop( + id=secrets.token_hex(4), + interval_seconds=seconds, + prompt=prompt, + next_fire_at=now + seconds, + created_at=now, + ) + self._loops.append(loop) + await self._persist() + return LoopOkResult( + message=( + f"Scheduled loop `{loop.id}` every {format_duration(seconds)}: {prompt}" + ) + ) + + async def _cancel(self, target: str) -> LoopOkResult | LoopErrorResult: + if not target: + return LoopErrorResult(message="Missing loop id.") + if target.lower() == "all": + count = len(self._loops) + self._loops.clear() + await self._persist() + return LoopOkResult(message=f"Cancelled {count} scheduled loop(s).") + match = next((loop for loop in self._loops if loop.id == target), None) + if match is None: + return LoopErrorResult(message=f"No scheduled loop with id `{target}`.") + self._loops.remove(match) + await self._persist() + return LoopOkResult(message=f"Cancelled loop `{match.id}`: {match.prompt}") + + async def _persist(self) -> None: + metadata = self._session_logger.session_metadata + if metadata is not None: + metadata.loops = [*self._loops] + try: + await self._session_logger.persist_loops() + except Exception as e: + logger.error("Failed to persist scheduled loops", exc_info=e) diff --git a/vibe/core/programmatic.py b/vibe/core/programmatic.py index 8f0f429..16dbd7c 100644 --- a/vibe/core/programmatic.py +++ b/vibe/core/programmatic.py @@ -91,6 +91,7 @@ def run_programmatic( # noqa: PLR0913, PLR0917 return formatter.finalize() finally: + agent_loop.emit_session_closed_telemetry() await agent_loop.telemetry_client.aclose() return asyncio.run(_async_run()) diff --git a/vibe/core/prompts/cli.md b/vibe/core/prompts/cli.md index 56a18de..61e9c35 100644 --- a/vibe/core/prompts/cli.md +++ b/vibe/core/prompts/cli.md @@ -27,8 +27,8 @@ Never claim completion without verification — a passing test, correct read-bac Hard Rules: -Never Commit -Do not run `git commit`, `git push`, or `git add` unless the user explicitly asks you to. Saving files is sufficient — the user will review changes and commit themselves. +Never Commit Proactively +Do not proactively run `git add`, `git commit`, or `git push`. Saving files is the default — the user usually reviews and commits themselves. If the user explicitly asks you to stage, commit, or push, do it. Respect User Constraints "No writes", "just analyze", "plan only", "don't touch X" - these are hard constraints. Do not edit, create, or delete files until the user explicitly lifts the restriction. Violation of explicit user instructions is the worst failure mode. diff --git a/vibe/core/scratchpad.py b/vibe/core/scratchpad.py index 666f695..f1d0a57 100644 --- a/vibe/core/scratchpad.py +++ b/vibe/core/scratchpad.py @@ -4,6 +4,7 @@ from pathlib import Path import tempfile from vibe.core.logger import logger +from vibe.core.session.session_id import shorten_session_id _active_scratchpads: dict[str, Path] = {} @@ -17,7 +18,11 @@ def init_scratchpad(session_id: str) -> Path | None: return _active_scratchpads[session_id] try: - dir_path = Path(tempfile.mkdtemp(prefix=f"vibe-scratchpad-{session_id[:8]}-")) + dir_path = Path( + tempfile.mkdtemp( + prefix=f"vibe-scratchpad-{shorten_session_id(session_id)}-" + ) + ) _active_scratchpads[session_id] = dir_path logger.debug("Scratchpad initialized at %s", dir_path) return dir_path diff --git a/vibe/core/session/resume_sessions.py b/vibe/core/session/resume_sessions.py index fc29bea..86683a3 100644 --- a/vibe/core/session/resume_sessions.py +++ b/vibe/core/session/resume_sessions.py @@ -8,17 +8,14 @@ from vibe.core.config import VibeConfig from vibe.core.logger import logger from vibe.core.nuage.client import WorkflowsClient from vibe.core.nuage.workflow import WorkflowExecutionStatus +from vibe.core.session.session_id import shorten_session_id from vibe.core.session.session_loader import SessionLoader ResumeSessionSource = Literal["local", "remote"] -SHORT_SESSION_ID_LEN = 8 - def short_session_id(session_id: str, source: ResumeSessionSource = "local") -> str: - if source == "remote": - return session_id[-SHORT_SESSION_ID_LEN:] - return session_id[:SHORT_SESSION_ID_LEN] + return shorten_session_id(session_id, from_end=source == "remote") _ACTIVE_STATUSES = [ diff --git a/vibe/core/session/session_id.py b/vibe/core/session/session_id.py index 96a39db..3bb72f1 100644 --- a/vibe/core/session/session_id.py +++ b/vibe/core/session/session_id.py @@ -22,3 +22,13 @@ def generate_session_id(*, suffix: str | None = None) -> str: def extract_suffix(session_id: str) -> str: """Extract the stable suffix (last segment after the final hyphen).""" return session_id.rsplit("-", 1)[-1] + + +_SHORT_LEN = 8 + + +def shorten_session_id(session_id: str, *, from_end: bool = False) -> str: + """Return a short human-readable slice of a session ID (8 chars).""" + if from_end: + return session_id[-_SHORT_LEN:] + return session_id[:_SHORT_LEN] diff --git a/vibe/core/session/session_loader.py b/vibe/core/session/session_loader.py index cec0318..6d7c904 100644 --- a/vibe/core/session/session_loader.py +++ b/vibe/core/session/session_loader.py @@ -5,6 +5,7 @@ import json from pathlib import Path from typing import TYPE_CHECKING, Any, TypedDict +from vibe.core.session.session_id import shorten_session_id from vibe.core.types import LLMMessage, SessionMetadata from vibe.core.utils.io import read_safe @@ -129,7 +130,7 @@ class SessionLoader: if not save_dir.exists(): return [] - short_id = session_id[:8] + short_id = shorten_session_id(session_id) return list(save_dir.glob(f"{config.session_prefix}_*_{short_id}")) @staticmethod diff --git a/vibe/core/session/session_logger.py b/vibe/core/session/session_logger.py index 236660e..b6457fb 100644 --- a/vibe/core/session/session_logger.py +++ b/vibe/core/session/session_logger.py @@ -12,6 +12,7 @@ from typing import TYPE_CHECKING, Any, Literal from anyio import NamedTemporaryFile, Path as AsyncPath +from vibe.core.session.session_id import shorten_session_id from vibe.core.session.session_loader import ( MESSAGES_FILENAME, METADATA_FILENAME, @@ -63,9 +64,20 @@ class SessionLogger: ) timestamp = utc_now().strftime("%Y%m%d_%H%M%S") - folder_name = f"{self.session_prefix}_{timestamp}_{self.session_id[:8]}" + folder_name = ( + f"{self.session_prefix}_{timestamp}_{shorten_session_id(self.session_id)}" + ) return self.save_dir / folder_name + def _get_session_info(self) -> tuple[Path, SessionMetadata] | None: + if ( + not self.enabled + or self.session_dir is None + or self.session_metadata is None + ): + return None + return (self.session_dir, self.session_metadata) + @property def metadata_filepath(self) -> Path: if self.session_dir is None: @@ -244,34 +256,34 @@ class SessionLogger: tool_manager: ToolManager, agent_profile: AgentProfile, ) -> None: - if not self.enabled or self.session_dir is None: - return - - if self.session_metadata is None: + session_info = self._get_session_info() + if session_info is None: return + session_dir, session_metadata = session_info + metadata_path = session_dir / METADATA_FILENAME if not any(msg.role != Role.system for msg in messages): return # If the session directory does not exist, create it try: - self.session_dir.mkdir(parents=True, exist_ok=True) + session_dir.mkdir(parents=True, exist_ok=True) except OSError as e: raise RuntimeError( - f"Failed to create session directory at {self.session_dir}: {type(e).__name__}: {e}" + f"Failed to create session directory at {session_dir}: {type(e).__name__}: {e}" ) from e # Read old metadata and get total_messages try: - if self.metadata_filepath.exists(): - raw = (await read_safe_async(self.metadata_filepath)).text + if metadata_path.exists(): + raw = (await read_safe_async(metadata_path)).text old_metadata = json.loads(raw) old_total_messages = old_metadata["total_messages"] else: old_total_messages = 0 except Exception as e: raise RuntimeError( - f"Failed to read session metadata at {self.metadata_filepath}: {e}" + f"Failed to read session metadata at {metadata_path}: {e}" ) from e try: @@ -283,7 +295,7 @@ class SessionLogger: return messages_data = [m.model_dump(exclude_none=True) for m in new_messages] - await SessionLogger.persist_messages(messages_data, self.session_dir) + await SessionLogger.persist_messages(messages_data, session_dir) # If message update succeeded, write metadata tools_available = [ @@ -307,7 +319,7 @@ class SessionLogger: total_messages = len(non_system_messages) metadata_dump = { - **self.session_metadata.model_dump(), + **session_metadata.model_dump(), "end_time": utc_now().isoformat(), "stats": stats.model_dump(), "title": title, @@ -321,14 +333,32 @@ class SessionLogger: "system_prompt": system_prompt, } - await SessionLogger.persist_metadata(metadata_dump, self.session_dir) + await SessionLogger.persist_metadata(metadata_dump, session_dir) except Exception as e: - raise RuntimeError( - f"Failed to save session to {self.session_dir}: {e}" - ) from e + raise RuntimeError(f"Failed to save session to {session_dir}: {e}") from e finally: self.maybe_cleanup_tmp_files() + async def persist_loops(self) -> None: + session_info = self._get_session_info() + if session_info is None: + return + session_dir, session_metadata = session_info + metadata_path = session_dir / METADATA_FILENAME + if not metadata_path.exists(): + return + try: + raw = (await read_safe_async(metadata_path)).text + metadata = json.loads(raw) + except (OSError, json.JSONDecodeError) as e: + raise RuntimeError( + f"Failed to read session metadata at {metadata_path}: {e}" + ) from e + metadata["loops"] = [ + loop.model_dump(mode="json") for loop in session_metadata.loops + ] + await SessionLogger.persist_metadata(metadata, session_dir) + def reset_session( self, session_id: str, *, parent_session_id: str | None = None ) -> None: diff --git a/vibe/core/skills/builtins/vibe.py b/vibe/core/skills/builtins/vibe.py index c9c99a0..37d3cfe 100644 --- a/vibe/core/skills/builtins/vibe.py +++ b/vibe/core/skills/builtins/vibe.py @@ -194,6 +194,15 @@ disabled_agents = ["auto-approve"] # Opt-in builtin agents (only affects agents with install_required=True, e.g. lean) installed_agents = ["lean"] + +# Agent profile to use when --agent is not passed in interactive mode +# (default: "default"). Valid values: "default", "plan", "accept-edits", +# "auto-approve", "lean" (only when listed in installed_agents), or any +# custom agent name from ~/.vibe/agents/ or .vibe/agents/. Subagents +# (e.g. "explore") are rejected. Ignored in programmatic mode +# (-p/--prompt), which falls back to "auto-approve" when --agent is not +# provided. +default_agent = "plan" ``` ### MCP Servers @@ -321,7 +330,7 @@ Tool, skill, and agent names support three matching modes: ``` vibe [PROMPT] # Start interactive session with optional prompt vibe -p TEXT / --prompt TEXT # Programmatic mode (auto-approve, one-shot, exit) -vibe --agent NAME # Select agent profile +vibe --agent NAME # Select agent profile (falls back to `default_agent` config) vibe --workdir DIR # Change working directory vibe --trust # Trust cwd for this invocation only (not persisted) vibe -c / --continue # Continue most recent session @@ -374,6 +383,15 @@ Custom agents are TOML files in `~/.vibe/agents/NAME.toml`. - `/mcp` - Display available MCP servers (pass a server name to list its tools) - `/resume` (or `/continue`) - Browse and resume past sessions - `/rewind` - Rewind to a previous message +- `/loop ` - Schedule a recurring prompt (e.g. `/loop 30s ping`). + Intervals: `Ns/Nm/Nh/Nd`, minimum 30s, max 50 loops/session. + - `/loop` (or `/loop list` / `/loop ls`) - List current scheduled loops. + - `/loop cancel ` (aliases `rm`, `stop`, `delete`) - Cancel a loop. + - Loops fire only when the agent is idle and the input bar is focused. At + most one loop fires per poll. Overdue loops fire once on the next poll + (no catch-up); `next_fire_at` advances to `now + interval`. + - Loops are persisted in the session metadata (`loops` field of `meta.json`) + and restored on `--resume`/`--continue`. - `/terminal-setup` - Configure Shift+Enter for newlines - `/proxy-setup` - Configure proxy and SSL certificate settings - `/leanstall` - Install the Lean 4 agent (leanstral) @@ -415,6 +433,13 @@ Detailed instructions for the model... - `MISTRAL_API_KEY` - API key for Mistral provider - `VIBE_ACTIVE_MODEL` - Override active model - `VIBE_*` - Any config field can be overridden with the `VIBE_` prefix +- `LOG_LEVEL` - Logging level for `$VIBE_HOME/logs/vibe.log`. One of `DEBUG`, + `INFO`, `WARNING` (default), `ERROR`, `CRITICAL`. Invalid values fall back + to `WARNING`. +- `LOG_MAX_BYTES` - Max size in bytes of `vibe.log` before rotation + (default: `10485760`, i.e. 10 MiB). +- `DEBUG_MODE` - When `true`, forces `DEBUG`-level logging. Under `vibe-acp` + it also attaches `debugpy` on `localhost:5678`. ## API Keys (.env file) diff --git a/vibe/core/telemetry/send.py b/vibe/core/telemetry/send.py index 30e6fc9..d5f06ae 100644 --- a/vibe/core/telemetry/send.py +++ b/vibe/core/telemetry/send.py @@ -16,6 +16,9 @@ from vibe.core.telemetry.types import ( AgentEntrypoint, EntrypointMetadata, TelemetryCallType, + TeleportCompletedPayload, + TeleportFailedPayload, + TeleportFailureStage, ) from vibe.core.utils import get_server_url_from_api_base, get_user_agent @@ -63,7 +66,7 @@ class TelemetryClient: def _get_mistral_provider_and_api_key(self) -> tuple[ProviderConfig, str] | None: try: provider = self._config_getter().get_mistral_provider() - except ValueError: + except Exception: return None if provider is None: return None @@ -77,7 +80,7 @@ class TelemetryClient: """Check if telemetry is enabled in the current config.""" try: return self._config_getter().enable_telemetry - except ValueError: + except Exception: return False def is_active(self) -> bool: @@ -270,6 +273,9 @@ class TelemetryClient: } self.send_telemetry_event("vibe.new_session", payload) + def send_session_closed(self) -> None: + self.send_telemetry_event("vibe.session_closed", {}) + def send_onboarding_api_key_added(self) -> None: self.send_telemetry_event( "vibe.onboarding_api_key_added", {"version": __version__} @@ -322,3 +328,35 @@ class TelemetryClient: {"rating": rating, "version": __version__, "model": model}, correlation_id=self.last_correlation_id, ) + + def send_teleport_completed( + self, + *, + push_required: bool, + github_auth_required: bool, + nb_session_messages: int, + ) -> None: + payload: TeleportCompletedPayload = { + "push_required": push_required, + "github_auth_required": github_auth_required, + "nb_session_messages": nb_session_messages, + } + self.send_telemetry_event("vibe.teleport_completed", dict(payload)) + + def send_teleport_failed( + self, + *, + stage: TeleportFailureStage, + error_class: str, + push_required: bool, + github_auth_required: bool, + nb_session_messages: int, + ) -> None: + payload: TeleportFailedPayload = { + "stage": stage, + "error_class": error_class, + "push_required": push_required, + "github_auth_required": github_auth_required, + "nb_session_messages": nb_session_messages, + } + self.send_telemetry_event("vibe.teleport_failed", dict(payload)) diff --git a/vibe/core/telemetry/types.py b/vibe/core/telemetry/types.py index a1d41ea..aef6a37 100644 --- a/vibe/core/telemetry/types.py +++ b/vibe/core/telemetry/types.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Literal +from typing import Literal, TypedDict from pydantic import BaseModel @@ -36,3 +36,26 @@ class TelemetryRequestMetadata(TelemetryBaseMetadata): call_type: TelemetryCallType call_source: str = "vibe_code" message_id: str | None = None + + +TeleportFailureStage = Literal[ + "no_history", + "remote_session", + "git_check", + "push", + "workflow_start", + "github_auth", + "fetch_url", + "cancelled", +] + + +class TeleportCompletedPayload(TypedDict): + push_required: bool + github_auth_required: bool + nb_session_messages: int + + +class TeleportFailedPayload(TeleportCompletedPayload): + stage: TeleportFailureStage + error_class: str diff --git a/vibe/core/teleport/telemetry.py b/vibe/core/teleport/telemetry.py new file mode 100644 index 0000000..f33e104 --- /dev/null +++ b/vibe/core/teleport/telemetry.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from vibe.core.telemetry.send import TelemetryClient +from vibe.core.telemetry.types import TeleportFailureStage +from vibe.core.teleport.errors import ServiceTeleportError +from vibe.core.teleport.types import ( + TeleportAuthRequiredEvent, + TeleportCheckingGitEvent, + TeleportCompleteEvent, + TeleportFetchingUrlEvent, + TeleportPushingEvent, + TeleportPushRequiredEvent, + TeleportStartingWorkflowEvent, + TeleportWaitingForGitHubEvent, + TeleportYieldEvent, +) + + +def send_teleport_early_failure_telemetry( + telemetry_client: TelemetryClient, + *, + stage: TeleportFailureStage, + error_class: str, + nb_session_messages: int, +) -> None: + telemetry_client.send_teleport_failed( + stage=stage, + error_class=error_class, + push_required=False, + github_auth_required=False, + nb_session_messages=nb_session_messages, + ) + + +@dataclass +class TeleportTelemetryTracker: + telemetry_client: TelemetryClient + nb_session_messages: int + stage: TeleportFailureStage + push_required: bool = False + github_auth_required: bool = False + success: bool = False + error_class: str | None = None + + def record_event(self, event: TeleportYieldEvent) -> None: + match event: + case TeleportCheckingGitEvent(): + self.stage = "git_check" + case TeleportPushRequiredEvent(): + self.push_required = True + self.stage = "cancelled" + case TeleportPushingEvent(): + self.stage = "push" + case TeleportStartingWorkflowEvent(): + self.stage = "workflow_start" + case TeleportWaitingForGitHubEvent(): + self.stage = "github_auth" + case TeleportAuthRequiredEvent(): + self.github_auth_required = True + self.stage = "github_auth" + case TeleportFetchingUrlEvent(): + self.stage = "fetch_url" + case TeleportCompleteEvent(): + self.success = True + + def record_service_error(self, error: ServiceTeleportError) -> None: + self.error_class = type(error).__name__ + + def record_cancelled(self) -> None: + self.stage = "cancelled" + self.error_class = "CancelledError" + + def record_unexpected_error(self, error: Exception) -> None: + self.error_class = type(error).__name__ + + def send_success(self) -> None: + self.telemetry_client.send_teleport_completed( + push_required=self.push_required, + github_auth_required=self.github_auth_required, + nb_session_messages=self.nb_session_messages, + ) + + def send_failure_if_needed(self) -> None: + if self.success or self.error_class is None: + return + self.telemetry_client.send_teleport_failed( + stage=self.stage, + error_class=self.error_class, + push_required=self.push_required, + github_auth_required=self.github_auth_required, + nb_session_messages=self.nb_session_messages, + ) diff --git a/vibe/core/tools/builtins/read_file.py b/vibe/core/tools/builtins/read_file.py index 0d16a53..0068eb5 100644 --- a/vibe/core/tools/builtins/read_file.py +++ b/vibe/core/tools/builtins/read_file.py @@ -141,7 +141,7 @@ class ReadFile( try: raw_lines: list[bytes] = [] bytes_read = 0 - was_truncated = False + was_truncated = True async with await anyio.Path(file_path).open("rb") as f: line_index = 0 @@ -155,12 +155,13 @@ class ReadFile( line_bytes = len(raw_line) if bytes_read + line_bytes > self.config.max_read_bytes: - was_truncated = True break raw_lines.append(raw_line) bytes_read += line_bytes line_index += 1 + else: + was_truncated = False except OSError as exc: raise ToolError(f"Error reading {file_path}: {exc}") from exc diff --git a/vibe/core/tracing.py b/vibe/core/tracing.py index d2d22be..27483ed 100644 --- a/vibe/core/tracing.py +++ b/vibe/core/tracing.py @@ -21,7 +21,7 @@ VIBE_AGENT_NAME = "mistral-vibe" def setup_tracing(config: VibeConfig) -> None: - if not config.enable_otel: + if not config.enable_telemetry or not config.enable_otel: return exporter_cfg = config.otel_span_exporter_config diff --git a/vibe/core/types.py b/vibe/core/types.py index 52b1dd6..6f3412f 100644 --- a/vibe/core/types.py +++ b/vibe/core/types.py @@ -26,6 +26,16 @@ from pydantic import ( ) +class ScheduledLoop(BaseModel): + model_config = ConfigDict(extra="forbid") + + id: str + interval_seconds: int + prompt: str + next_fire_at: float + created_at: float + + class Backend(StrEnum): MISTRAL = auto() GENERIC = auto() @@ -142,6 +152,7 @@ class SessionMetadata(BaseModel): git_branch: str | None environment: dict[str, str | None] username: str + loops: list[ScheduledLoop] = Field(default_factory=list) title: str | None = None title_source: Literal["auto", "manual"] = "auto" @@ -418,6 +429,8 @@ class CompactEndEvent(BaseEvent): old_context_tokens: int new_context_tokens: int summary_length: int + old_session_id: str | None = None + new_session_id: str | None = None # WORKAROUND: Using tool_call to communicate compact events to the client. # This should be revisited when the ACP protocol defines how compact events # should be represented. diff --git a/vibe/core/utils/display.py b/vibe/core/utils/display.py index 52744a9..96f5b70 100644 --- a/vibe/core/utils/display.py +++ b/vibe/core/utils/display.py @@ -1,13 +1,31 @@ from __future__ import annotations +from vibe.core.session.session_id import shorten_session_id -def compact_reduction_display(old_tokens: int | None, new_tokens: int | None) -> str: - if old_tokens is None or new_tokens is None: - return "Compaction complete" - reduction = old_tokens - new_tokens - reduction_pct = (reduction / old_tokens * 100) if old_tokens > 0 else 0 - return ( - f"Compaction complete: {old_tokens:,} → " - f"{new_tokens:,} tokens ({-reduction_pct:+#0.2g}%)" - ) +def compact_reduction_display( + old_tokens: int | None, + new_tokens: int | None, + *, + old_session_id: str | None = None, + new_session_id: str | None = None, +) -> str: + + message = "Compaction complete" + if old_tokens is not None and new_tokens is not None: + reduction = old_tokens - new_tokens + reduction_pct = (reduction / old_tokens * 100) if old_tokens > 0 else 0 + message = ( + f"{message}: {old_tokens:,} → " + f"{new_tokens:,} tokens ({-reduction_pct:+#0.2g}%)" + ) + + if old_session_id is not None and new_session_id is not None: + short_old = shorten_session_id(old_session_id) + short_new = shorten_session_id(new_session_id) + message = ( + f"{message}\n" + f"session: {short_old} (before compaction) → {short_new} (after compaction)" + ) + + return message diff --git a/vibe/whats_new.md b/vibe/whats_new.md index 0ee9980..68e7de4 100644 --- a/vibe/whats_new.md +++ b/vibe/whats_new.md @@ -1,5 +1,5 @@ -# What's new in v2.9.4 +# What's new in v2.9.5 -- **`/rename` command**: Rename the current session from the slash menu. -- **Persistent "always allow"**: Tool permissions granted with "always allow" now stick across sessions. -- **Faster bash bang commands**: `!command` runs via async subprocess. +- **`/loop` command**: Run a prompt or slash command on a recurring interval. +- **`default_agent` config**: Set which agent profile starts each session. +- **Parallel-safe history**: Multiple `vibe` instances no longer clobber each other's history file.