v2.16.1 (#808)
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:
parent
c2cb612ac1
commit
564a14365e
22 changed files with 952 additions and 252 deletions
12
CHANGELOG.md
12
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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
4
uv.lock
generated
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue