diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index 7674bea..dc72f8d 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -18,7 +18,7 @@ body: - CLI - ACP - Both - - None + - Other validations: required: true diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml index 279c21d..42df0e6 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yml +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -18,7 +18,7 @@ body: - CLI - ACP - Both - - None + - Other validations: required: true diff --git a/.vscode/launch.json b/.vscode/launch.json index 162d0ef..5cbcde4 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,5 +1,5 @@ { - "version": "1.3.1", + "version": "1.3.2", "configurations": [ { "name": "ACP Server", diff --git a/CHANGELOG.md b/CHANGELOG.md index a1ed39b..46f1639 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.3.2] - 2025-12-24 + +### Added + +- User definable reasoning field + +### Fixed + +- Fix rendering issue with spinner + ## [1.3.1] - 2025-12-24 ### Fixed diff --git a/distribution/zed/extension.toml b/distribution/zed/extension.toml index 1cbd0f0..958eacc 100644 --- a/distribution/zed/extension.toml +++ b/distribution/zed/extension.toml @@ -1,7 +1,7 @@ id = "mistral-vibe" name = "Mistral Vibe" description = "Mistral's open-source coding assistant" -version = "1.3.1" +version = "1.3.2" schema_version = 1 authors = ["Mistral AI"] repository = "https://github.com/mistralai/mistral-vibe" @@ -11,25 +11,25 @@ name = "Mistral Vibe" icon = "./icons/mistral_vibe.svg" [agent_servers.mistral-vibe.targets.darwin-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.1/vibe-acp-darwin-aarch64-1.3.1.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.2/vibe-acp-darwin-aarch64-1.3.2.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.darwin-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.1/vibe-acp-darwin-x86_64-1.3.1.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.2/vibe-acp-darwin-x86_64-1.3.2.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.linux-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.1/vibe-acp-linux-aarch64-1.3.1.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.2/vibe-acp-linux-aarch64-1.3.2.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.linux-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.1/vibe-acp-linux-x86_64-1.3.1.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.2/vibe-acp-linux-x86_64-1.3.2.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.windows-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.1/vibe-acp-windows-aarch64-1.3.1.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.2/vibe-acp-windows-aarch64-1.3.2.zip" cmd = "./vibe-acp.exe" [agent_servers.mistral-vibe.targets.windows-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.1/vibe-acp-windows-x86_64-1.3.1.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.2/vibe-acp-windows-x86_64-1.3.2.zip" cmd = "./vibe-acp.exe" diff --git a/pyproject.toml b/pyproject.toml index 38b0499..9a09604 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mistral-vibe" -version = "1.3.1" +version = "1.3.2" description = "Minimal CLI coding agent by Mistral" readme = "README.md" requires-python = ">=3.12" diff --git a/tests/acp/test_initialize.py b/tests/acp/test_initialize.py index 2fd3b6b..34eb8a2 100644 --- a/tests/acp/test_initialize.py +++ b/tests/acp/test_initialize.py @@ -41,7 +41,7 @@ class TestACPInitialize: ), ) assert response.agentInfo == Implementation( - name="@mistralai/mistral-vibe", title="Mistral Vibe", version="1.3.1" + name="@mistralai/mistral-vibe", title="Mistral Vibe", version="1.3.2" ) assert response.authMethods == [] @@ -63,7 +63,7 @@ class TestACPInitialize: ), ) assert response.agentInfo == Implementation( - name="@mistralai/mistral-vibe", title="Mistral Vibe", version="1.3.1" + name="@mistralai/mistral-vibe", title="Mistral Vibe", version="1.3.2" ) assert response.authMethods is not None diff --git a/tests/test_reasoning_content.py b/tests/test_reasoning_content.py index 518d13c..697eb31 100644 --- a/tests/test_reasoning_content.py +++ b/tests/test_reasoning_content.py @@ -16,8 +16,8 @@ from vibe.core.config import ( SessionLoggingConfig, VibeConfig, ) -from vibe.core.llm.backend.generic import GenericBackend -from vibe.core.llm.backend.mistral import MistralMapper, ParsedContent +from vibe.core.llm.backend.generic import GenericBackend, OpenAIAdapter +from vibe.core.llm.backend.mistral import MistralBackend, MistralMapper, ParsedContent from vibe.core.llm.format import APIToolFormatHandler from vibe.core.types import AssistantEvent, LLMMessage, ReasoningEvent, Role @@ -358,3 +358,175 @@ class TestLLMMessageReasoningContent: dumped = msg.model_dump(exclude_none=True) assert "reasoning_content" not in dumped + + +class TestReasoningFieldNameConversion: + def test_reasoning_to_api_keeps_default_field(self): + adapter = OpenAIAdapter() + msg_dict = { + "role": "assistant", + "content": "Answer", + "reasoning_content": "Thinking...", + } + + result = adapter._reasoning_to_api(msg_dict, "reasoning_content") + + assert result["reasoning_content"] == "Thinking..." + assert "reasoning" not in result + + def test_reasoning_to_api_renames_to_custom_field(self): + adapter = OpenAIAdapter() + msg_dict = { + "role": "assistant", + "content": "Answer", + "reasoning_content": "Thinking...", + } + + result = adapter._reasoning_to_api(msg_dict, "reasoning") + + assert result["reasoning"] == "Thinking..." + assert "reasoning_content" not in result + + def test_reasoning_from_api_converts_custom_field(self): + adapter = OpenAIAdapter() + msg_dict = { + "role": "assistant", + "content": "Answer", + "reasoning": "Thinking...", + } + + result = adapter._reasoning_from_api(msg_dict, "reasoning") + + assert result["reasoning_content"] == "Thinking..." + assert "reasoning" not in result + + def test_reasoning_from_api_keeps_default_field(self): + adapter = OpenAIAdapter() + msg_dict = { + "role": "assistant", + "content": "Answer", + "reasoning_content": "Thinking...", + } + + result = adapter._reasoning_from_api(msg_dict, "reasoning_content") + + assert result["reasoning_content"] == "Thinking..." + + @pytest.mark.asyncio + async def test_complete_with_custom_reasoning_field_name(self): + base_url = "https://api.example.com" + json_response = { + "id": "fake_id", + "created": 1234567890, + "model": "test-model", + "usage": {"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30}, + "object": "chat.completion", + "choices": [ + { + "index": 0, + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": "The answer is 42.", + "reasoning": "Let me think step by step...", + }, + } + ], + } + + with respx.mock(base_url=base_url) as mock_api: + mock_api.post("/v1/chat/completions").mock( + return_value=httpx.Response(status_code=200, json=json_response) + ) + provider = ProviderConfig( + name="test", + api_base=f"{base_url}/v1", + api_key_env_var="API_KEY", + reasoning_field_name="reasoning", + ) + backend = GenericBackend(provider=provider) + model = ModelConfig(name="test-model", provider="test", alias="test") + messages = [LLMMessage(role=Role.user, content="What is the answer?")] + + result = await backend.complete( + model=model, + messages=messages, + temperature=0.2, + tools=None, + max_tokens=None, + tool_choice=None, + extra_headers=None, + ) + + assert result.message.content == "The answer is 42." + assert result.message.reasoning_content == "Let me think step by step..." + + @pytest.mark.asyncio + async def test_streaming_with_custom_reasoning_field_name(self): + base_url = "https://api.example.com" + chunks = [ + b'data: {"id":"id1","object":"chat.completion.chunk","created":123,"model":"test","choices":[{"index":0,"delta":{"role":"assistant","reasoning":"Thinking..."},"finish_reason":null}]}', + b'data: {"id":"id1","object":"chat.completion.chunk","created":123,"model":"test","choices":[{"index":0,"delta":{"content":"Answer"},"finish_reason":null}]}', + b'data: {"id":"id1","object":"chat.completion.chunk","created":123,"model":"test","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":5}}', + b"data: [DONE]", + ] + + with respx.mock(base_url=base_url) as mock_api: + mock_api.post("/v1/chat/completions").mock( + return_value=httpx.Response( + status_code=200, + stream=httpx.ByteStream(stream=b"\n\n".join(chunks)), + headers={"Content-Type": "text/event-stream"}, + ) + ) + provider = ProviderConfig( + name="test", + api_base=f"{base_url}/v1", + api_key_env_var="API_KEY", + reasoning_field_name="reasoning", + ) + backend = GenericBackend(provider=provider) + model = ModelConfig(name="test-model", provider="test", alias="test") + messages = [LLMMessage(role=Role.user, content="Stream please")] + + results = [] + async for chunk in backend.complete_streaming( + model=model, + messages=messages, + temperature=0.2, + tools=None, + max_tokens=None, + tool_choice=None, + extra_headers=None, + ): + results.append(chunk) + + assert results[0].message.reasoning_content == "Thinking..." + assert results[1].message.content == "Answer" + + +class TestMistralReasoningFieldNameValidation: + def test_mistral_backend_rejects_custom_reasoning_field_name(self): + provider = ProviderConfig( + name="mistral", + api_base="https://api.mistral.ai/v1", + api_key_env_var="MISTRAL_API_KEY", + reasoning_field_name="reasoning", + ) + + with pytest.raises(ValueError) as exc_info: + MistralBackend(provider=provider) + + assert "does not support custom reasoning_field_name" in str(exc_info.value) + assert "reasoning" in str(exc_info.value) + + def test_mistral_backend_accepts_default_reasoning_field_name(self): + provider = ProviderConfig( + name="mistral", + api_base="https://api.mistral.ai/v1", + api_key_env_var="MISTRAL_API_KEY", + reasoning_field_name="reasoning_content", + ) + + backend = MistralBackend(provider=provider) + assert backend is not None diff --git a/uv.lock b/uv.lock index 571a87e..cc2c935 100644 --- a/uv.lock +++ b/uv.lock @@ -661,7 +661,7 @@ wheels = [ [[package]] name = "mistral-vibe" -version = "1.3.1" +version = "1.3.2" source = { editable = "." } dependencies = [ { name = "agent-client-protocol" }, diff --git a/vibe/__init__.py b/vibe/__init__.py index f972a53..7b76d30 100644 --- a/vibe/__init__.py +++ b/vibe/__init__.py @@ -3,4 +3,4 @@ from __future__ import annotations from pathlib import Path VIBE_ROOT = Path(__file__).parent -__version__ = "1.3.1" +__version__ = "1.3.2" diff --git a/vibe/cli/textual_ui/widgets/loading.py b/vibe/cli/textual_ui/widgets/loading.py index e842eea..8b72cfa 100644 --- a/vibe/cli/textual_ui/widgets/loading.py +++ b/vibe/cli/textual_ui/widgets/loading.py @@ -9,11 +9,12 @@ from textual.app import ComposeResult from textual.containers import Horizontal from textual.widgets import Static -from vibe.cli.textual_ui.widgets.spinner import BrailleSpinner +from vibe.cli.textual_ui.widgets.spinner import SpinnerMixin, SpinnerType -class LoadingWidget(Static): +class LoadingWidget(SpinnerMixin, Static): TARGET_COLORS = ("#FFD800", "#FFAF00", "#FF8205", "#FA500F", "#E10500") + SPINNER_TYPE = SpinnerType.BRAILLE EASTER_EGGS: ClassVar[list[str]] = [ "Eating a chocolatine", @@ -50,12 +51,11 @@ class LoadingWidget(Static): def __init__(self, status: str | None = None) -> None: super().__init__(classes="loading-widget") + self.init_spinner() self.status = status or self._get_default_status() self.current_color_index = 0 self.transition_progress = 0 - self._spinner = BrailleSpinner() self.char_widgets: list[Static] = [] - self.spinner_widget: Static | None = None self.ellipsis_widget: Static | None = None self.hint_widget: Static | None = None self.start_time: float | None = None @@ -89,10 +89,10 @@ class LoadingWidget(Static): def compose(self) -> ComposeResult: with Horizontal(classes="loading-container"): - self.spinner_widget = Static( + self._indicator_widget = Static( self._spinner.current_frame(), classes="loading-indicator" ) - yield self.spinner_widget + yield self._indicator_widget with Horizontal(classes="loading-status"): for char in self.status: @@ -120,12 +120,20 @@ class LoadingWidget(Static): self.char_widgets.append(widget) status_container.mount(widget) - self.update_animation() + self._update_animation() def on_mount(self) -> None: self.start_time = time() - self.update_animation() - self.set_interval(0.1, self.update_animation) + self._update_animation() + self.start_spinner_timer() + + def on_resize(self) -> None: + self.refresh_spinner() + + def _update_spinner_frame(self) -> None: + if not self._is_spinning: + return + self._update_animation() def _get_color_for_position(self, position: int) -> str: current_color = self.TARGET_COLORS[self.current_color_index] @@ -136,13 +144,13 @@ class LoadingWidget(Static): return next_color return current_color - def update_animation(self) -> None: + def _update_animation(self) -> None: total_elements = 1 + len(self.char_widgets) + 2 - if self.spinner_widget: + if self._indicator_widget: spinner_char = self._spinner.next_frame() color = self._get_color_for_position(0) - self.spinner_widget.update(f"[{color}]{spinner_char}[/]") + self._indicator_widget.update(f"[{color}]{spinner_char}[/]") for i, widget in enumerate(self.char_widgets): position = 1 + i diff --git a/vibe/cli/textual_ui/widgets/messages.py b/vibe/cli/textual_ui/widgets/messages.py index 332ae5d..b3e14ba 100644 --- a/vibe/cli/textual_ui/widgets/messages.py +++ b/vibe/cli/textual_ui/widgets/messages.py @@ -153,6 +153,9 @@ class ReasoningMessage(SpinnerMixin, StreamingMessageBase): def on_mount(self) -> None: self.start_spinner_timer() + def on_resize(self) -> None: + self.refresh_spinner() + async def on_click(self) -> None: await self._toggle_collapsed() diff --git a/vibe/cli/textual_ui/widgets/spinner.py b/vibe/cli/textual_ui/widgets/spinner.py index a11f8b4..e14c51c 100644 --- a/vibe/cli/textual_ui/widgets/spinner.py +++ b/vibe/cli/textual_ui/widgets/spinner.py @@ -129,6 +129,10 @@ class SpinnerMixin: return self._indicator_widget.update(self._spinner.next_frame()) + def refresh_spinner(self) -> None: + if self._indicator_widget: + self._indicator_widget.refresh() + def stop_spinning(self, success: bool = True) -> None: self._is_spinning = False if self._spinner_timer: diff --git a/vibe/cli/textual_ui/widgets/status_message.py b/vibe/cli/textual_ui/widgets/status_message.py index 1d1fc83..32592ed 100644 --- a/vibe/cli/textual_ui/widgets/status_message.py +++ b/vibe/cli/textual_ui/widgets/status_message.py @@ -38,6 +38,9 @@ class StatusMessage(SpinnerMixin, Static): self.update_display() self.start_spinner_timer() + def on_resize(self) -> None: + self.refresh_spinner() + def _update_spinner_frame(self) -> None: if not self._is_spinning: return diff --git a/vibe/core/config.py b/vibe/core/config.py index a5546df..24238f7 100644 --- a/vibe/core/config.py +++ b/vibe/core/config.py @@ -131,6 +131,7 @@ class ProviderConfig(BaseModel): api_key_env_var: str = "" api_style: str = "openai" backend: Backend = Backend.GENERIC + reasoning_field_name: str = "reasoning_content" class _MCPBase(BaseModel): diff --git a/vibe/core/llm/backend/generic.py b/vibe/core/llm/backend/generic.py index ddfc476..6ecee93 100644 --- a/vibe/core/llm/backend/generic.py +++ b/vibe/core/llm/backend/generic.py @@ -46,7 +46,9 @@ class APIAdapter(Protocol): api_key: str | None = None, ) -> PreparedRequest: ... - def parse_response(self, data: dict[str, Any]) -> LLMChunk: ... + def parse_response( + self, data: dict[str, Any], provider: ProviderConfig + ) -> LLMChunk: ... BACKEND_ADAPTERS: dict[str, APIAdapter] = {} @@ -103,6 +105,20 @@ class OpenAIAdapter(APIAdapter): headers["Authorization"] = f"Bearer {api_key}" return headers + def _reasoning_to_api( + self, msg_dict: dict[str, Any], field_name: str + ) -> dict[str, Any]: + if field_name != "reasoning_content" and "reasoning_content" in msg_dict: + msg_dict[field_name] = msg_dict.pop("reasoning_content") + return msg_dict + + def _reasoning_from_api( + self, msg_dict: dict[str, Any], field_name: str + ) -> dict[str, Any]: + if field_name != "reasoning_content" and field_name in msg_dict: + msg_dict["reasoning_content"] = msg_dict.pop(field_name) + return msg_dict + def prepare_request( self, *, @@ -116,7 +132,11 @@ class OpenAIAdapter(APIAdapter): provider: ProviderConfig, api_key: str | None = None, ) -> PreparedRequest: - converted_messages = [msg.model_dump(exclude_none=True) for msg in messages] + field_name = provider.reasoning_field_name + converted_messages = [ + self._reasoning_to_api(msg.model_dump(exclude_none=True), field_name) + for msg in messages + ] payload = self.build_payload( model_name, converted_messages, temperature, tools, max_tokens, tool_choice @@ -130,25 +150,37 @@ class OpenAIAdapter(APIAdapter): payload["stream_options"] = stream_options headers = self.build_headers(api_key) - body = json.dumps(payload).encode("utf-8") return PreparedRequest(self.endpoint, headers, body) - def parse_response(self, data: dict[str, Any]) -> LLMChunk: + def _parse_message( + self, data: dict[str, Any], field_name: str + ) -> LLMMessage | None: if data.get("choices"): - if "message" in data["choices"][0]: - message = LLMMessage.model_validate(data["choices"][0]["message"]) - elif "delta" in data["choices"][0]: - message = LLMMessage.model_validate(data["choices"][0]["delta"]) - else: - raise ValueError("Invalid response data") + choice = data["choices"][0] + if "message" in choice: + msg_dict = self._reasoning_from_api(choice["message"], field_name) + return LLMMessage.model_validate(msg_dict) + if "delta" in choice: + msg_dict = self._reasoning_from_api(choice["delta"], field_name) + return LLMMessage.model_validate(msg_dict) + raise ValueError("Invalid response data: missing message or delta") - elif "message" in data: - message = LLMMessage.model_validate(data["message"]) - elif "delta" in data: - message = LLMMessage.model_validate(data["delta"]) - else: + if "message" in data: + msg_dict = self._reasoning_from_api(data["message"], field_name) + return LLMMessage.model_validate(msg_dict) + if "delta" in data: + msg_dict = self._reasoning_from_api(data["delta"], field_name) + return LLMMessage.model_validate(msg_dict) + + return None + + def parse_response( + self, data: dict[str, Any], provider: ProviderConfig + ) -> LLMChunk: + message = self._parse_message(data, provider.reasoning_field_name) + if message is None: message = LLMMessage(role=Role.assistant, content="") usage_data = data.get("usage") or {} @@ -244,7 +276,7 @@ class GenericBackend: try: res_data, _ = await self._make_request(url, body, headers) - return adapter.parse_response(res_data) + return adapter.parse_response(res_data, self._provider) except httpx.HTTPStatusError as e: raise BackendErrorBuilder.build_http_error( @@ -309,7 +341,7 @@ class GenericBackend: try: async for res_data in self._make_streaming_request(url, body, headers): - yield adapter.parse_response(res_data) + yield adapter.parse_response(res_data, self._provider) except httpx.HTTPStatusError as e: raise BackendErrorBuilder.build_http_error( diff --git a/vibe/core/llm/backend/mistral.py b/vibe/core/llm/backend/mistral.py index f9901cc..c2c16e3 100644 --- a/vibe/core/llm/backend/mistral.py +++ b/vibe/core/llm/backend/mistral.py @@ -160,6 +160,13 @@ class MistralBackend: else None ) + reasoning_field = getattr(provider, "reasoning_field_name", "reasoning_content") + if reasoning_field != "reasoning_content": + raise ValueError( + f"Mistral backend does not support custom reasoning_field_name " + f"(got '{reasoning_field}'). Mistral uses ThinkChunk for reasoning." + ) + # Mistral SDK takes server URL without api version as input url_pattern = r"(https?://[^/]+)(/v.*)" match = re.match(url_pattern, self._provider.api_base) diff --git a/vibe/core/types.py b/vibe/core/types.py index 7d2db19..4b372e5 100644 --- a/vibe/core/types.py +++ b/vibe/core/types.py @@ -179,15 +179,11 @@ class LLMMessage(BaseModel): if isinstance(v, dict): v.setdefault("content", "") v.setdefault("role", "assistant") - v.setdefault( - "reasoning_content", v.get("reasoning_content") or v.get("reasoning") - ) return v return { "role": str(getattr(v, "role", "assistant")), "content": getattr(v, "content", ""), - "reasoning_content": getattr(v, "reasoning_content", None) - or getattr(v, "reasoning", None), + "reasoning_content": getattr(v, "reasoning_content", None), "tool_calls": getattr(v, "tool_calls", None), "name": getattr(v, "name", None), "tool_call_id": getattr(v, "tool_call_id", None),