Co-authored-by: Clément Drouin <clement.drouin@mistral.ai>
Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai>
Co-authored-by: Lucas Marandat <31749711+lucasmrdt@users.noreply.github.com>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Mathias Gesbert 2026-04-29 19:32:15 +02:00 committed by GitHub
parent 1fd7eea289
commit 3b8f65b306
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 300 additions and 52 deletions

View file

@ -5,6 +5,13 @@ 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.9.2] - 2026-04-29
### Fixed
- Teleport surfaces the latest GitHub connection status while polling
## [2.9.1] - 2026-04-29 ## [2.9.1] - 2026-04-29
### Added ### Added

View file

@ -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.9.1" version = "2.9.2"
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,25 +11,25 @@ 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.9.1/vibe-acp-darwin-aarch64-2.9.1.zip" archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.2/vibe-acp-darwin-aarch64-2.9.2.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.9.1/vibe-acp-darwin-x86_64-2.9.1.zip" archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.2/vibe-acp-darwin-x86_64-2.9.2.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.9.1/vibe-acp-linux-aarch64-2.9.1.zip" archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.2/vibe-acp-linux-aarch64-2.9.2.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.9.1/vibe-acp-linux-x86_64-2.9.1.zip" archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.2/vibe-acp-linux-x86_64-2.9.2.zip"
cmd = "./vibe-acp" cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.windows-aarch64] [agent_servers.mistral-vibe.targets.windows-aarch64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.1/vibe-acp-windows-aarch64-2.9.1.zip" archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.2/vibe-acp-windows-aarch64-2.9.2.zip"
cmd = "./vibe-acp.exe" cmd = "./vibe-acp.exe"
[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.9.1/vibe-acp-windows-x86_64-2.9.1.zip" archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.2/vibe-acp-windows-x86_64-2.9.2.zip"
cmd = "./vibe-acp.exe" cmd = "./vibe-acp.exe"

View file

@ -1,6 +1,6 @@
[project] [project]
name = "mistral-vibe" name = "mistral-vibe"
version = "2.9.1" version = "2.9.2"
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"

View file

@ -34,7 +34,7 @@ class TestACPInitialize:
), ),
) )
assert response.agent_info == Implementation( assert response.agent_info == Implementation(
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.9.1" name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.9.2"
) )
assert response.auth_methods == [] assert response.auth_methods == []
@ -62,7 +62,7 @@ class TestACPInitialize:
), ),
) )
assert response.agent_info == Implementation( assert response.agent_info == Implementation(
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.9.1" name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.9.2"
) )
assert response.auth_methods is not None assert response.auth_methods is not None

View file

@ -0,0 +1,128 @@
from __future__ import annotations
from typing import Annotated
from pydantic import BaseModel, Field
import pytest
from vibe.core.config import (
ConfigDefinitionError,
ConfigFragment,
ConfigSchema,
WithConcatMerge,
WithReplaceMerge,
WithShallowMerge,
WithUnionMerge,
)
class ToolSettings(BaseModel):
enabled: bool = True
class ModelDefinition(BaseModel):
alias: str
provider: str
class ToolsFragment(ConfigFragment):
disabled_tools: Annotated[list[str], WithConcatMerge()] = Field(
default_factory=list
)
tool_settings: Annotated[dict[str, ToolSettings], WithShallowMerge()] = Field(
default_factory=dict
)
class ModelsFragment(ConfigFragment):
default_model: Annotated[str, WithReplaceMerge()] = "devstral-small"
available_models: Annotated[
list[ModelDefinition], WithUnionMerge(merge_key="alias")
] = Field(default_factory=list)
class MiniVibeConfigSchema(ConfigSchema):
active_model: Annotated[str, WithReplaceMerge()] = "devstral-2"
models: ModelsFragment = Field(default_factory=ModelsFragment)
tools: ToolsFragment = Field(default_factory=ToolsFragment)
def test_config_fragment_accepts_merge_aware_fields() -> None:
fragment = ToolsFragment(
disabled_tools=["bash"], tool_settings={"search": ToolSettings(enabled=False)}
)
assert fragment.disabled_tools == ["bash"]
assert fragment.tool_settings["search"].enabled is False
def test_config_fragment_rejects_plain_fields() -> None:
with pytest.raises(
ConfigDefinitionError, match="BrokenFragment.name must declare merge metadata"
):
class BrokenFragment(ConfigFragment):
name: str = "bash"
def test_scenario_mini_vibe_config_schema() -> None:
config = MiniVibeConfigSchema(
active_model="codestral-latest",
models=ModelsFragment(
available_models=[
ModelDefinition(alias="devstral-small", provider="mistral"),
ModelDefinition(alias="codestral-latest", provider="mistral"),
]
),
tools=ToolsFragment(disabled_tools=["bash"]),
)
assert config.active_model == "codestral-latest"
assert config.models.default_model == "devstral-small"
assert [model.alias for model in config.models.available_models] == [
"devstral-small",
"codestral-latest",
]
assert config.tools.disabled_tools == ["bash"]
def test_config_schema_rejects_plain_top_level_fields() -> None:
with pytest.raises(
ConfigDefinitionError,
match="BrokenSchema.active_model must declare merge metadata",
):
class BrokenSchema(ConfigSchema):
active_model: str = "devstral-2"
def test_config_schema_rejects_plain_nested_models() -> None:
with pytest.raises(
ConfigDefinitionError,
match="BrokenSchema.tools must declare merge metadata or use a ConfigFragment subclass",
):
class BrokenSchema(ConfigSchema):
tools: ToolSettings = Field(default_factory=ToolSettings)
def test_config_schema_rejects_merge_metadata_on_fragments() -> None:
with pytest.raises(
ConfigDefinitionError,
match="BrokenSchema.tools is a ConfigFragment field and must not declare merge metadata",
):
class BrokenSchema(ConfigSchema):
tools: Annotated[ToolsFragment, WithReplaceMerge()] = Field(
default_factory=ToolsFragment
)
def test_config_schema_rejects_optional_fragments() -> None:
with pytest.raises(
ConfigDefinitionError,
match="BrokenSchema.tools must declare merge metadata or use a ConfigFragment subclass",
):
class BrokenSchema(ConfigSchema):
tools: ToolsFragment | None = None

View file

@ -23,9 +23,10 @@ class TestWaitForGithubConnection:
connected = GitHubPublicData(status=GitHubStatus.CONNECTED) connected = GitHubPublicData(status=GitHubStatus.CONNECTED)
client.get_github_integration = AsyncMock(return_value=connected) client.get_github_integration = AsyncMock(return_value=connected)
result = await client.wait_for_github_connection("exec-1") results = [data async for data in client.wait_for_github_connection("exec-1")]
assert result.connected is True assert len(results) == 1
assert results[0].connected is True
client.get_github_integration.assert_called_once_with("exec-1") client.get_github_integration.assert_called_once_with("exec-1")
@pytest.mark.asyncio @pytest.mark.asyncio
@ -40,9 +41,12 @@ class TestWaitForGithubConnection:
) )
with patch("vibe.core.teleport.nuage.asyncio.sleep", new_callable=AsyncMock): with patch("vibe.core.teleport.nuage.asyncio.sleep", new_callable=AsyncMock):
result = await client.wait_for_github_connection("exec-1") results = [
data async for data in client.wait_for_github_connection("exec-1")
]
assert result.connected is True assert len(results) == 3
assert results[-1].connected is True
assert client.get_github_integration.call_count == 3 assert client.get_github_integration.call_count == 3
@pytest.mark.asyncio @pytest.mark.asyncio
@ -53,7 +57,8 @@ class TestWaitForGithubConnection:
client.get_github_integration = AsyncMock(return_value=error_data) client.get_github_integration = AsyncMock(return_value=error_data)
with pytest.raises(ServiceTeleportError, match="App not installed"): with pytest.raises(ServiceTeleportError, match="App not installed"):
await client.wait_for_github_connection("exec-1") async for _ in client.wait_for_github_connection("exec-1"):
pass
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_raises_on_oauth_timeout(self, client: NuageClient) -> None: async def test_raises_on_oauth_timeout(self, client: NuageClient) -> None:
@ -61,7 +66,8 @@ class TestWaitForGithubConnection:
client.get_github_integration = AsyncMock(return_value=timeout_data) client.get_github_integration = AsyncMock(return_value=timeout_data)
with pytest.raises(ServiceTeleportError, match="oauth_timeout"): with pytest.raises(ServiceTeleportError, match="oauth_timeout"):
await client.wait_for_github_connection("exec-1") async for _ in client.wait_for_github_connection("exec-1"):
pass
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_raises_on_timeout(self, client: NuageClient) -> None: async def test_raises_on_timeout(self, client: NuageClient) -> None:
@ -77,7 +83,8 @@ class TestWaitForGithubConnection:
patch("vibe.core.teleport.nuage.asyncio.sleep", new_callable=AsyncMock), patch("vibe.core.teleport.nuage.asyncio.sleep", new_callable=AsyncMock),
pytest.raises(ServiceTeleportError, match="timed out"), pytest.raises(ServiceTeleportError, match="timed out"),
): ):
await client.wait_for_github_connection("exec-1", timeout=600.0) async for _ in client.wait_for_github_connection("exec-1", timeout=600.0):
pass
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_sleeps_with_correct_interval(self, client: NuageClient) -> None: async def test_sleeps_with_correct_interval(self, client: NuageClient) -> None:
@ -88,8 +95,31 @@ class TestWaitForGithubConnection:
with patch( with patch(
"vibe.core.teleport.nuage.asyncio.sleep", new_callable=AsyncMock "vibe.core.teleport.nuage.asyncio.sleep", new_callable=AsyncMock
) as mock_sleep: ) as mock_sleep:
await client.wait_for_github_connection("exec-1", interval=5.0) async for _ in client.wait_for_github_connection("exec-1", interval=5.0):
pass
mock_sleep.assert_called_once() mock_sleep.assert_called_once()
sleep_duration = mock_sleep.call_args[0][0] sleep_duration = mock_sleep.call_args[0][0]
assert sleep_duration <= 5.0 assert sleep_duration <= 5.0
@pytest.mark.asyncio
async def test_yields_each_poll_result(self, client: NuageClient) -> None:
pending = GitHubPublicData(
status=GitHubStatus.WAITING_FOR_OAUTH,
error="Please connect GitHub",
oauth_url="https://github.com/auth",
)
connected = GitHubPublicData(status=GitHubStatus.CONNECTED)
client.get_github_integration = AsyncMock(
side_effect=[pending, pending, connected]
)
with patch("vibe.core.teleport.nuage.asyncio.sleep", new_callable=AsyncMock):
results = [
data async for data in client.wait_for_github_connection("exec-1")
]
assert len(results) == 3
assert results[0].error == "Please connect GitHub"
assert results[1].error == "Please connect GitHub"
assert results[2].connected is True

View file

@ -260,11 +260,12 @@ class TestTeleportServiceExecute:
service._git.get_info = AsyncMock(return_value=git_info) service._git.get_info = AsyncMock(return_value=git_info)
service._git.is_commit_pushed = AsyncMock(return_value=True) service._git.is_commit_pushed = AsyncMock(return_value=True)
async def _connected_gen(*_a: object, **_kw: object): # type: ignore[no-untyped-def]
yield mock_github_connected
mock_nuage = MagicMock() mock_nuage = MagicMock()
mock_nuage.start_workflow = AsyncMock(return_value="exec-123") mock_nuage.start_workflow = AsyncMock(return_value="exec-123")
mock_nuage.get_github_integration = AsyncMock( mock_nuage.wait_for_github_connection = _connected_gen
return_value=mock_github_connected
)
mock_nuage.get_chat_assistant_url = AsyncMock( mock_nuage.get_chat_assistant_url = AsyncMock(
return_value="https://chat.example.com/123" return_value="https://chat.example.com/123"
) )
@ -279,9 +280,10 @@ class TestTeleportServiceExecute:
assert isinstance(events[0], TeleportCheckingGitEvent) assert isinstance(events[0], TeleportCheckingGitEvent)
assert isinstance(events[1], TeleportStartingWorkflowEvent) assert isinstance(events[1], TeleportStartingWorkflowEvent)
assert isinstance(events[2], TeleportWaitingForGitHubEvent) assert isinstance(events[2], TeleportWaitingForGitHubEvent)
assert isinstance(events[3], TeleportFetchingUrlEvent) assert isinstance(events[3], TeleportAuthCompleteEvent)
assert isinstance(events[4], TeleportCompleteEvent) assert isinstance(events[4], TeleportFetchingUrlEvent)
assert events[4].url == "https://chat.example.com/123" assert isinstance(events[5], TeleportCompleteEvent)
assert events[5].url == "https://chat.example.com/123"
workflow_params = mock_nuage.start_workflow.call_args.args[0] workflow_params = mock_nuage.start_workflow.call_args.args[0]
assert workflow_params.integrations.chat_assistant is not None assert workflow_params.integrations.chat_assistant is not None
assert workflow_params.integrations.chat_assistant.project_name is None assert workflow_params.integrations.chat_assistant.project_name is None
@ -298,11 +300,12 @@ class TestTeleportServiceExecute:
service._git.get_unpushed_commit_count = AsyncMock(return_value=3) service._git.get_unpushed_commit_count = AsyncMock(return_value=3)
service._git.push_current_branch = AsyncMock(return_value=True) service._git.push_current_branch = AsyncMock(return_value=True)
async def _connected_gen(*_a: object, **_kw: object): # type: ignore[no-untyped-def]
yield mock_github_connected
mock_nuage = MagicMock() mock_nuage = MagicMock()
mock_nuage.start_workflow = AsyncMock(return_value="exec-123") mock_nuage.start_workflow = AsyncMock(return_value="exec-123")
mock_nuage.get_github_integration = AsyncMock( mock_nuage.wait_for_github_connection = _connected_gen
return_value=mock_github_connected
)
mock_nuage.get_chat_assistant_url = AsyncMock( mock_nuage.get_chat_assistant_url = AsyncMock(
return_value="https://chat.example.com/123" return_value="https://chat.example.com/123"
) )
@ -357,18 +360,22 @@ class TestTeleportServiceExecute:
github_pending = MagicMock() github_pending = MagicMock()
github_pending.connected = False github_pending.connected = False
github_pending.oauth_url = "https://github.com/login/oauth" github_pending.oauth_url = "https://github.com/login/oauth"
github_pending.error = None github_pending.error = "Please connect GitHub"
github_pending.status = GitHubStatus.WAITING_FOR_OAUTH github_pending.status = GitHubStatus.WAITING_FOR_OAUTH
github_connected = MagicMock() github_connected = MagicMock()
github_connected.connected = True github_connected.connected = True
github_connected.oauth_url = None github_connected.oauth_url = None
github_connected.error = None
github_connected.status = GitHubStatus.CONNECTED github_connected.status = GitHubStatus.CONNECTED
async def _oauth_gen(*_a: object, **_kw: object): # type: ignore[no-untyped-def]
yield github_pending
yield github_connected
mock_nuage = MagicMock() mock_nuage = MagicMock()
mock_nuage.start_workflow = AsyncMock(return_value="exec-123") mock_nuage.start_workflow = AsyncMock(return_value="exec-123")
mock_nuage.get_github_integration = AsyncMock(return_value=github_pending) mock_nuage.wait_for_github_connection = _oauth_gen
mock_nuage.wait_for_github_connection = AsyncMock(return_value=github_connected)
mock_nuage.get_chat_assistant_url = AsyncMock( mock_nuage.get_chat_assistant_url = AsyncMock(
return_value="https://chat.example.com/123" return_value="https://chat.example.com/123"
) )
@ -383,9 +390,12 @@ class TestTeleportServiceExecute:
assert isinstance(events[0], TeleportCheckingGitEvent) assert isinstance(events[0], TeleportCheckingGitEvent)
assert isinstance(events[1], TeleportStartingWorkflowEvent) assert isinstance(events[1], TeleportStartingWorkflowEvent)
assert isinstance(events[2], TeleportWaitingForGitHubEvent) assert isinstance(events[2], TeleportWaitingForGitHubEvent)
assert events[2].message is None
assert isinstance(events[3], TeleportAuthRequiredEvent) assert isinstance(events[3], TeleportAuthRequiredEvent)
assert events[3].oauth_url == "https://github.com/login/oauth" assert events[3].oauth_url == "https://github.com/login/oauth"
assert isinstance(events[4], TeleportAuthCompleteEvent) assert isinstance(events[4], TeleportWaitingForGitHubEvent)
assert events[4].message == "Please connect GitHub"
assert isinstance(events[5], TeleportAuthCompleteEvent)
assert isinstance(events[-1], TeleportCompleteEvent) assert isinstance(events[-1], TeleportCompleteEvent)
@pytest.mark.asyncio @pytest.mark.asyncio
@ -398,11 +408,12 @@ class TestTeleportServiceExecute:
service._git.get_info = AsyncMock(return_value=git_info) service._git.get_info = AsyncMock(return_value=git_info)
service._git.is_commit_pushed = AsyncMock(return_value=True) service._git.is_commit_pushed = AsyncMock(return_value=True)
async def _connected_gen(*_a: object, **_kw: object): # type: ignore[no-untyped-def]
yield mock_github_connected
mock_nuage = MagicMock() mock_nuage = MagicMock()
mock_nuage.start_workflow = AsyncMock(return_value="exec-123") mock_nuage.start_workflow = AsyncMock(return_value="exec-123")
mock_nuage.get_github_integration = AsyncMock( mock_nuage.wait_for_github_connection = _connected_gen
return_value=mock_github_connected
)
mock_nuage.get_chat_assistant_url = AsyncMock(return_value=None) mock_nuage.get_chat_assistant_url = AsyncMock(return_value=None)
service._nuage_client_instance = mock_nuage service._nuage_client_instance = mock_nuage
@ -423,11 +434,12 @@ class TestTeleportServiceExecute:
service._git.get_info = AsyncMock(return_value=git_info) service._git.get_info = AsyncMock(return_value=git_info)
service._git.is_commit_pushed = AsyncMock(return_value=True) service._git.is_commit_pushed = AsyncMock(return_value=True)
async def _connected_gen(*_a: object, **_kw: object): # type: ignore[no-untyped-def]
yield mock_github_connected
mock_nuage = MagicMock() mock_nuage = MagicMock()
mock_nuage.start_workflow = AsyncMock(return_value="exec-123") mock_nuage.start_workflow = AsyncMock(return_value="exec-123")
mock_nuage.get_github_integration = AsyncMock( mock_nuage.wait_for_github_connection = _connected_gen
return_value=mock_github_connected
)
mock_nuage.get_chat_assistant_url = AsyncMock( mock_nuage.get_chat_assistant_url = AsyncMock(
return_value="https://chat.example.com/123" return_value="https://chat.example.com/123"
) )

4
uv.lock generated
View file

@ -3,7 +3,7 @@ revision = 3
requires-python = ">=3.12" requires-python = ">=3.12"
[options] [options]
exclude-newer = "2026-04-22T13:12:26.10035Z" exclude-newer = "2026-04-22T17:04:46.06461Z"
exclude-newer-span = "P7D" exclude-newer-span = "P7D"
[options.exclude-newer-package] [options.exclude-newer-package]
@ -808,7 +808,7 @@ wheels = [
[[package]] [[package]]
name = "mistral-vibe" name = "mistral-vibe"
version = "2.9.1" version = "2.9.2"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "agent-client-protocol" }, { name = "agent-client-protocol" },

View file

@ -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.9.1" __version__ = "2.9.2"

View file

@ -1357,8 +1357,8 @@ class VibeApp(App): # noqa: PLR0904
teleport_msg.set_status("Syncing with remote...") teleport_msg.set_status("Syncing with remote...")
case TeleportStartingWorkflowEvent(): case TeleportStartingWorkflowEvent():
teleport_msg.set_status("Teleporting...") teleport_msg.set_status("Teleporting...")
case TeleportWaitingForGitHubEvent(): case TeleportWaitingForGitHubEvent(message=msg):
teleport_msg.set_status("Connecting to GitHub...") teleport_msg.set_status(msg or "Connecting to GitHub...")
case TeleportAuthRequiredEvent(oauth_url=url, message=msg): case TeleportAuthRequiredEvent(oauth_url=url, message=msg):
webbrowser.open(url) webbrowser.open(url)
teleport_msg.set_status(msg or "Authorizing GitHub...") teleport_msg.set_status(msg or "Authorizing GitHub...")

View file

@ -49,6 +49,9 @@ from vibe.core.config.patch import (
SetField, SetField,
) )
from vibe.core.config.schema import ( from vibe.core.config.schema import (
ConfigDefinitionError,
ConfigFragment,
ConfigSchema,
DuplicateMergeMetadataError, DuplicateMergeMetadataError,
MergeFieldMetadata, MergeFieldMetadata,
WithConcatMerge, WithConcatMerge,
@ -68,8 +71,11 @@ __all__ = [
"DEFAULT_TTS_PROVIDERS", "DEFAULT_TTS_PROVIDERS",
"THINKING_LEVELS", "THINKING_LEVELS",
"AppendToList", "AppendToList",
"ConfigDefinitionError",
"ConfigFragment",
"ConfigLayer", "ConfigLayer",
"ConfigLayerError", "ConfigLayerError",
"ConfigSchema",
"ConnectorConfig", "ConnectorConfig",
"DeleteField", "DeleteField",
"DuplicateMergeMetadataError", "DuplicateMergeMetadataError",

View file

@ -2,6 +2,7 @@ from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pydantic import BaseModel
from pydantic.fields import FieldInfo from pydantic.fields import FieldInfo
from vibe.core.utils.merge import MergeStrategy from vibe.core.utils.merge import MergeStrategy
@ -11,6 +12,61 @@ class DuplicateMergeMetadataError(TypeError):
"""Raised when a field declares more than one MergeFieldMetadata marker.""" """Raised when a field declares more than one MergeFieldMetadata marker."""
class ConfigDefinitionError(TypeError):
"""Raised when a config schema or fragment is declared with invalid fields."""
class ConfigSchema(BaseModel):
"""Base for composite config schemas composed of fragments and merge-aware fields."""
@classmethod
def __pydantic_on_complete__(cls) -> None:
super().__pydantic_on_complete__()
if cls.__name__ == "ConfigSchema" and cls.__module__ == __name__:
return
for field_name, field_info in cls.model_fields.items():
has_merge_metadata = MergeFieldMetadata.from_field(field_info) is not None
is_fragment = isinstance(field_info.annotation, type) and issubclass(
field_info.annotation, ConfigFragment
)
if is_fragment and has_merge_metadata:
raise ConfigDefinitionError(
f"{cls.__name__}.{field_name} is a ConfigFragment field and "
"must not declare merge metadata"
)
if is_fragment or has_merge_metadata:
continue
raise ConfigDefinitionError(
f"{cls.__name__}.{field_name} must declare merge metadata or use a "
"ConfigFragment subclass"
)
class ConfigFragment(BaseModel):
"""Base for domain config groups with merge-aware top-level fields."""
@classmethod
def __pydantic_on_complete__(cls) -> None:
super().__pydantic_on_complete__()
if cls.__name__ == "ConfigFragment" and cls.__module__ == __name__:
return
for field_name, field_info in cls.model_fields.items():
if MergeFieldMetadata.from_field(field_info) is not None:
continue
raise ConfigDefinitionError(
f"{cls.__name__}.{field_name} must declare merge metadata via "
"MergeFieldMetadata"
)
@dataclass(frozen=True) @dataclass(frozen=True)
class MergeFieldMetadata: class MergeFieldMetadata:
"""Base Pydantic Annotated marker that declares how a config field merges across layers. """Base Pydantic Annotated marker that declares how a config field merges across layers.

View file

@ -62,8 +62,8 @@ class TextOutputFormatter(OutputFormatter):
self._print("Syncing with remote...") self._print("Syncing with remote...")
case TeleportStartingWorkflowEvent(): case TeleportStartingWorkflowEvent():
self._print("Teleporting...") self._print("Teleporting...")
case TeleportWaitingForGitHubEvent(): case TeleportWaitingForGitHubEvent(message=msg):
self._print("Connecting to GitHub...") self._print(msg or "Connecting to GitHub...")
case TeleportAuthRequiredEvent(oauth_url=url, message=msg): case TeleportAuthRequiredEvent(oauth_url=url, message=msg):
self._print(msg or f"Open to authorize GitHub: {url}") self._print(msg or f"Open to authorize GitHub: {url}")
case TeleportAuthCompleteEvent(): case TeleportAuthCompleteEvent():

View file

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from collections.abc import AsyncGenerator
from enum import StrEnum, auto from enum import StrEnum, auto
import time import time
import types import types
@ -184,12 +185,13 @@ class NuageClient:
async def wait_for_github_connection( async def wait_for_github_connection(
self, execution_id: str, timeout: float = 600.0, interval: float = 2.0 self, execution_id: str, timeout: float = 600.0, interval: float = 2.0
) -> GitHubPublicData: ) -> AsyncGenerator[GitHubPublicData, None]:
deadline = time.monotonic() + timeout deadline = time.monotonic() + timeout
while time.monotonic() < deadline: while time.monotonic() < deadline:
github_data = await self.get_github_integration(execution_id) github_data = await self.get_github_integration(execution_id)
yield github_data
if github_data.connected: if github_data.connected:
return github_data return
if github_data.is_error: if github_data.is_error:
raise ServiceTeleportError( raise ServiceTeleportError(
github_data.error github_data.error

View file

@ -184,14 +184,21 @@ class TeleportService:
) )
yield TeleportWaitingForGitHubEvent() yield TeleportWaitingForGitHubEvent()
github_data = await self._nuage_client.get_github_integration(execution_id)
if not github_data.connected: auth_event_sent = False
if github_data.oauth_url: async for github_data in self._nuage_client.wait_for_github_connection(
execution_id
):
if github_data.connected:
break
if not auth_event_sent and github_data.oauth_url:
yield TeleportAuthRequiredEvent( yield TeleportAuthRequiredEvent(
oauth_url=github_data.oauth_url, message=github_data.error oauth_url=github_data.oauth_url, message=github_data.error
) )
await self._nuage_client.wait_for_github_connection(execution_id) auth_event_sent = True
if github_data.error:
yield TeleportWaitingForGitHubEvent(message=github_data.error)
yield TeleportAuthCompleteEvent() yield TeleportAuthCompleteEvent()
yield TeleportFetchingUrlEvent() yield TeleportFetchingUrlEvent()

View file

@ -34,7 +34,7 @@ class TeleportPushingEvent(BaseEvent):
class TeleportWaitingForGitHubEvent(BaseEvent): class TeleportWaitingForGitHubEvent(BaseEvent):
pass message: str | None = None
class TeleportFetchingUrlEvent(BaseEvent): class TeleportFetchingUrlEvent(BaseEvent):