Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai>
Co-authored-by: Paul VEZIA <166131032+le-codeur-rapide@users.noreply.github.com>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Clément Drouin 2026-06-16 10:43:46 +02:00 committed by GitHub
parent c2cb612ac1
commit 564a14365e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 952 additions and 252 deletions

View file

@ -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

View file

@ -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"

View file

@ -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"

View file

@ -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

View file

@ -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,

View file

@ -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(

View file

@ -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(

View file

@ -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

4
uv.lock generated
View file

@ -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" },

View file

@ -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"

View file

@ -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)

View file

@ -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)

View file

@ -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:

View file

@ -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()

View file

@ -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

View file

@ -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

View file

@ -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]:

View file

@ -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

View file

@ -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")

View file

@ -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

View file

@ -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

View file

@ -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):