From 564a14365e583515c66d8dc6d80c3f464f79049f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Drouin?= Date: Tue, 16 Jun 2026 10:43:46 +0200 Subject: [PATCH] v2.16.1 (#808) Co-authored-by: Mathias Gesbert Co-authored-by: Paul VEZIA <166131032+le-codeur-rapide@users.noreply.github.com> Co-authored-by: Mistral Vibe --- CHANGELOG.md | 12 + distribution/zed/extension.toml | 12 +- pyproject.toml | 2 +- tests/acp/test_initialize.py | 4 +- tests/acp/test_load_session.py | 36 ++- tests/acp/test_new_session.py | 244 ++++++++++++++++- tests/cli/test_startup_update_prompt.py | 4 +- tests/session/test_resume_sessions.py | 256 +++++++++++------- uv.lock | 4 +- vibe/__init__.py | 2 +- vibe/acp/acp_agent_loop.py | 89 +++++- vibe/cli/cli.py | 7 +- vibe/cli/entrypoint.py | 51 +--- vibe/cli/textual_ui/app.py | 118 ++++---- vibe/cli/textual_ui/widgets/session_picker.py | 47 ++++ vibe/core/nuage/client.py | 3 + vibe/core/nuage/remote_events_source.py | 2 +- vibe/core/session/resume_sessions.py | 154 +++++++++-- vibe/core/session/session_loader.py | 59 ++-- vibe/core/trusted_folders.py | 86 ++++++ vibe/core/utils/io.py | 3 + .../trusted_folders/trust_folder_dialog.py | 9 +- 22 files changed, 952 insertions(+), 252 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c02c178..a01d3a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ 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.16.1] - 2026-06-16 + +### Added + +- ACP workspace trust gated behind the `workspace-trust` client capability + +### Changed + +- `/resume` now opens much faster +- Startup update-failure message is now gentler and no longer alarms when an update can't be applied + + ## [2.16.0] - 2026-06-15 ### Added diff --git a/distribution/zed/extension.toml b/distribution/zed/extension.toml index 641e8a1..b4427cf 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.16.0" +version = "2.16.1" 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.16.0/vibe-acp-darwin-aarch64-2.16.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.16.1/vibe-acp-darwin-aarch64-2.16.1.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.darwin-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.16.0/vibe-acp-darwin-x86_64-2.16.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.16.1/vibe-acp-darwin-x86_64-2.16.1.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.linux-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.16.0/vibe-acp-linux-aarch64-2.16.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.16.1/vibe-acp-linux-aarch64-2.16.1.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.linux-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.16.0/vibe-acp-linux-x86_64-2.16.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.16.1/vibe-acp-linux-x86_64-2.16.1.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.windows-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.16.0/vibe-acp-windows-x86_64-2.16.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.16.1/vibe-acp-windows-x86_64-2.16.1.zip" cmd = "./vibe-acp.exe" diff --git a/pyproject.toml b/pyproject.toml index f41be37..1209189 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mistral-vibe" -version = "2.16.0" +version = "2.16.1" description = "Minimal CLI coding agent by Mistral" readme = "README.md" requires-python = ">=3.12" diff --git a/tests/acp/test_initialize.py b/tests/acp/test_initialize.py index 8e7fabd..9173ba9 100644 --- a/tests/acp/test_initialize.py +++ b/tests/acp/test_initialize.py @@ -72,7 +72,7 @@ class TestACPInitialize: ), ) assert response.agent_info == Implementation( - name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.16.0" + name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.16.1" ) assert response.auth_methods is not None @@ -172,7 +172,7 @@ class TestACPInitialize: ), ) assert response.agent_info == Implementation( - name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.16.0" + name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.16.1" ) assert response.auth_methods is not None diff --git a/tests/acp/test_load_session.py b/tests/acp/test_load_session.py index 8099c83..c90b8d0 100644 --- a/tests/acp/test_load_session.py +++ b/tests/acp/test_load_session.py @@ -1,11 +1,13 @@ from __future__ import annotations from pathlib import Path +from unittest.mock import AsyncMock from acp import RequestError from acp.schema import ( AgentMessageChunk, AgentThoughtChunk, + ClientCapabilities, ToolCallProgress, ToolCallStart, UserMessageChunk, @@ -15,10 +17,11 @@ import pytest from tests.conftest import build_test_vibe_config from tests.stubs.fake_backend import FakeBackend from tests.stubs.fake_client import FakeClient -from vibe.acp.acp_agent_loop import VibeAcpAgentLoop +from vibe.acp.acp_agent_loop import WORKSPACE_TRUST_CAPABILITY, VibeAcpAgentLoop from vibe.core.agent_loop import AgentLoop from vibe.core.agents.models import BuiltinAgentName from vibe.core.config import ModelConfig, SessionLoggingConfig +from vibe.core.trusted_folders import trusted_folders_manager from vibe.core.types import Role @@ -122,6 +125,37 @@ class TestLoadSession: assert response.config_options[2].category == "thinking" assert response.config_options[2].current_value == "off" + @pytest.mark.asyncio + async def test_load_session_resolves_workspace_trust_before_loading_config( + self, + acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient], + temp_session_dir: Path, + create_test_session, + tmp_working_directory: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + acp_agent, client = acp_agent_with_session_config + acp_agent.client_capabilities = ClientCapabilities( + field_meta={WORKSPACE_TRUST_CAPABILITY: True} + ) + request_trust = AsyncMock(return_value={"decision": "trust_cwd"}) + monkeypatch.setattr(client, "ext_method", request_trust) + (tmp_working_directory / "AGENTS.md").write_text( + "Loaded session project instructions", encoding="utf-8" + ) + session_id = "test-sess-trust" + create_test_session(temp_session_dir, session_id, str(tmp_working_directory)) + + await acp_agent.load_session( + cwd=str(tmp_working_directory), mcp_servers=[], session_id=session_id + ) + + request_trust.assert_awaited_once() + assert trusted_folders_manager.is_trusted(tmp_working_directory) is True + system_prompt = acp_agent.sessions[session_id].agent_loop.messages[0].content + assert system_prompt is not None + assert "Loaded session project instructions" in system_prompt + @pytest.mark.asyncio async def test_load_session_registers_session_with_original_id( self, diff --git a/tests/acp/test_new_session.py b/tests/acp/test_new_session.py index 748b443..2fb55d0 100644 --- a/tests/acp/test_new_session.py +++ b/tests/acp/test_new_session.py @@ -1,16 +1,31 @@ from __future__ import annotations from pathlib import Path -from unittest.mock import patch +from unittest.mock import AsyncMock, patch +from acp import RequestError +from acp.schema import ClientCapabilities import pytest from tests.acp.conftest import _create_acp_agent from tests.conftest import build_test_vibe_config -from vibe.acp.acp_agent_loop import VibeAcpAgentLoop +from vibe.acp.acp_agent_loop import WORKSPACE_TRUST_CAPABILITY, VibeAcpAgentLoop +from vibe.acp.exceptions import InvalidRequestError from vibe.core.agent_loop import AgentLoop from vibe.core.agents.models import BuiltinAgentName from vibe.core.config import ModelConfig +from vibe.core.trusted_folders import trusted_folders_manager + + +def _system_prompt(acp_agent_loop: VibeAcpAgentLoop, session_id: str) -> str: + session = acp_agent_loop.sessions[session_id] + return session.agent_loop.messages[0].content or "" + + +def _enable_workspace_trust(acp_agent_loop: VibeAcpAgentLoop) -> None: + acp_agent_loop.client_capabilities = ClientCapabilities( + field_meta={WORKSPACE_TRUST_CAPABILITY: True} + ) @pytest.fixture @@ -137,6 +152,231 @@ class TestACPNewSession: assert thinking_config.current_value == "off" assert len(thinking_config.options) == 5 + @pytest.mark.asyncio + async def test_new_session_loads_root_agents_md_from_workspace_cwd( + self, + acp_agent_loop: VibeAcpAgentLoop, + tmp_working_directory: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + (tmp_working_directory / "AGENTS.md").write_text( + "Root project instructions", encoding="utf-8" + ) + _enable_workspace_trust(acp_agent_loop) + request_trust = AsyncMock(return_value={"decision": "trust_cwd"}) + monkeypatch.setattr(acp_agent_loop.client, "ext_method", request_trust) + + session_response = await acp_agent_loop.new_session( + cwd=str(tmp_working_directory), mcp_servers=[] + ) + assert session_response.session_id is not None + + request_trust.assert_awaited_once() + await_args = request_trust.await_args + assert await_args is not None + method, params = await_args.args + assert method == "trust/request" + assert params["cwd"] == str(tmp_working_directory.resolve()) + assert params["detectedFiles"] == ["AGENTS.md"] + assert params["repoDetectedFiles"] == [] + assert params["availableDecisions"] == ["trust_cwd", "trust_session", "decline"] + assert trusted_folders_manager.is_trusted(tmp_working_directory) is True + assert "Root project instructions" in _system_prompt( + acp_agent_loop, session_response.session_id + ) + + @pytest.mark.asyncio + async def test_new_session_can_trust_full_repo_from_subdirectory( + self, + acp_agent_loop: VibeAcpAgentLoop, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + repo = tmp_path / "repo" + cwd = repo / "src" / "pkg" + (repo / ".git").mkdir(parents=True) + (repo / ".git" / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8") + cwd.mkdir(parents=True) + (repo / "AGENTS.md").write_text("Repo instructions", encoding="utf-8") + _enable_workspace_trust(acp_agent_loop) + request_trust = AsyncMock(return_value={"decision": "trust_repo"}) + monkeypatch.setattr(acp_agent_loop.client, "ext_method", request_trust) + + session_response = await acp_agent_loop.new_session( + cwd=str(cwd), mcp_servers=[] + ) + assert session_response.session_id is not None + + request_trust.assert_awaited_once() + await_args = request_trust.await_args + assert await_args is not None + method, params = await_args.args + assert method == "trust/request" + assert params["cwd"] == str(cwd.resolve()) + assert params["repoRoot"] == str(repo.resolve()) + assert params["detectedFiles"] == [] + assert params["repoDetectedFiles"] == ["AGENTS.md"] + assert params["availableDecisions"] == [ + "trust_repo", + "trust_cwd", + "trust_session", + "decline", + ] + assert trusted_folders_manager.is_trusted(repo) is True + assert trusted_folders_manager.is_trusted(cwd) is True + assert "Repo instructions" in _system_prompt( + acp_agent_loop, session_response.session_id + ) + + @pytest.mark.asyncio + async def test_new_session_decline_skips_project_docs( + self, + acp_agent_loop: VibeAcpAgentLoop, + tmp_working_directory: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + (tmp_working_directory / "AGENTS.md").write_text( + "Do not load this", encoding="utf-8" + ) + _enable_workspace_trust(acp_agent_loop) + monkeypatch.setattr( + acp_agent_loop.client, + "ext_method", + AsyncMock(return_value={"decision": "decline"}), + ) + + session_response = await acp_agent_loop.new_session( + cwd=str(tmp_working_directory), mcp_servers=[] + ) + assert session_response.session_id is not None + + assert trusted_folders_manager.is_trusted(tmp_working_directory) is False + assert "Do not load this" not in _system_prompt( + acp_agent_loop, session_response.session_id + ) + + @pytest.mark.asyncio + async def test_new_session_session_trust_loads_docs_without_persisting( + self, + acp_agent_loop: VibeAcpAgentLoop, + tmp_working_directory: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + (tmp_working_directory / "AGENTS.md").write_text( + "Session-only instructions", encoding="utf-8" + ) + _enable_workspace_trust(acp_agent_loop) + monkeypatch.setattr( + acp_agent_loop.client, + "ext_method", + AsyncMock(return_value={"decision": "trust_session"}), + ) + + session_response = await acp_agent_loop.new_session( + cwd=str(tmp_working_directory), mcp_servers=[] + ) + assert session_response.session_id is not None + + normalized = str(tmp_working_directory.resolve()) + assert trusted_folders_manager.is_trusted(tmp_working_directory) is True + assert normalized in trusted_folders_manager._session_trusted + assert normalized not in trusted_folders_manager._trusted + assert "Session-only instructions" in _system_prompt( + acp_agent_loop, session_response.session_id + ) + + @pytest.mark.asyncio + async def test_new_session_skips_trust_prompt_without_trustable_files( + self, + acp_agent_loop: VibeAcpAgentLoop, + tmp_working_directory: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + _enable_workspace_trust(acp_agent_loop) + request_trust = AsyncMock(return_value={"decision": "trust_cwd"}) + monkeypatch.setattr(acp_agent_loop.client, "ext_method", request_trust) + + await acp_agent_loop.new_session(cwd=str(tmp_working_directory), mcp_servers=[]) + + request_trust.assert_not_awaited() + assert trusted_folders_manager.is_trusted(tmp_working_directory) is None + + @pytest.mark.asyncio + async def test_new_session_direct_client_fallback_skips_project_docs( + self, + acp_agent_loop: VibeAcpAgentLoop, + tmp_working_directory: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + (tmp_working_directory / "AGENTS.md").write_text( + "Direct client should skip this", encoding="utf-8" + ) + _enable_workspace_trust(acp_agent_loop) + monkeypatch.setattr( + acp_agent_loop.client, + "ext_method", + AsyncMock(side_effect=RequestError.method_not_found("trust/request")), + ) + + session_response = await acp_agent_loop.new_session( + cwd=str(tmp_working_directory), mcp_servers=[] + ) + assert session_response.session_id is not None + + assert trusted_folders_manager.is_trusted(tmp_working_directory) is None + assert "Direct client should skip this" not in _system_prompt( + acp_agent_loop, session_response.session_id + ) + + @pytest.mark.asyncio + async def test_new_session_without_workspace_trust_capability_skips_prompt( + self, + acp_agent_loop: VibeAcpAgentLoop, + tmp_working_directory: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + (tmp_working_directory / "AGENTS.md").write_text( + "Unsupported client should skip this", encoding="utf-8" + ) + request_trust = AsyncMock(return_value={"decision": "trust_cwd"}) + monkeypatch.setattr(acp_agent_loop.client, "ext_method", request_trust) + + session_response = await acp_agent_loop.new_session( + cwd=str(tmp_working_directory), mcp_servers=[] + ) + assert session_response.session_id is not None + + request_trust.assert_not_awaited() + assert trusted_folders_manager.is_trusted(tmp_working_directory) is None + assert "Unsupported client should skip this" not in _system_prompt( + acp_agent_loop, session_response.session_id + ) + + @pytest.mark.asyncio + async def test_new_session_cancelled_trust_prompt_cancels_session_creation( + self, + acp_agent_loop: VibeAcpAgentLoop, + tmp_working_directory: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + (tmp_working_directory / "AGENTS.md").write_text( + "Cancelled prompt", encoding="utf-8" + ) + _enable_workspace_trust(acp_agent_loop) + monkeypatch.setattr( + acp_agent_loop.client, + "ext_method", + AsyncMock(return_value={"decision": "cancelled"}), + ) + + with pytest.raises(InvalidRequestError): + await acp_agent_loop.new_session( + cwd=str(tmp_working_directory), mcp_servers=[] + ) + + assert trusted_folders_manager.is_trusted(tmp_working_directory) is None + assert acp_agent_loop.sessions == {} + @pytest.mark.skip(reason="TODO: Fix this test") @pytest.mark.asyncio async def test_new_session_preserves_model_after_set_model( diff --git a/tests/cli/test_startup_update_prompt.py b/tests/cli/test_startup_update_prompt.py index e800ec9..d6b368a 100644 --- a/tests/cli/test_startup_update_prompt.py +++ b/tests/cli/test_startup_update_prompt.py @@ -194,7 +194,9 @@ def test_failed_update_prints_error_message( ): _maybe_run_startup_update_prompt(config, repository) - assert "could not be updated automatically" in capsys.readouterr().out + out = capsys.readouterr().out + assert "could not update automatically" in out + assert "package manager" in out def test_failed_update_does_not_dismiss_so_user_is_reprompted_on_next_launch( diff --git a/tests/session/test_resume_sessions.py b/tests/session/test_resume_sessions.py index 996c08c..f6c364e 100644 --- a/tests/session/test_resume_sessions.py +++ b/tests/session/test_resume_sessions.py @@ -1,18 +1,82 @@ from __future__ import annotations -from unittest.mock import AsyncMock, MagicMock, patch +import asyncio +from collections.abc import Sequence +from dataclasses import dataclass +from datetime import datetime +from unittest.mock import MagicMock import pytest +from vibe.core.nuage.workflow import ( + WorkflowExecutionListResponse, + WorkflowExecutionStatus, + WorkflowExecutionWithoutResultResponse, +) from vibe.core.session.resume_sessions import ( + RemoteResumeResult, + RemoteResumeSessions, ResumeSessionInfo, can_delete_resume_session_source, list_remote_resume_sessions, + session_latest_messages, short_session_id, ) from vibe.core.session.session_id import shorten_session_id +@dataclass(frozen=True) +class RemoteResumeRequest: + workflow_identifier: str | None + page_size: int + status: Sequence[WorkflowExecutionStatus] | None + + +class FakeRemoteResumeClient: + def __init__( + self, + response: WorkflowExecutionListResponse | None = None, + *, + delay: float = 0.0, + ) -> None: + self.response = response or WorkflowExecutionListResponse(executions=[]) + self.delay = delay + self.requests: list[RemoteResumeRequest] = [] + self.closed = False + + async def get_workflow_runs( + self, + workflow_identifier: str | None = None, + page_size: int = 50, + next_page_token: str | None = None, + status: Sequence[WorkflowExecutionStatus] | None = None, + user_id: str = "current", + ) -> WorkflowExecutionListResponse: + self.requests.append( + RemoteResumeRequest( + workflow_identifier=workflow_identifier, + page_size=page_size, + status=status, + ) + ) + if self.delay: + await asyncio.sleep(self.delay) + return self.response + + async def aclose(self) -> None: + self.closed = True + + +def enabled_vibe_code_config() -> MagicMock: + config = MagicMock() + config.vibe_code_enabled = True + config.vibe_code_api_key = "test-key" + config.vibe_code_base_url = "https://test.example.com" + config.api_timeout = 30 + config.vibe_code_workflow_id = "workflow-1" + return config + + class TestShortenSessionId: def test_shortens_to_first_8_chars(self) -> None: sid = "abcdef1234567890" @@ -67,40 +131,8 @@ class TestCanDeleteResumeSession: class TestListRemoteResumeSessions: - @pytest.mark.asyncio - async def test_returns_empty_when_vibe_code_disabled(self) -> None: - config = MagicMock() - config.vibe_code_enabled = False - config.vibe_code_api_key = "key" - result = await list_remote_resume_sessions(config) - assert result == [] - - @pytest.mark.asyncio - async def test_returns_empty_when_no_api_key(self) -> None: - config = MagicMock() - config.vibe_code_enabled = True - config.vibe_code_api_key = None - result = await list_remote_resume_sessions(config) - assert result == [] - - @pytest.mark.asyncio - async def test_returns_empty_when_both_missing(self) -> None: - config = MagicMock() - config.vibe_code_enabled = False - config.vibe_code_api_key = None - result = await list_remote_resume_sessions(config) - assert result == [] - @pytest.mark.asyncio async def test_passes_active_statuses_to_api(self) -> None: - from datetime import datetime - - from vibe.core.nuage.workflow import ( - WorkflowExecutionListResponse, - WorkflowExecutionStatus, - WorkflowExecutionWithoutResultResponse, - ) - running = WorkflowExecutionWithoutResultResponse( workflow_name="vibe", execution_id="exec-running", @@ -117,47 +149,28 @@ class TestListRemoteResumeSessions: ) mock_response = WorkflowExecutionListResponse(executions=[running, continued]) + client = FakeRemoteResumeClient(mock_response) - config = MagicMock() - config.vibe_code_enabled = True - config.vibe_code_api_key = "test-key" - config.vibe_code_base_url = "https://test.example.com" - config.api_timeout = 30 - config.vibe_code_workflow_id = "workflow-1" - - with patch("vibe.core.session.resume_sessions.WorkflowsClient") as MockClient: - mock_client = AsyncMock() - mock_client.get_workflow_runs.return_value = mock_response - MockClient.return_value.__aenter__ = AsyncMock(return_value=mock_client) - MockClient.return_value.__aexit__ = AsyncMock(return_value=False) - - result = await list_remote_resume_sessions(config) + result = await list_remote_resume_sessions(client, "workflow-1") assert len(result) == 2 session_ids = {s.session_id for s in result} assert "exec-running" in session_ids assert "exec-continued" in session_ids assert all(s.source == "remote" for s in result) - - mock_client.get_workflow_runs.assert_called_once_with( - workflow_identifier="workflow-1", - page_size=50, - status=[ - WorkflowExecutionStatus.RUNNING, - WorkflowExecutionStatus.CONTINUED_AS_NEW, - ], - ) + assert client.requests == [ + RemoteResumeRequest( + workflow_identifier="workflow-1", + page_size=50, + status=[ + WorkflowExecutionStatus.RUNNING, + WorkflowExecutionStatus.CONTINUED_AS_NEW, + ], + ) + ] @pytest.mark.asyncio async def test_deduplicates_execution_ids_keeps_latest(self) -> None: - from datetime import datetime - - from vibe.core.nuage.workflow import ( - WorkflowExecutionListResponse, - WorkflowExecutionStatus, - WorkflowExecutionWithoutResultResponse, - ) - older = WorkflowExecutionWithoutResultResponse( workflow_name="vibe", execution_id="exec-1", @@ -181,21 +194,9 @@ class TestListRemoteResumeSessions: ) mock_response = WorkflowExecutionListResponse(executions=[older, newer, other]) + client = FakeRemoteResumeClient(mock_response) - config = MagicMock() - config.vibe_code_enabled = True - config.vibe_code_api_key = "test-key" - config.vibe_code_base_url = "https://test.example.com" - config.api_timeout = 30 - config.vibe_code_workflow_id = "workflow-1" - - with patch("vibe.core.session.resume_sessions.WorkflowsClient") as MockClient: - mock_client = AsyncMock() - mock_client.get_workflow_runs.return_value = mock_response - MockClient.return_value.__aenter__ = AsyncMock(return_value=mock_client) - MockClient.return_value.__aexit__ = AsyncMock(return_value=False) - - result = await list_remote_resume_sessions(config) + result = await list_remote_resume_sessions(client, "workflow-1") assert len(result) == 2 by_id = {s.session_id: s for s in result} @@ -206,14 +207,6 @@ class TestListRemoteResumeSessions: async def test_dedup_keeps_latest_start_time_when_previous_has_end_time( self, ) -> None: - from datetime import datetime - - from vibe.core.nuage.workflow import ( - WorkflowExecutionListResponse, - WorkflowExecutionStatus, - WorkflowExecutionWithoutResultResponse, - ) - previous = WorkflowExecutionWithoutResultResponse( workflow_name="vibe", execution_id="exec-1", @@ -230,22 +223,85 @@ class TestListRemoteResumeSessions: ) mock_response = WorkflowExecutionListResponse(executions=[previous, newer]) + client = FakeRemoteResumeClient(mock_response) - config = MagicMock() - config.vibe_code_enabled = True - config.vibe_code_api_key = "test-key" - config.vibe_code_base_url = "https://test.example.com" - config.api_timeout = 30 - config.vibe_code_workflow_id = "workflow-1" - - with patch("vibe.core.session.resume_sessions.WorkflowsClient") as MockClient: - mock_client = AsyncMock() - mock_client.get_workflow_runs.return_value = mock_response - MockClient.return_value.__aenter__ = AsyncMock(return_value=mock_client) - MockClient.return_value.__aexit__ = AsyncMock(return_value=False) - - result = await list_remote_resume_sessions(config) + result = await list_remote_resume_sessions(client, "workflow-1") assert len(result) == 1 assert result[0].session_id == "exec-1" assert result[0].status == WorkflowExecutionStatus.RUNNING + + +class TestSessionLatestMessages: + def test_remote_session_formats_title_and_status(self) -> None: + session = ResumeSessionInfo( + session_id="exec-1", + source="remote", + cwd="", + title="My run", + end_time=None, + status="RUNNING", + ) + messages = session_latest_messages([session], MagicMock()) + assert messages[session.option_id] == "My run (running)" + + +class TestRemoteResumeSessions: + @pytest.mark.asyncio + async def test_fetch_skips_when_vibe_code_disabled(self) -> None: + config = MagicMock() + config.vibe_code_enabled = False + config.vibe_code_api_key = "key" + created_clients: list[FakeRemoteResumeClient] = [] + + def client_factory(_config: object) -> FakeRemoteResumeClient: + client = FakeRemoteResumeClient() + created_clients.append(client) + return client + + remote = RemoteResumeSessions(lambda: config, client_factory) + + result = await remote.fetch(10.0) + + assert result == RemoteResumeResult([], None) + assert created_clients == [] + + @pytest.mark.asyncio + async def test_start_cancels_previous_fetch(self) -> None: + config = enabled_vibe_code_config() + client = FakeRemoteResumeClient(delay=10.0) + remote = RemoteResumeSessions(lambda: config, lambda _config: client) + + first = remote.start(100.0) + await asyncio.sleep(0) + second = remote.start(100.0) + await asyncio.sleep(0) + await remote.aclose() + + assert first.cancelled() + assert first is not second + + @pytest.mark.asyncio + async def test_fetch_returns_error_tuple_on_timeout(self) -> None: + config = enabled_vibe_code_config() + client = FakeRemoteResumeClient(delay=10.0) + remote = RemoteResumeSessions(lambda: config, lambda _config: client) + + sessions, error = await remote.fetch(0.01) + await remote.aclose() + + assert sessions == [] + assert error is not None and "Timed out" in error + + @pytest.mark.asyncio + async def test_aclose_cancels_inflight_fetch_before_closing_client(self) -> None: + config = enabled_vibe_code_config() + client = FakeRemoteResumeClient(delay=10.0) + remote = RemoteResumeSessions(lambda: config, lambda _config: client) + + task = remote.start(100.0) + await asyncio.sleep(0) + await remote.aclose() + + assert task.cancelled() + assert client.closed is True diff --git a/uv.lock b/uv.lock index 366ca33..60a684e 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.12" [options] -exclude-newer = "2026-06-08T15:00:29.625263Z" +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P7D" [[package]] @@ -825,7 +825,7 @@ wheels = [ [[package]] name = "mistral-vibe" -version = "2.16.0" +version = "2.16.1" source = { editable = "." } dependencies = [ { name = "agent-client-protocol" }, diff --git a/vibe/__init__.py b/vibe/__init__.py index 17e1b77..713349f 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.16.0" +__version__ = "2.16.1" diff --git a/vibe/acp/acp_agent_loop.py b/vibe/acp/acp_agent_loop.py index b3c6219..0c04137 100644 --- a/vibe/acp/acp_agent_loop.py +++ b/vibe/acp/acp_agent_loop.py @@ -10,7 +10,7 @@ import os from pathlib import Path import signal import sys -from typing import Any, Protocol, cast, override +from typing import Any, Literal, Protocol, cast, override from uuid import uuid4 from acp import ( @@ -21,6 +21,7 @@ from acp import ( LoadSessionResponse, NewSessionResponse, PromptResponse, + RequestError, SetSessionModelResponse, SetSessionModeResponse, run_agent, @@ -148,6 +149,13 @@ from vibe.core.telemetry.build_metadata import build_entrypoint_metadata from vibe.core.telemetry.send import TelemetryClient from vibe.core.telemetry.types import EntrypointMetadata from vibe.core.tools.permissions import RequiredPermission +from vibe.core.trusted_folders import ( + WorkspaceTrustDecision, + WorkspaceTrustPrompt, + apply_workspace_trust_decision, + available_workspace_trust_decisions, + maybe_build_workspace_trust_prompt, +) from vibe.core.types import ( AgentProfileChangedEvent, ApprovalCallback, @@ -193,6 +201,8 @@ logger = logging.getLogger("vibe") NON_INTERACTIVE_DISABLED_TOOLS = ["ask_user_question", "exit_plan_mode"] INITIAL_AVAILABLE_COMMANDS_DELAY_SECONDS = 0.1 +WORKSPACE_TRUST_CAPABILITY = "workspace-trust" +TRUST_REQUEST_METHOD = "trust/request" def _merge_non_interactive_disabled_tools(config: VibeConfig) -> None: @@ -232,6 +242,24 @@ class TelemetrySendNotification(BaseModel): session_id: str = Field(validation_alias=AliasChoices("session_id", "sessionId")) +class WorkspaceTrustRequest(BaseModel): + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + cwd: str + repo_root: str | None = Field(default=None, alias="repoRoot") + detected_files: list[str] = Field(alias="detectedFiles") + repo_detected_files: list[str] = Field(alias="repoDetectedFiles") + available_decisions: list[WorkspaceTrustDecision] = Field( + alias="availableDecisions" + ) + + +class WorkspaceTrustResponse(BaseModel): + model_config = ConfigDict(extra="forbid") + + decision: WorkspaceTrustDecision | Literal["cancelled"] + + class AuthStatusResponse(BaseModel): model_config = ConfigDict(extra="forbid", populate_by_name=True) @@ -407,6 +435,14 @@ class VibeAcpAgentLoop(AcpAgent): is True ) + def _client_supports_workspace_trust(self) -> bool: + return bool( + self.client_capabilities + and self.client_capabilities.field_meta + and self.client_capabilities.field_meta.get(WORKSPACE_TRUST_CAPABILITY) + is True + ) + @override async def initialize( self, @@ -699,6 +735,55 @@ class VibeAcpAgentLoop(AcpAgent): ) return modes_state, modes_config, models_state, models_config + def _build_workspace_trust_request( + self, prompt: WorkspaceTrustPrompt + ) -> WorkspaceTrustRequest: + return WorkspaceTrustRequest( + cwd=str(prompt.cwd.resolve()), + repoRoot=str(prompt.repo_root.resolve()) + if prompt.offer_repo_trust and prompt.repo_root + else None, + detectedFiles=prompt.detected_files, + repoDetectedFiles=prompt.repo_detected_files, + availableDecisions=available_workspace_trust_decisions( + prompt, include_session=True + ), + ) + + async def _resolve_workspace_trust(self, cwd: Path) -> None: + if not self._client_supports_workspace_trust(): + return + + prompt = maybe_build_workspace_trust_prompt(cwd) + if prompt is None: + return + + request = self._build_workspace_trust_request(prompt) + + try: + raw_response = await self.client.ext_method( + TRUST_REQUEST_METHOD, request.model_dump(mode="json", by_alias=True) + ) + except RequestError as exc: + if exc.code == NotImplementedMethodError.code: + return + raise + + try: + response = WorkspaceTrustResponse.model_validate(raw_response) + except ValidationError as exc: + raise InvalidRequestError( + f"Invalid ACP trust decision response: {exc}" + ) from exc + + if response.decision == "cancelled": + raise InvalidRequestError("Workspace trust prompt was cancelled.") + + try: + apply_workspace_trust_decision(prompt, response.decision) + except ValueError as exc: + raise InvalidRequestError(str(exc)) from exc + @override async def new_session( self, @@ -709,6 +794,7 @@ class VibeAcpAgentLoop(AcpAgent): ) -> NewSessionResponse: load_dotenv_values() os.chdir(cwd) + await self._resolve_workspace_trust(Path.cwd()) config = self._load_config() hook_config_result = load_hooks_from_fs(config) @@ -973,6 +1059,7 @@ class VibeAcpAgentLoop(AcpAgent): ) -> LoadSessionResponse | None: load_dotenv_values() os.chdir(cwd) + await self._resolve_workspace_trust(Path.cwd()) config = self._load_config() hook_config_result = load_hooks_from_fs(config) diff --git a/vibe/cli/cli.py b/vibe/cli/cli.py index 616036a..c771252 100644 --- a/vibe/cli/cli.py +++ b/vibe/cli/cli.py @@ -292,7 +292,12 @@ def _maybe_run_startup_update_prompt( ) sys.exit(0) case UpdatePromptResult.UPDATE_FAILED: - rprint("[red]✗ Vibe could not be updated automatically.[/]") + rprint( + "[yellow]Vibe could not update automatically.[/]\n" + " Update manually with your package manager (for example " + "[bold]uv tool upgrade mistral-vibe[/]), or keep using " + f"the current version ({__version__}) for now." + ) sys.exit(1) diff --git a/vibe/cli/entrypoint.py b/vibe/cli/entrypoint.py index ced0a12..c7bd2e1 100644 --- a/vibe/cli/entrypoint.py +++ b/vibe/cli/entrypoint.py @@ -10,13 +10,11 @@ from rich import print as rprint from vibe import __version__ from vibe.core.config.harness_files import init_harness_files_manager from vibe.core.trusted_folders import ( - find_git_repo_ancestor, - find_repo_trustable_files_for_cwd, - find_trustable_files, + apply_workspace_trust_decision, + maybe_build_workspace_trust_prompt, trusted_folders_manager, ) from vibe.setup.trusted_folders.trust_folder_dialog import ( - TrustDecision, TrustDialogQuitException, ask_trust_folder, ) @@ -157,38 +155,18 @@ def parse_arguments() -> argparse.Namespace: def check_and_resolve_trusted_folder(cwd: Path) -> None: - if cwd.resolve() == Path.home().resolve(): + prompt = maybe_build_workspace_trust_prompt(cwd) + if prompt is None: return - if trusted_folders_manager.is_trusted(cwd) is True: - return - if trusted_folders_manager.is_explicitly_untrusted(cwd): - return - - repo_root = find_git_repo_ancestor(cwd) - detected_files = find_trustable_files(cwd) - repo_detected_files = find_repo_trustable_files_for_cwd(cwd, repo_root) - if not detected_files and not repo_detected_files: - return - - offer_repo_trust = ( - repo_root is not None - and trusted_folders_manager.is_trusted(repo_root) is not True - and not trusted_folders_manager.is_explicitly_untrusted(repo_root) - ) - repo_explicitly_untrusted = ( - repo_root is not None - and trusted_folders_manager.is_explicitly_untrusted(repo_root) - ) - try: decision = ask_trust_folder( - cwd, - repo_root, - detected_files, - repo_detected_files=repo_detected_files, - offer_repo_trust=offer_repo_trust, - repo_explicitly_untrusted=repo_explicitly_untrusted, + prompt.cwd, + prompt.repo_root, + prompt.detected_files, + repo_detected_files=prompt.repo_detected_files, + offer_repo_trust=prompt.offer_repo_trust, + repo_explicitly_untrusted=prompt.repo_explicitly_untrusted, ) except (KeyboardInterrupt, EOFError, TrustDialogQuitException): sys.exit(0) @@ -196,13 +174,8 @@ def check_and_resolve_trusted_folder(cwd: Path) -> None: rprint(f"[yellow]Error showing trust dialog: {e}[/]") return - match decision: - case TrustDecision.TRUST_REPO if repo_root is not None: - trusted_folders_manager.add_trusted(repo_root) - case TrustDecision.TRUST_CWD: - trusted_folders_manager.add_trusted(cwd) - case TrustDecision.DECLINE: - trusted_folders_manager.add_untrusted(cwd) + if decision is not None: + apply_workspace_trust_decision(prompt, decision) def main() -> None: diff --git a/vibe/cli/textual_ui/app.py b/vibe/cli/textual_ui/app.py index 5b8d04c..fe50c48 100644 --- a/vibe/cli/textual_ui/app.py +++ b/vibe/cli/textual_ui/app.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio import codecs from collections.abc import AsyncGenerator -from contextlib import aclosing +from contextlib import aclosing, suppress from dataclasses import dataclass from enum import StrEnum, auto import gc @@ -170,10 +170,12 @@ from vibe.core.paths import HISTORY_FILE from vibe.core.rewind import RewindError from vibe.core.session.image_snapshot import ImageSnapshotError, snapshot_image from vibe.core.session.resume_sessions import ( + RemoteResumeResult, + RemoteResumeSessions, ResumeSessionInfo, can_delete_resume_session_source, list_local_resume_sessions, - list_remote_resume_sessions, + session_latest_messages, short_session_id, ) from vibe.core.session.saved_sessions import ( @@ -428,6 +430,8 @@ class VibeApp(App): # noqa: PLR0904 self._bash_task: asyncio.Task | None = None self._queue = QueueController(self._build_queue_ports()) self._remote_manager = RemoteSessionManager() + self._remote_resume = RemoteResumeSessions(lambda: self.config) + self._resume_merge_task: asyncio.Task[None] | None = None self._loading_widget: LoadingWidget | None = None self._pending_approval: asyncio.Future | None = None @@ -2216,29 +2220,71 @@ class VibeApp(App): # noqa: PLR0904 UserCommandMessage(f'Session renamed to "{renamed_title}".') ) - async def _show_session_picker(self, **kwargs: Any) -> None: - cwd = str(Path.cwd()) - local_sessions = ( - list_local_resume_sessions(self.config, cwd) - if self.config.session_logging.enabled - else [] + def _build_picker(self, sessions: list[ResumeSessionInfo]) -> SessionPickerApp: + sessions = sorted(sessions, key=lambda s: s.end_time or "", reverse=True) + return SessionPickerApp( + sessions=sessions, + latest_messages=session_latest_messages(sessions, self.config), + current_session_id=self.agent_loop.session_id, + cwd=str(Path.cwd()), ) + + async def _merge_remote_into_picker( + self, picker: SessionPickerApp, remote_task: asyncio.Task[RemoteResumeResult] + ) -> None: + remote_sessions, remote_error = await remote_task + if not picker.is_mounted: + return + if remote_error is not None: + await self._mount_and_scroll( + ErrorMessage(remote_error, collapsed=self._tools_collapsed) + ) + if remote_sessions: + picker.add_sessions( + remote_sessions, session_latest_messages(remote_sessions, self.config) + ) + + async def _cancel_resume_merge(self) -> None: + """Cancel the task that fetch and merges remote sessions into the picker, if it exists.""" + if self._resume_merge_task is not None and not self._resume_merge_task.done(): + self._resume_merge_task.cancel() + with suppress(asyncio.CancelledError): + await self._resume_merge_task + self._resume_merge_task = None + + async def _close_remote_resume(self) -> None: + """Close the remote resume connection and cancel any ongoing merge task. + Used to gracefully close the connection when the app is exiting + """ + await self._cancel_resume_merge() + await self._remote_resume.aclose() + + async def _show_session_picker(self, **kwargs: Any) -> None: + await self._cancel_resume_merge() remote_list_timeout = max(float(self.config.api_timeout), 10.0) - remote_error: str | None = None + remote_task = self._remote_resume.start(remote_list_timeout) + + # If there are no local sessions, show the remote picker directly + if not self.config.session_logging.enabled or not ( + local_sessions := list_local_resume_sessions(self.config, str(Path.cwd())) + ): + await self._show_session_picker_remote_only(remote_task) + return + + picker = self._build_picker(local_sessions) + await self._switch_from_input(picker) + self._resume_merge_task = asyncio.create_task( + self._merge_remote_into_picker(picker, remote_task) + ) + + async def _show_session_picker_remote_only( + self, remote_task: asyncio.Task[RemoteResumeResult] + ) -> None: await self._ensure_loading_widget("Loading sessions") try: - remote_sessions = await asyncio.wait_for( - list_remote_resume_sessions(self.config), timeout=remote_list_timeout - ) - except TimeoutError: - remote_sessions = [] - remote_error = ( - "Timed out while listing remote sessions " - f"after {remote_list_timeout:.0f}s." - ) - except Exception as e: - remote_sessions = [] - remote_error = f"Failed to list remote sessions: {e}" + remote_sessions, remote_error = await remote_task + except asyncio.CancelledError: + return finally: await self._remove_loading_widget() @@ -2247,37 +2293,13 @@ class VibeApp(App): # noqa: PLR0904 ErrorMessage(remote_error, collapsed=self._tools_collapsed) ) - raw_sessions = [*local_sessions, *remote_sessions] - - if not raw_sessions: + if not remote_sessions: await self._mount_and_scroll( UserCommandMessage("No sessions found for this directory.") ) return - sessions = sorted(raw_sessions, key=lambda s: s.end_time or "", reverse=True) - - latest_messages = { - s.option_id: s.title - or SessionLoader.get_first_user_message( - s.session_id, self.config.session_logging - ) - for s in sessions - if s.source == "local" - } - for session in sessions: - if session.source == "remote": - latest_messages[session.option_id] = ( - f"{session.title or 'Remote workflow'} ({(session.status or 'RUNNING').lower()})" - ) - - picker = SessionPickerApp( - sessions=sessions, - latest_messages=latest_messages, - current_session_id=self.agent_loop.session_id, - cwd=cwd, - ) - await self._switch_from_input(picker) + await self._switch_from_input(self._build_picker(remote_sessions)) async def on_session_picker_app_session_selected( self, event: SessionPickerApp.SessionSelected @@ -2686,6 +2708,7 @@ class VibeApp(App): # noqa: PLR0904 self._log_reader.shutdown() await self._voice_manager.close() await self._narrator_manager.close() + await self._close_remote_resume() await self.agent_loop.aclose() try: await self.agent_loop.telemetry_client.aclose() @@ -3437,6 +3460,7 @@ class VibeApp(App): # noqa: PLR0904 self._log_reader.shutdown() self._narrator_manager.cancel() + await self._close_remote_resume() await self.agent_loop.aclose() try: await self.agent_loop.telemetry_client.aclose() diff --git a/vibe/cli/textual_ui/widgets/session_picker.py b/vibe/cli/textual_ui/widgets/session_picker.py index 5f965c4..d0792f3 100644 --- a/vibe/cli/textual_ui/widgets/session_picker.py +++ b/vibe/cli/textual_ui/widgets/session_picker.py @@ -168,6 +168,18 @@ class SessionPickerApp(Container): def _normal_option_text(self, session: ResumeSessionInfo) -> Text: return _build_option_text(session, self._session_message(session)) + def _option_text(self, session: ResumeSessionInfo) -> Text: + state = self._delete_state + if state is None or state.option_id != session.option_id: + return self._normal_option_text(session) + match state.kind: + case "confirmation": + return self._delete_confirmation_option_text(session) + case "feedback": + return self._delete_feedback_option_text(session) + case "pending": + return self._delete_pending_option_text(session) + def _delete_confirmation_option_text(self, session: ResumeSessionInfo) -> Text: text = _build_option_text(session, "") text.append("Press D again to delete") @@ -239,6 +251,41 @@ class SessionPickerApp(Container): self._option_list().remove_option(option_id) return True + def add_sessions( + self, sessions: list[ResumeSessionInfo], latest_messages: dict[str, str] + ) -> None: + existing = {s.option_id for s in self._sessions} + new_sessions = [s for s in sessions if s.option_id not in existing] + if not new_sessions: + return + + self._sessions = sorted( + [*self._sessions, *new_sessions], + key=lambda s: s.end_time or "", + reverse=True, + ) + self._latest_messages.update(latest_messages) + + option_list = self._option_list() + highlighted = self._highlighted_option_id() + option_list.clear_options() + option_list.add_options([ + Option(self._option_text(session), id=session.option_id) + for session in self._sessions + ]) + self._refresh_header() + if highlighted is None: + return + for index, session in enumerate(self._sessions): + if session.option_id == highlighted: + option_list.highlighted = index + return + + def _refresh_header(self) -> None: + has_remote = any(session.source == "remote" for session in self._sessions) + header = self.query_one(".sessionpicker-header", NoMarkupStatic) + header.update(_build_header_text(self._cwd, has_remote)) + def clear_pending_delete(self, option_id: str) -> bool: if not self._delete_state_matches(option_id, "pending"): return False diff --git a/vibe/core/nuage/client.py b/vibe/core/nuage/client.py index a33ffaf..5efeef1 100644 --- a/vibe/core/nuage/client.py +++ b/vibe/core/nuage/client.py @@ -45,6 +45,9 @@ class WorkflowsClient: exc_val: BaseException | None, exc_tb: Any, ) -> None: + await self.aclose() + + async def aclose(self) -> None: if self._owns_client and self._client: await self._client.aclose() self._client = None diff --git a/vibe/core/nuage/remote_events_source.py b/vibe/core/nuage/remote_events_source.py index 71cb074..1fdddbd 100644 --- a/vibe/core/nuage/remote_events_source.py +++ b/vibe/core/nuage/remote_events_source.py @@ -85,7 +85,7 @@ class RemoteEventsSource: async def close(self) -> None: if self._client is not None: - await self._client.__aexit__(None, None, None) + await self._client.aclose() self._client = None async def attach(self) -> AsyncGenerator[BaseEvent, None]: diff --git a/vibe/core/session/resume_sessions.py b/vibe/core/session/resume_sessions.py index 815a126..a2b6eb4 100644 --- a/vibe/core/session/resume_sessions.py +++ b/vibe/core/session/resume_sessions.py @@ -1,13 +1,19 @@ from __future__ import annotations +import asyncio +from collections.abc import Callable, Sequence +import contextlib from dataclasses import dataclass from datetime import datetime -from typing import Literal +from typing import Literal, NamedTuple, Protocol 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.nuage.workflow import ( + WorkflowExecutionListResponse, + WorkflowExecutionStatus, +) from vibe.core.session.session_id import shorten_session_id from vibe.core.session.session_loader import SessionLoader @@ -28,6 +34,21 @@ _ACTIVE_STATUSES = [ ] +class RemoteWorkflowRunsClient(Protocol): + async def get_workflow_runs( + self, + workflow_identifier: str | None = None, + page_size: int = 50, + next_page_token: str | None = None, + status: Sequence[WorkflowExecutionStatus] | None = None, + user_id: str = "current", + ) -> WorkflowExecutionListResponse: ... + + +class RemoteResumeClient(RemoteWorkflowRunsClient, Protocol): + async def aclose(self) -> None: ... + + @dataclass(frozen=True) class ResumeSessionInfo: session_id: str @@ -61,21 +82,12 @@ def list_local_resume_sessions( ] -async def list_remote_resume_sessions(config: VibeConfig) -> list[ResumeSessionInfo]: - if not config.vibe_code_enabled or not config.vibe_code_api_key: - logger.debug("Remote resume listing skipped: missing Vibe Code configuration") - return [] - - async with WorkflowsClient( - base_url=config.vibe_code_base_url, - api_key=config.vibe_code_api_key, - timeout=config.api_timeout, - ) as client: - response = await client.get_workflow_runs( - workflow_identifier=config.vibe_code_workflow_id, - page_size=50, - status=_ACTIVE_STATUSES, - ) +async def list_remote_resume_sessions( + client: RemoteWorkflowRunsClient, workflow_id: str +) -> list[ResumeSessionInfo]: + response = await client.get_workflow_runs( + workflow_identifier=workflow_id, page_size=50, status=_ACTIVE_STATUSES + ) seen: dict[str, ResumeSessionInfo] = {} latest_start: dict[str, datetime] = {} @@ -101,3 +113,111 @@ async def list_remote_resume_sessions(config: VibeConfig) -> list[ResumeSessionI logger.debug("Remote resume listing filtered sessions: %d", len(sessions)) return sessions + + +def session_latest_messages( + sessions: list[ResumeSessionInfo], config: VibeConfig +) -> dict[str, str]: + messages: dict[str, str] = {} + for session in sessions: + if session.source == "remote": + status = (session.status or "RUNNING").lower() + messages[session.option_id] = ( + f"{session.title or 'Remote workflow'} ({status})" + ) + continue + messages[session.option_id] = ( + session.title + or SessionLoader.get_first_user_message( + session.session_id, config.session_logging + ) + ) + return messages + + +class RemoteResumeResult(NamedTuple): + sessions: list[ResumeSessionInfo] + error: str | None + + +def _default_remote_resume_client(config: VibeConfig) -> RemoteResumeClient: + return WorkflowsClient( + base_url=config.vibe_code_base_url, + api_key=config.vibe_code_api_key, + timeout=config.api_timeout, + ) + + +class RemoteResumeSessions: + def __init__( + self, + get_config: Callable[[], VibeConfig], + client_factory: Callable[ + [VibeConfig], RemoteResumeClient + ] = _default_remote_resume_client, + ) -> None: + self._get_config = get_config + self._client_factory = client_factory + self._client: RemoteResumeClient | None = None + self._client_settings: tuple[str, str, float] | None = None + self._fetch_task: asyncio.Task[RemoteResumeResult] | None = None + + async def _reusable_client(self, config: VibeConfig) -> RemoteResumeClient: + settings = ( + config.vibe_code_base_url, + config.vibe_code_api_key, + config.api_timeout, + ) + if self._client is not None and self._client_settings == settings: + return self._client + if self._client is not None: + await self._close_client() + self._client = self._client_factory(config) + self._client_settings = settings + return self._client + + def start(self, timeout: float) -> asyncio.Task[RemoteResumeResult]: + if self._fetch_task is not None and not self._fetch_task.done(): + self._fetch_task.cancel() + self._fetch_task = asyncio.create_task(self.fetch(timeout)) + return self._fetch_task + + async def fetch(self, timeout: float) -> RemoteResumeResult: + config = self._get_config() + if not config.vibe_code_enabled or not config.vibe_code_api_key: + logger.debug( + "Remote resume listing skipped: missing Vibe Code configuration" + ) + return RemoteResumeResult([], None) + try: + client = await self._reusable_client(config) + sessions = await asyncio.wait_for( + list_remote_resume_sessions(client, config.vibe_code_workflow_id), + timeout=timeout, + ) + except TimeoutError: + return RemoteResumeResult( + [], f"Timed out while listing remote sessions after {timeout:.0f}s." + ) + except Exception as e: + return RemoteResumeResult([], f"Failed to list remote sessions: {e}") + return RemoteResumeResult(sessions, None) + + async def aclose(self) -> None: + if self._fetch_task is not None and not self._fetch_task.done(): + self._fetch_task.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await self._fetch_task + self._fetch_task = None + await self._close_client() + + async def _close_client(self) -> None: + if self._client is None: + return + try: + await self._client.aclose() + except Exception as exc: + logger.error("Failed to close resume workflows client", exc_info=exc) + finally: + self._client = None + self._client_settings = None diff --git a/vibe/core/session/session_loader.py b/vibe/core/session/session_loader.py index 4ea6c06..5dc404d 100644 --- a/vibe/core/session/session_loader.py +++ b/vibe/core/session/session_loader.py @@ -26,39 +26,57 @@ class SessionInfo(TypedDict): class SessionLoader: @staticmethod - def _is_valid_session( # noqa: PLR0911 + def _parse_message_lines(text: str) -> list[dict[str, Any]] | None: + lines = text.split("\n") + if lines and lines[-1] == "": + lines.pop() + + messages: list[dict[str, Any]] = [] + for line in lines: + message = json.loads(line) + if not isinstance(message, dict): + return None + messages.append(message) + return messages or None + + @staticmethod + def _read_validated_session( session_dir: Path, working_directory: Path | None = None - ) -> bool: - """Check if a session directory contains valid metadata and messages.""" + ) -> dict[str, Any] | None: metadata_path = session_dir / METADATA_FILENAME messages_path = session_dir / MESSAGES_FILENAME if not metadata_path.is_file() or not messages_path.is_file(): - return False + return None try: metadata = json.loads(read_safe(metadata_path).text) if not isinstance(metadata, dict): - return False + return None if working_directory is not None: session_working_directory = (metadata.get("environment") or {}).get( "working_directory" ) if session_working_directory != str(working_directory): - return False + return None - has_messages = False - for line in read_safe(messages_path).text.splitlines(): - has_messages = True - message = json.loads(line) - if not isinstance(message, dict): - return False - if not has_messages: - return False + messages = SessionLoader._parse_message_lines(read_safe(messages_path).text) except (OSError, json.JSONDecodeError): - return False + return None - return True + if messages is None: + return None + + return metadata + + @staticmethod + def _is_valid_session( + session_dir: Path, working_directory: Path | None = None + ) -> bool: + return ( + SessionLoader._read_validated_session(session_dir, working_directory) + is not None + ) @staticmethod def latest_session( @@ -158,13 +176,8 @@ class SessionLoader: sessions: list[SessionInfo] = [] for session_dir in session_dirs: - if not SessionLoader._is_valid_session(session_dir): - continue - - metadata_path = session_dir / METADATA_FILENAME - try: - metadata = json.loads(read_safe(metadata_path).text) - except (OSError, json.JSONDecodeError): + metadata = SessionLoader._read_validated_session(session_dir) + if metadata is None: continue session_id = metadata.get("session_id") diff --git a/vibe/core/trusted_folders.py b/vibe/core/trusted_folders.py index 515b6dd..f4d62f0 100644 --- a/vibe/core/trusted_folders.py +++ b/vibe/core/trusted_folders.py @@ -1,5 +1,7 @@ from __future__ import annotations +from dataclasses import dataclass +from enum import StrEnum from pathlib import Path import tomllib @@ -13,6 +15,23 @@ from vibe.core.paths import ( ) +class WorkspaceTrustDecision(StrEnum): + TRUST_REPO = "trust_repo" + TRUST_CWD = "trust_cwd" + TRUST_SESSION = "trust_session" + DECLINE = "decline" + + +@dataclass(frozen=True) +class WorkspaceTrustPrompt: + cwd: Path + repo_root: Path | None + detected_files: list[str] + repo_detected_files: list[str] + offer_repo_trust: bool + repo_explicitly_untrusted: bool + + def has_agents_md_file(path: Path) -> bool: agents_md = path / AGENTS_MD_FILENAME try: @@ -91,6 +110,73 @@ def find_repo_trustable_files_for_cwd(cwd: Path, repo_root: Path | None) -> list return sorted(found) +def maybe_build_workspace_trust_prompt(cwd: Path) -> WorkspaceTrustPrompt | None: + resolved_cwd = cwd.resolve() + if resolved_cwd == Path.home().resolve(): + return None + + if trusted_folders_manager.is_trusted(cwd) is True: + return None + if trusted_folders_manager.is_explicitly_untrusted(cwd): + return None + + repo_root = find_git_repo_ancestor(cwd) + detected_files = find_trustable_files(cwd) + repo_detected_files = find_repo_trustable_files_for_cwd(cwd, repo_root) + if not detected_files and not repo_detected_files: + return None + + resolved_repo_root = repo_root.resolve() if repo_root else None + offer_repo_trust = ( + resolved_repo_root is not None + and resolved_repo_root in resolved_cwd.parents + and trusted_folders_manager.is_trusted(resolved_repo_root) is not True + and not trusted_folders_manager.is_explicitly_untrusted(resolved_repo_root) + ) + repo_explicitly_untrusted = ( + resolved_repo_root is not None + and trusted_folders_manager.is_explicitly_untrusted(resolved_repo_root) + ) + + return WorkspaceTrustPrompt( + cwd=cwd, + repo_root=resolved_repo_root, + detected_files=detected_files, + repo_detected_files=repo_detected_files, + offer_repo_trust=offer_repo_trust, + repo_explicitly_untrusted=repo_explicitly_untrusted, + ) + + +def available_workspace_trust_decisions( + prompt: WorkspaceTrustPrompt, *, include_session: bool = False +) -> list[WorkspaceTrustDecision]: + decisions = [WorkspaceTrustDecision.TRUST_CWD, WorkspaceTrustDecision.DECLINE] + if include_session: + decisions.insert(1, WorkspaceTrustDecision.TRUST_SESSION) + if prompt.offer_repo_trust: + decisions.insert(0, WorkspaceTrustDecision.TRUST_REPO) + return decisions + + +def apply_workspace_trust_decision( + prompt: WorkspaceTrustPrompt, decision: WorkspaceTrustDecision +) -> None: + match decision: + case WorkspaceTrustDecision.TRUST_REPO if ( + prompt.offer_repo_trust and prompt.repo_root is not None + ): + trusted_folders_manager.add_trusted(prompt.repo_root) + case WorkspaceTrustDecision.TRUST_CWD: + trusted_folders_manager.add_trusted(prompt.cwd) + case WorkspaceTrustDecision.TRUST_SESSION: + trusted_folders_manager.trust_for_session(prompt.cwd) + case WorkspaceTrustDecision.DECLINE: + trusted_folders_manager.add_untrusted(prompt.cwd) + case _: + raise ValueError(f"Unsupported trust decision: {decision}") + + class TrustedFoldersManager: def __init__(self) -> None: self._file_path = TRUSTED_FOLDERS_FILE.path diff --git a/vibe/core/utils/io.py b/vibe/core/utils/io.py index 0d83862..9a2f051 100644 --- a/vibe/core/utils/io.py +++ b/vibe/core/utils/io.py @@ -93,6 +93,9 @@ def _get_candidate_encodings( def normalize_newlines(text: str) -> tuple[str, str]: r"""Return ``text`` with ``\n`` newlines and the detected original style.""" + if "\r" not in text: + newline = "\n" if "\n" in text else os.linesep + return text, newline newline = _detect_newline(text) return text.replace("\r\n", "\n").replace("\r", "\n"), newline diff --git a/vibe/setup/trusted_folders/trust_folder_dialog.py b/vibe/setup/trusted_folders/trust_folder_dialog.py index fd0ae86..ebb855e 100644 --- a/vibe/setup/trusted_folders/trust_folder_dialog.py +++ b/vibe/setup/trusted_folders/trust_folder_dialog.py @@ -1,6 +1,5 @@ from __future__ import annotations -from enum import Enum from pathlib import Path from typing import Any, ClassVar @@ -19,18 +18,14 @@ from textual.widgets import Static from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic from vibe.core.paths import TRUSTED_FOLDERS_FILE +from vibe.core.trusted_folders import WorkspaceTrustDecision class TrustDialogQuitException(Exception): pass -class TrustDecision(Enum): - """User's choice from the trust dialog.""" - - TRUST_REPO = "trust_repo" - TRUST_CWD = "trust_cwd" - DECLINE = "decline" +TrustDecision = WorkspaceTrustDecision class TrustFolderDialog(CenterMiddle):