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/),
|
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).
|
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
|
## [2.16.0] - 2026-06-15
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
id = "mistral-vibe"
|
id = "mistral-vibe"
|
||||||
name = "Mistral Vibe"
|
name = "Mistral Vibe"
|
||||||
description = "Mistral's open-source coding assistant"
|
description = "Mistral's open-source coding assistant"
|
||||||
version = "2.16.0"
|
version = "2.16.1"
|
||||||
schema_version = 1
|
schema_version = 1
|
||||||
authors = ["Mistral AI"]
|
authors = ["Mistral AI"]
|
||||||
repository = "https://github.com/mistralai/mistral-vibe"
|
repository = "https://github.com/mistralai/mistral-vibe"
|
||||||
|
|
@ -11,21 +11,21 @@ name = "Mistral Vibe"
|
||||||
icon = "./icons/mistral_vibe.svg"
|
icon = "./icons/mistral_vibe.svg"
|
||||||
|
|
||||||
[agent_servers.mistral-vibe.targets.darwin-aarch64]
|
[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"
|
cmd = "./vibe-acp"
|
||||||
|
|
||||||
[agent_servers.mistral-vibe.targets.darwin-x86_64]
|
[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"
|
cmd = "./vibe-acp"
|
||||||
|
|
||||||
[agent_servers.mistral-vibe.targets.linux-aarch64]
|
[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"
|
cmd = "./vibe-acp"
|
||||||
|
|
||||||
[agent_servers.mistral-vibe.targets.linux-x86_64]
|
[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"
|
cmd = "./vibe-acp"
|
||||||
|
|
||||||
[agent_servers.mistral-vibe.targets.windows-x86_64]
|
[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"
|
cmd = "./vibe-acp.exe"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[project]
|
[project]
|
||||||
name = "mistral-vibe"
|
name = "mistral-vibe"
|
||||||
version = "2.16.0"
|
version = "2.16.1"
|
||||||
description = "Minimal CLI coding agent by Mistral"
|
description = "Minimal CLI coding agent by Mistral"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ class TestACPInitialize:
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
assert response.agent_info == Implementation(
|
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
|
assert response.auth_methods is not None
|
||||||
|
|
@ -172,7 +172,7 @@ class TestACPInitialize:
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
assert response.agent_info == Implementation(
|
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
|
assert response.auth_methods is not None
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
from acp import RequestError
|
from acp import RequestError
|
||||||
from acp.schema import (
|
from acp.schema import (
|
||||||
AgentMessageChunk,
|
AgentMessageChunk,
|
||||||
AgentThoughtChunk,
|
AgentThoughtChunk,
|
||||||
|
ClientCapabilities,
|
||||||
ToolCallProgress,
|
ToolCallProgress,
|
||||||
ToolCallStart,
|
ToolCallStart,
|
||||||
UserMessageChunk,
|
UserMessageChunk,
|
||||||
|
|
@ -15,10 +17,11 @@ import pytest
|
||||||
from tests.conftest import build_test_vibe_config
|
from tests.conftest import build_test_vibe_config
|
||||||
from tests.stubs.fake_backend import FakeBackend
|
from tests.stubs.fake_backend import FakeBackend
|
||||||
from tests.stubs.fake_client import FakeClient
|
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.agent_loop import AgentLoop
|
||||||
from vibe.core.agents.models import BuiltinAgentName
|
from vibe.core.agents.models import BuiltinAgentName
|
||||||
from vibe.core.config import ModelConfig, SessionLoggingConfig
|
from vibe.core.config import ModelConfig, SessionLoggingConfig
|
||||||
|
from vibe.core.trusted_folders import trusted_folders_manager
|
||||||
from vibe.core.types import Role
|
from vibe.core.types import Role
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -122,6 +125,37 @@ class TestLoadSession:
|
||||||
assert response.config_options[2].category == "thinking"
|
assert response.config_options[2].category == "thinking"
|
||||||
assert response.config_options[2].current_value == "off"
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_load_session_registers_session_with_original_id(
|
async def test_load_session_registers_session_with_original_id(
|
||||||
self,
|
self,
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,31 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
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
|
import pytest
|
||||||
|
|
||||||
from tests.acp.conftest import _create_acp_agent
|
from tests.acp.conftest import _create_acp_agent
|
||||||
from tests.conftest import build_test_vibe_config
|
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.agent_loop import AgentLoop
|
||||||
from vibe.core.agents.models import BuiltinAgentName
|
from vibe.core.agents.models import BuiltinAgentName
|
||||||
from vibe.core.config import ModelConfig
|
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
|
@pytest.fixture
|
||||||
|
|
@ -137,6 +152,231 @@ class TestACPNewSession:
|
||||||
assert thinking_config.current_value == "off"
|
assert thinking_config.current_value == "off"
|
||||||
assert len(thinking_config.options) == 5
|
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.skip(reason="TODO: Fix this test")
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_new_session_preserves_model_after_set_model(
|
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)
|
_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(
|
def test_failed_update_does_not_dismiss_so_user_is_reprompted_on_next_launch(
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,82 @@
|
||||||
from __future__ import annotations
|
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
|
import pytest
|
||||||
|
|
||||||
|
from vibe.core.nuage.workflow import (
|
||||||
|
WorkflowExecutionListResponse,
|
||||||
|
WorkflowExecutionStatus,
|
||||||
|
WorkflowExecutionWithoutResultResponse,
|
||||||
|
)
|
||||||
from vibe.core.session.resume_sessions import (
|
from vibe.core.session.resume_sessions import (
|
||||||
|
RemoteResumeResult,
|
||||||
|
RemoteResumeSessions,
|
||||||
ResumeSessionInfo,
|
ResumeSessionInfo,
|
||||||
can_delete_resume_session_source,
|
can_delete_resume_session_source,
|
||||||
list_remote_resume_sessions,
|
list_remote_resume_sessions,
|
||||||
|
session_latest_messages,
|
||||||
short_session_id,
|
short_session_id,
|
||||||
)
|
)
|
||||||
from vibe.core.session.session_id import shorten_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:
|
class TestShortenSessionId:
|
||||||
def test_shortens_to_first_8_chars(self) -> None:
|
def test_shortens_to_first_8_chars(self) -> None:
|
||||||
sid = "abcdef1234567890"
|
sid = "abcdef1234567890"
|
||||||
|
|
@ -67,40 +131,8 @@ class TestCanDeleteResumeSession:
|
||||||
|
|
||||||
|
|
||||||
class TestListRemoteResumeSessions:
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_passes_active_statuses_to_api(self) -> None:
|
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(
|
running = WorkflowExecutionWithoutResultResponse(
|
||||||
workflow_name="vibe",
|
workflow_name="vibe",
|
||||||
execution_id="exec-running",
|
execution_id="exec-running",
|
||||||
|
|
@ -117,47 +149,28 @@ class TestListRemoteResumeSessions:
|
||||||
)
|
)
|
||||||
|
|
||||||
mock_response = WorkflowExecutionListResponse(executions=[running, continued])
|
mock_response = WorkflowExecutionListResponse(executions=[running, continued])
|
||||||
|
client = FakeRemoteResumeClient(mock_response)
|
||||||
|
|
||||||
config = MagicMock()
|
result = await list_remote_resume_sessions(client, "workflow-1")
|
||||||
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)
|
|
||||||
|
|
||||||
assert len(result) == 2
|
assert len(result) == 2
|
||||||
session_ids = {s.session_id for s in result}
|
session_ids = {s.session_id for s in result}
|
||||||
assert "exec-running" in session_ids
|
assert "exec-running" in session_ids
|
||||||
assert "exec-continued" in session_ids
|
assert "exec-continued" in session_ids
|
||||||
assert all(s.source == "remote" for s in result)
|
assert all(s.source == "remote" for s in result)
|
||||||
|
assert client.requests == [
|
||||||
mock_client.get_workflow_runs.assert_called_once_with(
|
RemoteResumeRequest(
|
||||||
workflow_identifier="workflow-1",
|
workflow_identifier="workflow-1",
|
||||||
page_size=50,
|
page_size=50,
|
||||||
status=[
|
status=[
|
||||||
WorkflowExecutionStatus.RUNNING,
|
WorkflowExecutionStatus.RUNNING,
|
||||||
WorkflowExecutionStatus.CONTINUED_AS_NEW,
|
WorkflowExecutionStatus.CONTINUED_AS_NEW,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
]
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_deduplicates_execution_ids_keeps_latest(self) -> None:
|
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(
|
older = WorkflowExecutionWithoutResultResponse(
|
||||||
workflow_name="vibe",
|
workflow_name="vibe",
|
||||||
execution_id="exec-1",
|
execution_id="exec-1",
|
||||||
|
|
@ -181,21 +194,9 @@ class TestListRemoteResumeSessions:
|
||||||
)
|
)
|
||||||
|
|
||||||
mock_response = WorkflowExecutionListResponse(executions=[older, newer, other])
|
mock_response = WorkflowExecutionListResponse(executions=[older, newer, other])
|
||||||
|
client = FakeRemoteResumeClient(mock_response)
|
||||||
|
|
||||||
config = MagicMock()
|
result = await list_remote_resume_sessions(client, "workflow-1")
|
||||||
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)
|
|
||||||
|
|
||||||
assert len(result) == 2
|
assert len(result) == 2
|
||||||
by_id = {s.session_id: s for s in result}
|
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(
|
async def test_dedup_keeps_latest_start_time_when_previous_has_end_time(
|
||||||
self,
|
self,
|
||||||
) -> None:
|
) -> None:
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from vibe.core.nuage.workflow import (
|
|
||||||
WorkflowExecutionListResponse,
|
|
||||||
WorkflowExecutionStatus,
|
|
||||||
WorkflowExecutionWithoutResultResponse,
|
|
||||||
)
|
|
||||||
|
|
||||||
previous = WorkflowExecutionWithoutResultResponse(
|
previous = WorkflowExecutionWithoutResultResponse(
|
||||||
workflow_name="vibe",
|
workflow_name="vibe",
|
||||||
execution_id="exec-1",
|
execution_id="exec-1",
|
||||||
|
|
@ -230,22 +223,85 @@ class TestListRemoteResumeSessions:
|
||||||
)
|
)
|
||||||
|
|
||||||
mock_response = WorkflowExecutionListResponse(executions=[previous, newer])
|
mock_response = WorkflowExecutionListResponse(executions=[previous, newer])
|
||||||
|
client = FakeRemoteResumeClient(mock_response)
|
||||||
|
|
||||||
config = MagicMock()
|
result = await list_remote_resume_sessions(client, "workflow-1")
|
||||||
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)
|
|
||||||
|
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert result[0].session_id == "exec-1"
|
assert result[0].session_id == "exec-1"
|
||||||
assert result[0].status == WorkflowExecutionStatus.RUNNING
|
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"
|
requires-python = ">=3.12"
|
||||||
|
|
||||||
[options]
|
[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"
|
exclude-newer-span = "P7D"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -825,7 +825,7 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mistral-vibe"
|
name = "mistral-vibe"
|
||||||
version = "2.16.0"
|
version = "2.16.1"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "agent-client-protocol" },
|
{ name = "agent-client-protocol" },
|
||||||
|
|
|
||||||
|
|
@ -3,4 +3,4 @@ from __future__ import annotations
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
VIBE_ROOT = Path(__file__).parent
|
VIBE_ROOT = Path(__file__).parent
|
||||||
__version__ = "2.16.0"
|
__version__ = "2.16.1"
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
from typing import Any, Protocol, cast, override
|
from typing import Any, Literal, Protocol, cast, override
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from acp import (
|
from acp import (
|
||||||
|
|
@ -21,6 +21,7 @@ from acp import (
|
||||||
LoadSessionResponse,
|
LoadSessionResponse,
|
||||||
NewSessionResponse,
|
NewSessionResponse,
|
||||||
PromptResponse,
|
PromptResponse,
|
||||||
|
RequestError,
|
||||||
SetSessionModelResponse,
|
SetSessionModelResponse,
|
||||||
SetSessionModeResponse,
|
SetSessionModeResponse,
|
||||||
run_agent,
|
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.send import TelemetryClient
|
||||||
from vibe.core.telemetry.types import EntrypointMetadata
|
from vibe.core.telemetry.types import EntrypointMetadata
|
||||||
from vibe.core.tools.permissions import RequiredPermission
|
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 (
|
from vibe.core.types import (
|
||||||
AgentProfileChangedEvent,
|
AgentProfileChangedEvent,
|
||||||
ApprovalCallback,
|
ApprovalCallback,
|
||||||
|
|
@ -193,6 +201,8 @@ logger = logging.getLogger("vibe")
|
||||||
|
|
||||||
NON_INTERACTIVE_DISABLED_TOOLS = ["ask_user_question", "exit_plan_mode"]
|
NON_INTERACTIVE_DISABLED_TOOLS = ["ask_user_question", "exit_plan_mode"]
|
||||||
INITIAL_AVAILABLE_COMMANDS_DELAY_SECONDS = 0.1
|
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:
|
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"))
|
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):
|
class AuthStatusResponse(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
||||||
|
|
||||||
|
|
@ -407,6 +435,14 @@ class VibeAcpAgentLoop(AcpAgent):
|
||||||
is True
|
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
|
@override
|
||||||
async def initialize(
|
async def initialize(
|
||||||
self,
|
self,
|
||||||
|
|
@ -699,6 +735,55 @@ class VibeAcpAgentLoop(AcpAgent):
|
||||||
)
|
)
|
||||||
return modes_state, modes_config, models_state, models_config
|
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
|
@override
|
||||||
async def new_session(
|
async def new_session(
|
||||||
self,
|
self,
|
||||||
|
|
@ -709,6 +794,7 @@ class VibeAcpAgentLoop(AcpAgent):
|
||||||
) -> NewSessionResponse:
|
) -> NewSessionResponse:
|
||||||
load_dotenv_values()
|
load_dotenv_values()
|
||||||
os.chdir(cwd)
|
os.chdir(cwd)
|
||||||
|
await self._resolve_workspace_trust(Path.cwd())
|
||||||
|
|
||||||
config = self._load_config()
|
config = self._load_config()
|
||||||
hook_config_result = load_hooks_from_fs(config)
|
hook_config_result = load_hooks_from_fs(config)
|
||||||
|
|
@ -973,6 +1059,7 @@ class VibeAcpAgentLoop(AcpAgent):
|
||||||
) -> LoadSessionResponse | None:
|
) -> LoadSessionResponse | None:
|
||||||
load_dotenv_values()
|
load_dotenv_values()
|
||||||
os.chdir(cwd)
|
os.chdir(cwd)
|
||||||
|
await self._resolve_workspace_trust(Path.cwd())
|
||||||
|
|
||||||
config = self._load_config()
|
config = self._load_config()
|
||||||
hook_config_result = load_hooks_from_fs(config)
|
hook_config_result = load_hooks_from_fs(config)
|
||||||
|
|
|
||||||
|
|
@ -292,7 +292,12 @@ def _maybe_run_startup_update_prompt(
|
||||||
)
|
)
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
case UpdatePromptResult.UPDATE_FAILED:
|
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)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,11 @@ from rich import print as rprint
|
||||||
from vibe import __version__
|
from vibe import __version__
|
||||||
from vibe.core.config.harness_files import init_harness_files_manager
|
from vibe.core.config.harness_files import init_harness_files_manager
|
||||||
from vibe.core.trusted_folders import (
|
from vibe.core.trusted_folders import (
|
||||||
find_git_repo_ancestor,
|
apply_workspace_trust_decision,
|
||||||
find_repo_trustable_files_for_cwd,
|
maybe_build_workspace_trust_prompt,
|
||||||
find_trustable_files,
|
|
||||||
trusted_folders_manager,
|
trusted_folders_manager,
|
||||||
)
|
)
|
||||||
from vibe.setup.trusted_folders.trust_folder_dialog import (
|
from vibe.setup.trusted_folders.trust_folder_dialog import (
|
||||||
TrustDecision,
|
|
||||||
TrustDialogQuitException,
|
TrustDialogQuitException,
|
||||||
ask_trust_folder,
|
ask_trust_folder,
|
||||||
)
|
)
|
||||||
|
|
@ -157,38 +155,18 @@ def parse_arguments() -> argparse.Namespace:
|
||||||
|
|
||||||
|
|
||||||
def check_and_resolve_trusted_folder(cwd: Path) -> None:
|
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
|
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:
|
try:
|
||||||
decision = ask_trust_folder(
|
decision = ask_trust_folder(
|
||||||
cwd,
|
prompt.cwd,
|
||||||
repo_root,
|
prompt.repo_root,
|
||||||
detected_files,
|
prompt.detected_files,
|
||||||
repo_detected_files=repo_detected_files,
|
repo_detected_files=prompt.repo_detected_files,
|
||||||
offer_repo_trust=offer_repo_trust,
|
offer_repo_trust=prompt.offer_repo_trust,
|
||||||
repo_explicitly_untrusted=repo_explicitly_untrusted,
|
repo_explicitly_untrusted=prompt.repo_explicitly_untrusted,
|
||||||
)
|
)
|
||||||
except (KeyboardInterrupt, EOFError, TrustDialogQuitException):
|
except (KeyboardInterrupt, EOFError, TrustDialogQuitException):
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
@ -196,13 +174,8 @@ def check_and_resolve_trusted_folder(cwd: Path) -> None:
|
||||||
rprint(f"[yellow]Error showing trust dialog: {e}[/]")
|
rprint(f"[yellow]Error showing trust dialog: {e}[/]")
|
||||||
return
|
return
|
||||||
|
|
||||||
match decision:
|
if decision is not None:
|
||||||
case TrustDecision.TRUST_REPO if repo_root is not None:
|
apply_workspace_trust_decision(prompt, decision)
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ from __future__ import annotations
|
||||||
import asyncio
|
import asyncio
|
||||||
import codecs
|
import codecs
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
from contextlib import aclosing
|
from contextlib import aclosing, suppress
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import StrEnum, auto
|
from enum import StrEnum, auto
|
||||||
import gc
|
import gc
|
||||||
|
|
@ -170,10 +170,12 @@ from vibe.core.paths import HISTORY_FILE
|
||||||
from vibe.core.rewind import RewindError
|
from vibe.core.rewind import RewindError
|
||||||
from vibe.core.session.image_snapshot import ImageSnapshotError, snapshot_image
|
from vibe.core.session.image_snapshot import ImageSnapshotError, snapshot_image
|
||||||
from vibe.core.session.resume_sessions import (
|
from vibe.core.session.resume_sessions import (
|
||||||
|
RemoteResumeResult,
|
||||||
|
RemoteResumeSessions,
|
||||||
ResumeSessionInfo,
|
ResumeSessionInfo,
|
||||||
can_delete_resume_session_source,
|
can_delete_resume_session_source,
|
||||||
list_local_resume_sessions,
|
list_local_resume_sessions,
|
||||||
list_remote_resume_sessions,
|
session_latest_messages,
|
||||||
short_session_id,
|
short_session_id,
|
||||||
)
|
)
|
||||||
from vibe.core.session.saved_sessions import (
|
from vibe.core.session.saved_sessions import (
|
||||||
|
|
@ -428,6 +430,8 @@ class VibeApp(App): # noqa: PLR0904
|
||||||
self._bash_task: asyncio.Task | None = None
|
self._bash_task: asyncio.Task | None = None
|
||||||
self._queue = QueueController(self._build_queue_ports())
|
self._queue = QueueController(self._build_queue_ports())
|
||||||
self._remote_manager = RemoteSessionManager()
|
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._loading_widget: LoadingWidget | None = None
|
||||||
self._pending_approval: asyncio.Future | 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}".')
|
UserCommandMessage(f'Session renamed to "{renamed_title}".')
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _show_session_picker(self, **kwargs: Any) -> None:
|
def _build_picker(self, sessions: list[ResumeSessionInfo]) -> SessionPickerApp:
|
||||||
cwd = str(Path.cwd())
|
sessions = sorted(sessions, key=lambda s: s.end_time or "", reverse=True)
|
||||||
local_sessions = (
|
return SessionPickerApp(
|
||||||
list_local_resume_sessions(self.config, cwd)
|
sessions=sessions,
|
||||||
if self.config.session_logging.enabled
|
latest_messages=session_latest_messages(sessions, self.config),
|
||||||
else []
|
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_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")
|
await self._ensure_loading_widget("Loading sessions")
|
||||||
try:
|
try:
|
||||||
remote_sessions = await asyncio.wait_for(
|
remote_sessions, remote_error = await remote_task
|
||||||
list_remote_resume_sessions(self.config), timeout=remote_list_timeout
|
except asyncio.CancelledError:
|
||||||
)
|
return
|
||||||
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}"
|
|
||||||
finally:
|
finally:
|
||||||
await self._remove_loading_widget()
|
await self._remove_loading_widget()
|
||||||
|
|
||||||
|
|
@ -2247,37 +2293,13 @@ class VibeApp(App): # noqa: PLR0904
|
||||||
ErrorMessage(remote_error, collapsed=self._tools_collapsed)
|
ErrorMessage(remote_error, collapsed=self._tools_collapsed)
|
||||||
)
|
)
|
||||||
|
|
||||||
raw_sessions = [*local_sessions, *remote_sessions]
|
if not remote_sessions:
|
||||||
|
|
||||||
if not raw_sessions:
|
|
||||||
await self._mount_and_scroll(
|
await self._mount_and_scroll(
|
||||||
UserCommandMessage("No sessions found for this directory.")
|
UserCommandMessage("No sessions found for this directory.")
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
sessions = sorted(raw_sessions, key=lambda s: s.end_time or "", reverse=True)
|
await self._switch_from_input(self._build_picker(remote_sessions))
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
async def on_session_picker_app_session_selected(
|
async def on_session_picker_app_session_selected(
|
||||||
self, event: SessionPickerApp.SessionSelected
|
self, event: SessionPickerApp.SessionSelected
|
||||||
|
|
@ -2686,6 +2708,7 @@ class VibeApp(App): # noqa: PLR0904
|
||||||
self._log_reader.shutdown()
|
self._log_reader.shutdown()
|
||||||
await self._voice_manager.close()
|
await self._voice_manager.close()
|
||||||
await self._narrator_manager.close()
|
await self._narrator_manager.close()
|
||||||
|
await self._close_remote_resume()
|
||||||
await self.agent_loop.aclose()
|
await self.agent_loop.aclose()
|
||||||
try:
|
try:
|
||||||
await self.agent_loop.telemetry_client.aclose()
|
await self.agent_loop.telemetry_client.aclose()
|
||||||
|
|
@ -3437,6 +3460,7 @@ class VibeApp(App): # noqa: PLR0904
|
||||||
|
|
||||||
self._log_reader.shutdown()
|
self._log_reader.shutdown()
|
||||||
self._narrator_manager.cancel()
|
self._narrator_manager.cancel()
|
||||||
|
await self._close_remote_resume()
|
||||||
await self.agent_loop.aclose()
|
await self.agent_loop.aclose()
|
||||||
try:
|
try:
|
||||||
await self.agent_loop.telemetry_client.aclose()
|
await self.agent_loop.telemetry_client.aclose()
|
||||||
|
|
|
||||||
|
|
@ -168,6 +168,18 @@ class SessionPickerApp(Container):
|
||||||
def _normal_option_text(self, session: ResumeSessionInfo) -> Text:
|
def _normal_option_text(self, session: ResumeSessionInfo) -> Text:
|
||||||
return _build_option_text(session, self._session_message(session))
|
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:
|
def _delete_confirmation_option_text(self, session: ResumeSessionInfo) -> Text:
|
||||||
text = _build_option_text(session, "")
|
text = _build_option_text(session, "")
|
||||||
text.append("Press D again to delete")
|
text.append("Press D again to delete")
|
||||||
|
|
@ -239,6 +251,41 @@ class SessionPickerApp(Container):
|
||||||
self._option_list().remove_option(option_id)
|
self._option_list().remove_option(option_id)
|
||||||
return True
|
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:
|
def clear_pending_delete(self, option_id: str) -> bool:
|
||||||
if not self._delete_state_matches(option_id, "pending"):
|
if not self._delete_state_matches(option_id, "pending"):
|
||||||
return False
|
return False
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,9 @@ class WorkflowsClient:
|
||||||
exc_val: BaseException | None,
|
exc_val: BaseException | None,
|
||||||
exc_tb: Any,
|
exc_tb: Any,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
await self.aclose()
|
||||||
|
|
||||||
|
async def aclose(self) -> None:
|
||||||
if self._owns_client and self._client:
|
if self._owns_client and self._client:
|
||||||
await self._client.aclose()
|
await self._client.aclose()
|
||||||
self._client = None
|
self._client = None
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,7 @@ class RemoteEventsSource:
|
||||||
|
|
||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
if self._client is not None:
|
if self._client is not None:
|
||||||
await self._client.__aexit__(None, None, None)
|
await self._client.aclose()
|
||||||
self._client = None
|
self._client = None
|
||||||
|
|
||||||
async def attach(self) -> AsyncGenerator[BaseEvent, None]:
|
async def attach(self) -> AsyncGenerator[BaseEvent, None]:
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,19 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections.abc import Callable, Sequence
|
||||||
|
import contextlib
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Literal
|
from typing import Literal, NamedTuple, Protocol
|
||||||
|
|
||||||
from vibe.core.config import VibeConfig
|
from vibe.core.config import VibeConfig
|
||||||
from vibe.core.logger import logger
|
from vibe.core.logger import logger
|
||||||
from vibe.core.nuage.client import WorkflowsClient
|
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_id import shorten_session_id
|
||||||
from vibe.core.session.session_loader import SessionLoader
|
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)
|
@dataclass(frozen=True)
|
||||||
class ResumeSessionInfo:
|
class ResumeSessionInfo:
|
||||||
session_id: str
|
session_id: str
|
||||||
|
|
@ -61,21 +82,12 @@ def list_local_resume_sessions(
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
async def list_remote_resume_sessions(config: VibeConfig) -> list[ResumeSessionInfo]:
|
async def list_remote_resume_sessions(
|
||||||
if not config.vibe_code_enabled or not config.vibe_code_api_key:
|
client: RemoteWorkflowRunsClient, workflow_id: str
|
||||||
logger.debug("Remote resume listing skipped: missing Vibe Code configuration")
|
) -> list[ResumeSessionInfo]:
|
||||||
return []
|
response = await client.get_workflow_runs(
|
||||||
|
workflow_identifier=workflow_id, page_size=50, status=_ACTIVE_STATUSES
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
seen: dict[str, ResumeSessionInfo] = {}
|
seen: dict[str, ResumeSessionInfo] = {}
|
||||||
latest_start: dict[str, datetime] = {}
|
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))
|
logger.debug("Remote resume listing filtered sessions: %d", len(sessions))
|
||||||
return 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:
|
class SessionLoader:
|
||||||
@staticmethod
|
@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
|
session_dir: Path, working_directory: Path | None = None
|
||||||
) -> bool:
|
) -> dict[str, Any] | None:
|
||||||
"""Check if a session directory contains valid metadata and messages."""
|
|
||||||
metadata_path = session_dir / METADATA_FILENAME
|
metadata_path = session_dir / METADATA_FILENAME
|
||||||
messages_path = session_dir / MESSAGES_FILENAME
|
messages_path = session_dir / MESSAGES_FILENAME
|
||||||
|
|
||||||
if not metadata_path.is_file() or not messages_path.is_file():
|
if not metadata_path.is_file() or not messages_path.is_file():
|
||||||
return False
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
metadata = json.loads(read_safe(metadata_path).text)
|
metadata = json.loads(read_safe(metadata_path).text)
|
||||||
if not isinstance(metadata, dict):
|
if not isinstance(metadata, dict):
|
||||||
return False
|
return None
|
||||||
if working_directory is not None:
|
if working_directory is not None:
|
||||||
session_working_directory = (metadata.get("environment") or {}).get(
|
session_working_directory = (metadata.get("environment") or {}).get(
|
||||||
"working_directory"
|
"working_directory"
|
||||||
)
|
)
|
||||||
if session_working_directory != str(working_directory):
|
if session_working_directory != str(working_directory):
|
||||||
return False
|
return None
|
||||||
|
|
||||||
has_messages = False
|
messages = SessionLoader._parse_message_lines(read_safe(messages_path).text)
|
||||||
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
|
|
||||||
except (OSError, json.JSONDecodeError):
|
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
|
@staticmethod
|
||||||
def latest_session(
|
def latest_session(
|
||||||
|
|
@ -158,13 +176,8 @@ class SessionLoader:
|
||||||
|
|
||||||
sessions: list[SessionInfo] = []
|
sessions: list[SessionInfo] = []
|
||||||
for session_dir in session_dirs:
|
for session_dir in session_dirs:
|
||||||
if not SessionLoader._is_valid_session(session_dir):
|
metadata = SessionLoader._read_validated_session(session_dir)
|
||||||
continue
|
if metadata is None:
|
||||||
|
|
||||||
metadata_path = session_dir / METADATA_FILENAME
|
|
||||||
try:
|
|
||||||
metadata = json.loads(read_safe(metadata_path).text)
|
|
||||||
except (OSError, json.JSONDecodeError):
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
session_id = metadata.get("session_id")
|
session_id = metadata.get("session_id")
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import StrEnum
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import tomllib
|
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:
|
def has_agents_md_file(path: Path) -> bool:
|
||||||
agents_md = path / AGENTS_MD_FILENAME
|
agents_md = path / AGENTS_MD_FILENAME
|
||||||
try:
|
try:
|
||||||
|
|
@ -91,6 +110,73 @@ def find_repo_trustable_files_for_cwd(cwd: Path, repo_root: Path | None) -> list
|
||||||
return sorted(found)
|
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:
|
class TrustedFoldersManager:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._file_path = TRUSTED_FOLDERS_FILE.path
|
self._file_path = TRUSTED_FOLDERS_FILE.path
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,9 @@ def _get_candidate_encodings(
|
||||||
|
|
||||||
def normalize_newlines(text: str) -> tuple[str, str]:
|
def normalize_newlines(text: str) -> tuple[str, str]:
|
||||||
r"""Return ``text`` with ``\n`` newlines and the detected original style."""
|
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)
|
newline = _detect_newline(text)
|
||||||
return text.replace("\r\n", "\n").replace("\r", "\n"), newline
|
return text.replace("\r\n", "\n").replace("\r", "\n"), newline
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from enum import Enum
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, ClassVar
|
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.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||||
from vibe.core.paths import TRUSTED_FOLDERS_FILE
|
from vibe.core.paths import TRUSTED_FOLDERS_FILE
|
||||||
|
from vibe.core.trusted_folders import WorkspaceTrustDecision
|
||||||
|
|
||||||
|
|
||||||
class TrustDialogQuitException(Exception):
|
class TrustDialogQuitException(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class TrustDecision(Enum):
|
TrustDecision = WorkspaceTrustDecision
|
||||||
"""User's choice from the trust dialog."""
|
|
||||||
|
|
||||||
TRUST_REPO = "trust_repo"
|
|
||||||
TRUST_CWD = "trust_cwd"
|
|
||||||
DECLINE = "decline"
|
|
||||||
|
|
||||||
|
|
||||||
class TrustFolderDialog(CenterMiddle):
|
class TrustFolderDialog(CenterMiddle):
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue