diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b786cf3..89d78f6 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -15,6 +15,7 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
+
steps:
- name: Checkout repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
@@ -54,6 +55,7 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
+
steps:
- name: Checkout repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
@@ -89,6 +91,7 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
+
steps:
- name: Checkout repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
diff --git a/.vscode/launch.json b/.vscode/launch.json
index 219f9fd..155baf9 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -1,5 +1,5 @@
{
- "version": "1.3.5",
+ "version": "2.0.0",
"configurations": [
{
"name": "ACP Server",
@@ -15,7 +15,10 @@
"type": "debugpy",
"request": "launch",
"module": "pytest",
- "args": ["-v", "-s"],
+ "args": [
+ "-v",
+ "-s"
+ ],
"console": "integratedTerminal",
"justMyCode": false,
"cwd": "${workspaceFolder}",
@@ -28,7 +31,13 @@
"type": "debugpy",
"request": "launch",
"module": "pytest",
- "args": ["-k", "${input:test_identifier}", "-vvv", "-s", "--no-header"],
+ "args": [
+ "-k",
+ "${input:test_identifier}",
+ "-vvv",
+ "-s",
+ "--no-header"
+ ],
"console": "integratedTerminal",
"justMyCode": false,
"cwd": "${workspaceFolder}",
@@ -53,6 +62,16 @@
"request": "attach",
"processId": "${command:pickProcess}",
"justMyCode": true
+ },
+ {
+ "name": "Attach to Zed Process (Port)",
+ "type": "debugpy",
+ "request": "attach",
+ "connect": {
+ "host": "localhost",
+ "port": 5678
+ },
+ "justMyCode": true
}
],
"inputs": [
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fafa1eb..7fd2d82 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,52 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [2.0.0] - 2026-01-27
+
+### Added
+
+- Subagent support
+- AskUserQuestion tool for interactive user input
+- User-defined slash commands through skills
+- What's new message display on version update
+- Auto-update feature
+- Environment variables and timeout support for MCP servers
+- Editor shortcut support
+- Shift+enter support for VS Code Insiders
+- Message ID property for messages
+- Client notification of compaction events
+- debugpy support for macOS debugging
+
+### Changed
+
+- Mode system refactored to Agents
+- Standardized managers
+- Improved system prompt
+- Updated session storage to separate metadata from messages
+- Use shell environment to determine shell in bash tool
+- Expanded user input handling
+- Bumped agent-client-protocol to 0.7.1
+- Refactored UI to require AgentLoop at VibeApp construction
+- Updated README with new MCP server config
+- Improved readability of the AskUserQuerstion tool output
+
+### Fixed
+
+- Use ensure_ascii=False for all JSON dumps
+- Delete long-living temporary session files
+- Ignore system prompt when saving/loading session messages
+- Bash tool timeout handling
+- Clipboard: no markup parsing of selected texts
+- Canonical imports
+- Remove last user message from compaction
+- Pause tool timer while awaiting user action
+
+### Removed
+
+- instructions.md support
+- workdir setting in config file
+
+
## [1.3.5] - 2026-01-12
### Fixed
diff --git a/README.md b/README.md
index 318220c..6967706 100644
--- a/README.md
+++ b/README.md
@@ -53,6 +53,39 @@ uv tool install mistral-vibe
pip install mistral-vibe
```
+## Table of Contents
+
+- [Features](#features)
+ - [Built-in Agents](#built-in-agents)
+ - [Subagents and Task Delegation](#subagents-and-task-delegation)
+ - [Interactive User Questions](#interactive-user-questions)
+- [Terminal Requirements](#terminal-requirements)
+- [Quick Start](#quick-start)
+- [Usage](#usage)
+ - [Interactive Mode](#interactive-mode)
+ - [Trust Folder System](#trust-folder-system)
+ - [Programmatic Mode](#programmatic-mode)
+- [Slash Commands](#slash-commands)
+ - [Built-in Slash Commands](#built-in-slash-commands)
+ - [Custom Slash Commands via Skills](#custom-slash-commands-via-skills)
+- [Skills System](#skills-system)
+ - [Creating Skills](#creating-skills)
+ - [Skill Discovery](#skill-discovery)
+ - [Managing Skills](#managing-skills)
+- [Configuration](#configuration)
+ - [Configuration File Location](#configuration-file-location)
+ - [API Key Configuration](#api-key-configuration)
+ - [Custom System Prompts](#custom-system-prompts)
+ - [Custom Agent Configurations](#custom-agent-configurations)
+ - [Tool Management](#tool-management)
+ - [MCP Server Configuration](#mcp-server-configuration)
+ - [Session Management](#session-management)
+ - [Update Settings](#update-settings)
+ - [Custom Vibe Home Directory](#custom-vibe-home-directory)
+- [Editors/IDEs](#editorsides)
+- [Resources](#resources)
+- [License](#license)
+
## Features
- **Interactive Chat**: A conversational AI agent that understands your requests and breaks down complex tasks.
@@ -61,6 +94,8 @@ pip install mistral-vibe
- Execute shell commands in a stateful terminal (`bash`).
- Recursively search code with `grep` (with `ripgrep` support).
- Manage a `todo` list to track the agent's work.
+ - Ask interactive questions to gather user input (`ask_user_question`).
+ - Delegate tasks to subagents for parallel work (`task`).
- **Project-Aware Context**: Vibe automatically scans your project's file structure and Git status to provide relevant context to the agent, improving its understanding of your codebase.
- **Advanced CLI Experience**: Built with modern libraries for a smooth and efficient workflow.
- Autocompletion for slash commands (`/`) and file paths (`@`).
@@ -68,6 +103,70 @@ pip install mistral-vibe
- Beautiful Themes.
- **Highly Configurable**: Customize models, providers, tool permissions, and UI preferences through a simple `config.toml` file.
- **Safety First**: Features tool execution approval.
+- **Multiple Built-in Agents**: Choose from different agent profiles tailored for specific workflows.
+
+### Built-in Agents
+
+Vibe comes with several built-in agent profiles, each designed for different use cases:
+
+- **`default`**: Standard agent that requires approval for tool executions. Best for general use.
+- **`plan`**: Read-only agent for exploration and planning. Auto-approves safe tools like `grep` and `read_file`.
+- **`accept-edits`**: Auto-approves file edits only (`write_file`, `search_replace`). Useful for code refactoring.
+- **`auto-approve`**: Auto-approves all tool executions. Use with caution.
+
+Use the `--agent` flag to select a different agent:
+
+```bash
+vibe --agent plan
+```
+
+### Subagents and Task Delegation
+
+Vibe supports subagents for delegating tasks. Subagents run independently and can perform specialized work without user interaction, preventing the context from being overloaded.
+
+The `task` tool allows the agent to delegate work to subagents:
+
+```
+> Can you explore the codebase structure while I work on something else?
+
+🤖 I'll use the task tool to delegate this to the explore subagent.
+
+> task(task="Analyze the project structure and architecture", agent="explore")
+```
+
+Create custom subagents by adding `agent_type = "subagent"` to your agent configuration. Vibe comes with a built-in subagent called `explore`, a read-only subagent for codebase exploration used internally for delegation.
+
+### Interactive User Questions
+
+The `ask_user_question` tool allows the agent to ask you clarifying questions during its work. This enables more interactive and collaborative workflows.
+
+```
+> Can you help me refactor this function?
+
+🤖 I need to understand your requirements better before proceeding.
+
+> ask_user_question(questions=[{
+ "question": "What's the main goal of this refactoring?",
+ "options": [
+ {"label": "Performance", "description": "Make it run faster"},
+ {"label": "Readability", "description": "Make it easier to understand"},
+ {"label": "Maintainability", "description": "Make it easier to modify"}
+ ]
+}])
+```
+
+The agent can ask multiple questions at once, displayed as tabs. Each question supports 2-4 options plus an automatic "Other" option for free text responses.
+
+## Terminal Requirements
+
+Vibe's interactive interface requires a modern terminal emulator. Recommended terminal emulators include:
+
+- **WezTerm** (cross-platform)
+- **Alacritty** (cross-platform)
+- **Ghostty** (Linux and macOS)
+- **Kitty** (Linux and macOS)
+
+Most modern terminals should work, but older or minimal terminal emulators may have display issues.
## Quick Start
@@ -89,6 +188,8 @@ pip install mistral-vibe
- Prompt you to enter your API key if it's not already configured
- Save your API key to `~/.vibe/.env` for future use
+ Alternatively, you can configure your API key separately using `vibe --setup`.
+
4. Start interacting with the agent!
```
@@ -112,8 +213,12 @@ Simply run `vibe` to enter the interactive chat loop.
- **Multi-line Input**: Press `Ctrl+J` or `Shift+Enter` for select terminals to insert a newline.
- **File Paths**: Reference files in your prompt using the `@` symbol for smart autocompletion (e.g., `> Read the file @src/agent.py`).
- **Shell Commands**: Prefix any command with `!` to execute it directly in your shell, bypassing the agent (e.g., `> !ls -l`).
+- **External Editor**: Press `Ctrl+G` to edit your current input in an external editor.
+- **Tool Output Toggle**: Press `Ctrl+O` to toggle the tool output view.
+- **Todo View Toggle**: Press `Ctrl+T` to toggle the todo list view.
+- **Auto-Approve Toggle**: Press `Shift+Tab` to toggle auto-approve mode on/off.
-You can start Vibe with a prompt with the following command:
+You can start Vibe with a prompt using the following command:
```bash
vibe "Refactor the main function in cli/main.py to be more modular."
@@ -121,6 +226,14 @@ vibe "Refactor the main function in cli/main.py to be more modular."
**Note**: The `--auto-approve` flag automatically approves all tool executions without prompting. In interactive mode, you can also toggle auto-approve on/off using `Shift+Tab`.
+### Trust Folder System
+
+Vibe includes a trust folder system to ensure you only run the agent in directories you trust. When you first run Vibe in a new directory which contains a `.vibe` subfolder, it may ask you to confirm whether you trust the folder.
+
+Trusted folders are remembered for future sessions. You can manage trusted folders through its configuration file `~/.vibe/trusted_folders.toml`.
+
+This safety feature helps prevent accidental execution in sensitive directories.
+
### Programmatic Mode
You can run Vibe non-interactively by piping input or using the `--prompt` flag. This is useful for scripting.
@@ -129,18 +242,126 @@ You can run Vibe non-interactively by piping input or using the `--prompt` flag.
vibe --prompt "Refactor the main function in cli/main.py to be more modular."
```
-by default it will use `auto-approve` mode.
+By default, it uses `auto-approve` mode.
-### Slash Commands
+#### Programmatic Mode Options
+
+When using `--prompt`, you can specify additional options:
+
+- **`--max-turns N`**: Limit the maximum number of assistant turns. The session will stop after N turns.
+- **`--max-price DOLLARS`**: Set a maximum cost limit in dollars. The session will be interrupted if the cost exceeds this limit.
+- **`--enabled-tools TOOL`**: Enable specific tools. In programmatic mode, this disables all other tools. Can be specified multiple times. Supports exact names, glob patterns (e.g., `bash*`), or regex with `re:` prefix (e.g., `re:^serena_.*$`).
+- **`--output FORMAT`**: Set the output format. Options:
+ - `text` (default): Human-readable text output
+ - `json`: All messages as JSON at the end
+ - `streaming`: Newline-delimited JSON per message
+
+Example:
+
+```bash
+vibe --prompt "Analyze the codebase" --max-turns 5 --max-price 1.0 --output json
+```
+
+## Slash Commands
Use slash commands for meta-actions and configuration changes during a session.
+### Built-in Slash Commands
+
+Vibe provides several built-in slash commands. Use slash commands by typing them in the input box:
+
+```
+> /help
+```
+
+### Custom Slash Commands via Skills
+
+You can define your own slash commands through the skills system. Skills are reusable components that extend Vibe's functionality.
+
+To create a custom slash command:
+
+1. Create a skill directory with a `SKILL.md` file
+2. Set `user-invocable = true` in the skill metadata
+3. Define the command logic in your skill
+
+Example skill metadata:
+
+```markdown
+---
+name: my-skill
+description: My custom skill with slash commands
+user-invocable: true
+---
+```
+
+Custom slash commands appear in the autocompletion menu alongside built-in commands.
+
+## Skills System
+
+Vibe's skills system allows you to extend functionality through reusable components. Skills can add new tools, slash commands, and specialized behaviors.
+
+Vibe follows the [Agent Skills specification](https://agentskills.io/specification) for skill format and structure.
+
+### Creating Skills
+
+Skills are defined in directories with a `SKILL.md` file containing metadata in YAML frontmatter. For example, `~/.vibe/skills/code-review/SKILL.md`:
+
+```markdown
+---
+name: code-review
+description: Perform automated code reviews
+license: MIT
+compatibility: Python 3.12+
+user-invocable: true
+allowed-tools:
+ - read_file
+ - grep
+ - ask_user_question
+---
+
+# Code Review Skill
+
+This skill helps analyze code quality and suggest improvements.
+```
+
+### Skill Discovery
+
+Vibe discovers skills from multiple locations:
+
+1. **Global skills directory**: `~/.vibe/skills/`
+2. **Local project skills**: `.vibe/skills/` in your project
+3. **Custom paths**: Configured in `config.toml`
+
+```toml
+skill_paths = ["/path/to/custom/skills"]
+```
+
+### Managing Skills
+
+Enable or disable skills using patterns in your configuration:
+
+```toml
+# Enable specific skills
+enabled_skills = ["code-review", "test-*"]
+
+# Disable specific skills
+disabled_skills = ["experimental-*"]
+```
+
+Skills support the same pattern matching as tools (exact names, glob patterns, and regex).
+
## Configuration
+### Configuration File Location
+
Vibe is configured via a `config.toml` file. It looks for this file first in `./.vibe/config.toml` and then falls back to `~/.vibe/config.toml`.
### API Key Configuration
+To use Vibe, you'll need a Mistral API key. You can obtain one by signing up at [https://console.mistral.ai](https://console.mistral.ai).
+
+You can configure your API key using `vibe --setup`, or through one of the methods below.
+
Vibe supports multiple ways to configure your API keys:
1. **Interactive Setup (Recommended for first-time users)**: When you run Vibe for the first time or if your API key is missing, Vibe will prompt you to enter it. The key will be securely saved to `~/.vibe/.env` for future sessions.
@@ -204,7 +425,32 @@ permission = "always"
permission = "always"
```
-Note: this implies that you have setup a redteam prompt names `~/.vibe/prompts/redteam.md`
+Note: This implies that you have set up a redteam prompt named `~/.vibe/prompts/redteam.md`.
+
+### Tool Management
+
+#### Enable/Disable Tools with Patterns
+
+You can control which tools are active using `enabled_tools` and `disabled_tools`.
+These fields support exact names, glob patterns, and regular expressions.
+
+Examples:
+
+```toml
+# Only enable tools that start with "serena_" (glob)
+enabled_tools = ["serena_*"]
+
+# Regex (prefix with re:) — matches full tool name (case-insensitive)
+enabled_tools = ["re:^serena_.*$"]
+
+# Disable a group with glob; everything else stays enabled
+disabled_tools = ["mcp_*", "grep"]
+```
+
+Notes:
+
+- MCP tool names use underscores, e.g., `serena_list` not `serena.list`.
+- Regex patterns are matched against the full tool name using fullmatch.
### MCP Server Configuration
@@ -232,6 +478,7 @@ name = "fetch_server"
transport = "stdio"
command = "uvx"
args = ["mcp-server-fetch"]
+env = { "DEBUG" = "1", "LOG_LEVEL" = "info" }
```
Supported transports:
@@ -249,6 +496,9 @@ Key fields:
- `api_key_env`: Environment variable containing the API key
- `command`: Command to run for stdio transport
- `args`: Additional arguments for stdio transport
+- `startup_timeout_sec`: Timeout in seconds for the server to start and initialize (default 10s)
+- `tool_timeout_sec`: Timeout in seconds for tool execution (default 60s)
+- `env`: Environment variables to set for the MCP server of transport type stdio
MCP tools are named using the pattern `{server_name}_{tool_name}` and can be configured with permissions like built-in tools:
@@ -261,31 +511,63 @@ permission = "always"
permission = "ask"
```
-### Enable/disable tools with patterns
+MCP server configurations support additional features:
-You can control which tools are active using `enabled_tools` and `disabled_tools`.
-These fields support exact names, glob patterns, and regular expressions.
+- **Environment variables**: Set environment variables for MCP servers
+- **Custom timeouts**: Configure startup and tool execution timeouts
-Examples:
+Example with environment variables and timeouts:
```toml
-# Only enable tools that start with "serena_" (glob)
-enabled_tools = ["serena_*"]
-
-# Regex (prefix with re:) — matches full tool name (case-insensitive)
-enabled_tools = ["re:^serena_.*$"]
-
-# Heuristic regex support (patterns like `serena.*` are treated as regex)
-enabled_tools = ["serena.*"]
-
-# Disable a group with glob; everything else stays enabled
-disabled_tools = ["mcp_*", "grep"]
+[[mcp_servers]]
+name = "my_server"
+transport = "http"
+url = "http://localhost:8000"
+env = { "DEBUG" = "1", "LOG_LEVEL" = "info" }
+startup_timeout_sec = 15
+tool_timeout_sec = 120
```
-Notes:
+### Session Management
-- MCP tool names use underscores, e.g., `serena_list` not `serena.list`.
-- Regex patterns are matched against the full tool name using fullmatch.
+#### Session Continuation and Resumption
+
+Vibe supports continuing from previous sessions:
+
+- **`--continue`** or **`-c`**: Continue from the most recent saved session
+- **`--resume SESSION_ID`**: Resume a specific session by ID (supports partial matching)
+
+```bash
+# Continue from last session
+vibe --continue
+
+# Resume specific session
+vibe --resume abc123
+```
+
+Session logging must be enabled in your configuration for these features to work.
+
+#### Working Directory Control
+
+Use the `--workdir` option to specify a working directory:
+
+```bash
+vibe --workdir /path/to/project
+```
+
+This is useful when you want to run Vibe from a different location than your current directory.
+
+### Update Settings
+
+#### Auto-Update
+
+Vibe includes an automatic update feature that keeps your installation current. This is enabled by default.
+
+To disable auto-updates, add this to your `config.toml`:
+
+```toml
+enable_auto_update = false
+```
### Custom Vibe Home Directory
@@ -302,7 +584,7 @@ This affects where Vibe looks for:
- `agents/` - Custom agent configurations
- `prompts/` - Custom system prompts
- `tools/` - Custom tools
-- `logs/` - Session logsRetryTo run code, enable code execution and file creation in Settings > Capabilities.
+- `logs/` - Session logs
## Editors/IDEs
diff --git a/distribution/zed/extension.toml b/distribution/zed/extension.toml
index b76f83f..32a2823 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.5"
+version = "2.0.0"
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.5/vibe-acp-darwin-aarch64-1.3.5.zip"
+archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.0.0/vibe-acp-darwin-aarch64-2.0.0.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.darwin-x86_64]
-archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.5/vibe-acp-darwin-x86_64-1.3.5.zip"
+archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.0.0/vibe-acp-darwin-x86_64-2.0.0.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.linux-aarch64]
-archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.5/vibe-acp-linux-aarch64-1.3.5.zip"
+archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.0.0/vibe-acp-linux-aarch64-2.0.0.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.linux-x86_64]
-archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.5/vibe-acp-linux-x86_64-1.3.5.zip"
+archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.0.0/vibe-acp-linux-x86_64-2.0.0.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.windows-aarch64]
-archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.5/vibe-acp-windows-aarch64-1.3.5.zip"
+archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.0.0/vibe-acp-windows-aarch64-2.0.0.zip"
cmd = "./vibe-acp.exe"
[agent_servers.mistral-vibe.targets.windows-x86_64]
-archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.5/vibe-acp-windows-x86_64-1.3.5.zip"
+archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.0.0/vibe-acp-windows-x86_64-2.0.0.zip"
cmd = "./vibe-acp.exe"
diff --git a/pyproject.toml b/pyproject.toml
index d2b5af3..03d4e17 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "mistral-vibe"
-version = "1.3.5"
+version = "2.0.0"
description = "Minimal CLI coding agent by Mistral"
readme = "README.md"
requires-python = ">=3.12"
@@ -27,8 +27,8 @@ classifiers = [
"Topic :: Utilities",
]
dependencies = [
- "agent-client-protocol==0.6.3",
- "aiofiles>=24.1.0",
+ "agent-client-protocol==0.7.1",
+ "anyio>=4.12.0",
"httpx>=0.28.1",
"mcp>=1.14.0",
"mistralai==1.9.11",
@@ -90,6 +90,7 @@ dev = [
"typos>=1.34.0",
"vulture>=2.14",
"pytest-xdist>=3.8.0",
+ "debugpy>=1.8.19",
]
build = ["pyinstaller>=6.17.0"]
diff --git a/scripts/bump_version.py b/scripts/bump_version.py
index 71b873d..de79c2b 100755
--- a/scripts/bump_version.py
+++ b/scripts/bump_version.py
@@ -10,6 +10,8 @@ This script increments the version in pyproject.toml based on the specified bump
from __future__ import annotations
import argparse
+from datetime import date
+import os
from pathlib import Path
import re
import subprocess
@@ -50,7 +52,6 @@ def update_hard_values_files(filepath: str, patterns: list[tuple[str, str]]) ->
if not path.exists():
raise FileNotFoundError(f"{filepath} not found in current directory")
- # Replace patterns
for pattern, replacement in patterns:
content = path.read_text()
updated_content = re.sub(pattern, replacement, content, flags=re.MULTILINE)
@@ -71,7 +72,6 @@ def get_current_version() -> str:
content = pyproject_path.read_text()
- # Find version line
version_match = re.search(r'^version = "([^"]+)"$', content, re.MULTILINE)
if not version_match:
raise ValueError("Version not found in pyproject.toml")
@@ -79,7 +79,60 @@ def get_current_version() -> str:
return version_match.group(1)
+def update_changelog(new_version: str) -> None:
+ changelog_path = Path("CHANGELOG.md")
+
+ if not changelog_path.exists():
+ raise FileNotFoundError("CHANGELOG.md not found in current directory")
+
+ content = changelog_path.read_text()
+ today = date.today().isoformat()
+
+ first_entry_match = re.search(r"^## \[[\d.]+\]", content, re.MULTILINE)
+ if not first_entry_match:
+ raise ValueError("Could not find version entry in CHANGELOG.md")
+
+ insert_position = first_entry_match.start()
+
+ new_entry = f"## [{new_version}] - {today}\n\n"
+ new_entry += "### Added\n\n"
+ new_entry += "### Changed\n\n"
+ new_entry += "### Fixed\n\n"
+ new_entry += "### Removed\n\n"
+ new_entry += "\n"
+
+ updated_content = content[:insert_position] + new_entry + content[insert_position:]
+ changelog_path.write_text(updated_content)
+
+ print(f"Added changelog entry for version {new_version}")
+
+
+def print_warning(new_version: str) -> None:
+ warning = f"""
+{"=" * 80}
+⚠️ WARNING: CHANGELOG UPDATE REQUIRED ⚠️
+{"=" * 80}
+
+Don't forget to fill in the changelog entry for version {new_version} in CHANGELOG.md! 📝
+
+Also, remember to fill in vibe/whats_new.md if needed (you can leave it blank). 📝
+
+{"=" * 80}
+"""
+ print(warning, file=sys.stderr)
+
+
+def clean_up_whats_new_message() -> None:
+ whats_new_path = Path("vibe/whats_new.md")
+ if not whats_new_path.exists():
+ raise FileNotFoundError("whats_new.md not found in current directory")
+
+ whats_new_path.write_text("")
+
+
def main() -> None:
+ os.chdir(Path(__file__).parent.parent)
+
parser = argparse.ArgumentParser(
description="Bump semver version in pyproject.toml",
formatter_class=argparse.RawDescriptionHelpFormatter,
@@ -140,9 +193,15 @@ Examples:
[(f'version="{current_version}"', f'version="{new_version}"')],
)
+ # Update CHANGELOG.md
+ update_changelog(new_version)
+
+ clean_up_whats_new_message()
+
subprocess.run(["uv", "lock"], check=True)
print(f"\nSuccessfully bumped version from {current_version} to {new_version}")
+ print_warning(new_version)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
diff --git a/scripts/prepare_release.py b/scripts/prepare_release.py
new file mode 100755
index 0000000..a8de360
--- /dev/null
+++ b/scripts/prepare_release.py
@@ -0,0 +1,284 @@
+#!/usr/bin/env python3
+
+from __future__ import annotations
+
+import argparse
+from pathlib import Path
+import re
+import subprocess
+import sys
+
+
+def run_git_command(
+ *args: str, check: bool = True, capture_output: bool = False
+) -> subprocess.CompletedProcess[str]:
+ """Run a git command and return the result."""
+ result = subprocess.run(
+ ["git"] + list(args), check=check, capture_output=capture_output, text=True
+ )
+ return result
+
+
+def ensure_public_remote() -> None:
+ result = run_git_command("remote", "-v", capture_output=True, check=False)
+ remotes = result.stdout
+
+ public_remote_url = "git@github.com:mistralai/mistral-vibe.git"
+ if public_remote_url in remotes:
+ print("Public remote already exists with correct URL")
+ return
+
+ print(f"Creating public remote: {public_remote_url}")
+ run_git_command("remote", "add", "public", public_remote_url)
+ print("Public remote created successfully")
+
+
+def switch_to_tag(version: str) -> None:
+ tag = f"v{version}"
+ print(f"Switching to tag {tag}...")
+
+ result = run_git_command(
+ "rev-parse", "--verify", tag, capture_output=True, check=False
+ )
+ if result.returncode != 0:
+ raise ValueError(f"Tag {tag} does not exist")
+
+ run_git_command("switch", "--detach", tag)
+ print(f"Successfully switched to tag {tag}")
+
+
+def get_version_from_pyproject() -> str:
+ pyproject_path = Path("pyproject.toml")
+ if not pyproject_path.exists():
+ raise FileNotFoundError("pyproject.toml not found in current directory")
+
+ content = pyproject_path.read_text()
+ version_match = re.search(r'^version = "([^"]+)"$', content, re.MULTILINE)
+ if not version_match:
+ raise ValueError("Version not found in pyproject.toml")
+
+ return version_match.group(1)
+
+
+def get_latest_version() -> str:
+ result = run_git_command("ls-remote", "--tags", "public", capture_output=True)
+ remote_tags_output = (
+ result.stdout.strip().split("\n") if result.stdout.strip() else []
+ )
+
+ if not remote_tags_output:
+ raise ValueError("No version tags found on public remote")
+
+ versions: list[tuple[int, int, int, str]] = []
+ MIN_PARTS_IN_LS_REMOTE_LINE = 2 # hash and ref
+ for line in remote_tags_output:
+ parts = line.split()
+ if len(parts) < MIN_PARTS_IN_LS_REMOTE_LINE:
+ continue
+
+ _hash, tag_ref = parts[0], parts[1]
+ if not tag_ref.startswith("refs/tags/"):
+ continue
+
+ tag = tag_ref.replace("refs/tags/", "")
+ match = re.match(r"^v(\d+\.\d+\.\d+)$", tag)
+ if not match:
+ continue
+
+ tag_version = match.group(1)
+ try:
+ major, minor, patch = parse_version(tag_version)
+ versions.append((major, minor, patch, tag_version))
+ except ValueError:
+ continue
+
+ if not versions:
+ raise ValueError(
+ "No valid version tags found on public remote (format: vX.Y.Z)"
+ )
+
+ versions.sort()
+
+ return max(versions)[3]
+
+
+def parse_version(version_str: str) -> tuple[int, int, int]:
+ match = re.match(r"^(\d+)\.(\d+)\.(\d+)$", version_str.strip())
+ if not match:
+ raise ValueError(f"Invalid version format: {version_str}")
+
+ return int(match.group(1)), int(match.group(2)), int(match.group(3))
+
+
+def create_release_branch(version: str) -> None:
+ branch_name = f"release/v{version}"
+ print(f"Creating release branch: {branch_name}")
+
+ result = run_git_command(
+ "branch", "--list", branch_name, capture_output=True, check=False
+ )
+ if result.stdout.strip():
+ print(f"Warning: Branch {branch_name} already exists", file=sys.stderr)
+ response = input(f"Delete and recreate {branch_name}? (y/N): ")
+ if response.lower() == "y":
+ run_git_command("branch", "-D", branch_name)
+ else:
+ print("Aborting", file=sys.stderr)
+ sys.exit(1)
+
+ run_git_command("switch", "-c", branch_name)
+ print(f"Created and switched to branch {branch_name}")
+
+
+def cherry_pick_commits(previous_version: str, current_version: str) -> None:
+ previous_tag = f"v{previous_version}-private"
+ current_tag = f"v{current_version}-private"
+
+ result = run_git_command(
+ "rev-parse", "--verify", previous_tag, capture_output=True, check=False
+ )
+ if result.returncode != 0:
+ raise ValueError(f"Tag {previous_tag} does not exist")
+
+ result = run_git_command(
+ "rev-parse", "--verify", current_tag, capture_output=True, check=False
+ )
+ if result.returncode != 0:
+ raise ValueError(f"Tag {current_tag} does not exist")
+
+ print(f"Cherry-picking commits from {previous_tag}..{current_tag}...")
+ run_git_command("cherry-pick", f"{previous_tag}..{current_tag}")
+ print("Successfully cherry-picked all commits")
+
+
+def get_commits_summary(previous_version: str, current_version: str) -> str:
+ previous_tag = f"v{previous_version}-private"
+ current_tag = f"v{current_version}-private"
+
+ result = run_git_command(
+ "log", f"{previous_tag}..{current_tag}", "--oneline", capture_output=True
+ )
+ return result.stdout.strip()
+
+
+def get_changelog_entry(version: str) -> str:
+ changelog_path = Path("CHANGELOG.md")
+ if not changelog_path.exists():
+ return "CHANGELOG.md not found"
+
+ content = changelog_path.read_text()
+
+ pattern = rf"^## \[{re.escape(version)}\] - .+?(?=^## \[|\Z)"
+ match = re.search(pattern, content, re.MULTILINE | re.DOTALL)
+
+ if not match:
+ return f"No changelog entry found for version {version}"
+
+ return match.group(0).strip()
+
+
+def print_summary(
+ current_version: str,
+ previous_version: str,
+ commits_summary: str,
+ changelog_entry: str,
+) -> None:
+ print("\n" + "=" * 80)
+ print("RELEASE PREPARATION SUMMARY")
+ print("=" * 80)
+ print(f"\nVersion: {current_version}")
+ print(f"Previous version: {previous_version}")
+ print(f"Release branch: release/v{current_version}")
+
+ print("\n" + "-" * 80)
+ print("COMMITS IN THIS RELEASE")
+ print("-" * 80)
+ if commits_summary:
+ print(commits_summary)
+ else:
+ print("No commits found")
+
+ print("\n" + "-" * 80)
+ print("CHANGELOG ENTRY")
+ print("-" * 80)
+ print(changelog_entry)
+
+ print("\n" + "-" * 80)
+ print("NEXT STEPS")
+ print("-" * 80)
+ print(
+ f"To review/edit commits before publishing, use interactive rebase:\n"
+ f" git rebase -i v{previous_version}"
+ )
+
+ print("\n" + "-" * 80)
+ print("REMINDERS")
+ print("-" * 80)
+ print("Before publishing the release:")
+ print(" ✓ Credit any public contributors in the release notes")
+ print(" ✓ Close related issues once the release is published")
+ print(
+ " ✓ Review and update the changelog if needed "
+ "(should be made in the private main branch)"
+ )
+ print("\n" + "=" * 80)
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(
+ description="Prepare a release branch by cherry-picking from private tags",
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ )
+
+ parser.add_argument("version", help="Version to prepare release for (e.g., 1.1.3)")
+
+ args = parser.parse_args()
+ current_version = args.version
+
+ try:
+ # Step 1: Ensure public remote exists
+ ensure_public_remote()
+
+ # Step 2: Fetch all remotes
+ print("Fetching all remotes...")
+ run_git_command("fetch", "--all")
+ print("Successfully fetched all remotes")
+
+ # Step 3: Find latest version
+ previous_version = get_latest_version()
+ print(f"Previous version: {previous_version}")
+
+ # Step 4: Verify version matches pyproject.toml
+ pyproject_version = get_version_from_pyproject()
+ if current_version != pyproject_version:
+ raise ValueError(
+ f"Version mismatch: provided version '{current_version}' does not match "
+ f"pyproject.toml version '{pyproject_version}'"
+ )
+ print(f"Version verified: {current_version}")
+
+ # Step 5: Switch to previous version tag
+ switch_to_tag(previous_version)
+
+ # Step 6: Create release branch
+ create_release_branch(current_version)
+
+ # Step 7: Cherry-pick commits
+ cherry_pick_commits(previous_version, current_version)
+
+ # Step 8: Get summary information
+ commits_summary = get_commits_summary(previous_version, current_version)
+ changelog_entry = get_changelog_entry(current_version)
+
+ # Step 9: Print summary
+ print_summary(
+ current_version, previous_version, commits_summary, changelog_entry
+ )
+
+ except Exception as e:
+ print(f"Error: {e}", file=sys.stderr)
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/acp/conftest.py b/tests/acp/conftest.py
new file mode 100644
index 0000000..a3a5bd7
--- /dev/null
+++ b/tests/acp/conftest.py
@@ -0,0 +1,42 @@
+from __future__ import annotations
+
+from unittest.mock import patch
+
+import pytest
+
+from tests.stubs.fake_backend import FakeBackend
+from tests.stubs.fake_client import FakeClient
+from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
+from vibe.core.agent_loop import AgentLoop
+from vibe.core.types import LLMChunk, LLMMessage, LLMUsage, Role
+
+
+@pytest.fixture
+def backend() -> FakeBackend:
+ backend = FakeBackend(
+ LLMChunk(
+ message=LLMMessage(role=Role.assistant, content="Hi"),
+ usage=LLMUsage(prompt_tokens=1, completion_tokens=1),
+ )
+ )
+ return backend
+
+
+def _create_acp_agent() -> VibeAcpAgentLoop:
+ vibe_acp_agent = VibeAcpAgentLoop()
+ client = FakeClient()
+
+ vibe_acp_agent.on_connect(client)
+ client.on_connect(vibe_acp_agent)
+
+ return vibe_acp_agent # pyright: ignore[reportReturnType]
+
+
+@pytest.fixture
+def acp_agent_loop(backend: FakeBackend) -> VibeAcpAgentLoop:
+ class PatchedAgent(AgentLoop):
+ def __init__(self, *args, **kwargs) -> None:
+ super().__init__(*args, **kwargs, backend=backend)
+
+ patch("vibe.acp.acp_agent_loop.AgentLoop", side_effect=PatchedAgent).start()
+ return _create_acp_agent()
diff --git a/tests/acp/test_acp.py b/tests/acp/test_acp.py
index 3bbe9ed..304d647 100644
--- a/tests/acp/test_acp.py
+++ b/tests/acp/test_acp.py
@@ -189,7 +189,7 @@ class WriteTextFileJsonRpcResponse(JsonRpcResponse):
result: None = None
-async def get_acp_agent_process(
+async def get_acp_agent_loop_process(
mock_env: dict[str, str], vibe_home: Path
) -> AsyncGenerator[asyncio.subprocess.Process]:
current_env = os.environ.copy()
@@ -358,43 +358,43 @@ def parse_conversation(message_texts: list[str]) -> list[JsonRpcMessage]:
return parsed_messages
-async def initialize_session(acp_agent_process: asyncio.subprocess.Process) -> str:
+async def initialize_session(acp_agent_loop_process: asyncio.subprocess.Process) -> str:
await send_json_rpc(
- acp_agent_process,
- InitializeJsonRpcRequest(id=1, params=InitializeRequest(protocolVersion=1)),
+ acp_agent_loop_process,
+ InitializeJsonRpcRequest(id=1, params=InitializeRequest(protocol_version=1)),
)
initialize_response = await read_response_for_id(
- acp_agent_process, expected_id=1, timeout=5.0
+ acp_agent_loop_process, expected_id=1, timeout=5.0
)
assert initialize_response is not None
await send_json_rpc(
- acp_agent_process,
+ acp_agent_loop_process,
NewSessionJsonRpcRequest(
- id=2, params=NewSessionRequest(cwd=str(PLAYGROUND_DIR), mcpServers=[])
+ id=2, params=NewSessionRequest(cwd=str(PLAYGROUND_DIR), mcp_servers=[])
),
)
- session_response = await read_response_for_id(acp_agent_process, expected_id=2)
+ session_response = await read_response_for_id(acp_agent_loop_process, expected_id=2)
assert session_response is not None
session_response_json = json.loads(session_response)
session_response_obj = NewSessionJsonRpcResponse.model_validate(
session_response_json
)
assert session_response_obj.result is not None, "No result in response"
- return session_response_obj.result.sessionId
+ return session_response_obj.result.session_id
class TestSessionManagement:
@pytest.mark.asyncio
async def test_multiple_sessions_unique_ids(self, vibe_home_dir: Path) -> None:
mock_env = get_mocking_env(mock_chunks=[mock_llm_chunk() for _ in range(3)])
- async for process in get_acp_agent_process(
+ async for process in get_acp_agent_loop_process(
mock_env=mock_env, vibe_home=vibe_home_dir
):
await send_json_rpc(
process,
InitializeJsonRpcRequest(
- id=1, params=InitializeRequest(protocolVersion=1)
+ id=1, params=InitializeRequest(protocol_version=1)
),
)
await read_response_for_id(process, expected_id=1, timeout=5.0)
@@ -406,7 +406,7 @@ class TestSessionManagement:
NewSessionJsonRpcRequest(
id=i + 2,
params=NewSessionRequest(
- cwd=str(PLAYGROUND_DIR), mcpServers=[]
+ cwd=str(PLAYGROUND_DIR), mcp_servers=[]
),
),
)
@@ -418,16 +418,18 @@ class TestSessionManagement:
response = NewSessionJsonRpcResponse.model_validate(response_json)
assert response.error is None, f"JSON-RPC error: {response.error}"
assert response.result is not None, "No result in response"
- session_ids.append(response.result.sessionId)
+ session_ids.append(response.result.session_id)
assert len(set(session_ids)) == 3
class TestSessionUpdates:
@pytest.mark.asyncio
- async def test_agent_message_chunk_structure(self, vibe_home_dir: Path) -> None:
+ async def test_agent_loop_message_chunk_structure(
+ self, vibe_home_dir: Path
+ ) -> None:
mock_env = get_mocking_env([mock_llm_chunk(content="Hi")])
- async for process in get_acp_agent_process(
+ async for process in get_acp_agent_loop_process(
mock_env=mock_env, vibe_home=vibe_home_dir
):
# Check stderr for error details if process failed
@@ -444,11 +446,19 @@ class TestSessionUpdates:
PromptJsonRpcRequest(
id=3,
params=PromptRequest(
- sessionId=session_id,
+ session_id=session_id,
prompt=[TextContentBlock(type="text", text="Just say hi")],
),
),
)
+ user_response_text = await read_response(process)
+ assert user_response_text is not None
+ user_response = UpdateJsonRpcNotification.model_validate(
+ json.loads(user_response_text)
+ )
+ assert user_response.params is not None
+ assert user_response.params.update.session_update == "user_message_chunk"
+
text_response = await read_response(process)
assert text_response is not None
response = UpdateJsonRpcNotification.model_validate(
@@ -456,8 +466,9 @@ class TestSessionUpdates:
)
assert response.params is not None
- assert response.params.update.sessionUpdate == "agent_message_chunk"
+ assert response.params.update.session_update == "agent_message_chunk"
assert response.params.update.content is not None
+ assert isinstance(response.params.update.content, TextContentBlock)
assert response.params.update.content.type == "text"
assert response.params.update.content.text is not None
assert response.params.update.content.text == "Hi"
@@ -478,7 +489,7 @@ class TestSessionUpdates:
),
mock_llm_chunk(content="The files containing the pattern 'auth' are ..."),
])
- async for process in get_acp_agent_process(
+ async for process in get_acp_agent_loop_process(
mock_env=mock_env, vibe_home=vibe_home_dir
):
session_id = await initialize_session(process)
@@ -488,7 +499,7 @@ class TestSessionUpdates:
PromptJsonRpcRequest(
id=3,
params=PromptRequest(
- sessionId=session_id,
+ session_id=session_id,
prompt=[
TextContentBlock(
type="text",
@@ -511,7 +522,7 @@ class TestSessionUpdates:
for r in responses
if isinstance(r, UpdateJsonRpcNotification)
and r.params is not None
- and r.params.update.sessionUpdate == "tool_call"
+ and r.params.update.session_update == "tool_call"
),
None,
)
@@ -519,11 +530,11 @@ class TestSessionUpdates:
assert tool_call.params is not None
assert tool_call.params.update is not None
- assert tool_call.params.update.sessionUpdate == "tool_call"
+ assert tool_call.params.update.session_update == "tool_call"
assert tool_call.params.update.kind == "search"
- assert tool_call.params.update.title == "grep: 'auth'"
+ assert tool_call.params.update.title == "Grepping 'auth'"
assert (
- tool_call.params.update.rawInput
+ tool_call.params.update.raw_input
== '{"pattern":"auth","path":".","max_matches":null,"use_default_ignore":true}'
)
@@ -537,7 +548,7 @@ async def start_session_with_request_permission(
PromptJsonRpcRequest(
id=3,
params=PromptRequest(
- sessionId=session_id,
+ session_id=session_id,
prompt=[TextContentBlock(type="text", text=prompt)],
),
),
@@ -575,7 +586,7 @@ class TestToolCallStructure:
)
]
mock_env = get_mocking_env(custom_results)
- async for process in get_acp_agent_process(
+ async for process in get_acp_agent_loop_process(
mock_env=mock_env, vibe_home=vibe_home_grep_ask
):
session_id = await initialize_session(process)
@@ -584,7 +595,7 @@ class TestToolCallStructure:
PromptJsonRpcRequest(
id=3,
params=PromptRequest(
- sessionId=session_id,
+ session_id=session_id,
prompt=[
TextContentBlock(
type="text",
@@ -611,8 +622,8 @@ class TestToolCallStructure:
first_request = permission_requests[0]
assert first_request.params is not None
- assert first_request.params.toolCall is not None
- assert first_request.params.toolCall.toolCallId is not None
+ assert first_request.params.tool_call is not None
+ assert first_request.params.tool_call.tool_call_id is not None
@pytest.mark.asyncio
async def test_tool_call_update_approved_structure(
@@ -635,7 +646,7 @@ class TestToolCallStructure:
mock_llm_chunk(content="The file test.txt has been created"),
]
mock_env = get_mocking_env(custom_results)
- async for process in get_acp_agent_process(
+ async for process in get_acp_agent_loop_process(
mock_env=mock_env, vibe_home=vibe_home_grep_ask
):
permission_request = await start_session_with_request_permission(
@@ -649,7 +660,7 @@ class TestToolCallStructure:
id=permission_request.id,
result=RequestPermissionResponse(
outcome=AllowedOutcome(
- outcome="selected", optionId=selected_option_id
+ outcome="selected", option_id=selected_option_id
)
),
),
@@ -665,9 +676,9 @@ class TestToolCallStructure:
and r.method == "session/update"
and r.params is not None
and r.params.update is not None
- and r.params.update.sessionUpdate == "tool_call_update"
- and r.params.update.toolCallId
- == (permission_request.params.toolCall.toolCallId)
+ and r.params.update.session_update == "tool_call_update"
+ and r.params.update.tool_call_id
+ == (permission_request.params.tool_call.tool_call_id)
and r.params.update.status == "completed"
),
None,
@@ -697,7 +708,7 @@ class TestToolCallStructure:
),
]
mock_env = get_mocking_env(custom_results)
- async for process in get_acp_agent_process(
+ async for process in get_acp_agent_loop_process(
mock_env=mock_env, vibe_home=vibe_home_grep_ask
):
permission_request = await start_session_with_request_permission(
@@ -712,7 +723,7 @@ class TestToolCallStructure:
id=permission_request.id,
result=RequestPermissionResponse(
outcome=AllowedOutcome(
- outcome="selected", optionId=selected_option_id
+ outcome="selected", option_id=selected_option_id
)
),
),
@@ -727,9 +738,9 @@ class TestToolCallStructure:
if isinstance(r, UpdateJsonRpcNotification)
and r.method == "session/update"
and r.params is not None
- and r.params.update.sessionUpdate == "tool_call_update"
- and r.params.update.toolCallId
- == (permission_request.params.toolCall.toolCallId)
+ and r.params.update.session_update == "tool_call_update"
+ and r.params.update.tool_call_id
+ == (permission_request.params.tool_call.tool_call_id)
and r.params.update.status == "failed"
),
None,
@@ -758,7 +769,7 @@ class TestToolCallStructure:
mock_llm_chunk(content="The command sleep 3 has been run"),
]
mock_env = get_mocking_env(custom_results)
- async for process in get_acp_agent_process(
+ async for process in get_acp_agent_loop_process(
mock_env=mock_env, vibe_home=vibe_home_grep_ask
):
session_id = await initialize_session(process)
@@ -767,7 +778,7 @@ class TestToolCallStructure:
PromptJsonRpcRequest(
id=3,
params=PromptRequest(
- sessionId=session_id,
+ session_id=session_id,
prompt=[
TextContentBlock(
type="text",
@@ -786,7 +797,7 @@ class TestToolCallStructure:
for r in responses
if isinstance(r, UpdateJsonRpcNotification)
and r.params is not None
- and r.params.update.sessionUpdate == "tool_call_update"
+ and r.params.update.session_update == "tool_call_update"
and r.params.update.status == "in_progress"
]
@@ -817,7 +828,7 @@ class TestToolCallStructure:
),
]
mock_env = get_mocking_env(custom_results)
- async for process in get_acp_agent_process(
+ async for process in get_acp_agent_loop_process(
mock_env=mock_env, vibe_home=vibe_home_grep_ask
):
permission_request = await start_session_with_request_permission(
@@ -832,7 +843,7 @@ class TestToolCallStructure:
id=permission_request.id,
result=RequestPermissionResponse(
outcome=AllowedOutcome(
- outcome="selected", optionId=selected_option_id
+ outcome="selected", option_id=selected_option_id
)
),
),
@@ -847,10 +858,10 @@ class TestToolCallStructure:
for r in responses
if isinstance(r, UpdateJsonRpcNotification)
and r.params is not None
- and r.params.update.sessionUpdate == "tool_call_update"
+ and r.params.update.session_update == "tool_call_update"
and r.params.update.status == "failed"
- and r.params.update.rawOutput is not None
- and r.params.update.toolCallId is not None
+ and r.params.update.raw_output is not None
+ and r.params.update.tool_call_id is not None
),
None,
)
@@ -888,7 +899,7 @@ class TestCancellationStructure:
),
]
mock_env = get_mocking_env(custom_results)
- async for process in get_acp_agent_process(
+ async for process in get_acp_agent_loop_process(
mock_env=mock_env, vibe_home=vibe_home_dir
):
permission_request = await start_session_with_request_permission(
@@ -920,9 +931,9 @@ class TestCancellationStructure:
if isinstance(r, UpdateJsonRpcNotification)
and r.method == "session/update"
and r.params is not None
- and r.params.update.sessionUpdate == "tool_call_update"
- and r.params.update.toolCallId
- == (permission_request.params.toolCall.toolCallId)
+ and r.params.update.session_update == "tool_call_update"
+ and r.params.update.tool_call_id
+ == (permission_request.params.tool_call.tool_call_id)
and r.params.update.status == "failed"
),
None,
@@ -935,7 +946,7 @@ class TestCancellationStructure:
for r in responses
if isinstance(r, PromptJsonRpcResponse)
and r.result is not None
- and r.result.stopReason == "cancelled"
+ and r.result.stop_reason == "cancelled"
),
None,
)
diff --git a/tests/acp/test_bash.py b/tests/acp/test_bash.py
index 798c685..cb10c1d 100644
--- a/tests/acp/test_bash.py
+++ b/tests/acp/test_bash.py
@@ -2,9 +2,10 @@ from __future__ import annotations
import asyncio
-from acp.schema import TerminalOutputResponse, WaitForTerminalExitResponse
+from acp.schema import EnvVariable, TerminalOutputResponse, WaitForTerminalExitResponse
import pytest
+from tests.mock.utils import collect_result
from vibe.acp.tools.builtins.bash import AcpBashState, Bash
from vibe.core.tools.base import ToolError
from vibe.core.tools.builtins.bash import BashArgs, BashResult, BashToolConfig
@@ -26,7 +27,7 @@ class MockTerminalHandle:
async def wait_for_exit(self) -> WaitForTerminalExitResponse:
await asyncio.sleep(self._wait_delay)
- return WaitForTerminalExitResponse(exitCode=self._exit_code)
+ return WaitForTerminalExitResponse(exit_code=self._exit_code)
async def current_output(self) -> TerminalOutputResponse:
return TerminalOutputResponse(output=self._output, truncated=False)
@@ -38,36 +39,72 @@ class MockTerminalHandle:
pass
-class MockConnection:
+class MockClient:
def __init__(self, terminal_handle: MockTerminalHandle | None = None) -> None:
self._terminal_handle = terminal_handle or MockTerminalHandle()
self._create_terminal_called = False
self._session_update_called = False
self._create_terminal_error: Exception | None = None
- self._last_create_request = None
+ self._last_create_params: dict[
+ str, str | list[str] | list[EnvVariable] | int | None
+ ] = {}
- async def createTerminal(self, request) -> MockTerminalHandle:
+ async def create_terminal(
+ self,
+ command: str,
+ session_id: str,
+ args: list[str] | None = None,
+ cwd: str | None = None,
+ env: list | None = None,
+ output_byte_limit: int | None = None,
+ **kwargs,
+ ) -> MockTerminalHandle:
self._create_terminal_called = True
- self._last_create_request = request
+ self._last_create_params = {
+ "command": command,
+ "session_id": session_id,
+ "args": args,
+ "cwd": cwd,
+ "env": env,
+ "output_byte_limit": output_byte_limit,
+ }
if self._create_terminal_error:
raise self._create_terminal_error
return self._terminal_handle
- async def sessionUpdate(self, notification) -> None:
+ async def terminal_output(
+ self, session_id: str, terminal_id: str, **kwargs
+ ) -> TerminalOutputResponse:
+ return await self._terminal_handle.current_output()
+
+ async def wait_for_terminal_exit(
+ self, session_id: str, terminal_id: str, **kwargs
+ ) -> WaitForTerminalExitResponse:
+ return await self._terminal_handle.wait_for_exit()
+
+ async def release_terminal(
+ self, session_id: str, terminal_id: str, **kwargs
+ ) -> None:
+ await self._terminal_handle.release()
+
+ async def kill_terminal(self, session_id: str, terminal_id: str, **kwargs) -> None:
+ await self._terminal_handle.kill()
+
+ async def session_update(self, session_id: str, update, **kwargs) -> None:
self._session_update_called = True
@pytest.fixture
-def mock_connection() -> MockConnection:
- return MockConnection()
+def mock_client() -> MockClient:
+ return MockClient()
@pytest.fixture
-def acp_bash_tool(mock_connection: MockConnection) -> Bash:
+def acp_bash_tool(mock_client: MockClient) -> Bash:
config = BashToolConfig()
# Use model_construct to bypass Pydantic validation for testing
state = AcpBashState.model_construct(
- connection=mock_connection, # type: ignore[arg-type]
+ client=mock_client,
session_id="test_session_123",
tool_call_id="test_tool_call_456",
)
@@ -128,72 +165,69 @@ class TestAcpBashBasic:
class TestAcpBashExecution:
@pytest.mark.asyncio
async def test_run_success(
- self, acp_bash_tool: Bash, mock_connection: MockConnection
+ self, acp_bash_tool: Bash, mock_client: MockClient
) -> None:
from pathlib import Path
args = BashArgs(command="echo hello")
- result = await acp_bash_tool.run(args)
+ result = await collect_result(acp_bash_tool.run(args))
assert isinstance(result, BashResult)
assert result.stdout == "test output"
assert result.stderr == ""
assert result.returncode == 0
- assert mock_connection._create_terminal_called
+ assert mock_client._create_terminal_called
- # Verify CreateTerminalRequest was created correctly
- request = mock_connection._last_create_request
- assert request is not None
- assert request.sessionId == "test_session_123"
- assert request.command == "echo"
- assert request.args == ["hello"]
- assert request.cwd == str(Path.cwd()) # effective_workdir defaults to cwd
+ # Verify create_terminal was called correctly
+ params = mock_client._last_create_params
+ assert params["session_id"] == "test_session_123"
+ assert params["command"] == "echo"
+ assert params["args"] == ["hello"]
+ assert params["cwd"] == str(Path.cwd()) # effective_workdir defaults to cwd
@pytest.mark.asyncio
async def test_run_creates_terminal_with_env_vars(
- self, mock_connection: MockConnection
+ self, mock_client: MockClient
) -> None:
tool = Bash(
config=BashToolConfig(),
state=AcpBashState.model_construct(
- connection=mock_connection, # type: ignore[arg-type]
- session_id="test_session",
- tool_call_id="test_call",
+ client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
args = BashArgs(command="NODE_ENV=test npm run build")
- await tool.run(args)
+ await collect_result(tool.run(args))
- request = mock_connection._last_create_request
- assert request is not None
- assert len(request.env) == 1
- assert request.env[0].name == "NODE_ENV"
- assert request.env[0].value == "test"
- assert request.command == "npm"
- assert request.args == ["run", "build"]
+ params = mock_client._last_create_params
+ env = params["env"]
+ assert env is not None
+ assert (
+ isinstance(env, list) and len(env) > 0 and isinstance(env[0], EnvVariable)
+ )
+ assert len(env) == 1
+ assert env[0].name == "NODE_ENV"
+ assert env[0].value == "test"
+ assert params["command"] == "npm"
+ assert params["args"] == ["run", "build"]
@pytest.mark.asyncio
- async def test_run_with_nonzero_exit_code(
- self, mock_connection: MockConnection
- ) -> None:
+ async def test_run_with_nonzero_exit_code(self, mock_client: MockClient) -> None:
custom_handle = MockTerminalHandle(
terminal_id="custom_terminal", exit_code=1, output="error: command failed"
)
- mock_connection._terminal_handle = custom_handle
+ mock_client._terminal_handle = custom_handle
tool = Bash(
config=BashToolConfig(),
state=AcpBashState.model_construct(
- connection=mock_connection, # type: ignore[arg-type]
- session_id="test_session",
- tool_call_id="test_call",
+ client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
args = BashArgs(command="test_command")
with pytest.raises(ToolError) as exc_info:
- await tool.run(args)
+ await collect_result(tool.run(args))
assert (
str(exc_info.value)
@@ -201,23 +235,19 @@ class TestAcpBashExecution:
)
@pytest.mark.asyncio
- async def test_run_create_terminal_failure(
- self, mock_connection: MockConnection
- ) -> None:
- mock_connection._create_terminal_error = RuntimeError("Connection failed")
+ async def test_run_create_terminal_failure(self, mock_client: MockClient) -> None:
+ mock_client._create_terminal_error = RuntimeError("Connection failed")
tool = Bash(
config=BashToolConfig(),
state=AcpBashState.model_construct(
- connection=mock_connection, # type: ignore[arg-type]
- session_id="test_session",
- tool_call_id="test_call",
+ client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
args = BashArgs(command="test")
with pytest.raises(ToolError) as exc_info:
- await tool.run(args)
+ await collect_result(tool.run(args))
assert (
str(exc_info.value)
@@ -225,38 +255,36 @@ class TestAcpBashExecution:
)
@pytest.mark.asyncio
- async def test_run_without_connection(self) -> None:
+ async def test_run_without_client(self) -> None:
tool = Bash(
config=BashToolConfig(),
state=AcpBashState.model_construct(
- connection=None, session_id="test_session", tool_call_id="test_call"
+ client=None, session_id="test_session", tool_call_id="test_call"
),
)
args = BashArgs(command="test")
with pytest.raises(ToolError) as exc_info:
- await tool.run(args)
+ await collect_result(tool.run(args))
assert (
str(exc_info.value)
- == "Connection not available in tool state. This tool can only be used within an ACP session."
+ == "Client not available in tool state. This tool can only be used within an ACP session."
)
@pytest.mark.asyncio
async def test_run_without_session_id(self) -> None:
- mock_connection = MockConnection()
+ mock_client = MockClient()
tool = Bash(
config=BashToolConfig(),
state=AcpBashState.model_construct(
- connection=mock_connection, # type: ignore[arg-type]
- session_id=None,
- tool_call_id="test_call",
+ client=mock_client, session_id=None, tool_call_id="test_call"
),
)
args = BashArgs(command="test")
with pytest.raises(ToolError) as exc_info:
- await tool.run(args)
+ await collect_result(tool.run(args))
assert (
str(exc_info.value)
@@ -264,25 +292,21 @@ class TestAcpBashExecution:
)
@pytest.mark.asyncio
- async def test_run_with_none_exit_code(
- self, mock_connection: MockConnection
- ) -> None:
+ async def test_run_with_none_exit_code(self, mock_client: MockClient) -> None:
custom_handle = MockTerminalHandle(
terminal_id="none_exit_terminal", exit_code=None, output="output"
)
- mock_connection._terminal_handle = custom_handle
+ mock_client._terminal_handle = custom_handle
tool = Bash(
config=BashToolConfig(),
state=AcpBashState.model_construct(
- connection=mock_connection, # type: ignore[arg-type]
- session_id="test_session",
- tool_call_id="test_call",
+ client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
args = BashArgs(command="test_command")
- result = await tool.run(args)
+ result = await collect_result(tool.run(args))
assert result.returncode == 0
assert result.stdout == "output"
@@ -291,41 +315,39 @@ class TestAcpBashExecution:
class TestAcpBashTimeout:
@pytest.mark.asyncio
async def test_run_with_timeout_raises_error_and_kills(
- self, mock_connection: MockConnection
+ self, mock_client: MockClient
) -> None:
custom_handle = MockTerminalHandle(
terminal_id="timeout_terminal",
output="partial output",
wait_delay=20, # Longer than the 1 second timeout
)
- mock_connection._terminal_handle = custom_handle
+ mock_client._terminal_handle = custom_handle
# Use a config with different default timeout to verify args timeout overrides it
tool = Bash(
config=BashToolConfig(default_timeout=30),
state=AcpBashState.model_construct(
- connection=mock_connection, # type: ignore[arg-type]
- session_id="test_session",
- tool_call_id="test_call",
+ client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
args = BashArgs(command="slow_command", timeout=1)
with pytest.raises(ToolError) as exc_info:
- await tool.run(args)
+ await collect_result(tool.run(args))
assert str(exc_info.value) == "Command timed out after 1s: 'slow_command'"
assert custom_handle._killed
@pytest.mark.asyncio
async def test_run_timeout_handles_kill_failure(
- self, mock_connection: MockConnection
+ self, mock_client: MockClient
) -> None:
custom_handle = MockTerminalHandle(
terminal_id="kill_failure_terminal",
wait_delay=20, # Longer than the 1 second timeout
)
- mock_connection._terminal_handle = custom_handle
+ mock_client._terminal_handle = custom_handle
async def failing_kill() -> None:
raise RuntimeError("Kill failed")
@@ -335,78 +357,70 @@ class TestAcpBashTimeout:
tool = Bash(
config=BashToolConfig(),
state=AcpBashState.model_construct(
- connection=mock_connection, # type: ignore[arg-type]
- session_id="test_session",
- tool_call_id="test_call",
+ client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
args = BashArgs(command="slow_command", timeout=1)
# Should still raise timeout error even if kill fails
with pytest.raises(ToolError) as exc_info:
- await tool.run(args)
+ await collect_result(tool.run(args))
assert str(exc_info.value) == "Command timed out after 1s: 'slow_command'"
class TestAcpBashEmbedding:
@pytest.mark.asyncio
- async def test_run_with_embedding(self, mock_connection: MockConnection) -> None:
+ async def test_run_with_embedding(self, mock_client: MockClient) -> None:
tool = Bash(
config=BashToolConfig(),
state=AcpBashState.model_construct(
- connection=mock_connection, # type: ignore[arg-type]
- session_id="test_session",
- tool_call_id="test_call",
+ client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
args = BashArgs(command="test")
- await tool.run(args)
+ await collect_result(tool.run(args))
- assert mock_connection._session_update_called
+ assert mock_client._session_update_called
@pytest.mark.asyncio
async def test_run_embedding_without_tool_call_id(
- self, mock_connection: MockConnection
+ self, mock_client: MockClient
) -> None:
tool = Bash(
config=BashToolConfig(),
state=AcpBashState.model_construct(
- connection=mock_connection, # type: ignore[arg-type]
- session_id="test_session",
- tool_call_id=None,
+ client=mock_client, session_id="test_session", tool_call_id=None
),
)
args = BashArgs(command="test")
- await tool.run(args)
+ await collect_result(tool.run(args))
# Embedding should be skipped when tool_call_id is None
- assert not mock_connection._session_update_called
+ assert not mock_client._session_update_called
@pytest.mark.asyncio
async def test_run_embedding_handles_exception(
- self, mock_connection: MockConnection
+ self, mock_client: MockClient
) -> None:
- # Make sessionUpdate raise an exception
- async def failing_session_update(notification) -> None:
+ # Make session_update raise an exception
+ async def failing_session_update(session_id: str, update, **kwargs) -> None:
raise RuntimeError("Session update failed")
- mock_connection.sessionUpdate = failing_session_update
+ mock_client.session_update = failing_session_update
tool = Bash(
config=BashToolConfig(),
state=AcpBashState.model_construct(
- connection=mock_connection, # type: ignore[arg-type]
- session_id="test_session",
- tool_call_id="test_call",
+ client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
args = BashArgs(command="test")
# Should not raise, embedding failure is silently ignored
- result = await tool.run(args)
+ result = await collect_result(tool.run(args))
assert result is not None
assert result.stdout == "test output"
@@ -415,25 +429,23 @@ class TestAcpBashEmbedding:
class TestAcpBashConfig:
@pytest.mark.asyncio
async def test_run_uses_config_default_timeout(
- self, mock_connection: MockConnection
+ self, mock_client: MockClient
) -> None:
custom_handle = MockTerminalHandle(
terminal_id="config_timeout_terminal",
wait_delay=0.01, # Shorter than config timeout
)
- mock_connection._terminal_handle = custom_handle
+ mock_client._terminal_handle = custom_handle
tool = Bash(
config=BashToolConfig(default_timeout=30),
state=AcpBashState.model_construct(
- connection=mock_connection, # type: ignore[arg-type]
- session_id="test_session",
- tool_call_id="test_call",
+ client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
args = BashArgs(command="fast", timeout=None)
- result = await tool.run(args)
+ result = await collect_result(tool.run(args))
# Should succeed with config timeout
assert result.returncode == 0
@@ -442,10 +454,10 @@ class TestAcpBashConfig:
class TestAcpBashCleanup:
@pytest.mark.asyncio
async def test_run_releases_terminal_on_success(
- self, mock_connection: MockConnection
+ self, mock_client: MockClient
) -> None:
custom_handle = MockTerminalHandle(terminal_id="cleanup_terminal")
- mock_connection._terminal_handle = custom_handle
+ mock_client._terminal_handle = custom_handle
release_called = False
@@ -458,20 +470,18 @@ class TestAcpBashCleanup:
tool = Bash(
config=BashToolConfig(),
state=AcpBashState.model_construct(
- connection=mock_connection, # type: ignore[arg-type]
- session_id="test_session",
- tool_call_id="test_call",
+ client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
args = BashArgs(command="test")
- await tool.run(args)
+ await collect_result(tool.run(args))
assert release_called
@pytest.mark.asyncio
async def test_run_releases_terminal_on_timeout(
- self, mock_connection: MockConnection
+ self, mock_client: MockClient
) -> None:
# The handle will wait 2 seconds, but timeout is 1 second,
# so asyncio.wait_for() will raise TimeoutError
@@ -479,7 +489,7 @@ class TestAcpBashCleanup:
terminal_id="timeout_cleanup_terminal",
wait_delay=2.0, # Longer than the 1 second timeout
)
- mock_connection._terminal_handle = custom_handle
+ mock_client._terminal_handle = custom_handle
release_called = False
@@ -492,45 +502,39 @@ class TestAcpBashCleanup:
tool = Bash(
config=BashToolConfig(),
state=AcpBashState.model_construct(
- connection=mock_connection, # type: ignore[arg-type]
- session_id="test_session",
- tool_call_id="test_call",
+ client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
args = BashArgs(command="slow", timeout=1)
# Timeout raises an error, but terminal should still be released
try:
- await tool.run(args)
+ await collect_result(tool.run(args))
except ToolError:
pass
assert release_called
@pytest.mark.asyncio
- async def test_run_handles_release_failure(
- self, mock_connection: MockConnection
- ) -> None:
+ async def test_run_handles_release_failure(self, mock_client: MockClient) -> None:
custom_handle = MockTerminalHandle(terminal_id="release_failure_terminal")
async def failing_release() -> None:
raise RuntimeError("Release failed")
custom_handle.release = failing_release
- mock_connection._terminal_handle = custom_handle
+ mock_client._terminal_handle = custom_handle
tool = Bash(
config=BashToolConfig(),
state=AcpBashState.model_construct(
- connection=mock_connection, # type: ignore[arg-type]
- session_id="test_session",
- tool_call_id="test_call",
+ client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
args = BashArgs(command="test")
# Should not raise, release failure is silently ignored
- result = await tool.run(args)
+ result = await collect_result(tool.run(args))
assert result is not None
assert result.stdout == "test output"
diff --git a/tests/acp/test_compact_session_updates.py b/tests/acp/test_compact_session_updates.py
new file mode 100644
index 0000000..ff830e9
--- /dev/null
+++ b/tests/acp/test_compact_session_updates.py
@@ -0,0 +1,83 @@
+from __future__ import annotations
+
+from pathlib import Path
+from typing import cast
+from unittest.mock import patch
+
+from acp.schema import TextContentBlock, ToolCallProgress, ToolCallStart
+import pytest
+
+from tests.stubs.fake_backend import FakeBackend
+from tests.stubs.fake_client import FakeClient
+from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
+from vibe.core.agent_loop import AgentLoop
+from vibe.core.config import SessionLoggingConfig, VibeConfig
+
+
+@pytest.fixture
+def acp_agent_loop(backend: FakeBackend) -> VibeAcpAgentLoop:
+ class PatchedAgent(AgentLoop):
+ def __init__(self, *args, **kwargs) -> None:
+ # Force our config with auto_compact_threshold=1
+ kwargs["config"] = VibeConfig(
+ session_logging=SessionLoggingConfig(enabled=False),
+ auto_compact_threshold=1,
+ )
+ super().__init__(*args, **kwargs, backend=backend)
+
+ patch("vibe.acp.acp_agent_loop.AgentLoop", side_effect=PatchedAgent).start()
+ vibe_acp_agent = VibeAcpAgentLoop()
+ client = FakeClient()
+ vibe_acp_agent.on_connect(client)
+ client.on_connect(vibe_acp_agent)
+ return vibe_acp_agent
+
+
+class TestCompactEventHandling:
+ @pytest.mark.asyncio
+ async def test_prompt_handles_compact_events(
+ self, acp_agent_loop: VibeAcpAgentLoop
+ ) -> None:
+ """Verify prompt() sends tool_call session updates for compact events."""
+ session_response = await acp_agent_loop.new_session(
+ cwd=str(Path.cwd()), mcp_servers=[]
+ )
+ session = acp_agent_loop.sessions[session_response.session_id]
+ session.agent_loop.stats.context_tokens = 2
+
+ await acp_agent_loop.prompt(
+ prompt=[TextContentBlock(type="text", text="Hello")],
+ session_id=session_response.session_id,
+ )
+
+ mock_client = cast(FakeClient, acp_agent_loop.client)
+ updates = [n.update for n in mock_client._session_updates]
+
+ compact_start = next(
+ (
+ u
+ for u in updates
+ if isinstance(u, ToolCallStart)
+ and u.title.startswith("Compacting conversation history")
+ ),
+ None,
+ )
+ assert compact_start is not None
+ assert compact_start.session_update == "tool_call"
+ assert compact_start.kind == "other"
+ assert compact_start.status == "in_progress"
+
+ compact_end = next(
+ (
+ u
+ for u in updates
+ if isinstance(u, ToolCallProgress)
+ and u.tool_call_id == compact_start.tool_call_id
+ ),
+ None,
+ )
+ assert compact_end is not None
+ assert compact_end.session_update == "tool_call_update"
+ assert compact_end.status == "completed"
+
+ assert compact_start.tool_call_id == compact_end.tool_call_id
diff --git a/tests/acp/test_content.py b/tests/acp/test_content.py
index 6705455..69cc63a 100644
--- a/tests/acp/test_content.py
+++ b/tests/acp/test_content.py
@@ -1,9 +1,8 @@
from __future__ import annotations
from pathlib import Path
-from unittest.mock import patch
-from acp import AgentSideConnection, NewSessionRequest, PromptRequest
+from acp import PromptRequest
from acp.schema import (
EmbeddedResourceContentBlock,
ResourceContentBlock,
@@ -13,58 +12,28 @@ from acp.schema import (
import pytest
from tests.stubs.fake_backend import FakeBackend
-from tests.stubs.fake_connection import FakeAgentSideConnection
-from vibe.acp.acp_agent import VibeAcpAgent
-from vibe.core.agent import Agent
-from vibe.core.types import LLMChunk, LLMMessage, LLMUsage, Role
-
-
-@pytest.fixture
-def backend() -> FakeBackend:
- backend = FakeBackend(
- LLMChunk(
- message=LLMMessage(role=Role.assistant, content="Hi"),
- usage=LLMUsage(prompt_tokens=1, completion_tokens=1),
- )
- )
- return backend
-
-
-@pytest.fixture
-def acp_agent(backend: FakeBackend) -> VibeAcpAgent:
- class PatchedAgent(Agent):
- def __init__(self, *args, **kwargs) -> None:
- super().__init__(*args, **kwargs, backend=backend)
-
- patch("vibe.acp.acp_agent.VibeAgent", side_effect=PatchedAgent).start()
-
- vibe_acp_agent: VibeAcpAgent | None = None
-
- def _create_agent(connection: AgentSideConnection) -> VibeAcpAgent:
- nonlocal vibe_acp_agent
- vibe_acp_agent = VibeAcpAgent(connection)
- return vibe_acp_agent
-
- FakeAgentSideConnection(_create_agent)
- return vibe_acp_agent # pyright: ignore[reportReturnType]
+from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
+from vibe.core.types import Role
class TestACPContent:
@pytest.mark.asyncio
async def test_text_content(
- self, acp_agent: VibeAcpAgent, backend: FakeBackend
+ self, acp_agent_loop: VibeAcpAgentLoop, backend: FakeBackend
) -> None:
- session_response = await acp_agent.newSession(
- NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
+ session_response = await acp_agent_loop.new_session(
+ cwd=str(Path.cwd()), mcp_servers=[]
)
prompt_request = PromptRequest(
prompt=[TextContentBlock(type="text", text="Say hi")],
- sessionId=session_response.sessionId,
+ session_id=session_response.session_id,
)
- response = await acp_agent.prompt(params=prompt_request)
+ response = await acp_agent_loop.prompt(
+ prompt=prompt_request.prompt, session_id=session_response.session_id
+ )
- assert response.stopReason == "end_turn"
+ assert response.stop_reason == "end_turn"
user_message = next(
(msg for msg in backend._requests_messages[0] if msg.role == Role.user),
None,
@@ -74,12 +43,13 @@ class TestACPContent:
@pytest.mark.asyncio
async def test_resource_content(
- self, acp_agent: VibeAcpAgent, backend: FakeBackend
+ self, acp_agent_loop: VibeAcpAgentLoop, backend: FakeBackend
) -> None:
- session_response = await acp_agent.newSession(
- NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
+ session_response = await acp_agent_loop.new_session(
+ cwd=str(Path.cwd()), mcp_servers=[]
)
- prompt_request = PromptRequest(
+
+ response = await acp_agent_loop.prompt(
prompt=[
TextContentBlock(type="text", text="What does this file do?"),
EmbeddedResourceContentBlock(
@@ -87,16 +57,14 @@ class TestACPContent:
resource=TextResourceContents(
uri="file:///home/my_file.py",
text="def hello():\n print('Hello, world!')",
- mimeType="text/x-python",
+ mime_type="text/x-python",
),
),
],
- sessionId=session_response.sessionId,
+ session_id=session_response.session_id,
)
- response = await acp_agent.prompt(params=prompt_request)
-
- assert response.stopReason == "end_turn"
+ assert response.stop_reason == "end_turn"
user_message = next(
(msg for msg in backend._requests_messages[0] if msg.role == Role.user),
None,
@@ -111,12 +79,13 @@ class TestACPContent:
@pytest.mark.asyncio
async def test_resource_link_content(
- self, acp_agent: VibeAcpAgent, backend: FakeBackend
+ self, acp_agent_loop: VibeAcpAgentLoop, backend: FakeBackend
) -> None:
- session_response = await acp_agent.newSession(
- NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
+ session_response = await acp_agent_loop.new_session(
+ cwd=str(Path.cwd()), mcp_servers=[]
)
- prompt_request = PromptRequest(
+
+ response = await acp_agent_loop.prompt(
prompt=[
TextContentBlock(type="text", text="Analyze this resource"),
ResourceContentBlock(
@@ -125,16 +94,14 @@ class TestACPContent:
name="document.pdf",
title="Important Document",
description="A PDF document containing project specifications",
- mimeType="application/pdf",
+ mime_type="application/pdf",
size=1024,
),
],
- sessionId=session_response.sessionId,
+ session_id=session_response.session_id,
)
- response = await acp_agent.prompt(params=prompt_request)
-
- assert response.stopReason == "end_turn"
+ assert response.stop_reason == "end_turn"
user_message = next(
(msg for msg in backend._requests_messages[0] if msg.role == Role.user),
None,
@@ -146,19 +113,20 @@ class TestACPContent:
+ "\nname: document.pdf"
+ "\ntitle: Important Document"
+ "\ndescription: A PDF document containing project specifications"
- + "\nmimeType: application/pdf"
+ + "\nmime_type: application/pdf"
+ "\nsize: 1024"
)
assert user_message.content == expected_content
@pytest.mark.asyncio
async def test_resource_link_minimal(
- self, acp_agent: VibeAcpAgent, backend: FakeBackend
+ self, acp_agent_loop: VibeAcpAgentLoop, backend: FakeBackend
) -> None:
- session_response = await acp_agent.newSession(
- NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
+ session_response = await acp_agent_loop.new_session(
+ cwd=str(Path.cwd()), mcp_servers=[]
)
- prompt_request = PromptRequest(
+
+ response = await acp_agent_loop.prompt(
prompt=[
ResourceContentBlock(
type="resource_link",
@@ -166,12 +134,10 @@ class TestACPContent:
name="minimal.txt",
)
],
- sessionId=session_response.sessionId,
+ session_id=session_response.session_id,
)
- response = await acp_agent.prompt(params=prompt_request)
-
- assert response.stopReason == "end_turn"
+ assert response.stop_reason == "end_turn"
user_message = next(
(msg for msg in backend._requests_messages[0] if msg.role == Role.user),
None,
diff --git a/tests/acp/test_initialize.py b/tests/acp/test_initialize.py
index ab6a155..d04d298 100644
--- a/tests/acp/test_initialize.py
+++ b/tests/acp/test_initialize.py
@@ -1,6 +1,6 @@
from __future__ import annotations
-from acp import PROTOCOL_VERSION, AgentSideConnection, InitializeRequest
+from acp import PROTOCOL_VERSION
from acp.schema import (
AgentCapabilities,
ClientCapabilities,
@@ -9,66 +9,51 @@ from acp.schema import (
)
import pytest
-from tests.stubs.fake_connection import FakeAgentSideConnection
-from vibe.acp.acp_agent import VibeAcpAgent
-
-
-@pytest.fixture
-def acp_agent() -> VibeAcpAgent:
- vibe_acp_agent: VibeAcpAgent | None = None
-
- def _create_agent(connection: AgentSideConnection) -> VibeAcpAgent:
- nonlocal vibe_acp_agent
- vibe_acp_agent = VibeAcpAgent(connection)
- return vibe_acp_agent
-
- FakeAgentSideConnection(_create_agent)
- return vibe_acp_agent # pyright: ignore[reportReturnType]
+from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
class TestACPInitialize:
@pytest.mark.asyncio
- async def test_initialize(self, acp_agent: VibeAcpAgent) -> None:
- """Test regular initialize without terminal-auth capabilities."""
- request = InitializeRequest(protocolVersion=PROTOCOL_VERSION)
- response = await acp_agent.initialize(request)
+ async def test_initialize(self, acp_agent_loop: VibeAcpAgentLoop) -> None:
+ response = await acp_agent_loop.initialize(protocol_version=PROTOCOL_VERSION)
- assert response.protocolVersion == PROTOCOL_VERSION
- assert response.agentCapabilities == AgentCapabilities(
- loadSession=False,
- promptCapabilities=PromptCapabilities(
- audio=False, embeddedContext=True, image=False
+ assert response.protocol_version == PROTOCOL_VERSION
+ assert response.agent_capabilities == AgentCapabilities(
+ load_session=False,
+ prompt_capabilities=PromptCapabilities(
+ audio=False, embedded_context=True, image=False
),
)
- assert response.agentInfo == Implementation(
- name="@mistralai/mistral-vibe", title="Mistral Vibe", version="1.3.5"
+ assert response.agent_info == Implementation(
+ name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.0.0"
)
- assert response.authMethods == []
+ assert response.auth_methods == []
@pytest.mark.asyncio
- async def test_initialize_with_terminal_auth(self, acp_agent: VibeAcpAgent) -> None:
+ async def test_initialize_with_terminal_auth(
+ self, acp_agent_loop: VibeAcpAgentLoop
+ ) -> None:
"""Test initialize with terminal-auth capabilities to check it was included."""
client_capabilities = ClientCapabilities(field_meta={"terminal-auth": True})
- request = InitializeRequest(
- protocolVersion=PROTOCOL_VERSION, clientCapabilities=client_capabilities
+ response = await acp_agent_loop.initialize(
+ protocol_version=PROTOCOL_VERSION, client_capabilities=client_capabilities
)
- response = await acp_agent.initialize(request)
- assert response.protocolVersion == PROTOCOL_VERSION
- assert response.agentCapabilities == AgentCapabilities(
- loadSession=False,
- promptCapabilities=PromptCapabilities(
- audio=False, embeddedContext=True, image=False
+ assert response.protocol_version == PROTOCOL_VERSION
+ assert response.agent_capabilities == AgentCapabilities(
+ load_session=False,
+ prompt_capabilities=PromptCapabilities(
+ audio=False, embedded_context=True, image=False
),
)
- assert response.agentInfo == Implementation(
- name="@mistralai/mistral-vibe", title="Mistral Vibe", version="1.3.5"
+ assert response.agent_info == Implementation(
+ name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.0.0"
)
- assert response.authMethods is not None
- assert len(response.authMethods) == 1
- auth_method = response.authMethods[0]
+ assert response.auth_methods is not None
+ assert len(response.auth_methods) == 1
+ auth_method = response.auth_methods[0]
assert auth_method.id == "vibe-setup"
assert auth_method.name == "Register your API Key"
assert auth_method.description == "Register your API Key inside Mistral Vibe"
diff --git a/tests/acp/test_multi_session.py b/tests/acp/test_multi_session.py
index 1e07929..521e12f 100644
--- a/tests/acp/test_multi_session.py
+++ b/tests/acp/test_multi_session.py
@@ -2,101 +2,52 @@ from __future__ import annotations
import asyncio
from pathlib import Path
-from typing import Any
-from unittest.mock import patch
from uuid import uuid4
-from acp import (
- PROTOCOL_VERSION,
- InitializeRequest,
- NewSessionRequest,
- PromptRequest,
- RequestError,
-)
+from acp import PROTOCOL_VERSION, RequestError
from acp.schema import TextContentBlock
import pytest
from pytest import raises
from tests.mock.utils import mock_llm_chunk
from tests.stubs.fake_backend import FakeBackend
-from tests.stubs.fake_connection import FakeAgentSideConnection
-from vibe.acp.acp_agent import VibeAcpAgent
-from vibe.core.agent import Agent
-from vibe.core.config import ModelConfig, VibeConfig
+from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
from vibe.core.types import Role
-@pytest.fixture
-def backend() -> FakeBackend:
- backend = FakeBackend()
- return backend
-
-
-@pytest.fixture
-def acp_agent(backend: FakeBackend) -> VibeAcpAgent:
- config = VibeConfig(
- active_model="devstral-latest",
- models=[
- ModelConfig(
- name="devstral-latest", provider="mistral", alias="devstral-latest"
- )
- ],
- )
-
- class PatchedAgent(Agent):
- def __init__(self, *args, **kwargs) -> None:
- super().__init__(*args, **kwargs)
- self.backend = backend
- self.config = config
-
- patch("vibe.acp.acp_agent.VibeAgent", side_effect=PatchedAgent).start()
-
- vibe_acp_agent: VibeAcpAgent | None = None
-
- def _create_agent(connection: Any) -> VibeAcpAgent:
- nonlocal vibe_acp_agent
- vibe_acp_agent = VibeAcpAgent(connection)
- return vibe_acp_agent
-
- FakeAgentSideConnection(_create_agent)
- return vibe_acp_agent # pyright: ignore[reportReturnType]
-
-
class TestMultiSessionCore:
@pytest.mark.asyncio
async def test_different_sessions_use_different_agents(
- self, acp_agent: VibeAcpAgent
+ self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
- await acp_agent.initialize(InitializeRequest(protocolVersion=PROTOCOL_VERSION))
- session1_response = await acp_agent.newSession(
- NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
+ await acp_agent_loop.initialize(protocol_version=PROTOCOL_VERSION)
+ session1_response = await acp_agent_loop.new_session(
+ cwd=str(Path.cwd()), mcp_servers=[]
)
- session1 = acp_agent.sessions[session1_response.sessionId]
- session2_response = await acp_agent.newSession(
- NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
+ session1 = acp_agent_loop.sessions[session1_response.session_id]
+ session2_response = await acp_agent_loop.new_session(
+ cwd=str(Path.cwd()), mcp_servers=[]
)
- session2 = acp_agent.sessions[session2_response.sessionId]
+ session2 = acp_agent_loop.sessions[session2_response.session_id]
assert session1.id != session2.id
- # Each agent should be independent
- assert session1.agent is not session2.agent
- assert id(session1.agent) != id(session2.agent)
+ # Each agent loop should be independent
+ assert session1.agent_loop is not session2.agent_loop
+ assert id(session1.agent_loop) != id(session2.agent_loop)
@pytest.mark.asyncio
- async def test_error_on_nonexistent_session(self, acp_agent: VibeAcpAgent) -> None:
- await acp_agent.initialize(InitializeRequest(protocolVersion=PROTOCOL_VERSION))
- await acp_agent.newSession(
- NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
- )
+ async def test_error_on_nonexistent_session(
+ self, acp_agent_loop: VibeAcpAgentLoop
+ ) -> None:
+ await acp_agent_loop.initialize(protocol_version=PROTOCOL_VERSION)
+ await acp_agent_loop.new_session(cwd=str(Path.cwd()), mcp_servers=[])
fake_session_id = "fake-session-id-" + str(uuid4())
with raises(RequestError) as exc_info:
- await acp_agent.prompt(
- PromptRequest(
- sessionId=fake_session_id,
- prompt=[TextContentBlock(type="text", text="Hello, world!")],
- )
+ await acp_agent_loop.prompt(
+ session_id=fake_session_id,
+ prompt=[TextContentBlock(type="text", text="Hello, world!")],
)
assert isinstance(exc_info.value, RequestError)
@@ -104,17 +55,17 @@ class TestMultiSessionCore:
@pytest.mark.asyncio
async def test_simultaneous_message_processing(
- self, acp_agent: VibeAcpAgent, backend: FakeBackend
+ self, acp_agent_loop: VibeAcpAgentLoop, backend: FakeBackend
) -> None:
- await acp_agent.initialize(InitializeRequest(protocolVersion=PROTOCOL_VERSION))
- session1_response = await acp_agent.newSession(
- NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
+ await acp_agent_loop.initialize(protocol_version=PROTOCOL_VERSION)
+ session1_response = await acp_agent_loop.new_session(
+ cwd=str(Path.cwd()), mcp_servers=[]
)
- session1 = acp_agent.sessions[session1_response.sessionId]
- session2_response = await acp_agent.newSession(
- NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
+ session1 = acp_agent_loop.sessions[session1_response.session_id]
+ session2_response = await acp_agent_loop.new_session(
+ cwd=str(Path.cwd()), mcp_servers=[]
)
- session2 = acp_agent.sessions[session2_response.sessionId]
+ session2 = acp_agent_loop.sessions[session2_response.session_id]
backend._streams = [
[mock_llm_chunk(content="Response 1")],
@@ -122,40 +73,38 @@ class TestMultiSessionCore:
]
async def run_session1():
- await acp_agent.prompt(
- PromptRequest(
- sessionId=session1.id,
- prompt=[TextContentBlock(type="text", text="Prompt for session 1")],
- )
+ await acp_agent_loop.prompt(
+ session_id=session1.id,
+ prompt=[TextContentBlock(type="text", text="Prompt for session 1")],
)
async def run_session2():
- await acp_agent.prompt(
- PromptRequest(
- sessionId=session2.id,
- prompt=[TextContentBlock(type="text", text="Prompt for session 2")],
- )
+ await acp_agent_loop.prompt(
+ session_id=session2.id,
+ prompt=[TextContentBlock(type="text", text="Prompt for session 2")],
)
await asyncio.gather(run_session1(), run_session2())
user_message1 = next(
- (msg for msg in session1.agent.messages if msg.role == Role.user), None
+ (msg for msg in session1.agent_loop.messages if msg.role == Role.user), None
)
assert user_message1 is not None
assert user_message1.content == "Prompt for session 1"
assistant_message1 = next(
- (msg for msg in session1.agent.messages if msg.role == Role.assistant), None
+ (msg for msg in session1.agent_loop.messages if msg.role == Role.assistant),
+ None,
)
assert assistant_message1 is not None
assert assistant_message1.content == "Response 1"
user_message2 = next(
- (msg for msg in session2.agent.messages if msg.role == Role.user), None
+ (msg for msg in session2.agent_loop.messages if msg.role == Role.user), None
)
assert user_message2 is not None
assert user_message2.content == "Prompt for session 2"
assistant_message2 = next(
- (msg for msg in session2.agent.messages if msg.role == Role.assistant), None
+ (msg for msg in session2.agent_loop.messages if msg.role == Role.assistant),
+ None,
)
assert assistant_message2 is not None
assert assistant_message2.content == "Response 2"
diff --git a/tests/acp/test_new_session.py b/tests/acp/test_new_session.py
index c945a94..2ba0d45 100644
--- a/tests/acp/test_new_session.py
+++ b/tests/acp/test_new_session.py
@@ -3,31 +3,17 @@ from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
-from acp import AgentSideConnection, NewSessionRequest, SetSessionModelRequest
import pytest
-from tests.stubs.fake_backend import FakeBackend
-from tests.stubs.fake_connection import FakeAgentSideConnection
-from vibe.acp.acp_agent import VibeAcpAgent
-from vibe.core.agent import Agent
+from tests.acp.conftest import _create_acp_agent
+from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
+from vibe.core.agent_loop import AgentLoop
+from vibe.core.agents.models import BuiltinAgentName
from vibe.core.config import ModelConfig, VibeConfig
-from vibe.core.modes import AgentMode
-from vibe.core.types import LLMChunk, LLMMessage, LLMUsage, Role
@pytest.fixture
-def backend() -> FakeBackend:
- backend = FakeBackend(
- LLMChunk(
- message=LLMMessage(role=Role.assistant, content="Hi"),
- usage=LLMUsage(prompt_tokens=1, completion_tokens=1),
- )
- )
- return backend
-
-
-@pytest.fixture
-def acp_agent(backend: FakeBackend) -> VibeAcpAgent:
+def acp_agent_loop(backend) -> VibeAcpAgentLoop:
config = VibeConfig(
active_model="devstral-latest",
models=[
@@ -40,101 +26,90 @@ def acp_agent(backend: FakeBackend) -> VibeAcpAgent:
],
)
- class PatchedAgent(Agent):
+ class PatchedAgentLoop(AgentLoop):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **{**kwargs, "backend": backend})
- self.config = config
+ self._base_config = config
+ self.agent_manager.invalidate_config()
- patch("vibe.acp.acp_agent.VibeAgent", side_effect=PatchedAgent).start()
+ patch("vibe.acp.acp_agent_loop.AgentLoop", side_effect=PatchedAgentLoop).start()
- vibe_acp_agent: VibeAcpAgent | None = None
-
- def _create_agent(connection: AgentSideConnection) -> VibeAcpAgent:
- nonlocal vibe_acp_agent
- vibe_acp_agent = VibeAcpAgent(connection)
- return vibe_acp_agent
-
- FakeAgentSideConnection(_create_agent)
- return vibe_acp_agent # pyright: ignore[reportReturnType]
+ return _create_acp_agent()
class TestACPNewSession:
@pytest.mark.asyncio
async def test_new_session_response_structure(
- self, acp_agent: VibeAcpAgent
+ self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
- session_response = await acp_agent.newSession(
- NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
+ session_response = await acp_agent_loop.new_session(
+ cwd=str(Path.cwd()), mcp_servers=[]
)
- assert session_response.sessionId is not None
+ assert session_response.session_id is not None
acp_session = next(
(
s
- for s in acp_agent.sessions.values()
- if s.id == session_response.sessionId
+ for s in acp_agent_loop.sessions.values()
+ if s.id == session_response.session_id
),
None,
)
assert acp_session is not None
assert (
- acp_session.agent.interaction_logger.session_id
- == session_response.sessionId
+ acp_session.agent_loop.session_logger.session_id
+ == session_response.session_id
)
- assert session_response.sessionId == acp_session.agent.session_id
+ assert session_response.session_id == acp_session.agent_loop.session_id
assert session_response.models is not None
- assert session_response.models.currentModelId is not None
- assert session_response.models.availableModels is not None
- assert len(session_response.models.availableModels) == 2
+ assert session_response.models.current_model_id is not None
+ assert session_response.models.available_models is not None
+ assert len(session_response.models.available_models) == 2
- assert session_response.models.currentModelId == "devstral-latest"
- assert session_response.models.availableModels[0].modelId == "devstral-latest"
- assert session_response.models.availableModels[0].name == "devstral-latest"
- assert session_response.models.availableModels[1].modelId == "devstral-small"
- assert session_response.models.availableModels[1].name == "devstral-small"
+ assert session_response.models.current_model_id == "devstral-latest"
+ assert session_response.models.available_models[0].model_id == "devstral-latest"
+ assert session_response.models.available_models[0].name == "devstral-latest"
+ assert session_response.models.available_models[1].model_id == "devstral-small"
+ assert session_response.models.available_models[1].name == "devstral-small"
assert session_response.modes is not None
- assert session_response.modes.currentModeId is not None
- assert session_response.modes.availableModes is not None
- assert len(session_response.modes.availableModes) == 4
+ assert session_response.modes.current_mode_id is not None
+ assert session_response.modes.available_modes is not None
+ assert len(session_response.modes.available_modes) == 4
- assert session_response.modes.currentModeId == AgentMode.DEFAULT.value
- assert session_response.modes.availableModes[0].id == AgentMode.DEFAULT.value
- assert session_response.modes.availableModes[0].name == "Default"
- assert (
- session_response.modes.availableModes[1].id == AgentMode.AUTO_APPROVE.value
- )
- assert session_response.modes.availableModes[1].name == "Auto Approve"
- assert session_response.modes.availableModes[2].id == AgentMode.PLAN.value
- assert session_response.modes.availableModes[2].name == "Plan"
- assert (
- session_response.modes.availableModes[3].id == AgentMode.ACCEPT_EDITS.value
- )
- assert session_response.modes.availableModes[3].name == "Accept Edits"
+ assert session_response.modes.current_mode_id == BuiltinAgentName.DEFAULT
+ # Check that all primary agents are available (order may vary)
+ mode_ids = {m.id for m in session_response.modes.available_modes}
+ assert mode_ids == {
+ BuiltinAgentName.DEFAULT,
+ BuiltinAgentName.AUTO_APPROVE,
+ BuiltinAgentName.PLAN,
+ BuiltinAgentName.ACCEPT_EDITS,
+ }
@pytest.mark.skip(reason="TODO: Fix this test")
@pytest.mark.asyncio
async def test_new_session_preserves_model_after_set_model(
- self, acp_agent: VibeAcpAgent
+ self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
- session_response = await acp_agent.newSession(
- NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
+ session_response = await acp_agent_loop.new_session(
+ cwd=str(Path.cwd()), mcp_servers=[]
)
- session_id = session_response.sessionId
+ session_id = session_response.session_id
assert session_response.models is not None
- assert session_response.models.currentModelId == "devstral-latest"
+ assert session_response.models.current_model_id == "devstral-latest"
- response = await acp_agent.setSessionModel(
- SetSessionModelRequest(sessionId=session_id, modelId="devstral-small")
+ response = await acp_agent_loop.set_session_model(
+ session_id=session_id, model_id="devstral-small"
)
assert response is not None
- session_response = await acp_agent.newSession(
- NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
+ session_response = await acp_agent_loop.new_session(
+ cwd=str(Path.cwd()), mcp_servers=[]
)
assert session_response.models is not None
- assert session_response.models.currentModelId == "devstral-small"
+ assert session_response.models.current_model_id == "devstral-small"
diff --git a/tests/acp/test_read_file.py b/tests/acp/test_read_file.py
index 78fc2b1..87fbb1e 100644
--- a/tests/acp/test_read_file.py
+++ b/tests/acp/test_read_file.py
@@ -2,9 +2,10 @@ from __future__ import annotations
from pathlib import Path
-from acp import ReadTextFileRequest, ReadTextFileResponse
+from acp import ReadTextFileResponse
import pytest
+from tests.mock.utils import collect_result
from vibe.acp.tools.builtins.read_file import AcpReadFileState, ReadFile
from vibe.core.tools.base import ToolError
from vibe.core.tools.builtins.read_file import (
@@ -14,7 +15,7 @@ from vibe.core.tools.builtins.read_file import (
)
-class MockConnection:
+class MockClient:
def __init__(
self,
file_content: str = "line 1\nline 2\nline 3",
@@ -24,41 +25,54 @@ class MockConnection:
self._read_error = read_error
self._read_text_file_called = False
self._session_update_called = False
- self._last_read_request: ReadTextFileRequest | None = None
+ self._last_read_params: dict[str, str | int | None] = {}
- async def readTextFile(self, request: ReadTextFileRequest) -> ReadTextFileResponse:
+ async def read_text_file(
+ self,
+ path: str,
+ session_id: str,
+ limit: int | None = None,
+ line: int | None = None,
+ **kwargs,
+ ) -> ReadTextFileResponse:
self._read_text_file_called = True
- self._last_read_request = request
+ self._last_read_params = {
+ "path": path,
+ "session_id": session_id,
+ "limit": limit,
+ "line": line,
+ }
if self._read_error:
raise self._read_error
content = self._file_content
- if request.line is not None or request.limit is not None:
+ if line is not None or limit is not None:
lines = content.splitlines(keepends=True)
- start_line = (request.line or 1) - 1 # Convert to 0-indexed
- end_line = (
- start_line + request.limit if request.limit is not None else len(lines)
- )
+ start_line = (line or 1) - 1 # Convert to 0-indexed
+ end_line = start_line + limit if limit is not None else len(lines)
lines = lines[start_line:end_line]
content = "".join(lines)
return ReadTextFileResponse(content=content)
- async def sessionUpdate(self, notification) -> None:
+ async def session_update(self, session_id: str, update, **kwargs) -> None:
self._session_update_called = True
@pytest.fixture
-def mock_connection() -> MockConnection:
- return MockConnection()
+def mock_client() -> MockClient:
+ return MockClient()
@pytest.fixture
-def acp_read_file_tool(mock_connection: MockConnection, tmp_path: Path) -> ReadFile:
- config = ReadFileToolConfig(workdir=tmp_path)
+def acp_read_file_tool(
+ mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> ReadFile:
+ monkeypatch.chdir(tmp_path)
+ config = ReadFileToolConfig()
state = AcpReadFileState.model_construct(
- connection=mock_connection, # type: ignore[arg-type]
+ client=mock_client, # type: ignore[arg-type]
session_id="test_session_123",
tool_call_id="test_tool_call_456",
)
@@ -73,166 +87,159 @@ class TestAcpReadFileBasic:
class TestAcpReadFileExecution:
@pytest.mark.asyncio
async def test_run_success(
- self,
- acp_read_file_tool: ReadFile,
- mock_connection: MockConnection,
- tmp_path: Path,
+ self, acp_read_file_tool: ReadFile, mock_client: MockClient, tmp_path: Path
) -> None:
test_file = tmp_path / "test_file.txt"
test_file.touch()
args = ReadFileArgs(path=str(test_file))
- result = await acp_read_file_tool.run(args)
+ result = await collect_result(acp_read_file_tool.run(args))
assert isinstance(result, ReadFileResult)
assert result.path == str(test_file)
assert result.content == "line 1\nline 2\nline 3"
assert result.lines_read == 3
- assert mock_connection._read_text_file_called
- assert mock_connection._session_update_called
+ assert mock_client._read_text_file_called
+ assert mock_client._session_update_called
- # Verify ReadTextFileRequest was created correctly
- request = mock_connection._last_read_request
- assert request is not None
- assert request.sessionId == "test_session_123"
- assert request.path == str(test_file)
- assert request.line is None # offset=0 means no line specified
- assert request.limit is None
+ # Verify read_text_file was called correctly
+ params = mock_client._last_read_params
+ assert params["session_id"] == "test_session_123"
+ assert params["path"] == str(test_file)
+ assert params["line"] is None # offset=0 means no line specified
+ assert params["limit"] is None
@pytest.mark.asyncio
async def test_run_with_offset(
- self, mock_connection: MockConnection, tmp_path: Path
+ self, mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
+ monkeypatch.chdir(tmp_path)
test_file = tmp_path / "test_file.txt"
test_file.touch()
tool = ReadFile(
- config=ReadFileToolConfig(workdir=tmp_path),
+ config=ReadFileToolConfig(),
state=AcpReadFileState.model_construct(
- connection=mock_connection, # type: ignore[arg-type]
- session_id="test_session",
- tool_call_id="test_call",
+ client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
args = ReadFileArgs(path=str(test_file), offset=1)
- result = await tool.run(args)
+ result = await collect_result(tool.run(args))
assert result.lines_read == 2
assert result.content == "line 2\nline 3"
- request = mock_connection._last_read_request
- assert request is not None
- assert request.line == 2 # offset=1 means line 2 (1-indexed)
+ params = mock_client._last_read_params
+ assert params["line"] == 2 # offset=1 means line 2 (1-indexed)
@pytest.mark.asyncio
async def test_run_with_limit(
- self, mock_connection: MockConnection, tmp_path: Path
+ self, mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
+ monkeypatch.chdir(tmp_path)
test_file = tmp_path / "test_file.txt"
test_file.touch()
tool = ReadFile(
- config=ReadFileToolConfig(workdir=tmp_path),
+ config=ReadFileToolConfig(),
state=AcpReadFileState.model_construct(
- connection=mock_connection, # type: ignore[arg-type]
- session_id="test_session",
- tool_call_id="test_call",
+ client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
args = ReadFileArgs(path=str(test_file), limit=2)
- result = await tool.run(args)
+ result = await collect_result(tool.run(args))
assert result.lines_read == 2
assert result.content == "line 1\nline 2\n"
- request = mock_connection._last_read_request
- assert request is not None
- assert request.limit == 2
+ params = mock_client._last_read_params
+ assert params["limit"] == 2
@pytest.mark.asyncio
async def test_run_with_offset_and_limit(
- self, mock_connection: MockConnection, tmp_path: Path
+ self, mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
+ monkeypatch.chdir(tmp_path)
test_file = tmp_path / "test_file.txt"
test_file.touch()
tool = ReadFile(
- config=ReadFileToolConfig(workdir=tmp_path),
+ config=ReadFileToolConfig(),
state=AcpReadFileState.model_construct(
- connection=mock_connection, # type: ignore[arg-type]
- session_id="test_session",
- tool_call_id="test_call",
+ client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
args = ReadFileArgs(path=str(test_file), offset=1, limit=1)
- result = await tool.run(args)
+ result = await collect_result(tool.run(args))
assert result.lines_read == 1
assert result.content == "line 2\n"
- request = mock_connection._last_read_request
- assert request is not None
- assert request.line == 2
- assert request.limit == 1
+ params = mock_client._last_read_params
+ assert params["line"] == 2
+ assert params["limit"] == 1
@pytest.mark.asyncio
async def test_run_read_error(
- self, mock_connection: MockConnection, tmp_path: Path
+ self, mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
- mock_connection._read_error = RuntimeError("File not found")
+ monkeypatch.chdir(tmp_path)
+ mock_client._read_error = RuntimeError("File not found")
test_file = tmp_path / "test.txt"
test_file.touch()
tool = ReadFile(
- config=ReadFileToolConfig(workdir=tmp_path),
+ config=ReadFileToolConfig(),
state=AcpReadFileState.model_construct(
- connection=mock_connection, # type: ignore[arg-type]
- session_id="test_session",
- tool_call_id="test_call",
+ client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
args = ReadFileArgs(path=str(test_file))
with pytest.raises(ToolError) as exc_info:
- await tool.run(args)
+ await collect_result(tool.run(args))
assert str(exc_info.value) == f"Error reading {test_file}: File not found"
@pytest.mark.asyncio
- async def test_run_without_connection(self, tmp_path: Path) -> None:
+ async def test_run_without_connection(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.chdir(tmp_path)
test_file = tmp_path / "test.txt"
test_file.touch()
tool = ReadFile(
- config=ReadFileToolConfig(workdir=tmp_path),
+ config=ReadFileToolConfig(),
state=AcpReadFileState.model_construct(
- connection=None, session_id="test_session", tool_call_id="test_call"
+ client=None, session_id="test_session", tool_call_id="test_call"
),
)
args = ReadFileArgs(path=str(test_file))
with pytest.raises(ToolError) as exc_info:
- await tool.run(args)
+ await collect_result(tool.run(args))
assert (
str(exc_info.value)
- == "Connection not available in tool state. This tool can only be used within an ACP session."
+ == "Client not available in tool state. This tool can only be used within an ACP session."
)
@pytest.mark.asyncio
- async def test_run_without_session_id(self, tmp_path: Path) -> None:
+ async def test_run_without_session_id(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.chdir(tmp_path)
test_file = tmp_path / "test.txt"
test_file.touch()
- mock_connection = MockConnection()
+ mock_client = MockClient()
tool = ReadFile(
- config=ReadFileToolConfig(workdir=tmp_path),
+ config=ReadFileToolConfig(),
state=AcpReadFileState.model_construct(
- connection=mock_connection, # type: ignore[arg-type]
- session_id=None,
- tool_call_id="test_call",
+ client=mock_client, session_id=None, tool_call_id="test_call"
),
)
args = ReadFileArgs(path=str(test_file))
with pytest.raises(ToolError) as exc_info:
- await tool.run(args)
+ await collect_result(tool.run(args))
assert (
str(exc_info.value)
diff --git a/tests/acp/test_search_replace.py b/tests/acp/test_search_replace.py
index 1907ad7..da56457 100644
--- a/tests/acp/test_search_replace.py
+++ b/tests/acp/test_search_replace.py
@@ -2,9 +2,10 @@ from __future__ import annotations
from pathlib import Path
-from acp import ReadTextFileRequest, ReadTextFileResponse, WriteTextFileRequest
+from acp import ReadTextFileResponse
import pytest
+from tests.mock.utils import collect_result
from vibe.acp.tools.builtins.search_replace import AcpSearchReplaceState, SearchReplace
from vibe.core.tools.base import ToolError
from vibe.core.tools.builtins.search_replace import (
@@ -15,7 +16,7 @@ from vibe.core.tools.builtins.search_replace import (
from vibe.core.types import ToolCallEvent, ToolResultEvent
-class MockConnection:
+class MockClient:
def __init__(
self,
file_content: str = "original line 1\noriginal line 2\noriginal line 3",
@@ -28,43 +29,59 @@ class MockConnection:
self._read_text_file_called = False
self._write_text_file_called = False
self._session_update_called = False
- self._last_read_request: ReadTextFileRequest | None = None
- self._last_write_request: WriteTextFileRequest | None = None
- self._write_calls: list[WriteTextFileRequest] = []
+ self._last_read_params: dict[str, str | int | None] = {}
+ self._last_write_params: dict[str, str] = {}
+ self._write_calls: list[dict[str, str]] = []
- async def readTextFile(self, request: ReadTextFileRequest) -> ReadTextFileResponse:
+ async def read_text_file(
+ self,
+ path: str,
+ session_id: str,
+ limit: int | None = None,
+ line: int | None = None,
+ **kwargs,
+ ) -> ReadTextFileResponse:
self._read_text_file_called = True
- self._last_read_request = request
+ self._last_read_params = {
+ "path": path,
+ "session_id": session_id,
+ "limit": limit,
+ "line": line,
+ }
if self._read_error:
raise self._read_error
return ReadTextFileResponse(content=self._file_content)
- async def writeTextFile(self, request: WriteTextFileRequest) -> None:
+ async def write_text_file(
+ self, content: str, path: str, session_id: str, **kwargs
+ ) -> None:
self._write_text_file_called = True
- self._last_write_request = request
- self._write_calls.append(request)
+ params = {"content": content, "path": path, "session_id": session_id}
+ self._last_write_params = params
+ self._write_calls.append(params)
if self._write_error:
raise self._write_error
- async def sessionUpdate(self, notification) -> None:
+ async def session_update(self, session_id: str, update, **kwargs) -> None:
self._session_update_called = True
@pytest.fixture
-def mock_connection() -> MockConnection:
- return MockConnection()
+def mock_client() -> MockClient:
+ return MockClient()
@pytest.fixture
def acp_search_replace_tool(
- mock_connection: MockConnection, tmp_path: Path
+ mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> SearchReplace:
- config = SearchReplaceConfig(workdir=tmp_path)
+ monkeypatch.chdir(tmp_path)
+ config = SearchReplaceConfig()
state = AcpSearchReplaceState.model_construct(
- connection=mock_connection, # type: ignore[arg-type]
+ client=mock_client,
session_id="test_session_123",
tool_call_id="test_tool_call_456",
)
@@ -81,7 +98,7 @@ class TestAcpSearchReplaceExecution:
async def test_run_success(
self,
acp_search_replace_tool: SearchReplace,
- mock_connection: MockConnection,
+ mock_client: MockClient,
tmp_path: Path,
) -> None:
test_file = tmp_path / "test_file.txt"
@@ -92,41 +109,39 @@ class TestAcpSearchReplaceExecution:
args = SearchReplaceArgs(
file_path=str(test_file), content=search_replace_content
)
- result = await acp_search_replace_tool.run(args)
+ result = await collect_result(acp_search_replace_tool.run(args))
assert isinstance(result, SearchReplaceResult)
assert result.file == str(test_file)
assert result.blocks_applied == 1
- assert mock_connection._read_text_file_called
- assert mock_connection._write_text_file_called
- assert mock_connection._session_update_called
+ assert mock_client._read_text_file_called
+ assert mock_client._write_text_file_called
+ assert mock_client._session_update_called
- # Verify ReadTextFileRequest was created correctly
- read_request = mock_connection._last_read_request
- assert read_request is not None
- assert read_request.sessionId == "test_session_123"
- assert read_request.path == str(test_file)
+ # Verify read_text_file was called correctly
+ read_params = mock_client._last_read_params
+ assert read_params["session_id"] == "test_session_123"
+ assert read_params["path"] == str(test_file)
- # Verify WriteTextFileRequest was created correctly
- write_request = mock_connection._last_write_request
- assert write_request is not None
- assert write_request.sessionId == "test_session_123"
- assert write_request.path == str(test_file)
+ # Verify write_text_file was called correctly
+ write_params = mock_client._last_write_params
+ assert write_params["session_id"] == "test_session_123"
+ assert write_params["path"] == str(test_file)
assert (
- write_request.content == "original line 1\nmodified line 2\noriginal line 3"
+ write_params["content"]
+ == "original line 1\nmodified line 2\noriginal line 3"
)
@pytest.mark.asyncio
async def test_run_with_backup(
- self, mock_connection: MockConnection, tmp_path: Path
+ self, mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
- config = SearchReplaceConfig(create_backup=True, workdir=tmp_path)
+ monkeypatch.chdir(tmp_path)
+ config = SearchReplaceConfig(create_backup=True)
tool = SearchReplace(
config=config,
state=AcpSearchReplaceState.model_construct(
- connection=mock_connection, # type: ignore[arg-type]
- session_id="test_session",
- tool_call_id="test_call",
+ client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
@@ -138,26 +153,25 @@ class TestAcpSearchReplaceExecution:
args = SearchReplaceArgs(
file_path=str(test_file), content=search_replace_content
)
- result = await tool.run(args)
+ result = await collect_result(tool.run(args))
assert result.blocks_applied == 1
# Should have written the main file and the backup
- assert len(mock_connection._write_calls) >= 1
+ assert len(mock_client._write_calls) >= 1
# Check if backup was written (it should be written to .bak file)
- assert sum(w.path.endswith(".bak") for w in mock_connection._write_calls) == 1
+ assert sum(w["path"].endswith(".bak") for w in mock_client._write_calls) == 1
@pytest.mark.asyncio
async def test_run_read_error(
- self, mock_connection: MockConnection, tmp_path: Path
+ self, mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
- mock_connection._read_error = RuntimeError("File not found")
+ monkeypatch.chdir(tmp_path)
+ mock_client._read_error = RuntimeError("File not found")
tool = SearchReplace(
- config=SearchReplaceConfig(workdir=tmp_path),
+ config=SearchReplaceConfig(),
state=AcpSearchReplaceState.model_construct(
- connection=mock_connection, # type: ignore[arg-type]
- session_id="test_session",
- tool_call_id="test_call",
+ client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
@@ -168,7 +182,7 @@ class TestAcpSearchReplaceExecution:
file_path=str(test_file), content=search_replace_content
)
with pytest.raises(ToolError) as exc_info:
- await tool.run(args)
+ await collect_result(tool.run(args))
assert (
str(exc_info.value)
@@ -177,19 +191,18 @@ class TestAcpSearchReplaceExecution:
@pytest.mark.asyncio
async def test_run_write_error(
- self, mock_connection: MockConnection, tmp_path: Path
+ self, mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
- mock_connection._write_error = RuntimeError("Permission denied")
+ monkeypatch.chdir(tmp_path)
+ mock_client._write_error = RuntimeError("Permission denied")
test_file = tmp_path / "test.txt"
test_file.touch()
- mock_connection._file_content = "old" # Update mock to return correct content
+ mock_client._file_content = "old" # Update mock to return correct content
tool = SearchReplace(
- config=SearchReplaceConfig(workdir=tmp_path),
+ config=SearchReplaceConfig(),
state=AcpSearchReplaceState.model_construct(
- connection=mock_connection, # type: ignore[arg-type]
- session_id="test_session",
- tool_call_id="test_call",
+ client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
@@ -198,21 +211,21 @@ class TestAcpSearchReplaceExecution:
file_path=str(test_file), content=search_replace_content
)
with pytest.raises(ToolError) as exc_info:
- await tool.run(args)
+ await collect_result(tool.run(args))
assert str(exc_info.value) == f"Error writing {test_file}: Permission denied"
@pytest.mark.asyncio
@pytest.mark.parametrize(
- "connection,session_id,expected_error",
+ "client,session_id,expected_error",
[
(
None,
"test_session",
- "Connection not available in tool state. This tool can only be used within an ACP session.",
+ "Client not available in tool state. This tool can only be used within an ACP session.",
),
(
- MockConnection(),
+ MockClient(),
None,
"Session ID not available in tool state. This tool can only be used within an ACP session.",
),
@@ -221,18 +234,18 @@ class TestAcpSearchReplaceExecution:
async def test_run_without_required_state(
self,
tmp_path: Path,
- connection: MockConnection | None,
+ client: MockClient | None,
session_id: str | None,
expected_error: str,
+ monkeypatch: pytest.MonkeyPatch,
) -> None:
+ monkeypatch.chdir(tmp_path)
test_file = tmp_path / "test.txt"
test_file.touch()
tool = SearchReplace(
- config=SearchReplaceConfig(workdir=tmp_path),
+ config=SearchReplaceConfig(),
state=AcpSearchReplaceState.model_construct(
- connection=connection, # type: ignore[arg-type]
- session_id=session_id,
- tool_call_id="test_call",
+ client=client, session_id=session_id, tool_call_id="test_call"
),
)
@@ -241,7 +254,7 @@ class TestAcpSearchReplaceExecution:
file_path=str(test_file), content=search_replace_content
)
with pytest.raises(ToolError) as exc_info:
- await tool.run(args)
+ await collect_result(tool.run(args))
assert str(exc_info.value) == expected_error
@@ -262,16 +275,17 @@ class TestAcpSearchReplaceSessionUpdates:
update = SearchReplace.tool_call_session_update(event)
assert update is not None
- assert update.sessionUpdate == "tool_call"
- assert update.toolCallId == "test_call_123"
+ assert update.session_update == "tool_call"
+ assert update.tool_call_id == "test_call_123"
assert update.kind == "edit"
assert update.title is not None
assert update.content is not None
+ assert isinstance(update.content, list)
assert len(update.content) == 1
assert update.content[0].type == "diff"
assert update.content[0].path == "/tmp/test.txt"
- assert update.content[0].oldText == "old text"
- assert update.content[0].newText == "new text"
+ assert update.content[0].old_text == "old text"
+ assert update.content[0].new_text == "new text"
assert update.locations is not None
assert len(update.locations) == 1
assert update.locations[0].path == "/tmp/test.txt"
@@ -311,15 +325,16 @@ class TestAcpSearchReplaceSessionUpdates:
update = SearchReplace.tool_result_session_update(event)
assert update is not None
- assert update.sessionUpdate == "tool_call_update"
- assert update.toolCallId == "test_call_123"
+ assert update.session_update == "tool_call_update"
+ assert update.tool_call_id == "test_call_123"
assert update.status == "completed"
assert update.content is not None
+ assert isinstance(update.content, list)
assert len(update.content) == 1
assert update.content[0].type == "diff"
assert update.content[0].path == "/tmp/test.txt"
- assert update.content[0].oldText == "old text"
- assert update.content[0].newText == "new text"
+ assert update.content[0].old_text == "old text"
+ assert update.content[0].new_text == "new text"
assert update.locations is not None
assert len(update.locations) == 1
assert update.locations[0].path == "/tmp/test.txt"
diff --git a/tests/acp/test_set_mode.py b/tests/acp/test_set_mode.py
index 91b9376..3a146f4 100644
--- a/tests/acp/test_set_mode.py
+++ b/tests/acp/test_set_mode.py
@@ -1,207 +1,178 @@
from __future__ import annotations
from pathlib import Path
-from unittest.mock import patch
-from acp import AgentSideConnection, NewSessionRequest, SetSessionModeRequest
import pytest
-from tests.stubs.fake_backend import FakeBackend
-from tests.stubs.fake_connection import FakeAgentSideConnection
-from vibe.acp.acp_agent import VibeAcpAgent
-from vibe.core.agent import Agent
-from vibe.core.modes import AgentMode
-from vibe.core.types import LLMChunk, LLMMessage, LLMUsage, Role
-
-
-@pytest.fixture
-def backend() -> FakeBackend:
- backend = FakeBackend(
- LLMChunk(
- message=LLMMessage(role=Role.assistant, content="Hi"),
- usage=LLMUsage(prompt_tokens=1, completion_tokens=1),
- )
- )
- return backend
-
-
-@pytest.fixture
-def acp_agent(backend: FakeBackend) -> VibeAcpAgent:
- class PatchedAgent(Agent):
- def __init__(self, *args, **kwargs) -> None:
- super().__init__(*args, **kwargs, backend=backend)
-
- patch("vibe.acp.acp_agent.VibeAgent", side_effect=PatchedAgent).start()
-
- vibe_acp_agent: VibeAcpAgent | None = None
-
- def _create_agent(connection: AgentSideConnection) -> VibeAcpAgent:
- nonlocal vibe_acp_agent
- vibe_acp_agent = VibeAcpAgent(connection)
- return vibe_acp_agent
-
- FakeAgentSideConnection(_create_agent)
- return vibe_acp_agent # pyright: ignore[reportReturnType]
+from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
+from vibe.core.agents.models import BuiltinAgentName
class TestACPSetMode:
@pytest.mark.asyncio
- async def test_set_mode_to_default(self, acp_agent: VibeAcpAgent) -> None:
- session_response = await acp_agent.newSession(
- NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
+ async def test_set_mode_to_default(self, acp_agent_loop: VibeAcpAgentLoop) -> None:
+ session_response = await acp_agent_loop.new_session(
+ cwd=str(Path.cwd()), mcp_servers=[]
)
- session_id = session_response.sessionId
+ session_id = session_response.session_id
acp_session = next(
- (s for s in acp_agent.sessions.values() if s.id == session_id), None
+ (s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
- await acp_session.agent.switch_mode(AgentMode.AUTO_APPROVE)
+ await acp_session.agent_loop.switch_agent(BuiltinAgentName.AUTO_APPROVE)
- response = await acp_agent.setSessionMode(
- SetSessionModeRequest(sessionId=session_id, modeId=AgentMode.DEFAULT.value)
+ response = await acp_agent_loop.set_session_mode(
+ session_id=session_id, mode_id=BuiltinAgentName.DEFAULT
)
assert response is not None
- assert acp_session.agent.mode == AgentMode.DEFAULT
- assert acp_session.agent.auto_approve is False
+ assert acp_session.agent_loop.agent_profile.name == BuiltinAgentName.DEFAULT
+ assert acp_session.agent_loop.auto_approve is False
@pytest.mark.asyncio
- async def test_set_mode_to_auto_approve(self, acp_agent: VibeAcpAgent) -> None:
- session_response = await acp_agent.newSession(
- NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
+ async def test_set_mode_to_auto_approve(
+ self, acp_agent_loop: VibeAcpAgentLoop
+ ) -> None:
+ session_response = await acp_agent_loop.new_session(
+ cwd=str(Path.cwd()), mcp_servers=[]
)
- session_id = session_response.sessionId
+ session_id = session_response.session_id
acp_session = next(
- (s for s in acp_agent.sessions.values() if s.id == session_id), None
+ (s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
- assert acp_session.agent.mode == AgentMode.DEFAULT
- assert acp_session.agent.auto_approve is False
+ assert acp_session.agent_loop.agent_profile.name == BuiltinAgentName.DEFAULT
+ assert acp_session.agent_loop.auto_approve is False
- response = await acp_agent.setSessionMode(
- SetSessionModeRequest(
- sessionId=session_id, modeId=AgentMode.AUTO_APPROVE.value
- )
+ response = await acp_agent_loop.set_session_mode(
+ session_id=session_id, mode_id=BuiltinAgentName.AUTO_APPROVE
)
assert response is not None
- assert acp_session.agent.mode == AgentMode.AUTO_APPROVE
- assert acp_session.agent.auto_approve is True
-
- @pytest.mark.asyncio
- async def test_set_mode_to_plan(self, acp_agent: VibeAcpAgent) -> None:
- session_response = await acp_agent.newSession(
- NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
- )
- session_id = session_response.sessionId
- acp_session = next(
- (s for s in acp_agent.sessions.values() if s.id == session_id), None
- )
- assert acp_session is not None
-
- assert acp_session.agent.mode == AgentMode.DEFAULT
-
- response = await acp_agent.setSessionMode(
- SetSessionModeRequest(sessionId=session_id, modeId=AgentMode.PLAN.value)
- )
-
- assert response is not None
- assert acp_session.agent.mode == AgentMode.PLAN
assert (
- acp_session.agent.auto_approve is True
+ acp_session.agent_loop.agent_profile.name == BuiltinAgentName.AUTO_APPROVE
+ )
+ assert acp_session.agent_loop.auto_approve is True
+
+ @pytest.mark.asyncio
+ async def test_set_mode_to_plan(self, acp_agent_loop: VibeAcpAgentLoop) -> None:
+ session_response = await acp_agent_loop.new_session(
+ cwd=str(Path.cwd()), mcp_servers=[]
+ )
+ session_id = session_response.session_id
+ acp_session = next(
+ (s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
+ )
+ assert acp_session is not None
+
+ assert acp_session.agent_loop.agent_profile.name == BuiltinAgentName.DEFAULT
+
+ response = await acp_agent_loop.set_session_mode(
+ session_id=session_id, mode_id=BuiltinAgentName.PLAN
+ )
+
+ assert response is not None
+ assert acp_session.agent_loop.agent_profile.name == BuiltinAgentName.PLAN
+ assert (
+ acp_session.agent_loop.auto_approve is True
) # Plan mode auto-approves read-only tools
@pytest.mark.asyncio
- async def test_set_mode_to_accept_edits(self, acp_agent: VibeAcpAgent) -> None:
- session_response = await acp_agent.newSession(
- NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
+ async def test_set_mode_to_accept_edits(
+ self, acp_agent_loop: VibeAcpAgentLoop
+ ) -> None:
+ session_response = await acp_agent_loop.new_session(
+ cwd=str(Path.cwd()), mcp_servers=[]
)
- session_id = session_response.sessionId
+ session_id = session_response.session_id
acp_session = next(
- (s for s in acp_agent.sessions.values() if s.id == session_id), None
+ (s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
- assert acp_session.agent.mode == AgentMode.DEFAULT
+ assert acp_session.agent_loop.agent_profile.name == BuiltinAgentName.DEFAULT
- response = await acp_agent.setSessionMode(
- SetSessionModeRequest(
- sessionId=session_id, modeId=AgentMode.ACCEPT_EDITS.value
- )
+ response = await acp_agent_loop.set_session_mode(
+ session_id=session_id, mode_id=BuiltinAgentName.ACCEPT_EDITS
)
assert response is not None
- assert acp_session.agent.mode == AgentMode.ACCEPT_EDITS
assert (
- acp_session.agent.auto_approve is False
+ acp_session.agent_loop.agent_profile.name == BuiltinAgentName.ACCEPT_EDITS
+ )
+ assert (
+ acp_session.agent_loop.auto_approve is False
) # Accept Edits mode doesn't auto-approve all
@pytest.mark.asyncio
async def test_set_mode_invalid_mode_returns_none(
- self, acp_agent: VibeAcpAgent
+ self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
- session_response = await acp_agent.newSession(
- NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
+ session_response = await acp_agent_loop.new_session(
+ cwd=str(Path.cwd()), mcp_servers=[]
)
- session_id = session_response.sessionId
+ session_id = session_response.session_id
acp_session = next(
- (s for s in acp_agent.sessions.values() if s.id == session_id), None
+ (s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
- initial_mode = acp_session.agent.mode
- initial_auto_approve = acp_session.agent.auto_approve
+ initial_agent = acp_session.agent_loop.agent_profile.name
+ initial_auto_approve = acp_session.agent_loop.auto_approve
- response = await acp_agent.setSessionMode(
- SetSessionModeRequest(sessionId=session_id, modeId="invalid-mode")
+ response = await acp_agent_loop.set_session_mode(
+ session_id=session_id, mode_id="invalid-mode"
)
assert response is None
- assert acp_session.agent.mode == initial_mode
- assert acp_session.agent.auto_approve == initial_auto_approve
+ assert acp_session.agent_loop.agent_profile.name == initial_agent
+ assert acp_session.agent_loop.auto_approve == initial_auto_approve
@pytest.mark.asyncio
- async def test_set_mode_to_same_mode(self, acp_agent: VibeAcpAgent) -> None:
- session_response = await acp_agent.newSession(
- NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
+ async def test_set_mode_to_same_mode(
+ self, acp_agent_loop: VibeAcpAgentLoop
+ ) -> None:
+ session_response = await acp_agent_loop.new_session(
+ cwd=str(Path.cwd()), mcp_servers=[]
)
- session_id = session_response.sessionId
+ session_id = session_response.session_id
acp_session = next(
- (s for s in acp_agent.sessions.values() if s.id == session_id), None
+ (s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
- initial_mode = AgentMode.DEFAULT
- assert acp_session.agent.mode == initial_mode
+ assert acp_session.agent_loop.agent_profile.name == BuiltinAgentName.DEFAULT
- response = await acp_agent.setSessionMode(
- SetSessionModeRequest(sessionId=session_id, modeId=initial_mode.value)
+ response = await acp_agent_loop.set_session_mode(
+ session_id=session_id, mode_id=BuiltinAgentName.DEFAULT
)
assert response is not None
- assert acp_session.agent.mode == initial_mode
- assert acp_session.agent.auto_approve is False
+ assert acp_session.agent_loop.agent_profile.name == BuiltinAgentName.DEFAULT
+ assert acp_session.agent_loop.auto_approve is False
@pytest.mark.asyncio
- async def test_set_mode_with_empty_string(self, acp_agent: VibeAcpAgent) -> None:
- session_response = await acp_agent.newSession(
- NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
+ async def test_set_mode_with_empty_string(
+ self, acp_agent_loop: VibeAcpAgentLoop
+ ) -> None:
+ session_response = await acp_agent_loop.new_session(
+ cwd=str(Path.cwd()), mcp_servers=[]
)
- session_id = session_response.sessionId
+ session_id = session_response.session_id
acp_session = next(
- (s for s in acp_agent.sessions.values() if s.id == session_id), None
+ (s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
- initial_mode = acp_session.agent.mode
- initial_auto_approve = acp_session.agent.auto_approve
+ initial_agent = acp_session.agent_loop.agent_profile.name
+ initial_auto_approve = acp_session.agent_loop.auto_approve
- response = await acp_agent.setSessionMode(
- SetSessionModeRequest(sessionId=session_id, modeId="")
+ response = await acp_agent_loop.set_session_mode(
+ session_id=session_id, mode_id=""
)
assert response is None
- assert acp_session.agent.mode == initial_mode
- assert acp_session.agent.auto_approve == initial_auto_approve
+ assert acp_session.agent_loop.agent_profile.name == initial_agent
+ assert acp_session.agent_loop.auto_approve == initial_auto_approve
diff --git a/tests/acp/test_set_model.py b/tests/acp/test_set_model.py
index 8259faf..ef51871 100644
--- a/tests/acp/test_set_model.py
+++ b/tests/acp/test_set_model.py
@@ -3,30 +3,17 @@ from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
-from acp import AgentSideConnection, NewSessionRequest, SetSessionModelRequest
import pytest
-from tests.stubs.fake_backend import FakeBackend
-from tests.stubs.fake_connection import FakeAgentSideConnection
-from vibe.acp.acp_agent import VibeAcpAgent
-from vibe.core.agent import Agent
+from tests.acp.conftest import _create_acp_agent
+from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
+from vibe.core.agent_loop import AgentLoop
from vibe.core.config import ModelConfig, VibeConfig
-from vibe.core.types import LLMChunk, LLMMessage, LLMUsage, Role
+from vibe.core.types import LLMMessage, Role
@pytest.fixture
-def backend() -> FakeBackend:
- backend = FakeBackend(
- LLMChunk(
- message=LLMMessage(role=Role.assistant, content="Hi"),
- usage=LLMUsage(prompt_tokens=1, completion_tokens=1),
- )
- )
- return backend
-
-
-@pytest.fixture
-def acp_agent(backend: FakeBackend) -> VibeAcpAgent:
+def acp_agent_loop(backend) -> VibeAcpAgentLoop:
config = VibeConfig(
active_model="devstral-latest",
models=[
@@ -49,10 +36,11 @@ def acp_agent(backend: FakeBackend) -> VibeAcpAgent:
VibeConfig.dump_config(config.model_dump())
- class PatchedAgent(Agent):
+ class PatchedAgentLoop(AgentLoop):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **{**kwargs, "backend": backend})
- self.config = config
+ self._base_config = config
+ self.agent_manager.invalidate_config()
try:
active_model = config.get_active_model()
self.stats.input_price_per_million = active_model.input_price
@@ -60,90 +48,86 @@ def acp_agent(backend: FakeBackend) -> VibeAcpAgent:
except ValueError:
pass
- patch("vibe.acp.acp_agent.VibeAgent", side_effect=PatchedAgent).start()
+ patch("vibe.acp.acp_agent_loop.AgentLoop", side_effect=PatchedAgentLoop).start()
- vibe_acp_agent: VibeAcpAgent | None = None
-
- def _create_agent(connection: AgentSideConnection) -> VibeAcpAgent:
- nonlocal vibe_acp_agent
- vibe_acp_agent = VibeAcpAgent(connection)
- return vibe_acp_agent
-
- FakeAgentSideConnection(_create_agent)
- return vibe_acp_agent # pyright: ignore[reportReturnType]
+ return _create_acp_agent()
class TestACPSetModel:
@pytest.mark.asyncio
- async def test_set_model_success(self, acp_agent: VibeAcpAgent) -> None:
- session_response = await acp_agent.newSession(
- NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
+ async def test_set_model_success(self, acp_agent_loop: VibeAcpAgentLoop) -> None:
+ session_response = await acp_agent_loop.new_session(
+ cwd=str(Path.cwd()), mcp_servers=[]
)
- session_id = session_response.sessionId
+ session_id = session_response.session_id
acp_session = next(
- (s for s in acp_agent.sessions.values() if s.id == session_id), None
+ (s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
- assert acp_session.agent.config.active_model == "devstral-latest"
+ assert acp_session.agent_loop.config.active_model == "devstral-latest"
- response = await acp_agent.setSessionModel(
- SetSessionModelRequest(sessionId=session_id, modelId="devstral-small")
+ response = await acp_agent_loop.set_session_model(
+ session_id=session_id, model_id="devstral-small"
)
assert response is not None
- assert acp_session.agent.config.active_model == "devstral-small"
+ assert acp_session.agent_loop.config.active_model == "devstral-small"
@pytest.mark.asyncio
async def test_set_model_invalid_model_returns_none(
- self, acp_agent: VibeAcpAgent
+ self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
- session_response = await acp_agent.newSession(
- NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
+ session_response = await acp_agent_loop.new_session(
+ cwd=str(Path.cwd()), mcp_servers=[]
)
- session_id = session_response.sessionId
+ session_id = session_response.session_id
acp_session = next(
- (s for s in acp_agent.sessions.values() if s.id == session_id), None
+ (s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
- initial_model = acp_session.agent.config.active_model
+ initial_model = acp_session.agent_loop.config.active_model
- response = await acp_agent.setSessionModel(
- SetSessionModelRequest(sessionId=session_id, modelId="non-existent-model")
+ response = await acp_agent_loop.set_session_model(
+ session_id=session_id, model_id="non-existent-model"
)
assert response is None
- assert acp_session.agent.config.active_model == initial_model
+ assert acp_session.agent_loop.config.active_model == initial_model
@pytest.mark.asyncio
- async def test_set_model_to_same_model(self, acp_agent: VibeAcpAgent) -> None:
- session_response = await acp_agent.newSession(
- NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
+ async def test_set_model_to_same_model(
+ self, acp_agent_loop: VibeAcpAgentLoop
+ ) -> None:
+ session_response = await acp_agent_loop.new_session(
+ cwd=str(Path.cwd()), mcp_servers=[]
)
- session_id = session_response.sessionId
+ session_id = session_response.session_id
acp_session = next(
- (s for s in acp_agent.sessions.values() if s.id == session_id), None
+ (s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
)
initial_model = "devstral-latest"
assert acp_session is not None
- assert acp_session.agent.config.active_model == initial_model
+ assert acp_session.agent_loop.config.active_model == initial_model
- response = await acp_agent.setSessionModel(
- SetSessionModelRequest(sessionId=session_id, modelId=initial_model)
+ response = await acp_agent_loop.set_session_model(
+ session_id=session_id, model_id=initial_model
)
assert response is not None
- assert acp_session.agent.config.active_model == initial_model
+ assert acp_session.agent_loop.config.active_model == initial_model
@pytest.mark.asyncio
- async def test_set_model_saves_to_config(self, acp_agent: VibeAcpAgent) -> None:
- session_response = await acp_agent.newSession(
- NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
+ async def test_set_model_saves_to_config(
+ self, acp_agent_loop: VibeAcpAgentLoop
+ ) -> None:
+ session_response = await acp_agent_loop.new_session(
+ cwd=str(Path.cwd()), mcp_servers=[]
)
- session_id = session_response.sessionId
+ session_id = session_response.session_id
- with patch("vibe.acp.acp_agent.VibeConfig.save_updates") as mock_save:
- response = await acp_agent.setSessionModel(
- SetSessionModelRequest(sessionId=session_id, modelId="devstral-small")
+ with patch("vibe.acp.acp_agent_loop.VibeConfig.save_updates") as mock_save:
+ response = await acp_agent_loop.set_session_model(
+ session_id=session_id, model_id="devstral-small"
)
assert response is not None
@@ -151,157 +135,171 @@ class TestACPSetModel:
@pytest.mark.asyncio
async def test_set_model_does_not_save_on_invalid_model(
- self, acp_agent: VibeAcpAgent
+ self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
- session_response = await acp_agent.newSession(
- NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
+ session_response = await acp_agent_loop.new_session(
+ cwd=str(Path.cwd()), mcp_servers=[]
)
- session_id = session_response.sessionId
+ session_id = session_response.session_id
- with patch("vibe.acp.acp_agent.VibeConfig.save_updates") as mock_save:
- response = await acp_agent.setSessionModel(
- SetSessionModelRequest(
- sessionId=session_id, modelId="non-existent-model"
- )
+ with patch("vibe.acp.acp_agent_loop.VibeConfig.save_updates") as mock_save:
+ response = await acp_agent_loop.set_session_model(
+ session_id=session_id, model_id="non-existent-model"
)
assert response is None
mock_save.assert_not_called()
@pytest.mark.asyncio
- async def test_set_model_with_empty_string(self, acp_agent: VibeAcpAgent) -> None:
- session_response = await acp_agent.newSession(
- NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
+ async def test_set_model_with_empty_string(
+ self, acp_agent_loop: VibeAcpAgentLoop
+ ) -> None:
+ session_response = await acp_agent_loop.new_session(
+ cwd=str(Path.cwd()), mcp_servers=[]
)
- session_id = session_response.sessionId
+ session_id = session_response.session_id
acp_session = next(
- (s for s in acp_agent.sessions.values() if s.id == session_id), None
+ (s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
- initial_model = acp_session.agent.config.active_model
+ initial_model = acp_session.agent_loop.config.active_model
- response = await acp_agent.setSessionModel(
- SetSessionModelRequest(sessionId=session_id, modelId="")
+ response = await acp_agent_loop.set_session_model(
+ session_id=session_id, model_id=""
)
assert response is None
- assert acp_session.agent.config.active_model == initial_model
+ assert acp_session.agent_loop.config.active_model == initial_model
@pytest.mark.asyncio
async def test_set_model_updates_active_model(
- self, acp_agent: VibeAcpAgent
+ self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
- session_response = await acp_agent.newSession(
- NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
+ session_response = await acp_agent_loop.new_session(
+ cwd=str(Path.cwd()), mcp_servers=[]
)
- session_id = session_response.sessionId
+ session_id = session_response.session_id
acp_session = next(
- (s for s in acp_agent.sessions.values() if s.id == session_id), None
+ (s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
- assert acp_session.agent.config.get_active_model().alias == "devstral-latest"
-
- await acp_agent.setSessionModel(
- SetSessionModelRequest(sessionId=session_id, modelId="devstral-small")
+ assert (
+ acp_session.agent_loop.config.get_active_model().alias == "devstral-latest"
)
- assert acp_session.agent.config.get_active_model().alias == "devstral-small"
+ await acp_agent_loop.set_session_model(
+ session_id=session_id, model_id="devstral-small"
+ )
+
+ assert (
+ acp_session.agent_loop.config.get_active_model().alias == "devstral-small"
+ )
@pytest.mark.asyncio
async def test_set_model_calls_reload_with_initial_messages(
- self, acp_agent: VibeAcpAgent
+ self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
- session_response = await acp_agent.newSession(
- NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
+ session_response = await acp_agent_loop.new_session(
+ cwd=str(Path.cwd()), mcp_servers=[]
)
- session_id = session_response.sessionId
+ session_id = session_response.session_id
acp_session = next(
- (s for s in acp_agent.sessions.values() if s.id == session_id), None
+ (s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
with patch.object(
- acp_session.agent, "reload_with_initial_messages"
+ acp_session.agent_loop, "reload_with_initial_messages"
) as mock_reload:
- response = await acp_agent.setSessionModel(
- SetSessionModelRequest(sessionId=session_id, modelId="devstral-small")
+ response = await acp_agent_loop.set_session_model(
+ session_id=session_id, model_id="devstral-small"
)
assert response is not None
mock_reload.assert_called_once()
call_args = mock_reload.call_args
- assert call_args.kwargs["config"] is not None
- assert call_args.kwargs["config"].active_model == "devstral-small"
+ assert call_args.kwargs["base_config"] is not None
+ assert call_args.kwargs["base_config"].active_model == "devstral-small"
@pytest.mark.asyncio
async def test_set_model_preserves_conversation_history(
- self, acp_agent: VibeAcpAgent
+ self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
- session_response = await acp_agent.newSession(
- NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
+ session_response = await acp_agent_loop.new_session(
+ cwd=str(Path.cwd()), mcp_servers=[]
)
- session_id = session_response.sessionId
+ session_id = session_response.session_id
acp_session = next(
- (s for s in acp_agent.sessions.values() if s.id == session_id), None
+ (s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
user_msg = LLMMessage(role=Role.user, content="Hello")
assistant_msg = LLMMessage(role=Role.assistant, content="Hi there!")
- acp_session.agent.messages.append(user_msg)
- acp_session.agent.messages.append(assistant_msg)
+ acp_session.agent_loop.messages.append(user_msg)
+ acp_session.agent_loop.messages.append(assistant_msg)
- assert len(acp_session.agent.messages) == 3
+ assert len(acp_session.agent_loop.messages) == 3
- response = await acp_agent.setSessionModel(
- SetSessionModelRequest(sessionId=session_id, modelId="devstral-small")
+ response = await acp_agent_loop.set_session_model(
+ session_id=session_id, model_id="devstral-small"
)
assert response is not None
- assert len(acp_session.agent.messages) == 3
- assert acp_session.agent.messages[0].role == Role.system
- assert acp_session.agent.messages[1].content == "Hello"
- assert acp_session.agent.messages[2].content == "Hi there!"
+ assert len(acp_session.agent_loop.messages) == 3
+ assert acp_session.agent_loop.messages[0].role == Role.system
+ assert acp_session.agent_loop.messages[1].content == "Hello"
+ assert acp_session.agent_loop.messages[2].content == "Hi there!"
@pytest.mark.asyncio
async def test_set_model_resets_stats_with_new_model_pricing(
- self, acp_agent: VibeAcpAgent
+ self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
- session_response = await acp_agent.newSession(
- NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
+ session_response = await acp_agent_loop.new_session(
+ cwd=str(Path.cwd()), mcp_servers=[]
)
- session_id = session_response.sessionId
+ session_id = session_response.session_id
acp_session = next(
- (s for s in acp_agent.sessions.values() if s.id == session_id), None
+ (s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
- initial_model = acp_session.agent.config.get_active_model()
+ initial_model = acp_session.agent_loop.config.get_active_model()
initial_input_price = initial_model.input_price
initial_output_price = initial_model.output_price
- initial_stats_input = acp_session.agent.stats.input_price_per_million
- initial_stats_output = acp_session.agent.stats.output_price_per_million
+ initial_stats_input = acp_session.agent_loop.stats.input_price_per_million
+ initial_stats_output = acp_session.agent_loop.stats.output_price_per_million
- assert acp_session.agent.stats.input_price_per_million == initial_input_price
- assert acp_session.agent.stats.output_price_per_million == initial_output_price
+ assert (
+ acp_session.agent_loop.stats.input_price_per_million == initial_input_price
+ )
+ assert (
+ acp_session.agent_loop.stats.output_price_per_million
+ == initial_output_price
+ )
- response = await acp_agent.setSessionModel(
- SetSessionModelRequest(sessionId=session_id, modelId="devstral-small")
+ response = await acp_agent_loop.set_session_model(
+ session_id=session_id, model_id="devstral-small"
)
assert response is not None
- new_model = acp_session.agent.config.get_active_model()
+ new_model = acp_session.agent_loop.config.get_active_model()
new_input_price = new_model.input_price
new_output_price = new_model.output_price
assert new_input_price != initial_input_price
assert new_output_price != initial_output_price
- assert acp_session.agent.stats.input_price_per_million == new_input_price
- assert acp_session.agent.stats.output_price_per_million == new_output_price
+ assert acp_session.agent_loop.stats.input_price_per_million == new_input_price
+ assert acp_session.agent_loop.stats.output_price_per_million == new_output_price
- assert acp_session.agent.stats.input_price_per_million != initial_stats_input
- assert acp_session.agent.stats.output_price_per_million != initial_stats_output
+ assert (
+ acp_session.agent_loop.stats.input_price_per_million != initial_stats_input
+ )
+ assert (
+ acp_session.agent_loop.stats.output_price_per_million
+ != initial_stats_output
+ )
diff --git a/tests/acp/test_write_file.py b/tests/acp/test_write_file.py
index d800542..7badbe6 100644
--- a/tests/acp/test_write_file.py
+++ b/tests/acp/test_write_file.py
@@ -2,9 +2,9 @@ from __future__ import annotations
from pathlib import Path
-from acp import WriteTextFileRequest
import pytest
+from tests.mock.utils import collect_result
from vibe.acp.tools.builtins.write_file import AcpWriteFileState, WriteFile
from vibe.core.tools.base import ToolError
from vibe.core.tools.builtins.write_file import (
@@ -15,7 +15,7 @@ from vibe.core.tools.builtins.write_file import (
from vibe.core.types import ToolCallEvent, ToolResultEvent
-class MockConnection:
+class MockClient:
def __init__(
self, write_error: Exception | None = None, file_exists: bool = False
) -> None:
@@ -23,29 +23,38 @@ class MockConnection:
self._file_exists = file_exists
self._write_text_file_called = False
self._session_update_called = False
- self._last_write_request: WriteTextFileRequest | None = None
+ self._last_write_params: dict[str, str] = {}
- async def writeTextFile(self, request: WriteTextFileRequest) -> None:
+ async def write_text_file(
+ self, content: str, path: str, session_id: str, **kwargs
+ ) -> None:
self._write_text_file_called = True
- self._last_write_request = request
+ self._last_write_params = {
+ "content": content,
+ "path": path,
+ "session_id": session_id,
+ }
if self._write_error:
raise self._write_error
- async def sessionUpdate(self, notification) -> None:
+ async def session_update(self, session_id: str, update, **kwargs) -> None:
self._session_update_called = True
@pytest.fixture
-def mock_connection() -> MockConnection:
- return MockConnection()
+def mock_client() -> MockClient:
+ return MockClient()
@pytest.fixture
-def acp_write_file_tool(mock_connection: MockConnection, tmp_path: Path) -> WriteFile:
- config = WriteFileConfig(workdir=tmp_path)
+def acp_write_file_tool(
+ mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> WriteFile:
+ monkeypatch.chdir(tmp_path)
+ config = WriteFileConfig()
state = AcpWriteFileState.model_construct(
- connection=mock_connection, # type: ignore[arg-type]
+ client=mock_client,
session_id="test_session_123",
tool_call_id="test_tool_call_456",
)
@@ -60,40 +69,35 @@ class TestAcpWriteFileBasic:
class TestAcpWriteFileExecution:
@pytest.mark.asyncio
async def test_run_success_new_file(
- self,
- acp_write_file_tool: WriteFile,
- mock_connection: MockConnection,
- tmp_path: Path,
+ self, acp_write_file_tool: WriteFile, mock_client: MockClient, tmp_path: Path
) -> None:
test_file = tmp_path / "test_file.txt"
args = WriteFileArgs(path=str(test_file), content="Hello, world!")
- result = await acp_write_file_tool.run(args)
+ result = await collect_result(acp_write_file_tool.run(args))
assert isinstance(result, WriteFileResult)
assert result.path == str(test_file)
assert result.content == "Hello, world!"
assert result.bytes_written == len(b"Hello, world!")
assert result.file_existed is False
- assert mock_connection._write_text_file_called
- assert mock_connection._session_update_called
+ assert mock_client._write_text_file_called
+ assert mock_client._session_update_called
- # Verify WriteTextFileRequest was created correctly
- request = mock_connection._last_write_request
- assert request is not None
- assert request.sessionId == "test_session_123"
- assert request.path == str(test_file)
- assert request.content == "Hello, world!"
+ # Verify write_text_file was called correctly
+ params = mock_client._last_write_params
+ assert params["session_id"] == "test_session_123"
+ assert params["path"] == str(test_file)
+ assert params["content"] == "Hello, world!"
@pytest.mark.asyncio
async def test_run_success_overwrite(
- self, mock_connection: MockConnection, tmp_path: Path
+ self, mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
+ monkeypatch.chdir(tmp_path)
tool = WriteFile(
- config=WriteFileConfig(workdir=tmp_path),
+ config=WriteFileConfig(),
state=AcpWriteFileState.model_construct(
- connection=mock_connection, # type: ignore[arg-type]
- session_id="test_session",
- tool_call_id="test_call",
+ client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
@@ -102,78 +106,80 @@ class TestAcpWriteFileExecution:
# Simulate existing file by checking in the core tool logic
# The ACP tool doesn't check existence, it's handled by the core tool
args = WriteFileArgs(path=str(test_file), content="New content", overwrite=True)
- result = await tool.run(args)
+ result = await collect_result(tool.run(args))
assert isinstance(result, WriteFileResult)
assert result.path == str(test_file)
assert result.content == "New content"
assert result.bytes_written == len(b"New content")
assert result.file_existed is True
- assert mock_connection._write_text_file_called
- assert mock_connection._session_update_called
+ assert mock_client._write_text_file_called
+ assert mock_client._session_update_called
- # Verify WriteTextFileRequest was created correctly
- request = mock_connection._last_write_request
- assert request is not None
- assert request.sessionId == "test_session"
- assert request.path == str(test_file)
- assert request.content == "New content"
+ # Verify write_text_file was called correctly
+ params = mock_client._last_write_params
+ assert params["session_id"] == "test_session"
+ assert params["path"] == str(test_file)
+ assert params["content"] == "New content"
@pytest.mark.asyncio
async def test_run_write_error(
- self, mock_connection: MockConnection, tmp_path: Path
+ self, mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
- mock_connection._write_error = RuntimeError("Permission denied")
+ monkeypatch.chdir(tmp_path)
+ mock_client._write_error = RuntimeError("Permission denied")
tool = WriteFile(
- config=WriteFileConfig(workdir=tmp_path),
+ config=WriteFileConfig(),
state=AcpWriteFileState.model_construct(
- connection=mock_connection, # type: ignore[arg-type]
- session_id="test_session",
- tool_call_id="test_call",
+ client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
test_file = tmp_path / "test.txt"
args = WriteFileArgs(path=str(test_file), content="test")
with pytest.raises(ToolError) as exc_info:
- await tool.run(args)
+ await collect_result(tool.run(args))
assert str(exc_info.value) == f"Error writing {test_file}: Permission denied"
@pytest.mark.asyncio
- async def test_run_without_connection(self, tmp_path: Path) -> None:
+ async def test_run_without_connection(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.chdir(tmp_path)
tool = WriteFile(
- config=WriteFileConfig(workdir=tmp_path),
+ config=WriteFileConfig(),
state=AcpWriteFileState.model_construct(
- connection=None, session_id="test_session", tool_call_id="test_call"
+ client=None, session_id="test_session", tool_call_id="test_call"
),
)
args = WriteFileArgs(path=str(tmp_path / "test.txt"), content="test")
with pytest.raises(ToolError) as exc_info:
- await tool.run(args)
+ await collect_result(tool.run(args))
assert (
str(exc_info.value)
- == "Connection not available in tool state. This tool can only be used within an ACP session."
+ == "Client not available in tool state. This tool can only be used within an ACP session."
)
@pytest.mark.asyncio
- async def test_run_without_session_id(self, tmp_path: Path) -> None:
- mock_connection = MockConnection()
+ async def test_run_without_session_id(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.chdir(tmp_path)
+ mock_client = MockClient()
tool = WriteFile(
- config=WriteFileConfig(workdir=tmp_path),
+ config=WriteFileConfig(),
state=AcpWriteFileState.model_construct(
- connection=mock_connection, # type: ignore[arg-type]
- session_id=None,
- tool_call_id="test_call",
+ client=mock_client, session_id=None, tool_call_id="test_call"
),
)
args = WriteFileArgs(path=str(tmp_path / "test.txt"), content="test")
with pytest.raises(ToolError) as exc_info:
- await tool.run(args)
+ await collect_result(tool.run(args))
assert (
str(exc_info.value)
@@ -192,16 +198,17 @@ class TestAcpWriteFileSessionUpdates:
update = WriteFile.tool_call_session_update(event)
assert update is not None
- assert update.sessionUpdate == "tool_call"
- assert update.toolCallId == "test_call_123"
+ assert update.session_update == "tool_call"
+ assert update.tool_call_id == "test_call_123"
assert update.kind == "edit"
assert update.title is not None
assert update.content is not None
+ assert isinstance(update.content, list)
assert len(update.content) == 1
assert update.content[0].type == "diff"
assert update.content[0].path == "/tmp/test.txt"
- assert update.content[0].oldText is None
- assert update.content[0].newText == "Hello"
+ assert update.content[0].old_text is None
+ assert update.content[0].new_text == "Hello"
assert update.locations is not None
assert len(update.locations) == 1
assert update.locations[0].path == "/tmp/test.txt"
@@ -241,15 +248,16 @@ class TestAcpWriteFileSessionUpdates:
update = WriteFile.tool_result_session_update(event)
assert update is not None
- assert update.sessionUpdate == "tool_call_update"
- assert update.toolCallId == "test_call_123"
+ assert update.session_update == "tool_call_update"
+ assert update.tool_call_id == "test_call_123"
assert update.status == "completed"
assert update.content is not None
+ assert isinstance(update.content, list)
assert len(update.content) == 1
assert update.content[0].type == "diff"
assert update.content[0].path == "/tmp/test.txt"
- assert update.content[0].oldText is None
- assert update.content[0].newText == "Hello"
+ assert update.content[0].old_text is None
+ assert update.content[0].new_text == "Hello"
assert update.locations is not None
assert len(update.locations) == 1
assert update.locations[0].path == "/tmp/test.txt"
diff --git a/tests/autocompletion/test_slash_command_controller.py b/tests/autocompletion/test_slash_command_controller.py
index aba1b70..f12906d 100644
--- a/tests/autocompletion/test_slash_command_controller.py
+++ b/tests/autocompletion/test_slash_command_controller.py
@@ -61,7 +61,7 @@ def make_controller(
("/exit", "Exit application"),
("/vim", "Toggle vim keybindings"),
]
- completer = CommandCompleter(commands)
+ completer = CommandCompleter(lambda: commands)
view = StubView()
controller = SlashCommandController(completer, view)
@@ -162,3 +162,74 @@ def test_on_key_enter_submits_selected_completion() -> None:
assert result is CompletionResult.SUBMIT
assert view.replacements == [Replacement(0, 2, "/compact")]
assert view.reset_count == 1
+
+
+def test_callable_entries_updates_completions_dynamically() -> None:
+ """Test that CommandCompleter with a callable updates entries when the callable returns different values.
+
+ This simulates config reload where available skills change.
+ """
+ available_skills: list[tuple[str, str]] = []
+
+ def get_entries() -> list[tuple[str, str]]:
+ base_commands = [("/help", "Display help"), ("/config", "Show configuration")]
+ return base_commands + available_skills
+
+ completer = CommandCompleter(get_entries)
+ view = StubView()
+ controller = SlashCommandController(completer, view)
+
+ # Initially, only base commands are available
+ controller.on_text_changed("/", cursor_index=1)
+ suggestions, _ = view.suggestion_events[-1]
+ assert [s.alias for s in suggestions] == ["/help", "/config"]
+
+ # Simulate config reload: add a skill
+ available_skills.append(("/summarize", "Summarize the conversation"))
+
+ # Now completions should include the new skill
+ controller.on_text_changed("/", cursor_index=1)
+ suggestions, _ = view.suggestion_events[-1]
+ assert [s.alias for s in suggestions] == ["/help", "/config", "/summarize"]
+
+ # And searching for "/s" should find the new skill
+ controller.on_text_changed("/s", cursor_index=2)
+ suggestions, _ = view.suggestion_events[-1]
+ assert [s.alias for s in suggestions] == ["/summarize"]
+ assert suggestions[0].description == "Summarize the conversation"
+
+
+def test_callable_entries_reflects_enabled_disabled_skills() -> None:
+ """Test that skill enable/disable changes are reflected in completions.
+
+ This simulates the scenario where a user changes enabled_skills in config
+ and runs /reload.
+ """
+ enabled_skills: set[str] = {"commit", "review"}
+
+ all_skills = [
+ ("/commit", "Create a git commit"),
+ ("/review", "Review code changes"),
+ ("/deploy", "Deploy to production"),
+ ]
+
+ def get_entries() -> list[tuple[str, str]]:
+ return [(name, desc) for name, desc in all_skills if name[1:] in enabled_skills]
+
+ completer = CommandCompleter(get_entries)
+ view = StubView()
+ controller = SlashCommandController(completer, view)
+
+ # Initially only commit and review are enabled
+ controller.on_text_changed("/", cursor_index=1)
+ suggestions, _ = view.suggestion_events[-1]
+ assert [s.alias for s in suggestions] == ["/commit", "/review"]
+
+ # Simulate config reload: enable deploy, disable commit
+ enabled_skills.discard("commit")
+ enabled_skills.add("deploy")
+
+ # Now completions should reflect the change
+ controller.on_text_changed("/", cursor_index=1)
+ suggestions, _ = view.suggestion_events[-1]
+ assert [s.alias for s in suggestions] == ["/review", "/deploy"]
diff --git a/tests/autocompletion/test_ui_chat_autocompletion.py b/tests/autocompletion/test_ui_chat_autocompletion.py
index 94f0ea2..3e751c9 100644
--- a/tests/autocompletion/test_ui_chat_autocompletion.py
+++ b/tests/autocompletion/test_ui_chat_autocompletion.py
@@ -10,6 +10,7 @@ from textual.widgets import Markdown
from vibe.cli.textual_ui.app import VibeApp
from vibe.cli.textual_ui.widgets.chat_input.completion_popup import CompletionPopup
from vibe.cli.textual_ui.widgets.chat_input.container import ChatInputContainer
+from vibe.core.agent_loop import AgentLoop
from vibe.core.config import SessionLoggingConfig, VibeConfig
@@ -20,7 +21,8 @@ def vibe_config() -> VibeConfig:
@pytest.fixture
def vibe_app(vibe_config: VibeConfig) -> VibeApp:
- return VibeApp(config=vibe_config)
+ agent_loop = AgentLoop(vibe_config)
+ return VibeApp(agent_loop=agent_loop)
@pytest.mark.asyncio
@@ -60,7 +62,7 @@ async def test_pressing_tab_writes_selected_command_and_keeps_popup_visible(
await pilot.press(*"/co")
await pilot.press("tab")
- assert chat_input.value == "/config"
+ assert chat_input.value == "/compact"
assert popup.styles.display == "block"
@@ -88,11 +90,11 @@ async def test_arrow_navigation_updates_selected_suggestion(vibe_app: VibeApp) -
await pilot.press(*"/c")
- ensure_selected_command(popup, "/config")
- await pilot.press("down")
ensure_selected_command(popup, "/clear")
+ await pilot.press("down")
+ ensure_selected_command(popup, "/compact")
await pilot.press("up")
- ensure_selected_command(popup, "/config")
+ ensure_selected_command(popup, "/clear")
@pytest.mark.asyncio
@@ -102,11 +104,11 @@ async def test_arrow_navigation_cycles_through_suggestions(vibe_app: VibeApp) ->
await pilot.press(*"/co")
- ensure_selected_command(popup, "/config")
- await pilot.press("down")
ensure_selected_command(popup, "/compact")
- await pilot.press("up")
+ await pilot.press("down")
ensure_selected_command(popup, "/config")
+ await pilot.press("up")
+ ensure_selected_command(popup, "/compact")
@pytest.mark.asyncio
diff --git a/tests/cli/plan_offer/adapters/fake_whoami_gateway.py b/tests/cli/plan_offer/adapters/fake_whoami_gateway.py
new file mode 100644
index 0000000..1442e37
--- /dev/null
+++ b/tests/cli/plan_offer/adapters/fake_whoami_gateway.py
@@ -0,0 +1,32 @@
+from __future__ import annotations
+
+from vibe.cli.plan_offer.ports.whoami_gateway import (
+ WhoAmIGatewayError,
+ WhoAmIGatewayUnauthorized,
+ WhoAmIResponse,
+)
+
+
+class FakeWhoAmIGateway:
+ def __init__(
+ self,
+ response: WhoAmIResponse | None = None,
+ *,
+ unauthorized: bool = False,
+ error: bool = False,
+ ) -> None:
+ self._response = response
+ self._unauthorized = unauthorized
+ self._error = error
+ self.calls: list[str] = []
+
+ async def whoami(self, api_key: str) -> WhoAmIResponse:
+ self.calls.append(api_key)
+ if self._unauthorized:
+ raise WhoAmIGatewayUnauthorized()
+ if self._error:
+ raise WhoAmIGatewayError()
+ if self._response is None:
+ msg = "FakeWhoAmIGateway requires a response when no error is set."
+ raise RuntimeError(msg)
+ return self._response
diff --git a/tests/cli/plan_offer/test_decide_plan_offer.py b/tests/cli/plan_offer/test_decide_plan_offer.py
new file mode 100644
index 0000000..0b1d26f
--- /dev/null
+++ b/tests/cli/plan_offer/test_decide_plan_offer.py
@@ -0,0 +1,103 @@
+from __future__ import annotations
+
+import logging
+
+import pytest
+
+from tests.cli.plan_offer.adapters.fake_whoami_gateway import FakeWhoAmIGateway
+from vibe.cli.plan_offer.decide_plan_offer import PlanOfferAction, decide_plan_offer
+from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIResponse
+
+
+@pytest.mark.asyncio
+async def test_proposes_upgrade_without_call_when_api_key_is_empty() -> None:
+ gateway = FakeWhoAmIGateway(
+ WhoAmIResponse(
+ is_pro_plan=False,
+ advertise_pro_plan=False,
+ prompt_switching_to_pro_plan=False,
+ )
+ )
+ action = await decide_plan_offer("", gateway)
+
+ assert action is PlanOfferAction.UPGRADE
+ assert gateway.calls == []
+
+
+@pytest.mark.parametrize(
+ ("response", "expected"),
+ [
+ (
+ WhoAmIResponse(
+ is_pro_plan=True,
+ advertise_pro_plan=False,
+ prompt_switching_to_pro_plan=False,
+ ),
+ PlanOfferAction.NONE,
+ ),
+ (
+ WhoAmIResponse(
+ is_pro_plan=False,
+ advertise_pro_plan=True,
+ prompt_switching_to_pro_plan=False,
+ ),
+ PlanOfferAction.UPGRADE,
+ ),
+ (
+ WhoAmIResponse(
+ is_pro_plan=False,
+ advertise_pro_plan=False,
+ prompt_switching_to_pro_plan=True,
+ ),
+ PlanOfferAction.SWITCH_TO_PRO_KEY,
+ ),
+ ],
+ ids=["with-a-pro-plan", "without-a-pro-plan", "with-a-non-pro-key"],
+)
+@pytest.mark.asyncio
+async def test_proposes_an_action_based_on_current_plan_status(
+ response: WhoAmIResponse, expected: PlanOfferAction
+) -> None:
+ gateway = FakeWhoAmIGateway(response)
+ action = await decide_plan_offer("api-key", gateway)
+
+ assert action is expected
+ assert gateway.calls == ["api-key"]
+
+
+@pytest.mark.asyncio
+async def test_proposes_nothing_when_nothing_is_suggested() -> None:
+ gateway = FakeWhoAmIGateway(
+ WhoAmIResponse(
+ is_pro_plan=False,
+ advertise_pro_plan=False,
+ prompt_switching_to_pro_plan=False,
+ )
+ )
+
+ action = await decide_plan_offer("api-key", gateway)
+
+ assert action is PlanOfferAction.NONE
+ assert gateway.calls == ["api-key"]
+
+
+@pytest.mark.asyncio
+async def test_proposes_upgrade_when_api_key_is_unauthorized() -> None:
+ gateway = FakeWhoAmIGateway(unauthorized=True)
+ action = await decide_plan_offer("bad-key", gateway)
+
+ assert action is PlanOfferAction.UPGRADE
+ assert gateway.calls == ["bad-key"]
+
+
+@pytest.mark.asyncio
+async def test_proposes_none_and_logs_warning_when_gateway_error_occurs(
+ caplog: pytest.LogCaptureFixture,
+) -> None:
+ gateway = FakeWhoAmIGateway(error=True)
+ with caplog.at_level(logging.WARNING):
+ action = await decide_plan_offer("api-key", gateway)
+
+ assert action is PlanOfferAction.NONE
+ assert gateway.calls == ["api-key"]
+ assert "Failed to fetch plan status." in caplog.text
diff --git a/tests/cli/plan_offer/test_http_whoami_gateway.py b/tests/cli/plan_offer/test_http_whoami_gateway.py
new file mode 100644
index 0000000..5827e74
--- /dev/null
+++ b/tests/cli/plan_offer/test_http_whoami_gateway.py
@@ -0,0 +1,122 @@
+from __future__ import annotations
+
+import httpx
+import pytest
+import respx
+
+from vibe.cli.plan_offer.adapters.http_whoami_gateway import HttpWhoAmIGateway
+from vibe.cli.plan_offer.ports.whoami_gateway import (
+ WhoAmIGatewayError,
+ WhoAmIGatewayUnauthorized,
+ WhoAmIResponse,
+)
+
+
+@pytest.mark.asyncio
+async def test_returns_plan_flags(respx_mock: respx.MockRouter) -> None:
+ route = respx_mock.get("http://test/api/vibe/whoami").mock(
+ return_value=httpx.Response(
+ 200,
+ json={
+ "is_pro_plan": True,
+ "advertise_pro_plan": False,
+ "prompt_switching_to_pro_plan": False,
+ },
+ )
+ )
+
+ gateway = HttpWhoAmIGateway(base_url="http://test")
+ response = await gateway.whoami("api-key")
+
+ assert route.called
+ request = route.calls.last.request
+ assert request.headers["Authorization"] == "Bearer api-key"
+ assert response.is_pro_plan is True
+ assert response.advertise_pro_plan is False
+ assert response.prompt_switching_to_pro_plan is False
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("status_code", [401, 403])
+async def test_raises_on_unauthorized(
+ respx_mock: respx.MockRouter, status_code: int
+) -> None:
+ respx_mock.get("http://test/api/vibe/whoami").mock(
+ return_value=httpx.Response(status_code, json={"error": "unauthorized"})
+ )
+
+ gateway = HttpWhoAmIGateway(base_url="http://test")
+
+ with pytest.raises(WhoAmIGatewayUnauthorized):
+ await gateway.whoami("bad-key")
+
+
+@pytest.mark.asyncio
+async def test_raises_on_non_success(respx_mock: respx.MockRouter) -> None:
+ respx_mock.get("http://test/api/vibe/whoami").mock(
+ return_value=httpx.Response(500, json={"error": "boom"})
+ )
+
+ gateway = HttpWhoAmIGateway(base_url="http://test")
+
+ with pytest.raises(WhoAmIGatewayError):
+ await gateway.whoami("api-key")
+
+
+@pytest.mark.asyncio
+async def test_incomplete_payload_defaults_missing_flags_to_false(
+ respx_mock: respx.MockRouter,
+) -> None:
+ respx_mock.get("http://test/api/vibe/whoami").mock(
+ return_value=httpx.Response(200, json={"is_pro_plan": True})
+ )
+
+ gateway = HttpWhoAmIGateway(base_url="http://test")
+ response = await gateway.whoami("api-key")
+ assert response == WhoAmIResponse(
+ is_pro_plan=True, advertise_pro_plan=False, prompt_switching_to_pro_plan=False
+ )
+
+
+@pytest.mark.asyncio
+async def test_wraps_request_error(respx_mock: respx.MockRouter) -> None:
+ respx_mock.get("http://test/api/vibe/whoami").mock(
+ side_effect=httpx.ConnectError("boom")
+ )
+
+ gateway = HttpWhoAmIGateway(base_url="http://test")
+
+ with pytest.raises(WhoAmIGatewayError):
+ await gateway.whoami("api-key")
+
+
+@pytest.mark.asyncio
+async def test_parses_boolean_strings(respx_mock: respx.MockRouter) -> None:
+ respx_mock.get("http://test/api/vibe/whoami").mock(
+ return_value=httpx.Response(
+ 200,
+ json={
+ "is_pro_plan": "true",
+ "advertise_pro_plan": "false",
+ "prompt_switching_to_pro_plan": "true",
+ },
+ )
+ )
+
+ gateway = HttpWhoAmIGateway(base_url="http://test")
+ response = await gateway.whoami("api-key")
+ assert response == WhoAmIResponse(
+ is_pro_plan=True, advertise_pro_plan=False, prompt_switching_to_pro_plan=True
+ )
+
+
+@pytest.mark.asyncio
+async def test_raises_on_invalid_boolean_string(respx_mock: respx.MockRouter) -> None:
+ respx_mock.get("http://test/api/vibe/whoami").mock(
+ return_value=httpx.Response(200, json={"is_pro_plan": "yes"})
+ )
+
+ gateway = HttpWhoAmIGateway(base_url="http://test")
+
+ with pytest.raises(WhoAmIGatewayError):
+ await gateway.whoami("api-key")
diff --git a/tests/cli/plan_offer/test_plan_offer_in_app.py b/tests/cli/plan_offer/test_plan_offer_in_app.py
new file mode 100644
index 0000000..c9dc1ec
--- /dev/null
+++ b/tests/cli/plan_offer/test_plan_offer_in_app.py
@@ -0,0 +1,63 @@
+from __future__ import annotations
+
+import pytest
+
+from tests.cli.plan_offer.adapters.fake_whoami_gateway import FakeWhoAmIGateway
+from tests.stubs.fake_backend import FakeBackend
+from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIResponse
+from vibe.cli.textual_ui.app import VibeApp
+from vibe.cli.textual_ui.widgets.messages import PlanOfferMessage
+from vibe.core.agent_loop import AgentLoop
+from vibe.core.config import SessionLoggingConfig, VibeConfig
+
+
+def _make_app(gateway: FakeWhoAmIGateway, config: VibeConfig | None = None) -> VibeApp:
+ config = config or VibeConfig(
+ session_logging=SessionLoggingConfig(enabled=False), enable_update_checks=False
+ )
+ agent_loop = AgentLoop(config=config, backend=FakeBackend())
+ return VibeApp(agent_loop=agent_loop, plan_offer_gateway=gateway)
+
+
+@pytest.mark.asyncio
+async def test_app_shows_upgrade_offer_in_plan_offer_message(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setenv("MISTRAL_API_KEY", "api-key")
+ gateway = FakeWhoAmIGateway(
+ WhoAmIResponse(
+ is_pro_plan=False,
+ advertise_pro_plan=True,
+ prompt_switching_to_pro_plan=False,
+ )
+ )
+ app = _make_app(gateway)
+
+ async with app.run_test() as pilot:
+ await pilot.pause(0.1)
+ offer = app.query_one(PlanOfferMessage)
+ assert "Upgrade to" in offer.get_text()
+ assert "Pro" in offer.get_text()
+ assert gateway.calls == ["api-key"]
+
+
+@pytest.mark.asyncio
+async def test_app_shows_switch_to_pro_key_offer_in_plan_offer_message(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setenv("MISTRAL_API_KEY", "api-key")
+ gateway = FakeWhoAmIGateway(
+ WhoAmIResponse(
+ is_pro_plan=False,
+ advertise_pro_plan=False,
+ prompt_switching_to_pro_plan=True,
+ )
+ )
+ app = _make_app(gateway)
+
+ async with app.run_test() as pilot:
+ await pilot.pause(0.1)
+ offer = app.query_one(PlanOfferMessage)
+ assert "Switch to your" in offer.get_text()
+ assert "Pro API key" in offer.get_text()
+ assert gateway.calls == ["api-key"]
diff --git a/tests/cli/test_clipboard.py b/tests/cli/test_clipboard.py
index fd915fd..c677daa 100644
--- a/tests/cli/test_clipboard.py
+++ b/tests/cli/test_clipboard.py
@@ -102,7 +102,10 @@ def test_copy_selection_to_clipboard_success(
mock_copy_fn.assert_called_once_with("selected text")
mock_app.notify.assert_called_once_with(
- '"selected text" copied to clipboard', severity="information", timeout=2
+ '"selected text" copied to clipboard',
+ severity="information",
+ timeout=2,
+ markup=False,
)
@@ -126,7 +129,10 @@ def test_copy_selection_to_clipboard_tries_all(
fn_2.assert_called_once_with("selected text")
fn_3.assert_called_once_with("selected text")
mock_app.notify.assert_called_once_with(
- '"selected text" copied to clipboard', severity="information", timeout=2
+ '"selected text" copied to clipboard',
+ severity="information",
+ timeout=2,
+ markup=False,
)
@@ -175,6 +181,7 @@ def test_copy_selection_to_clipboard_multiple_widgets(mock_app: MagicMock) -> No
'"first selection⏎second selection" copied to clipboard',
severity="information",
timeout=2,
+ markup=False,
)
@@ -266,8 +273,13 @@ def test_get_copy_fns_no_system_tools(mock_which: MagicMock, mock_app: App) -> N
assert copy_fns[2] == mock_app.copy_to_clipboard
+@patch("vibe.cli.clipboard.platform.system")
@patch("vibe.cli.clipboard.shutil.which")
-def test_get_copy_fns_with_xclip(mock_which: MagicMock, mock_app: App) -> None:
+def test_get_copy_fns_with_xclip(
+ mock_which: MagicMock, mock_platform_system: MagicMock, mock_app: App
+) -> None:
+ mock_platform_system.return_value = "Linux"
+
def which_side_effect(cmd: str) -> str | None:
return "/usr/bin/xclip" if cmd == "xclip" else None
@@ -282,8 +294,13 @@ def test_get_copy_fns_with_xclip(mock_which: MagicMock, mock_app: App) -> None:
assert copy_fns[3] == mock_app.copy_to_clipboard
+@patch("vibe.cli.clipboard.platform.system")
@patch("vibe.cli.clipboard.shutil.which")
-def test_get_copy_fns_with_wl_copy(mock_which: MagicMock, mock_app: App) -> None:
+def test_get_copy_fns_with_wl_copy(
+ mock_which: MagicMock, mock_platform_system: MagicMock, mock_app: App
+) -> None:
+ mock_platform_system.return_value = "Linux"
+
def which_side_effect(cmd: str) -> str | None:
return "/usr/bin/wl-copy" if cmd == "wl-copy" else None
@@ -298,10 +315,13 @@ def test_get_copy_fns_with_wl_copy(mock_which: MagicMock, mock_app: App) -> None
assert copy_fns[3] == mock_app.copy_to_clipboard
+@patch("vibe.cli.clipboard.platform.system")
@patch("vibe.cli.clipboard.shutil.which")
def test_get_copy_fns_with_both_system_tools(
- mock_which: MagicMock, mock_app: App
+ mock_which: MagicMock, mock_platform_system: MagicMock, mock_app: App
) -> None:
+ mock_platform_system.return_value = "Linux"
+
def which_side_effect(cmd: str) -> str | None:
match cmd:
case "wl-copy":
diff --git a/tests/cli/test_external_editor.py b/tests/cli/test_external_editor.py
new file mode 100644
index 0000000..0194406
--- /dev/null
+++ b/tests/cli/test_external_editor.py
@@ -0,0 +1,72 @@
+"""Tests for the external editor module."""
+
+from __future__ import annotations
+
+from unittest.mock import patch
+
+from vibe.cli.textual_ui.external_editor import ExternalEditor
+
+
+class TestGetEditor:
+ def test_returns_visual_first(self) -> None:
+ with patch.dict("os.environ", {"VISUAL": "vim", "EDITOR": "nvim"}, clear=True):
+ assert ExternalEditor.get_editor() == "vim"
+
+ def test_falls_back_to_editor(self) -> None:
+ with patch.dict("os.environ", {"EDITOR": "nvim"}, clear=True):
+ assert ExternalEditor.get_editor() == "nvim"
+
+ def test_falls_back_when_no_editor(self) -> None:
+ with patch.dict("os.environ", {}, clear=True):
+ assert ExternalEditor.get_editor() == "nano"
+
+
+class TestEdit:
+ def test_returns_modified_content(self) -> None:
+ with patch.dict("os.environ", {"VISUAL": "vim"}, clear=True):
+ with patch("subprocess.run") as mock_run:
+ with patch("pathlib.Path.read_text", return_value="modified"):
+ with patch("pathlib.Path.unlink"):
+ editor = ExternalEditor()
+ result = editor.edit("original")
+ assert result == "modified"
+ mock_run.assert_called_once()
+
+ def test_returns_none_when_content_unchanged(self) -> None:
+ with patch.dict("os.environ", {"VISUAL": "vim"}, clear=True):
+ with patch("subprocess.run"):
+ with patch("pathlib.Path.read_text", return_value="same"):
+ with patch("pathlib.Path.unlink"):
+ editor = ExternalEditor()
+ result = editor.edit("same")
+ assert result is None
+
+ def test_strips_trailing_whitespace(self) -> None:
+ with patch.dict("os.environ", {"VISUAL": "vim"}, clear=True):
+ with patch("subprocess.run"):
+ with patch("pathlib.Path.read_text", return_value="content\n\n"):
+ with patch("pathlib.Path.unlink"):
+ editor = ExternalEditor()
+ result = editor.edit("original")
+ assert result == "content"
+
+ def test_handles_editor_with_args(self) -> None:
+ with patch.dict("os.environ", {"VISUAL": "code --wait"}, clear=True):
+ with patch("subprocess.run") as mock_run:
+ with patch("pathlib.Path.read_text", return_value="edited"):
+ with patch("pathlib.Path.unlink"):
+ editor = ExternalEditor()
+ editor.edit("original")
+ call_args = mock_run.call_args[0][0]
+ assert call_args[0] == "code"
+ assert call_args[1] == "--wait"
+
+ def test_returns_none_on_subprocess_error(self) -> None:
+ import subprocess as sp
+
+ with patch.dict("os.environ", {"VISUAL": "vim"}, clear=True):
+ with patch("subprocess.run", side_effect=sp.CalledProcessError(1, "vim")):
+ with patch("pathlib.Path.unlink"):
+ editor = ExternalEditor()
+ result = editor.edit("test")
+ assert result is None
diff --git a/tests/cli/test_question_app.py b/tests/cli/test_question_app.py
new file mode 100644
index 0000000..594d602
--- /dev/null
+++ b/tests/cli/test_question_app.py
@@ -0,0 +1,513 @@
+from __future__ import annotations
+
+import pytest
+
+from vibe.core.tools.builtins.ask_user_question import (
+ AskUserQuestionArgs,
+ Choice,
+ Question,
+)
+
+
+@pytest.fixture
+def single_question_args():
+ return AskUserQuestionArgs(
+ questions=[
+ Question(
+ question="Which database?",
+ header="DB",
+ options=[
+ Choice(label="PostgreSQL", description="Relational DB"),
+ Choice(label="MongoDB", description="Document DB"),
+ ],
+ )
+ ]
+ )
+
+
+@pytest.fixture
+def multi_question_args():
+ return AskUserQuestionArgs(
+ questions=[
+ Question(
+ question="Which database?",
+ header="DB",
+ options=[Choice(label="PostgreSQL"), Choice(label="MongoDB")],
+ ),
+ Question(
+ question="Which framework?",
+ header="Framework",
+ options=[Choice(label="FastAPI"), Choice(label="Django")],
+ ),
+ ]
+ )
+
+
+@pytest.fixture
+def multi_select_args():
+ return AskUserQuestionArgs(
+ questions=[
+ Question(
+ question="Which features?",
+ header="Features",
+ options=[
+ Choice(label="Auth"),
+ Choice(label="Caching"),
+ Choice(label="Logging"),
+ ],
+ multi_select=True,
+ )
+ ]
+ )
+
+
+class TestQuestionAppState:
+ def test_init_state(self, single_question_args):
+ from vibe.cli.textual_ui.widgets.question_app import QuestionApp
+
+ app = QuestionApp(single_question_args)
+
+ assert app.current_question_idx == 0
+ assert app.selected_option == 0
+ assert len(app.answers) == 0
+ assert len(app.other_texts) == 0
+
+ def test_total_options_single_select(self, single_question_args):
+ from vibe.cli.textual_ui.widgets.question_app import QuestionApp
+
+ app = QuestionApp(single_question_args)
+
+ # 2 options + Other = 3 (no Submit for single-select)
+ assert app._total_options == 3
+
+ def test_total_options_multi_select_includes_submit(self, multi_select_args):
+ from vibe.cli.textual_ui.widgets.question_app import QuestionApp
+
+ app = QuestionApp(multi_select_args)
+
+ # 3 options + Other + Submit = 5
+ assert app._total_options == 5
+ assert app._other_option_idx == 3
+ assert app._submit_option_idx == 4
+
+ def test_is_other_selected(self, single_question_args):
+ from vibe.cli.textual_ui.widgets.question_app import QuestionApp
+
+ app = QuestionApp(single_question_args)
+
+ assert app._is_other_selected is False
+ app.selected_option = 2 # Other option
+ assert app._is_other_selected is True
+
+ def test_is_submit_selected(self, multi_select_args):
+ from vibe.cli.textual_ui.widgets.question_app import QuestionApp
+
+ app = QuestionApp(multi_select_args)
+
+ assert app._is_submit_selected is False
+ app.selected_option = 4 # Submit option
+ assert app._is_submit_selected is True
+
+ def test_is_submit_selected_false_for_single_select(self, single_question_args):
+ from vibe.cli.textual_ui.widgets.question_app import QuestionApp
+
+ app = QuestionApp(single_question_args)
+
+ # Even if selected_option is 3, is_submit_selected is False for single-select
+ app.selected_option = 3
+ assert app._is_submit_selected is False
+
+ def test_store_other_text_per_question(self, multi_question_args):
+ from vibe.cli.textual_ui.widgets.question_app import QuestionApp
+
+ app = QuestionApp(multi_question_args)
+
+ # Store text for question 0
+ app.other_texts[0] = "Custom DB"
+
+ # Switch to question 1
+ app.current_question_idx = 1
+ app.other_texts[1] = "Custom Framework"
+
+ # Verify both stored separately
+ assert app._get_other_text(0) == "Custom DB"
+ assert app._get_other_text(1) == "Custom Framework"
+
+ def test_save_regular_option_answer(self, single_question_args):
+ from vibe.cli.textual_ui.widgets.question_app import QuestionApp
+
+ app = QuestionApp(single_question_args)
+ app.selected_option = 0 # PostgreSQL
+
+ app._save_current_answer()
+
+ assert 0 in app.answers
+ answer_text, is_other = app.answers[0]
+ assert answer_text == "PostgreSQL"
+ assert is_other is False
+
+ def test_save_other_option_answer(self, single_question_args):
+ from vibe.cli.textual_ui.widgets.question_app import QuestionApp
+
+ app = QuestionApp(single_question_args)
+ app.selected_option = 2 # Other
+ app.other_texts[0] = "SQLite"
+
+ app._save_current_answer()
+
+ assert 0 in app.answers
+ answer_text, is_other = app.answers[0]
+ assert answer_text == "SQLite"
+ assert is_other is True
+
+ def test_save_other_option_empty_does_not_save(self, single_question_args):
+ from vibe.cli.textual_ui.widgets.question_app import QuestionApp
+
+ app = QuestionApp(single_question_args)
+ app.selected_option = 2 # Other
+ app.other_texts[0] = "" # Empty
+
+ app._save_current_answer()
+
+ assert 0 not in app.answers
+
+ def test_all_answered_false_initially(self, multi_question_args):
+ from vibe.cli.textual_ui.widgets.question_app import QuestionApp
+
+ app = QuestionApp(multi_question_args)
+
+ assert app._all_answered() is False
+
+ def test_all_answered_true_when_complete(self, multi_question_args):
+ from vibe.cli.textual_ui.widgets.question_app import QuestionApp
+
+ app = QuestionApp(multi_question_args)
+ app.answers[0] = ("PostgreSQL", False)
+ app.answers[1] = ("FastAPI", False)
+
+ assert app._all_answered() is True
+
+ def test_multi_select_toggle(self, multi_select_args):
+ from vibe.cli.textual_ui.widgets.question_app import QuestionApp
+
+ app = QuestionApp(multi_select_args)
+
+ # Initially no selections
+ assert len(app.multi_selections.get(0, set())) == 0
+
+ # Add selection
+ app.multi_selections.setdefault(0, set()).add(0)
+ assert 0 in app.multi_selections[0]
+
+ # Add another
+ app.multi_selections[0].add(2)
+ assert 2 in app.multi_selections[0]
+
+ # Remove first
+ app.multi_selections[0].discard(0)
+ assert 0 not in app.multi_selections[0]
+ assert 2 in app.multi_selections[0]
+
+ def test_multi_select_save_answer(self, multi_select_args):
+ from vibe.cli.textual_ui.widgets.question_app import QuestionApp
+
+ app = QuestionApp(multi_select_args)
+ app.multi_selections[0] = {0, 2} # Auth and Logging
+
+ app._save_current_answer()
+
+ assert 0 in app.answers
+ answer_text, is_other = app.answers[0]
+ assert "Auth" in answer_text
+ assert "Logging" in answer_text
+ assert is_other is False
+
+ def test_multi_select_with_other(self, multi_select_args):
+ from vibe.cli.textual_ui.widgets.question_app import QuestionApp
+
+ app = QuestionApp(multi_select_args)
+ app.multi_selections[0] = {0, 3} # Auth and Other
+ app.other_texts[0] = "Custom Feature"
+
+ app._save_current_answer()
+
+ assert 0 in app.answers
+ answer_text, is_other = app.answers[0]
+ assert "Auth" in answer_text
+ assert "Custom Feature" in answer_text
+ assert is_other is True
+
+
+class TestQuestionAppActions:
+ def test_action_move_down(self, single_question_args):
+ from vibe.cli.textual_ui.widgets.question_app import QuestionApp
+
+ app = QuestionApp(single_question_args)
+ assert app.selected_option == 0
+
+ app.action_move_down()
+ assert app.selected_option == 1
+
+ app.action_move_down()
+ assert app.selected_option == 2 # Other
+
+ app.action_move_down()
+ assert app.selected_option == 0 # Wraps around
+
+ def test_action_move_up(self, single_question_args):
+ from vibe.cli.textual_ui.widgets.question_app import QuestionApp
+
+ app = QuestionApp(single_question_args)
+ assert app.selected_option == 0
+
+ app.action_move_up()
+ assert app.selected_option == 2 # Wraps to Other
+
+ app.action_move_up()
+ assert app.selected_option == 1
+
+ def test_switch_question_preserves_other_text(self, multi_question_args):
+ from vibe.cli.textual_ui.widgets.question_app import QuestionApp
+
+ app = QuestionApp(multi_question_args)
+ app.other_texts[0] = "Text for Q1"
+
+ app._switch_question(1)
+
+ assert app.current_question_idx == 1
+ assert app._get_other_text(0) == "Text for Q1"
+ assert app._get_other_text(1) == ""
+
+
+class TestMultiSelectOtherBehavior:
+ def test_multi_select_other_does_not_advance_on_save(self, multi_select_args):
+ from vibe.cli.textual_ui.widgets.question_app import QuestionApp
+
+ app = QuestionApp(multi_select_args)
+ app.selected_option = 3 # Other option (3 options + Other)
+ app.other_texts[0] = "Custom feature"
+
+ # Save should not advance for multi-select
+ app._save_current_answer()
+
+ # Should stay on same question
+ assert app.current_question_idx == 0
+
+ def test_multi_select_other_toggle_adds_to_selections(self, multi_select_args):
+ from vibe.cli.textual_ui.widgets.question_app import QuestionApp
+
+ app = QuestionApp(multi_select_args)
+ other_idx = len(app._current_question.options) # 3
+
+ # Initially no selections
+ assert len(app.multi_selections.get(0, set())) == 0
+
+ # Add Other to selections
+ app.multi_selections.setdefault(0, set()).add(other_idx)
+ app.other_texts[0] = "Custom"
+
+ # Can still add regular options
+ app.multi_selections[0].add(0) # Auth
+ app.multi_selections[0].add(1) # Caching
+
+ assert other_idx in app.multi_selections[0]
+ assert 0 in app.multi_selections[0]
+ assert 1 in app.multi_selections[0]
+
+ def test_multi_select_save_with_other_and_regular_options(self, multi_select_args):
+ from vibe.cli.textual_ui.widgets.question_app import QuestionApp
+
+ app = QuestionApp(multi_select_args)
+ other_idx = len(app._current_question.options)
+
+ # Select Auth (0), Logging (2), and Other (3)
+ app.multi_selections[0] = {0, 2, other_idx}
+ app.other_texts[0] = "Custom Feature"
+
+ app._save_current_answer()
+
+ assert 0 in app.answers
+ answer_text, is_other = app.answers[0]
+ assert "Auth" in answer_text
+ assert "Logging" in answer_text
+ assert "Custom Feature" in answer_text
+ assert is_other is True
+
+ def test_multi_select_other_without_text_not_in_answer(self, multi_select_args):
+ from vibe.cli.textual_ui.widgets.question_app import QuestionApp
+
+ app = QuestionApp(multi_select_args)
+ other_idx = len(app._current_question.options)
+
+ # Select Auth (0) and Other (3) but no text for Other
+ app.multi_selections[0] = {0, other_idx}
+ app.other_texts[0] = ""
+
+ app._save_current_answer()
+
+ assert 0 in app.answers
+ answer_text, is_other = app.answers[0]
+ assert "Auth" in answer_text
+ # Empty Other should not appear
+ assert is_other is False # No valid Other text
+
+ def test_multi_select_can_toggle_after_selecting_other(self, multi_select_args):
+ from vibe.cli.textual_ui.widgets.question_app import QuestionApp
+
+ app = QuestionApp(multi_select_args)
+ other_idx = len(app._current_question.options)
+
+ # Select Other first
+ app.multi_selections[0] = {other_idx}
+ app.other_texts[0] = "Custom"
+
+ # Now navigate to and toggle Auth
+ app.selected_option = 0
+ selections = app.multi_selections.setdefault(0, set())
+ selections.add(0) # Toggle Auth on
+
+ assert 0 in app.multi_selections[0]
+ assert other_idx in app.multi_selections[0]
+
+ # Toggle Auth off
+ selections.discard(0)
+ assert 0 not in app.multi_selections[0]
+ assert other_idx in app.multi_selections[0]
+
+ def test_multi_select_empty_selections_does_not_save(self, multi_select_args):
+ from vibe.cli.textual_ui.widgets.question_app import QuestionApp
+
+ app = QuestionApp(multi_select_args)
+
+ # No selections
+ app._save_current_answer()
+
+ assert 0 not in app.answers
+
+
+class TestSingleSelectOtherBehavior:
+ def test_single_select_other_with_text_saves(self, single_question_args):
+ from vibe.cli.textual_ui.widgets.question_app import QuestionApp
+
+ app = QuestionApp(single_question_args)
+ app.selected_option = 2 # Other
+ app.other_texts[0] = "Custom DB"
+
+ app._save_current_answer()
+
+ assert 0 in app.answers
+ answer_text, is_other = app.answers[0]
+ assert answer_text == "Custom DB"
+ assert is_other is True
+
+ def test_single_select_other_without_text_does_not_save(self, single_question_args):
+ from vibe.cli.textual_ui.widgets.question_app import QuestionApp
+
+ app = QuestionApp(single_question_args)
+ app.selected_option = 2 # Other
+ app.other_texts[0] = ""
+
+ app._save_current_answer()
+
+ assert 0 not in app.answers
+
+ def test_single_select_regular_option_saves_immediately(self, single_question_args):
+ from vibe.cli.textual_ui.widgets.question_app import QuestionApp
+
+ app = QuestionApp(single_question_args)
+ app.selected_option = 1 # MongoDB
+
+ app._save_current_answer()
+
+ assert 0 in app.answers
+ answer_text, is_other = app.answers[0]
+ assert answer_text == "MongoDB"
+ assert is_other is False
+
+
+class TestMultiSelectAutoSelect:
+ def test_typing_auto_selects_other(self, multi_select_args):
+ from unittest.mock import MagicMock
+
+ from vibe.cli.textual_ui.widgets.question_app import QuestionApp
+
+ app = QuestionApp(multi_select_args)
+ app.other_input = MagicMock()
+ app.other_input.value = "Custom text"
+
+ # Initially no selections
+ assert app._other_option_idx not in app.multi_selections.get(0, set())
+
+ # Simulate input change
+ from textual.widgets import Input
+
+ app.on_input_changed(Input.Changed(app.other_input, "Custom text"))
+
+ # Other should now be selected
+ assert app._other_option_idx in app.multi_selections[0]
+
+ def test_clearing_auto_deselects_other(self, multi_select_args):
+ from unittest.mock import MagicMock
+
+ from vibe.cli.textual_ui.widgets.question_app import QuestionApp
+
+ app = QuestionApp(multi_select_args)
+ app.other_input = MagicMock()
+
+ # Start with Other selected and text
+ app.multi_selections[0] = {app._other_option_idx}
+ app.other_input.value = "" # Cleared
+
+ # Simulate input change with empty value
+ from textual.widgets import Input
+
+ app.on_input_changed(Input.Changed(app.other_input, ""))
+
+ # Other should now be deselected
+ assert app._other_option_idx not in app.multi_selections[0]
+
+ def test_auto_select_preserves_other_selections(self, multi_select_args):
+ from unittest.mock import MagicMock
+
+ from vibe.cli.textual_ui.widgets.question_app import QuestionApp
+
+ app = QuestionApp(multi_select_args)
+ app.other_input = MagicMock()
+ app.other_input.value = "Custom"
+
+ # Pre-select Auth and Logging
+ app.multi_selections[0] = {0, 2}
+
+ # Simulate typing
+ from textual.widgets import Input
+
+ app.on_input_changed(Input.Changed(app.other_input, "Custom"))
+
+ # All selections should be preserved plus Other
+ assert 0 in app.multi_selections[0]
+ assert 2 in app.multi_selections[0]
+ assert app._other_option_idx in app.multi_selections[0]
+
+
+class TestMultiSelectSubmit:
+ def test_navigate_to_submit(self, multi_select_args):
+ from vibe.cli.textual_ui.widgets.question_app import QuestionApp
+
+ app = QuestionApp(multi_select_args)
+
+ # Navigate down through all options to Submit
+ for _ in range(4): # 0->1->2->3(Other)->4(Submit)
+ app.action_move_down()
+
+ assert app.selected_option == 4
+ assert app._is_submit_selected is True
+
+ def test_submit_wraps_around(self, multi_select_args):
+ from vibe.cli.textual_ui.widgets.question_app import QuestionApp
+
+ app = QuestionApp(multi_select_args)
+ app.selected_option = 4 # Submit
+
+ app.action_move_down()
+
+ assert app.selected_option == 0
diff --git a/tests/cli/test_ui_clipboard_notifications.py b/tests/cli/test_ui_clipboard_notifications.py
new file mode 100644
index 0000000..b214c8a
--- /dev/null
+++ b/tests/cli/test_ui_clipboard_notifications.py
@@ -0,0 +1,51 @@
+from __future__ import annotations
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+from textual.selection import Selection
+from textual.widget import Widget
+
+from vibe.cli.clipboard import copy_selection_to_clipboard
+from vibe.cli.textual_ui.app import VibeApp
+from vibe.core.agent_loop import AgentLoop
+from vibe.core.config import SessionLoggingConfig, VibeConfig
+
+
+class ClipboardSelectionWidget(Widget):
+ def __init__(self, selected_text: str) -> None:
+ super().__init__()
+ self._selected_text = selected_text
+
+ @property
+ def text_selection(self) -> Selection | None:
+ return Selection(None, None)
+
+ def get_selection(self, selection: Selection) -> tuple[str, str] | None:
+ return (self._selected_text, "\n")
+
+
+@pytest.mark.asyncio
+async def test_ui_clipboard_notification_does_not_crash_on_markup_text(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ agent_loop = AgentLoop(
+ config=VibeConfig(
+ session_logging=SessionLoggingConfig(enabled=False),
+ enable_update_checks=False,
+ )
+ )
+ app = VibeApp(agent_loop=agent_loop)
+
+ async with app.run_test(notifications=True) as pilot:
+ await app.mount(ClipboardSelectionWidget("[/]"))
+ with patch("vibe.cli.clipboard._get_copy_fns") as mock_get_copy_fns:
+ mock_get_copy_fns.return_value = [MagicMock()]
+ copy_selection_to_clipboard(app)
+
+ await pilot.pause(0.1)
+ notifications = list(app._notifications)
+ assert notifications
+ notification = notifications[-1]
+ assert notification.markup is False
+ assert "copied to clipboard" in notification.message
diff --git a/tests/cli/test_ui_session_resume.py b/tests/cli/test_ui_session_resume.py
new file mode 100644
index 0000000..983b436
--- /dev/null
+++ b/tests/cli/test_ui_session_resume.py
@@ -0,0 +1,135 @@
+from __future__ import annotations
+
+import pytest
+
+from tests.cli.plan_offer.adapters.fake_whoami_gateway import FakeWhoAmIGateway
+from vibe.cli.textual_ui.app import VibeApp
+from vibe.cli.textual_ui.widgets.messages import AssistantMessage, UserMessage
+from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage
+from vibe.core.agent_loop import AgentLoop
+from vibe.core.config import SessionLoggingConfig, VibeConfig
+from vibe.core.types import FunctionCall, LLMMessage, Role, ToolCall
+
+
+@pytest.fixture
+def vibe_config() -> VibeConfig:
+ return VibeConfig(
+ session_logging=SessionLoggingConfig(enabled=False), enable_update_checks=False
+ )
+
+
+@pytest.mark.asyncio
+async def test_ui_displays_messages_when_resuming_session(
+ vibe_config: VibeConfig,
+) -> None:
+ """Test that messages are properly displayed when resuming a session."""
+ agent_loop = AgentLoop(config=vibe_config, enable_streaming=False)
+
+ # Simulate a previous session with messages
+ user_msg = LLMMessage(role=Role.user, content="Hello, how are you?")
+ assistant_msg = LLMMessage(
+ role=Role.assistant,
+ content="I'm doing well, thank you!",
+ tool_calls=[
+ ToolCall(
+ id="tool_call_1",
+ index=0,
+ function=FunctionCall(
+ name="read_file", arguments='{"path": "test.txt"}'
+ ),
+ )
+ ],
+ )
+ tool_result_msg = LLMMessage(
+ role=Role.tool,
+ content="File content here",
+ name="read_file",
+ tool_call_id="tool_call_1",
+ )
+
+ agent_loop.messages.extend([user_msg, assistant_msg, tool_result_msg])
+
+ app = VibeApp(agent_loop=agent_loop, plan_offer_gateway=FakeWhoAmIGateway())
+
+ async with app.run_test() as pilot:
+ # Wait for the app to initialize and rebuild history
+ await pilot.pause(0.5)
+
+ # Verify user message is displayed
+ user_messages = app.query(UserMessage)
+ assert len(user_messages) == 1
+ assert user_messages[0]._content == "Hello, how are you?"
+
+ # Verify assistant message is displayed
+ assistant_messages = app.query(AssistantMessage)
+ assert len(assistant_messages) == 1
+ assert assistant_messages[0]._content == "I'm doing well, thank you!"
+
+ # Verify tool call message is displayed
+ tool_call_messages = app.query(ToolCallMessage)
+ assert len(tool_call_messages) == 1
+ assert tool_call_messages[0]._tool_name == "read_file"
+
+ # Verify tool result message is displayed
+ tool_result_messages = app.query(ToolResultMessage)
+ assert len(tool_result_messages) == 1
+ assert tool_result_messages[0].tool_name == "read_file"
+ assert tool_result_messages[0]._content == "File content here"
+
+
+@pytest.mark.asyncio
+async def test_ui_does_not_display_messages_when_only_system_messages_exist(
+ vibe_config: VibeConfig,
+) -> None:
+ """Test that no messages are displayed when only system messages exist."""
+ agent_loop = AgentLoop(config=vibe_config, enable_streaming=False)
+
+ # Only system messages
+ system_msg = LLMMessage(role=Role.system, content="System prompt")
+ agent_loop.messages.append(system_msg)
+
+ app = VibeApp(agent_loop=agent_loop, plan_offer_gateway=FakeWhoAmIGateway())
+
+ async with app.run_test() as pilot:
+ await pilot.pause(0.5)
+
+ # Verify no user or assistant messages are displayed
+ user_messages = app.query(UserMessage)
+ assert len(user_messages) == 0
+
+ assistant_messages = app.query(AssistantMessage)
+ assert len(assistant_messages) == 0
+
+
+@pytest.mark.asyncio
+async def test_ui_displays_multiple_user_assistant_turns(
+ vibe_config: VibeConfig,
+) -> None:
+ """Test that multiple conversation turns are properly displayed."""
+ agent_loop = AgentLoop(config=vibe_config, enable_streaming=False)
+
+ # Multiple conversation turns
+ messages = [
+ LLMMessage(role=Role.user, content="First question"),
+ LLMMessage(role=Role.assistant, content="First answer"),
+ LLMMessage(role=Role.user, content="Second question"),
+ LLMMessage(role=Role.assistant, content="Second answer"),
+ ]
+
+ agent_loop.messages.extend(messages)
+
+ app = VibeApp(agent_loop=agent_loop, plan_offer_gateway=FakeWhoAmIGateway())
+
+ async with app.run_test() as pilot:
+ await pilot.pause(0.5)
+
+ # Verify all messages are displayed
+ user_messages = app.query(UserMessage)
+ assert len(user_messages) == 2
+ assert user_messages[0]._content == "First question"
+ assert user_messages[1]._content == "Second question"
+
+ assistant_messages = app.query(AssistantMessage)
+ assert len(assistant_messages) == 2
+ assert assistant_messages[0]._content == "First answer"
+ assert assistant_messages[1]._content == "Second answer"
diff --git a/tests/conftest.py b/tests/conftest.py
index 241737f..6ea2b66 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -29,6 +29,7 @@ def get_base_config() -> dict[str, Any]:
"alias": "devstral-latest",
}
],
+ "enable_auto_update": False,
}
@@ -74,3 +75,8 @@ def _mock_platform(monkeypatch: pytest.MonkeyPatch) -> None:
"""
monkeypatch.setattr(sys, "platform", "linux")
monkeypatch.setenv("SHELL", "/bin/sh")
+
+
+@pytest.fixture(autouse=True)
+def _mock_update_commands(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr("vibe.cli.update_notifier.update.UPDATE_COMMANDS", ["true"])
diff --git a/tests/core/test_agents.py b/tests/core/test_agents.py
new file mode 100644
index 0000000..53b86e0
--- /dev/null
+++ b/tests/core/test_agents.py
@@ -0,0 +1,78 @@
+from __future__ import annotations
+
+import pytest
+
+from vibe.core.agents.manager import AgentManager
+from vibe.core.agents.models import BUILTIN_AGENTS, EXPLORE, AgentSafety, AgentType
+from vibe.core.config import VibeConfig
+
+
+class TestAgentProfile:
+ def test_explore_agent_is_subagent(self) -> None:
+ """Test that EXPLORE agent has SUBAGENT type."""
+ assert EXPLORE.agent_type == AgentType.SUBAGENT
+
+ def test_explore_agent_has_safe_safety(self) -> None:
+ """Test that EXPLORE agent has SAFE safety level."""
+ assert EXPLORE.safety == AgentSafety.SAFE
+
+ def test_explore_agent_has_enabled_tools(self) -> None:
+ """Test that EXPLORE agent has expected enabled tools."""
+ enabled_tools = EXPLORE.overrides.get("enabled_tools", [])
+ assert "grep" in enabled_tools
+ assert "read_file" in enabled_tools
+
+ def test_builtin_agents_contains_explore(self) -> None:
+ """Test that BUILTIN_AGENTS includes explore."""
+ assert "explore" in BUILTIN_AGENTS
+ assert BUILTIN_AGENTS["explore"] is EXPLORE
+
+
+class TestAgentManager:
+ @pytest.fixture
+ def manager(self) -> AgentManager:
+ config = VibeConfig(include_project_context=False, include_prompt_detail=False)
+ return AgentManager(lambda: config)
+
+ def test_get_subagents_returns_only_subagents(self, manager: AgentManager) -> None:
+ """Test that only SUBAGENT type agents are returned."""
+ subagents = manager.get_subagents()
+
+ for agent in subagents:
+ assert agent.agent_type == AgentType.SUBAGENT
+
+ def test_get_subagents_includes_explore(self, manager: AgentManager) -> None:
+ """Test that EXPLORE is included in subagents."""
+ subagents = manager.get_subagents()
+ names = [a.name for a in subagents]
+
+ assert "explore" in names
+
+ def test_get_subagents_excludes_agents(self, manager: AgentManager) -> None:
+ """Test that AGENT type agents are not returned."""
+ subagents = manager.get_subagents()
+ names = [a.name for a in subagents]
+
+ # These are AGENT type
+ assert "default" not in names
+ assert "plan" not in names
+ assert "auto-approve" not in names
+
+ def test_get_builtin_agent(self, manager: AgentManager) -> None:
+ """Test getting a builtin agent by name."""
+ agent = manager.get_agent("explore")
+
+ assert agent is EXPLORE
+ assert agent.agent_type == AgentType.SUBAGENT
+
+ def test_get_nonexistent_agent_raises(self, manager: AgentManager) -> None:
+ """Test that getting a nonexistent agent raises ValueError."""
+ with pytest.raises(ValueError, match="not found"):
+ manager.get_agent("nonexistent-agent")
+
+ def test_get_default_agent(self, manager: AgentManager) -> None:
+ """Test getting the default agent."""
+ agent = manager.get_agent("default")
+
+ assert agent.name == "default"
+ assert agent.agent_type == AgentType.AGENT
diff --git a/tests/mock/utils.py b/tests/mock/utils.py
index b486202..70342e4 100644
--- a/tests/mock/utils.py
+++ b/tests/mock/utils.py
@@ -1,8 +1,16 @@
from __future__ import annotations
+from collections.abc import AsyncGenerator
import json
-from vibe.core.types import LLMChunk, LLMMessage, LLMUsage, Role, ToolCall
+from vibe.core.types import (
+ LLMChunk,
+ LLMMessage,
+ LLMUsage,
+ Role,
+ ToolCall,
+ ToolStreamEvent,
+)
MOCK_DATA_ENV_VAR = "VIBE_MOCK_LLM_DATA"
@@ -39,4 +47,15 @@ def get_mocking_env(mock_chunks: list[LLMChunk] | None = None) -> dict[str, str]
mock_data = [LLMChunk.model_dump(mock_chunk) for mock_chunk in mock_chunks]
- return {MOCK_DATA_ENV_VAR: json.dumps(mock_data)}
+ return {MOCK_DATA_ENV_VAR: json.dumps(mock_data, ensure_ascii=False)}
+
+
+async def collect_result[T](gen: AsyncGenerator[ToolStreamEvent | T, None]) -> T:
+ """Collect the final result from an AsyncGenerator, filtering out stream events."""
+ result = None
+ async for item in gen:
+ if not isinstance(item, ToolStreamEvent):
+ result = item
+ if result is None:
+ raise RuntimeError("Generator did not yield a result")
+ return result
diff --git a/tests/onboarding/test_run_onboarding.py b/tests/onboarding/test_run_onboarding.py
index e315a77..52b6fa2 100644
--- a/tests/onboarding/test_run_onboarding.py
+++ b/tests/onboarding/test_run_onboarding.py
@@ -57,4 +57,4 @@ def test_successfully_completes(
onboarding.run_onboarding(StubApp("completed"))
out = capsys.readouterr().out
- assert out == ""
+ assert 'Setup complete 🎉. Run "vibe" to start using the Mistral Vibe CLI.' in out
diff --git a/tests/session/test_session_loader.py b/tests/session/test_session_loader.py
new file mode 100644
index 0000000..5bc6e1a
--- /dev/null
+++ b/tests/session/test_session_loader.py
@@ -0,0 +1,539 @@
+from __future__ import annotations
+
+from datetime import datetime
+import json
+from pathlib import Path
+import time
+
+import pytest
+
+from vibe.core.config import SessionLoggingConfig
+from vibe.core.session.session_loader import SessionLoader
+from vibe.core.types import LLMMessage, Role, ToolCall
+
+
+@pytest.fixture
+def temp_session_dir(tmp_path: Path) -> Path:
+ """Create a temporary directory for session loader tests."""
+ session_dir = tmp_path / "sessions"
+ session_dir.mkdir()
+ return session_dir
+
+
+@pytest.fixture
+def session_config(temp_session_dir: Path) -> SessionLoggingConfig:
+ """Create a session logging config for testing."""
+ return SessionLoggingConfig(
+ save_dir=str(temp_session_dir), session_prefix="test", enabled=True
+ )
+
+
+@pytest.fixture
+def create_test_session():
+ """Helper fixture to create a test session with messages and metadata."""
+
+ def _create_test_session(
+ session_dir: Path,
+ session_id: str,
+ messages: list[LLMMessage] | None = None,
+ metadata: dict | None = None,
+ ) -> Path:
+ """Create a test session directory with messages and metadata files."""
+ # Create session directory
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ session_folder = session_dir / f"test_{timestamp}_{session_id[:8]}"
+ session_folder.mkdir(exist_ok=True)
+
+ # Create messages file
+ messages_file = session_folder / "messages.jsonl"
+ if messages is None:
+ messages = [
+ LLMMessage(role=Role.system, content="System prompt"),
+ LLMMessage(role=Role.user, content="Hello"),
+ LLMMessage(role=Role.assistant, content="Hi there!"),
+ ]
+
+ with messages_file.open("w", encoding="utf-8") as f:
+ for message in messages:
+ f.write(
+ json.dumps(
+ message.model_dump(exclude_none=True), ensure_ascii=False
+ )
+ + "\n"
+ )
+
+ # Create metadata file
+ metadata_file = session_folder / "meta.json"
+ if metadata is None:
+ metadata = {
+ "session_id": session_id,
+ "start_time": "2023-01-01T12:00:00",
+ "end_time": "2023-01-01T12:05:00",
+ "total_messages": 2,
+ "stats": {
+ "steps": 1,
+ "session_prompt_tokens": 10,
+ "session_completion_tokens": 20,
+ },
+ "system_prompt": {"content": "System prompt", "role": "system"},
+ }
+
+ with metadata_file.open("w", encoding="utf-8") as f:
+ json.dump(metadata, f, indent=2)
+
+ return session_folder
+
+ return _create_test_session
+
+
+class TestSessionLoaderFindLatestSession:
+ def test_find_latest_session_no_sessions(
+ self, session_config: SessionLoggingConfig
+ ) -> None:
+ """Test finding latest session when no sessions exist."""
+ result = SessionLoader.find_latest_session(session_config)
+ assert result is None
+
+ def test_find_latest_session_single_session(
+ self, session_config: SessionLoggingConfig, create_test_session
+ ) -> None:
+ """Test finding latest session with a single session."""
+ session_dir = Path(session_config.save_dir)
+ session = create_test_session(session_dir, "session-123")
+
+ result = SessionLoader.find_latest_session(session_config)
+ assert result is not None
+ assert result.exists()
+ assert result == session
+
+ def test_find_latest_session_multiple_sessions(
+ self, session_config: SessionLoggingConfig, create_test_session
+ ) -> None:
+ """Test finding latest session with multiple sessions."""
+ session_dir = Path(session_config.save_dir)
+
+ create_test_session(session_dir, "session-123")
+ time.sleep(0.01)
+ create_test_session(session_dir, "session-456")
+ time.sleep(0.01)
+ latest = create_test_session(session_dir, "session-789")
+
+ result = SessionLoader.find_latest_session(session_config)
+ assert result is not None
+ assert result.exists()
+ assert result == latest
+
+ def test_find_latest_session_nonexistent_save_dir(self) -> None:
+ """Test finding latest session when save directory doesn't exist."""
+ # Modify config to point to non-existent directory
+ bad_config = SessionLoggingConfig(
+ save_dir="/nonexistent/path", session_prefix="test", enabled=True
+ )
+
+ result = SessionLoader.find_latest_session(bad_config)
+ assert result is None
+
+ def test_find_latest_session_with_invalid_sessions(
+ self, session_config: SessionLoggingConfig
+ ) -> None:
+ """Test finding latest session when only invalid sessions exist."""
+ session_dir = Path(session_config.save_dir)
+
+ invalid_session1 = session_dir / "test_20230101_120000_invalid1"
+ invalid_session1.mkdir()
+ (invalid_session1 / "messages.jsonl").write_text("[]") # Missing meta.json
+
+ invalid_session2 = session_dir / "test_20230101_120001_invalid2"
+ invalid_session2.mkdir()
+ (invalid_session2 / "meta.json").write_text('{"session_id": "invalid"}')
+
+ result = SessionLoader.find_latest_session(session_config)
+ assert result is None # Should return None when no valid sessions exist
+
+ def test_find_latest_session_with_mixed_valid_invalid(
+ self, session_config: SessionLoggingConfig, create_test_session
+ ) -> None:
+ """Test finding latest session when both valid and invalid sessions exist."""
+ session_dir = Path(session_config.save_dir)
+
+ invalid_session = session_dir / "test_20230101_120000_invalid"
+ invalid_session.mkdir()
+ (invalid_session / "messages.jsonl").write_text(
+ '{"role": "user", "content": "test"}\n'
+ )
+
+ time.sleep(0.01)
+
+ valid_session = create_test_session(session_dir, "test_20230101_120001_valid")
+
+ time.sleep(0.01)
+
+ newest_invalid = session_dir / "test_20230101_120002_newest"
+ newest_invalid.mkdir()
+ (newest_invalid / "messages.jsonl").write_text(
+ '{"role": "user", "content": "test"}\n'
+ )
+
+ result = SessionLoader.find_latest_session(session_config)
+ assert result is not None
+ assert result == valid_session
+
+ def test_find_latest_session_with_invalid_json(
+ self, session_config: SessionLoggingConfig, create_test_session
+ ) -> None:
+ """Test finding latest session when sessions have invalid JSON."""
+ session_dir = Path(session_config.save_dir)
+
+ invalid_meta_session = session_dir / "test_20230101_120000_invalid_meta"
+ invalid_meta_session.mkdir()
+ (invalid_meta_session / "messages.jsonl").write_text(
+ '{"role": "user", "content": "test"}\n'
+ )
+ (invalid_meta_session / "meta.json").write_text("{invalid json}")
+
+ time.sleep(0.01)
+
+ invalid_msg_session = session_dir / "test_20230101_120001_invalid_msg"
+ invalid_msg_session.mkdir()
+ (invalid_msg_session / "messages.jsonl").write_text("{invalid json}")
+ (invalid_msg_session / "meta.json").write_text('{"session_id": "invalid"}')
+
+ time.sleep(0.01)
+
+ valid_session = create_test_session(session_dir, "test_20230101_120002_valid")
+
+ result = SessionLoader.find_latest_session(session_config)
+ assert result is not None
+ assert result == valid_session
+
+
+class TestSessionLoaderFindSessionById:
+ def test_find_session_by_id_exact_match(
+ self, session_config: SessionLoggingConfig, create_test_session
+ ) -> None:
+ """Test finding session by exact ID match."""
+ session_dir = Path(session_config.save_dir)
+ session_folder = create_test_session(session_dir, "test-session-123")
+
+ # Test with full UUID format
+ result = SessionLoader.find_session_by_id("test-session-123", session_config)
+ assert result is not None
+ assert result == session_folder
+
+ def test_find_session_by_id_short_uuid(
+ self, session_config: SessionLoggingConfig, create_test_session
+ ) -> None:
+ """Test finding session by short UUID."""
+ session_dir = Path(session_config.save_dir)
+ session_folder = create_test_session(
+ session_dir, "abc12345-6789-0123-4567-89abcdef0123"
+ )
+
+ # Test with short UUID
+ result = SessionLoader.find_session_by_id("abc12345", session_config)
+ assert result is not None
+ assert result == session_folder
+
+ def test_find_session_by_id_partial_match(
+ self, session_config: SessionLoggingConfig, create_test_session
+ ) -> None:
+ """Test finding session by partial ID match"""
+ session_dir = Path(session_config.save_dir)
+ session_folder = create_test_session(session_dir, "abc12345678")
+
+ # Test with partial match
+ result = SessionLoader.find_session_by_id("abc12345", session_config)
+ assert result is not None
+ assert result == session_folder
+
+ def test_find_session_by_id_multiple_matches(
+ self, session_config: SessionLoggingConfig, create_test_session
+ ) -> None:
+ """Test finding session when multiple sessions match (should return most recent)."""
+ session_dir = Path(session_config.save_dir)
+
+ # Create first session
+ create_test_session(session_dir, "abcd1234")
+
+ # Sleep to ensure different modification times
+ time.sleep(0.01)
+
+ # Create second session with similar ID prefix
+ session_2 = create_test_session(session_dir, "abcd1234")
+
+ result = SessionLoader.find_session_by_id("abcd1234", session_config)
+ assert result is not None
+ assert result == session_2
+
+ def test_find_session_by_id_no_match(
+ self, session_config: SessionLoggingConfig, create_test_session
+ ) -> None:
+ """Test finding session by ID when no match exists."""
+ session_dir = Path(session_config.save_dir)
+ create_test_session(session_dir, "test-session-123")
+
+ result = SessionLoader.find_session_by_id("nonexistent", session_config)
+ assert result is None
+
+ def test_find_session_by_id_nonexistent_save_dir(self) -> None:
+ """Test finding session by ID when save directory doesn't exist."""
+ bad_config = SessionLoggingConfig(
+ save_dir="/nonexistent/path", session_prefix="test", enabled=True
+ )
+
+ result = SessionLoader.find_session_by_id("test-session", bad_config)
+ assert result is None
+
+
+class TestSessionLoaderLoadSession:
+ def test_load_session_success(
+ self, session_config: SessionLoggingConfig, create_test_session
+ ) -> None:
+ """Test successfully loading a session."""
+ session_dir = Path(session_config.save_dir)
+ session_folder = create_test_session(session_dir, "test-session-123")
+
+ messages, metadata = SessionLoader.load_session(session_folder)
+
+ # Verify messages
+ assert len(messages) == 2
+ assert messages[0].role == "user"
+ assert messages[0].content == "Hello"
+ assert messages[1].role == "assistant"
+ assert messages[1].content == "Hi there!"
+
+ # Verify metadata
+ assert metadata["session_id"] == "test-session-123"
+ assert metadata["total_messages"] == 2
+ assert "stats" in metadata
+ assert "system_prompt" in metadata
+
+ def test_load_session_empty_messages(
+ self, session_config: SessionLoggingConfig
+ ) -> None:
+ """Test loading session with empty messages file."""
+ session_dir = Path(session_config.save_dir)
+ session_folder = session_dir / "test_20230101_120000_test123"
+ session_folder.mkdir()
+
+ # Create empty messages file
+ messages_file = session_folder / "messages.jsonl"
+ messages_file.write_text("")
+
+ # Create metadata file
+ metadata_file = session_folder / "meta.json"
+ metadata_file.write_text('{"session_id": "test-session"}')
+
+ with pytest.raises(ValueError, match="Session messages file is empty"):
+ SessionLoader.load_session(session_folder)
+
+ def test_load_session_invalid_json_messages(
+ self, session_config: SessionLoggingConfig
+ ) -> None:
+ """Test loading session with invalid JSON in messages file."""
+ session_dir = Path(session_config.save_dir)
+ session_folder = session_dir / "test_20230101_120000_test123"
+ session_folder.mkdir()
+
+ # Create messages file with invalid JSON
+ messages_file = session_folder / "messages.jsonl"
+ messages_file.write_text("{invalid json}")
+
+ # Create metadata file
+ metadata_file = session_folder / "meta.json"
+ metadata_file.write_text('{"session_id": "test-session"}')
+
+ with pytest.raises(ValueError, match="Session messages contain invalid JSON"):
+ SessionLoader.load_session(session_folder)
+
+ def test_load_session_invalid_json_metadata(
+ self, session_config: SessionLoggingConfig
+ ) -> None:
+ """Test loading session with invalid JSON in metadata file."""
+ session_dir = Path(session_config.save_dir)
+ session_folder = session_dir / "test_20230101_120000_test123"
+ session_folder.mkdir()
+
+ # Create valid messages file
+ messages_file = session_folder / "messages.jsonl"
+ messages_file.write_text('{"role": "user", "content": "Hello"}\n')
+
+ # Create metadata file with invalid JSON
+ metadata_file = session_folder / "meta.json"
+ metadata_file.write_text("{invalid json}")
+
+ with pytest.raises(ValueError, match="Session metadata contains invalid JSON"):
+ SessionLoader.load_session(session_folder)
+
+ def test_load_session_no_metadata_file(
+ self, session_config: SessionLoggingConfig
+ ) -> None:
+ """Test loading session when metadata file doesn't exist."""
+ session_dir = Path(session_config.save_dir)
+ session_folder = session_dir / "test_20230101_120000_test123"
+ session_folder.mkdir()
+
+ # Create valid messages file using the same format as create_test_session
+ messages = [
+ LLMMessage(role=Role.system, content="System prompt"),
+ LLMMessage(role=Role.user, content="Hello"),
+ LLMMessage(role=Role.assistant, content="Hi there!"),
+ ]
+
+ messages_file = session_folder / "messages.jsonl"
+ with messages_file.open("w", encoding="utf-8") as f:
+ for message in messages:
+ f.write(
+ json.dumps(
+ message.model_dump(exclude_none=True), ensure_ascii=False
+ )
+ + "\n"
+ )
+
+ loaded_messages, metadata = SessionLoader.load_session(session_folder)
+
+ assert len(loaded_messages) == 2
+ assert loaded_messages[0].content == "Hello"
+ assert loaded_messages[0].role == Role.user
+ assert loaded_messages[1].content == "Hi there!"
+ assert loaded_messages[1].role == Role.assistant
+
+ assert metadata == {}
+
+ def test_load_session_nonexistent_directory(
+ self, session_config: SessionLoggingConfig
+ ) -> None:
+ """Test loading session from non-existent directory."""
+ nonexistent_dir = Path(session_config.save_dir) / "nonexistent"
+
+ with pytest.raises(ValueError, match="Error reading session messages"):
+ SessionLoader.load_session(nonexistent_dir)
+
+
+class TestSessionLoaderEdgeCases:
+ def test_find_latest_session_with_different_prefixes(
+ self, session_config: SessionLoggingConfig
+ ) -> None:
+ """Test finding latest session when sessions have different prefixes."""
+ session_dir = Path(session_config.save_dir)
+
+ # Create sessions with different prefixes
+ other_session = session_dir / "other_20230101_120000_test123"
+ other_session.mkdir()
+ (other_session / "messages.jsonl").write_text(
+ '{"role": "user", "content": "test"}\n'
+ )
+
+ test_session = session_dir / "test_20230101_120000_test456"
+ test_session.mkdir()
+ (test_session / "messages.jsonl").write_text(
+ '{"role": "user", "content": "test"}\n'
+ )
+ (test_session / "meta.json").write_text('{"session_id": "test456"}')
+
+ result = SessionLoader.find_latest_session(session_config)
+ assert result is not None
+ assert result.name == "test_20230101_120000_test456"
+
+ def test_find_session_by_id_with_special_characters(
+ self, session_config: SessionLoggingConfig, create_test_session
+ ) -> None:
+ """Test finding session by ID containing special characters."""
+ session_dir = Path(session_config.save_dir)
+ session_folder = create_test_session(
+ session_dir, "test-session_with-special.chars"
+ )
+
+ result = SessionLoader.find_session_by_id(
+ "test-session_with-special.chars", session_config
+ )
+ assert result is not None
+ assert result == session_folder
+
+ def test_load_session_with_complex_messages(
+ self, session_config: SessionLoggingConfig
+ ) -> None:
+ """Test loading session with complex message structures."""
+ session_dir = Path(session_config.save_dir)
+ session_folder = session_dir / "test_20230101_120000_test123"
+ session_folder.mkdir()
+
+ # Create messages with complex structure
+ complex_messages = [
+ LLMMessage(role=Role.system, content="System prompt"),
+ LLMMessage(
+ role=Role.user,
+ content="Complex message",
+ reasoning_content="Some reasoning",
+ tool_calls=[ToolCall(id="call1", index=1, type="function")],
+ ),
+ LLMMessage(
+ role=Role.assistant,
+ content="Response",
+ tool_calls=[ToolCall(id="call2", index=2, type="function")],
+ ),
+ ]
+
+ messages_file = session_folder / "messages.jsonl"
+ with messages_file.open("w", encoding="utf-8") as f:
+ for message in complex_messages:
+ f.write(
+ json.dumps(
+ message.model_dump(exclude_none=True), ensure_ascii=False
+ )
+ + "\n"
+ )
+
+ # Create metadata file
+ metadata_file = session_folder / "meta.json"
+ metadata_file.write_text('{"session_id": "complex-session"}')
+
+ messages, _ = SessionLoader.load_session(session_folder)
+
+ # Verify complex messages are loaded correctly
+ assert len(messages) == 2
+ assert messages[0].role == Role.user
+ assert messages[0].content == "Complex message"
+ assert messages[0].reasoning_content == "Some reasoning"
+ assert len(messages[0].tool_calls or []) == 1
+ assert messages[1].role == Role.assistant
+ assert len(messages[1].tool_calls or []) == 1
+ assert messages[1].content == "Response"
+
+ def test_load_session_system_prompt_ignored_in_messages(
+ self, session_config: SessionLoggingConfig
+ ) -> None:
+ """Test that system prompt is ignored when written in messages.jsonl."""
+ session_dir = Path(session_config.save_dir)
+ session_folder = session_dir / "test_20230101_120000_test123"
+ session_folder.mkdir()
+
+ messages_with_system = [
+ LLMMessage(role=Role.system, content="System prompt from messages"),
+ LLMMessage(role=Role.user, content="Hello"),
+ LLMMessage(role=Role.assistant, content="Hi there!"),
+ ]
+
+ messages_file = session_folder / "messages.jsonl"
+ with messages_file.open("w", encoding="utf-8") as f:
+ for message in messages_with_system:
+ f.write(
+ json.dumps(
+ message.model_dump(exclude_none=True), ensure_ascii=False
+ )
+ + "\n"
+ )
+
+ metadata_file = session_folder / "meta.json"
+ metadata_file.write_text(
+ json.dumps({"session_id": "test-session", "total_messages": 3})
+ )
+
+ messages, metadata = SessionLoader.load_session(session_folder)
+
+ # Verify that system prompt from messages.jsonl is ignored
+ assert len(messages) == 2
+ assert messages[0].role == Role.user
+ assert messages[0].content == "Hello"
+ assert messages[1].role == Role.assistant
+ assert messages[1].content == "Hi there!"
diff --git a/tests/session/test_session_logger.py b/tests/session/test_session_logger.py
new file mode 100644
index 0000000..daeed43
--- /dev/null
+++ b/tests/session/test_session_logger.py
@@ -0,0 +1,641 @@
+from __future__ import annotations
+
+from datetime import datetime, timedelta
+import json
+import os
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from vibe.core.agents.models import AgentProfile, AgentSafety
+from vibe.core.config import SessionLoggingConfig, VibeConfig
+from vibe.core.session.session_logger import SessionLogger
+from vibe.core.tools.manager import ToolManager
+from vibe.core.types import AgentStats, LLMMessage, Role, SessionMetadata
+
+
+@pytest.fixture
+def temp_session_dir(tmp_path: Path) -> Path:
+ """Create a temporary directory for session logging tests."""
+ session_dir = tmp_path / "sessions"
+ session_dir.mkdir()
+ return session_dir
+
+
+@pytest.fixture
+def session_config(temp_session_dir: Path) -> SessionLoggingConfig:
+ """Create a session logging config for testing."""
+ return SessionLoggingConfig(
+ save_dir=str(temp_session_dir), session_prefix="test", enabled=True
+ )
+
+
+@pytest.fixture
+def disabled_session_config() -> SessionLoggingConfig:
+ """Create a disabled session logging config for testing."""
+ return SessionLoggingConfig(
+ save_dir="/tmp/test", session_prefix="test", enabled=False
+ )
+
+
+@pytest.fixture
+def mock_agent_profile() -> AgentProfile:
+ """Create a mock agent profile for testing."""
+ return AgentProfile(
+ name="test-agent",
+ display_name="Test Agent",
+ description="A test agent",
+ safety=AgentSafety.NEUTRAL,
+ overrides={},
+ )
+
+
+@pytest.fixture
+def mock_tool_manager() -> ToolManager:
+ """Create a mock tool manager for testing."""
+ manager = MagicMock(spec=ToolManager)
+ manager.available_tools = {}
+ return manager
+
+
+@pytest.fixture
+def mock_vibe_config() -> VibeConfig:
+ """Create a mock vibe config for testing."""
+ return VibeConfig(active_model="test-model", models=[], providers=[])
+
+
+class TestSessionLoggerInitialization:
+ def test_enabled_session_logger_initialization(
+ self, session_config: SessionLoggingConfig
+ ) -> None:
+ """Test that SessionLogger initializes correctly when enabled."""
+ session_id = "test-session-123"
+ logger = SessionLogger(session_config, session_id)
+
+ assert logger.enabled is True
+ assert logger.session_id == session_id
+ assert logger.save_dir == Path(session_config.save_dir)
+ assert logger.session_prefix == session_config.session_prefix
+ assert logger.session_dir is not None
+ assert logger.session_metadata is not None
+ assert isinstance(logger.session_metadata, SessionMetadata)
+
+ # Check that session directory was created
+ assert logger.session_dir is not None
+ assert str(logger.session_dir).startswith(str(session_config.save_dir))
+
+ # Check session directory name format
+ dir_name = logger.session_dir.name
+ assert dir_name.startswith(f"{session_config.session_prefix}_")
+ assert session_id[:8] in dir_name
+
+ def test_disabled_session_logger_initialization(
+ self, disabled_session_config: SessionLoggingConfig
+ ) -> None:
+ """Test that SessionLogger initializes correctly when disabled."""
+ session_id = "test-session-123"
+ logger = SessionLogger(disabled_session_config, session_id)
+
+ assert logger.enabled is False
+ assert logger.session_id == "disabled"
+ assert logger.save_dir is None
+ assert logger.session_prefix is None
+ assert logger.session_dir is None
+ assert logger.session_metadata is None
+
+
+class TestSessionLoggerMetadata:
+ @patch("vibe.core.session.session_logger.subprocess.run")
+ @patch("vibe.core.session.session_logger.getpass.getuser")
+ def test_session_metadata_initialization(
+ self, mock_getuser, mock_subprocess, session_config: SessionLoggingConfig
+ ) -> None:
+ """Test that session metadata is correctly initialized."""
+ # Mock git commands
+ git_commit_mock = MagicMock()
+ git_commit_mock.returncode = 0
+ git_commit_mock.stdout = "abc123\n"
+
+ git_branch_mock = MagicMock()
+ git_branch_mock.returncode = 0
+ git_branch_mock.stdout = "main\n"
+
+ mock_subprocess.side_effect = [git_commit_mock, git_branch_mock]
+ mock_getuser.return_value = "testuser"
+
+ session_id = "test-session-123"
+ logger = SessionLogger(session_config, session_id)
+
+ assert logger.session_metadata is not None
+ metadata = logger.session_metadata
+
+ assert metadata.session_id == session_id
+ assert metadata.start_time == logger.session_start_time
+ assert metadata.end_time is None
+ assert metadata.git_commit == "abc123"
+ assert metadata.git_branch == "main"
+ assert metadata.username == "testuser"
+ assert "working_directory" in metadata.environment
+ assert metadata.environment["working_directory"] == str(Path.cwd())
+
+ @patch("vibe.core.session.session_logger.subprocess.run")
+ @patch("vibe.core.session.session_logger.getpass.getuser")
+ def test_session_metadata_with_git_errors(
+ self, mock_getuser, mock_subprocess, session_config: SessionLoggingConfig
+ ) -> None:
+ """Test that session metadata handles git command errors gracefully."""
+ # Mock git commands to fail
+ mock_subprocess.side_effect = FileNotFoundError("git not found")
+ mock_getuser.return_value = "testuser"
+
+ session_id = "test-session-123"
+ logger = SessionLogger(session_config, session_id)
+
+ assert logger.session_metadata is not None
+ metadata = logger.session_metadata
+
+ assert metadata.git_commit is None
+ assert metadata.git_branch is None
+ assert metadata.username == "testuser"
+
+
+class TestSessionLoggerSaveInteraction:
+ @pytest.mark.asyncio
+ async def test_save_interaction_disabled(
+ self, disabled_session_config: SessionLoggingConfig
+ ) -> None:
+ """Test that save_interaction returns None when logging is disabled."""
+ logger = SessionLogger(disabled_session_config, "test-session")
+
+ result = await logger.save_interaction(
+ messages=[],
+ stats=AgentStats(),
+ base_config=VibeConfig(active_model="test", models=[], providers=[]),
+ tool_manager=MagicMock(),
+ agent_profile=AgentProfile(
+ name="test",
+ display_name="Test",
+ description="Test agent",
+ safety=AgentSafety.NEUTRAL,
+ overrides={},
+ ),
+ )
+
+ assert result is None
+
+ @pytest.mark.asyncio
+ async def test_save_interaction_success(
+ self,
+ session_config: SessionLoggingConfig,
+ mock_vibe_config: VibeConfig,
+ mock_tool_manager: ToolManager,
+ mock_agent_profile: AgentProfile,
+ ) -> None:
+ """Test that save_interaction successfully saves session data."""
+ session_id = "test-session-123"
+ logger = SessionLogger(session_config, session_id)
+
+ # Create test messages
+ messages = [
+ LLMMessage(role=Role.system, content="System prompt"),
+ LLMMessage(role=Role.user, content="Hello"),
+ LLMMessage(role=Role.assistant, content="Hi there!"),
+ ]
+
+ # Create test stats
+ stats = AgentStats(
+ steps=1, session_prompt_tokens=10, session_completion_tokens=20
+ )
+
+ # Test that save_interaction returns a path when enabled
+ result = await logger.save_interaction(
+ messages=messages,
+ stats=stats,
+ base_config=mock_vibe_config,
+ tool_manager=mock_tool_manager,
+ agent_profile=mock_agent_profile,
+ )
+
+ # Verify the result
+ assert result is not None
+ assert str(logger.session_dir) in result
+
+ # Verify that files were created
+ assert logger.session_dir is not None
+ messages_file = logger.session_dir / "messages.jsonl"
+ metadata_file = logger.session_dir / "meta.json"
+
+ assert messages_file.exists()
+ assert metadata_file.exists()
+
+ # Verify that metadata contains expected data
+ with open(metadata_file) as f:
+ metadata = json.load(f)
+ assert metadata["session_id"] == session_id
+ assert metadata["total_messages"] == 2
+ assert metadata["stats"]["steps"] == stats.steps
+ assert "title" in metadata
+ assert metadata["title"] == "Hello"
+ assert "system_prompt" in metadata
+
+ @pytest.mark.asyncio
+ async def test_save_interaction_system_prompt_in_metadata(
+ self,
+ session_config: SessionLoggingConfig,
+ mock_vibe_config: VibeConfig,
+ mock_tool_manager: ToolManager,
+ mock_agent_profile: AgentProfile,
+ ) -> None:
+ """Test that system prompt is saved in metadata and not in messages."""
+ session_id = "test-session-123"
+ logger = SessionLogger(session_config, session_id)
+
+ messages = [
+ LLMMessage(role=Role.system, content="System prompt"),
+ LLMMessage(role=Role.user, content="Hello"),
+ LLMMessage(role=Role.assistant, content="Hi there!"),
+ ]
+
+ stats = AgentStats(
+ steps=1, session_prompt_tokens=10, session_completion_tokens=20
+ )
+
+ result = await logger.save_interaction(
+ messages=messages,
+ stats=stats,
+ base_config=mock_vibe_config,
+ tool_manager=mock_tool_manager,
+ agent_profile=mock_agent_profile,
+ )
+
+ assert result is not None
+ assert logger.session_dir is not None
+
+ metadata_file = logger.session_dir / "meta.json"
+ with open(metadata_file) as f:
+ metadata = json.load(f)
+ assert "system_prompt" in metadata
+ assert metadata["system_prompt"]["content"] == "System prompt"
+ assert metadata["system_prompt"]["role"] == "system"
+
+ messages_file = logger.session_dir / "messages.jsonl"
+ with open(messages_file) as f:
+ lines = f.readlines()
+ messages_data = [json.loads(line) for line in lines]
+
+ assert len(messages_data) == 2
+ assert messages_data[0]["role"] == "user"
+ assert messages_data[1]["role"] == "assistant"
+
+ @pytest.mark.asyncio
+ async def test_save_interaction_with_existing_messages(
+ self,
+ session_config: SessionLoggingConfig,
+ mock_vibe_config: VibeConfig,
+ mock_tool_manager: ToolManager,
+ mock_agent_profile: AgentProfile,
+ ) -> None:
+ """Test that save_interaction correctly handles existing messages."""
+ session_id = "test-session-123"
+ logger = SessionLogger(session_config, session_id)
+
+ # First save - create initial session
+ initial_messages = [
+ LLMMessage(role=Role.system, content="System prompt"),
+ LLMMessage(role=Role.user, content="Hello"),
+ LLMMessage(role=Role.assistant, content="Hi there!"),
+ ]
+
+ stats = AgentStats(
+ steps=1, session_prompt_tokens=10, session_completion_tokens=20
+ )
+
+ await logger.save_interaction(
+ messages=initial_messages,
+ stats=stats,
+ base_config=mock_vibe_config,
+ tool_manager=mock_tool_manager,
+ agent_profile=mock_agent_profile,
+ )
+
+ # Second save - add more messages
+ new_messages = [
+ LLMMessage(role=Role.user, content="How are you?"),
+ LLMMessage(role=Role.assistant, content="I'm fine, thanks!"),
+ ]
+
+ all_messages = initial_messages + new_messages
+ updated_stats = AgentStats(
+ steps=2, session_prompt_tokens=20, session_completion_tokens=40
+ )
+
+ result = await logger.save_interaction(
+ messages=all_messages,
+ stats=updated_stats,
+ base_config=mock_vibe_config,
+ tool_manager=mock_tool_manager,
+ agent_profile=mock_agent_profile,
+ )
+
+ # Verify that the result is not None
+ assert result is not None
+
+ # Verify that metadata was updated
+ assert logger.session_dir is not None
+ metadata_file = logger.session_dir / "meta.json"
+ with open(metadata_file) as f:
+ metadata = json.load(f)
+ assert metadata["total_messages"] == 4
+ assert metadata["stats"]["steps"] == updated_stats.steps
+
+ @pytest.mark.asyncio
+ async def test_save_interaction_no_user_messages(
+ self,
+ session_config: SessionLoggingConfig,
+ mock_vibe_config: VibeConfig,
+ mock_tool_manager: ToolManager,
+ mock_agent_profile: AgentProfile,
+ ) -> None:
+ """Test that save_interaction handles sessions with no user messages."""
+ session_id = "test-session-123"
+ logger = SessionLogger(session_config, session_id)
+
+ # Create messages with no user messages (only system and assistant)
+ messages = [
+ LLMMessage(role=Role.system, content="System prompt"),
+ LLMMessage(role=Role.assistant, content="Hi there!"),
+ ]
+
+ stats = AgentStats(
+ steps=1, session_prompt_tokens=10, session_completion_tokens=20
+ )
+
+ result = await logger.save_interaction(
+ messages=messages,
+ stats=stats,
+ base_config=mock_vibe_config,
+ tool_manager=mock_tool_manager,
+ agent_profile=mock_agent_profile,
+ )
+
+ # Verify the result
+ assert result is not None
+ assert str(logger.session_dir) in result
+
+ # Verify that metadata contains expected data
+ assert logger.session_dir is not None
+ metadata_file = logger.session_dir / "meta.json"
+ with open(metadata_file) as f:
+ metadata = json.load(f)
+ assert metadata["session_id"] == session_id
+ assert metadata["total_messages"] == 1
+ assert metadata["stats"]["steps"] == stats.steps
+ assert metadata["title"] == "Untitled session"
+
+ @pytest.mark.asyncio
+ async def test_save_interaction_long_user_message(
+ self,
+ session_config: SessionLoggingConfig,
+ mock_vibe_config: VibeConfig,
+ mock_tool_manager: ToolManager,
+ mock_agent_profile: AgentProfile,
+ ) -> None:
+ """Test that save_interaction truncates long user messages for title."""
+ session_id = "test-session-123"
+ logger = SessionLogger(session_config, session_id)
+
+ # Create a long user message (more than 50 characters)
+ long_message = "This is a very long user message that exceeds fifty characters and should be truncated"
+ messages = [
+ LLMMessage(role=Role.system, content="System prompt"),
+ LLMMessage(role=Role.user, content=long_message),
+ LLMMessage(role=Role.assistant, content="Response"),
+ ]
+
+ stats = AgentStats(
+ steps=1, session_prompt_tokens=10, session_completion_tokens=20
+ )
+
+ result = await logger.save_interaction(
+ messages=messages,
+ stats=stats,
+ base_config=mock_vibe_config,
+ tool_manager=mock_tool_manager,
+ agent_profile=mock_agent_profile,
+ )
+
+ # Verify the result
+ assert result is not None
+ assert str(logger.session_dir) in result
+
+ # Verify that metadata contains expected data
+ assert logger.session_dir is not None
+ metadata_file = logger.session_dir / "meta.json"
+ with open(metadata_file) as f:
+ metadata = json.load(f)
+ assert metadata["session_id"] == session_id
+ assert metadata["total_messages"] == 2
+ assert metadata["stats"]["steps"] == stats.steps
+ expected_title = long_message[:50] + "…"
+ assert metadata["title"] == expected_title
+
+
+class TestSessionLoggerResetSession:
+ def test_reset_session(self, session_config: SessionLoggingConfig) -> None:
+ """Test that reset_session correctly resets session information."""
+ session_id = "test-session-123"
+ logger = SessionLogger(session_config, session_id)
+
+ # Store original session info
+ original_session_id = logger.session_id
+ original_metadata = logger.session_metadata
+
+ # Reset session
+ new_session_id = "test-session-456"
+ logger.reset_session(new_session_id)
+
+ # Verify session was reset
+ assert logger.session_id == new_session_id
+ assert logger.session_start_time != "N/A" # Should be a valid timestamp
+ assert logger.session_metadata is not None
+ assert logger.session_metadata.session_id == new_session_id
+
+ # Verify that metadata was recreated (different object)
+ assert logger.session_metadata is not original_metadata
+
+ assert logger.session_id != original_session_id
+
+ def test_reset_session_disabled(
+ self, disabled_session_config: SessionLoggingConfig
+ ) -> None:
+ """Test that reset_session does nothing when logging is disabled."""
+ logger = SessionLogger(disabled_session_config, "test-session")
+
+ # Reset session should not raise any errors
+ logger.reset_session("new-session")
+
+ # Verify state is unchanged
+ assert logger.enabled is False
+ assert logger.session_id == "disabled"
+
+
+class TestSessionLoggerFileOperations:
+ def test_save_folder(self, session_config: SessionLoggingConfig) -> None:
+ """Test that save_folder creates correct folder name."""
+ session_id = "test-session-123"
+ logger = SessionLogger(session_config, session_id)
+
+ folder = logger.save_folder
+
+ assert folder.parent == Path(session_config.save_dir)
+ assert folder.name.startswith(f"{session_config.session_prefix}_")
+ assert session_id[:8] in folder.name
+
+ def test_metadata_filepath(self, session_config: SessionLoggingConfig) -> None:
+ """Test that metadata_filepath returns correct path."""
+ session_id = "test-session-123"
+ logger = SessionLogger(session_config, session_id)
+
+ metadata_file = logger.metadata_filepath
+
+ assert logger.session_dir is not None
+ assert metadata_file == logger.session_dir / "meta.json"
+
+ def test_messages_filepath(self, session_config: SessionLoggingConfig) -> None:
+ """Test that messages_filepath returns correct path."""
+ session_id = "test-session-123"
+ logger = SessionLogger(session_config, session_id)
+
+ messages_file = logger.messages_filepath
+
+ assert logger.session_dir is not None
+ assert messages_file == logger.session_dir / "messages.jsonl"
+
+ def test_disabled_file_operations_raise_errors(
+ self, disabled_session_config: SessionLoggingConfig
+ ) -> None:
+ """Test that file operations raise errors when logging is disabled."""
+ logger = SessionLogger(disabled_session_config, "test-session")
+
+ with pytest.raises(
+ RuntimeError,
+ match="Cannot get session save folder when logging is disabled",
+ ):
+ assert logger.save_folder is None
+
+ with pytest.raises(
+ RuntimeError,
+ match="Cannot get session metadata filepath when logging is disabled",
+ ):
+ assert logger.metadata_filepath is None
+
+ with pytest.raises(
+ RuntimeError,
+ match="Cannot get session messages filepath when logging is disabled",
+ ):
+ assert logger.messages_filepath is None
+
+
+def create_temp_file_ago(tmp_path: Path, filename: str, minutes_ago: int = 0) -> Path:
+ """Create a file with a modification time of `minutes_ago` minutes ago."""
+ file = tmp_path / filename
+ file.touch()
+ old_time = datetime.now() - timedelta(minutes=minutes_ago)
+ os.utime(file, (old_time.timestamp(), old_time.timestamp()))
+ return file
+
+
+class TestSessionLoggerCleanupTmpFiles:
+ def test_cleanup_tmp_files_disabled(
+ self, disabled_session_config: SessionLoggingConfig
+ ) -> None:
+ """Test that cleanup_tmp_files returns early when logging is disabled."""
+ logger = SessionLogger(disabled_session_config, "test-session")
+
+ logger.cleanup_tmp_files()
+
+ def test_cleanup_tmp_files_no_tmp_files(
+ self, session_config: SessionLoggingConfig
+ ) -> None:
+ """Test that cleanup_tmp_files handles no tmp files gracefully."""
+ session_id = "test-session-123"
+ logger = SessionLogger(session_config, session_id)
+
+ logger.cleanup_tmp_files()
+
+ def test_cleanup_tmp_files_deletes_old_files(
+ self, session_config: SessionLoggingConfig
+ ) -> None:
+ """Test that cleanup_tmp_files deletes tmp files older than 5 minutes."""
+ session_id = "test-session-123"
+ logger = SessionLogger(session_config, session_id)
+
+ assert logger.session_dir is not None
+ logger.session_dir.mkdir(parents=True, exist_ok=True)
+
+ old_tmp_file = create_temp_file_ago(
+ logger.session_dir, "session-123.json.tmp", 10
+ )
+ new_tmp_file = create_temp_file_ago(logger.session_dir, "session-123.json")
+
+ logger.cleanup_tmp_files()
+
+ assert not old_tmp_file.exists()
+ assert new_tmp_file.exists()
+
+ def test_cleanup_tmp_files_recursive(
+ self, session_config: SessionLoggingConfig
+ ) -> None:
+ """Test that cleanup_tmp_files works recursively in subdirectories."""
+ session_id = "test-session-123"
+ logger = SessionLogger(session_config, session_id)
+
+ assert logger.session_dir is not None
+ logger.session_dir.mkdir(parents=True, exist_ok=True)
+
+ subdir_1 = logger.session_dir / "session-123"
+ subdir_1.mkdir()
+
+ old_tmp_file = create_temp_file_ago(subdir_1, "meta.json.tmp", 10)
+ new_tmp_file = create_temp_file_ago(subdir_1, "meta.json")
+
+ subdir_2 = logger.session_dir / "session-456"
+ subdir_2.mkdir()
+
+ old_tmp_file_2 = create_temp_file_ago(subdir_2, "meta.json.tmp", 10)
+
+ logger.cleanup_tmp_files()
+
+ assert not old_tmp_file.exists()
+ assert not old_tmp_file_2.exists()
+ assert new_tmp_file.exists()
+
+ def test_cleanup_tmp_files_handles_exceptions(
+ self, session_config: SessionLoggingConfig
+ ) -> None:
+ """Test that cleanup_tmp_files handles exceptions gracefully."""
+ session_id = "test-session-123"
+ logger = SessionLogger(session_config, session_id)
+
+ assert logger.session_dir is not None
+ logger.session_dir.mkdir(parents=True, exist_ok=True)
+
+ old_tmp_file = create_temp_file_ago(logger.session_dir, "meta.json.tmp", 10)
+ another_old_tmp_file = create_temp_file_ago(
+ logger.session_dir, "meta-002.json.tmp", 10
+ )
+
+ # Mock the unlink method to raise an exception for the first file
+ original_unlink = Path.unlink
+
+ def mock_unlink(self):
+ if str(self) == str(old_tmp_file):
+ raise OSError("Mocked error")
+ return original_unlink(self)
+
+ with patch.object(Path, "unlink", mock_unlink):
+ logger.cleanup_tmp_files()
+
+ assert old_tmp_file.exists()
+ assert not another_old_tmp_file.exists()
diff --git a/tests/session/test_session_migration.py b/tests/session/test_session_migration.py
new file mode 100644
index 0000000..711275b
--- /dev/null
+++ b/tests/session/test_session_migration.py
@@ -0,0 +1,177 @@
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import pytest
+
+from vibe.core.config import SessionLoggingConfig
+from vibe.core.session.session_migration import migrate_sessions
+
+
+@pytest.fixture
+def temp_session_dir(tmp_path: Path) -> Path:
+ session_dir = tmp_path / "sessions"
+ session_dir.mkdir()
+ return session_dir
+
+
+@pytest.fixture
+def session_config(temp_session_dir: Path) -> SessionLoggingConfig:
+ return SessionLoggingConfig(
+ save_dir=str(temp_session_dir), session_prefix="test", enabled=True
+ )
+
+
+@pytest.fixture
+def disabled_session_config() -> SessionLoggingConfig:
+ return SessionLoggingConfig(
+ save_dir="/tmp/test", session_prefix="test", enabled=False
+ )
+
+
+@pytest.fixture
+def old_session_data() -> dict:
+ return {
+ "metadata": {
+ "session_id": "test-session-123",
+ "start_time": "2023-01-01T00:00:00",
+ "end_time": "2023-01-01T01:00:00",
+ "git_commit": "abc123",
+ "git_branch": "main",
+ "username": "testuser",
+ "environment": {"working_directory": "/test"},
+ },
+ "messages": [
+ {"role": "system", "content": "System prompt"},
+ {"role": "user", "content": "Hello"},
+ {"role": "assistant", "content": "Hi there!"},
+ ],
+ }
+
+
+class TestSessionMigration:
+ @pytest.mark.asyncio
+ async def test_migrate_sessions_disabled_config(
+ self, disabled_session_config: SessionLoggingConfig
+ ) -> None:
+ """Test that migration does nothing when config is disabled."""
+ result = await migrate_sessions(disabled_session_config)
+ assert result == 0
+
+ @pytest.mark.asyncio
+ async def test_migrate_sessions_no_save_dir(
+ self, session_config: SessionLoggingConfig
+ ) -> None:
+ """Test that migration handles missing save_dir gracefully."""
+ config = SessionLoggingConfig(save_dir="", session_prefix="test", enabled=True)
+ result = await migrate_sessions(config)
+ assert result == 0
+
+ @pytest.mark.asyncio
+ async def test_migrate_sessions_no_old_files(
+ self, session_config: SessionLoggingConfig
+ ) -> None:
+ """Test that migration handles no old session files gracefully."""
+ session_dir = Path(session_config.save_dir)
+ session_dir.mkdir(exist_ok=True)
+
+ result = await migrate_sessions(session_config)
+ assert result == 0
+
+ @pytest.mark.asyncio
+ async def test_migrate_sessions_successful_migration(
+ self, session_config: SessionLoggingConfig, old_session_data: dict
+ ) -> None:
+ """Test successful migration of old session files."""
+ session_dir = Path(session_config.save_dir)
+
+ old_session_file = session_dir / "test_session-123.json"
+ with open(old_session_file, "w") as f:
+ json.dump(old_session_data, f)
+
+ result = await migrate_sessions(session_config)
+ assert result == 1
+
+ assert not old_session_file.exists()
+ session_subdir = session_dir / "test_session-123"
+ assert session_subdir.is_dir()
+
+ metadata_file = session_subdir / "meta.json"
+ assert metadata_file.is_file()
+
+ with open(metadata_file) as f:
+ metadata = json.load(f)
+ assert metadata == old_session_data["metadata"]
+
+ messages_file = session_subdir / "messages.jsonl"
+ assert messages_file.exists()
+
+ with open(messages_file) as f:
+ lines = f.readlines()
+ assert len(lines) == len(old_session_data["messages"])
+
+ for i, line in enumerate(lines):
+ message = json.loads(line.strip())
+ assert message == old_session_data["messages"][i]
+
+ @pytest.mark.asyncio
+ async def test_migrate_sessions_multiple_files(
+ self, session_config: SessionLoggingConfig, old_session_data: dict
+ ) -> None:
+ """Test migration of multiple old session files."""
+ session_dir = Path(session_config.save_dir)
+
+ session_files = []
+ for i in range(3):
+ session_file = session_dir / f"test_session-{i:03d}.json"
+ session_files.append(session_file)
+
+ modified_data = old_session_data.copy()
+ modified_data["metadata"]["session_id"] = f"test-session-{i}"
+
+ with open(session_file, "w") as f:
+ json.dump(modified_data, f)
+
+ result = await migrate_sessions(session_config)
+ assert result == 3
+
+ for session_file in session_files:
+ assert not session_file.exists()
+
+ for i in range(3):
+ session_subdir = session_dir / f"test_session-{i:03d}"
+ assert session_subdir.exists()
+ assert session_subdir.is_dir()
+
+ metadata_file = session_subdir / "meta.json"
+ messages_file = session_subdir / "messages.jsonl"
+ assert metadata_file.exists()
+ assert messages_file.exists()
+
+ @pytest.mark.asyncio
+ async def test_migrate_sessions_error_handling(
+ self, session_config: SessionLoggingConfig
+ ) -> None:
+ """Test that migration handles errors gracefully and continues."""
+ session_dir = Path(session_config.save_dir)
+
+ valid_session_file = session_dir / "test_session-valid.json"
+ valid_data = {
+ "metadata": {"session_id": "valid-session"},
+ "messages": [{"role": "user", "content": "Hello"}],
+ }
+ with open(valid_session_file, "w") as f:
+ json.dump(valid_data, f)
+
+ invalid_session_file = session_dir / "test_session-invalid.json"
+ with open(invalid_session_file, "w") as f:
+ f.write("{invalid json}")
+
+ result = await migrate_sessions(session_config)
+ assert result == 1
+
+ valid_session_subdir = session_dir / "test_session-valid"
+ assert valid_session_subdir.exists()
+ assert not valid_session_file.exists()
+ assert invalid_session_file.exists()
diff --git a/tests/skills/conftest.py b/tests/skills/conftest.py
index c5af3e8..c82b1b3 100644
--- a/tests/skills/conftest.py
+++ b/tests/skills/conftest.py
@@ -35,6 +35,7 @@ def create_skill(
compatibility: str | None = None,
metadata: dict[str, str] | None = None,
allowed_tools: str | None = None,
+ user_invocable: bool | None = None,
body: str = "## Instructions\n\nTest instructions here.",
) -> Path:
skill_dir = skills_dir / name
@@ -49,6 +50,8 @@ def create_skill(
frontmatter["metadata"] = metadata
if allowed_tools:
frontmatter["allowed-tools"] = allowed_tools
+ if user_invocable is not None:
+ frontmatter["user-invocable"] = user_invocable
yaml_str = yaml.dump(frontmatter, default_flow_style=False, allow_unicode=True)
content = f"---\n{yaml_str}---\n\n{body}"
diff --git a/tests/skills/test_manager.py b/tests/skills/test_manager.py
index 1780e39..f05452a 100644
--- a/tests/skills/test_manager.py
+++ b/tests/skills/test_manager.py
@@ -273,3 +273,191 @@ class TestSkillManagerGetSkill:
def test_returns_none_for_unknown_skill(self, skill_manager: SkillManager) -> None:
assert skill_manager.get_skill("nonexistent-skill") is None
+
+
+class TestSkillManagerFiltering:
+ def test_enabled_skills_filters_to_only_enabled(self, skills_dir: Path) -> None:
+ create_skill(skills_dir, "skill-a", "Skill A")
+ create_skill(skills_dir, "skill-b", "Skill B")
+ create_skill(skills_dir, "skill-c", "Skill C")
+
+ config = VibeConfig(
+ session_logging=SessionLoggingConfig(enabled=False),
+ system_prompt_id="tests",
+ include_project_context=False,
+ skill_paths=[skills_dir],
+ enabled_skills=["skill-a", "skill-c"],
+ )
+ manager = SkillManager(lambda: config)
+
+ skills = manager.available_skills
+ assert len(skills) == 2
+ assert "skill-a" in skills
+ assert "skill-b" not in skills
+ assert "skill-c" in skills
+
+ def test_disabled_skills_excludes_disabled(self, skills_dir: Path) -> None:
+ create_skill(skills_dir, "skill-a", "Skill A")
+ create_skill(skills_dir, "skill-b", "Skill B")
+ create_skill(skills_dir, "skill-c", "Skill C")
+
+ config = VibeConfig(
+ session_logging=SessionLoggingConfig(enabled=False),
+ system_prompt_id="tests",
+ include_project_context=False,
+ skill_paths=[skills_dir],
+ disabled_skills=["skill-b"],
+ )
+ manager = SkillManager(lambda: config)
+
+ skills = manager.available_skills
+ assert len(skills) == 2
+ assert "skill-a" in skills
+ assert "skill-b" not in skills
+ assert "skill-c" in skills
+
+ def test_enabled_skills_takes_precedence_over_disabled(
+ self, skills_dir: Path
+ ) -> None:
+ create_skill(skills_dir, "skill-a", "Skill A")
+ create_skill(skills_dir, "skill-b", "Skill B")
+
+ config = VibeConfig(
+ session_logging=SessionLoggingConfig(enabled=False),
+ system_prompt_id="tests",
+ include_project_context=False,
+ skill_paths=[skills_dir],
+ enabled_skills=["skill-a"],
+ disabled_skills=["skill-a"], # Should be ignored
+ )
+ manager = SkillManager(lambda: config)
+
+ skills = manager.available_skills
+ assert len(skills) == 1
+ assert "skill-a" in skills
+
+ def test_glob_pattern_matching(self, skills_dir: Path) -> None:
+ create_skill(skills_dir, "search-code", "Search code")
+ create_skill(skills_dir, "search-docs", "Search docs")
+ create_skill(skills_dir, "other-skill", "Other skill")
+
+ config = VibeConfig(
+ session_logging=SessionLoggingConfig(enabled=False),
+ system_prompt_id="tests",
+ include_project_context=False,
+ skill_paths=[skills_dir],
+ enabled_skills=["search-*"],
+ )
+ manager = SkillManager(lambda: config)
+
+ skills = manager.available_skills
+ assert len(skills) == 2
+ assert "search-code" in skills
+ assert "search-docs" in skills
+ assert "other-skill" not in skills
+
+ def test_regex_pattern_matching(self, skills_dir: Path) -> None:
+ create_skill(skills_dir, "skill-v1", "Skill v1")
+ create_skill(skills_dir, "skill-v2", "Skill v2")
+ create_skill(skills_dir, "other-skill", "Other skill")
+
+ config = VibeConfig(
+ session_logging=SessionLoggingConfig(enabled=False),
+ system_prompt_id="tests",
+ include_project_context=False,
+ skill_paths=[skills_dir],
+ enabled_skills=["re:skill-v\\d+"],
+ )
+ manager = SkillManager(lambda: config)
+
+ skills = manager.available_skills
+ assert len(skills) == 2
+ assert "skill-v1" in skills
+ assert "skill-v2" in skills
+ assert "other-skill" not in skills
+
+ def test_get_skill_respects_filtering(self, skills_dir: Path) -> None:
+ create_skill(skills_dir, "enabled-skill", "Enabled")
+ create_skill(skills_dir, "disabled-skill", "Disabled")
+
+ config = VibeConfig(
+ session_logging=SessionLoggingConfig(enabled=False),
+ system_prompt_id="tests",
+ include_project_context=False,
+ skill_paths=[skills_dir],
+ disabled_skills=["disabled-skill"],
+ )
+ manager = SkillManager(lambda: config)
+
+ assert manager.get_skill("enabled-skill") is not None
+ assert manager.get_skill("disabled-skill") is None
+
+
+class TestSkillUserInvocable:
+ def test_user_invocable_defaults_to_true(self, skills_dir: Path) -> None:
+ create_skill(skills_dir, "default-skill", "A default skill")
+
+ config = VibeConfig(
+ session_logging=SessionLoggingConfig(enabled=False),
+ system_prompt_id="tests",
+ include_project_context=False,
+ skill_paths=[skills_dir],
+ )
+ manager = SkillManager(lambda: config)
+
+ skill = manager.get_skill("default-skill")
+ assert skill is not None
+ assert skill.user_invocable is True
+
+ def test_user_invocable_can_be_set_to_false(self, skills_dir: Path) -> None:
+ create_skill(skills_dir, "hidden-skill", "A hidden skill", user_invocable=False)
+
+ config = VibeConfig(
+ session_logging=SessionLoggingConfig(enabled=False),
+ system_prompt_id="tests",
+ include_project_context=False,
+ skill_paths=[skills_dir],
+ )
+ manager = SkillManager(lambda: config)
+
+ skill = manager.get_skill("hidden-skill")
+ assert skill is not None
+ assert skill.user_invocable is False
+
+ def test_user_invocable_can_be_explicitly_set_to_true(
+ self, skills_dir: Path
+ ) -> None:
+ create_skill(
+ skills_dir, "explicit-skill", "An explicit skill", user_invocable=True
+ )
+
+ config = VibeConfig(
+ session_logging=SessionLoggingConfig(enabled=False),
+ system_prompt_id="tests",
+ include_project_context=False,
+ skill_paths=[skills_dir],
+ )
+ manager = SkillManager(lambda: config)
+
+ skill = manager.get_skill("explicit-skill")
+ assert skill is not None
+ assert skill.user_invocable is True
+
+ def test_mixed_user_invocable_skills(self, skills_dir: Path) -> None:
+ create_skill(skills_dir, "visible-skill", "Visible", user_invocable=True)
+ create_skill(skills_dir, "hidden-skill", "Hidden", user_invocable=False)
+ create_skill(skills_dir, "default-skill", "Default")
+
+ config = VibeConfig(
+ session_logging=SessionLoggingConfig(enabled=False),
+ system_prompt_id="tests",
+ include_project_context=False,
+ skill_paths=[skills_dir],
+ )
+ manager = SkillManager(lambda: config)
+
+ skills = manager.available_skills
+ assert len(skills) == 3
+ assert skills["visible-skill"].user_invocable is True
+ assert skills["hidden-skill"].user_invocable is False
+ assert skills["default-skill"].user_invocable is True
diff --git a/tests/skills/test_models.py b/tests/skills/test_models.py
index c114fcc..8a451ef 100644
--- a/tests/skills/test_models.py
+++ b/tests/skills/test_models.py
@@ -18,6 +18,7 @@ class TestSkillMetadata:
assert meta.compatibility is None
assert meta.metadata == {}
assert meta.allowed_tools == []
+ assert meta.user_invocable is True
def test_creates_with_all_fields(self) -> None:
meta = SkillMetadata(
@@ -27,6 +28,7 @@ class TestSkillMetadata:
compatibility="Requires git",
metadata={"author": "Test Author", "version": "1.0"},
allowed_tools=["bash", "read_file"],
+ user_invocable=False,
)
assert meta.name == "full-skill"
@@ -35,6 +37,7 @@ class TestSkillMetadata:
assert meta.compatibility == "Requires git"
assert meta.metadata == {"author": "Test Author", "version": "1.0"}
assert meta.allowed_tools == ["bash", "read_file"]
+ assert meta.user_invocable is False
def test_raises_error_for_uppercase_name(self) -> None:
with pytest.raises(ValidationError) as exc_info:
@@ -144,6 +147,7 @@ class TestSkillInfo:
compatibility="git, docker",
metadata={"author": "Test"},
allowed_tools=["bash"],
+ user_invocable=False,
skill_path=skill_path,
)
@@ -153,6 +157,7 @@ class TestSkillInfo:
assert info.compatibility == "git, docker"
assert info.metadata == {"author": "Test"}
assert info.allowed_tools == ["bash"]
+ assert info.user_invocable is False
assert info.skill_path == skill_path
assert info.skill_dir == skill_path.parent.resolve()
@@ -179,6 +184,7 @@ class TestSkillInfo:
compatibility="Requires Python 3.12",
metadata={"key": "value"},
allowed_tools=["bash", "grep"],
+ user_invocable=False,
)
info = SkillInfo.from_metadata(meta, skill_path)
@@ -186,3 +192,4 @@ class TestSkillInfo:
assert info.compatibility == meta.compatibility
assert info.metadata == meta.metadata
assert info.allowed_tools == meta.allowed_tools
+ assert info.user_invocable == meta.user_invocable
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_ask_user_question/test_snapshot_ask_user_question_collapsed.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_ask_user_question/test_snapshot_ask_user_question_collapsed.svg
new file mode 100644
index 0000000..9b7d8c9
--- /dev/null
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_ask_user_question/test_snapshot_ask_user_question_collapsed.svg
@@ -0,0 +1,140 @@
+
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_ask_user_question/test_snapshot_ask_user_question_expanded.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_ask_user_question/test_snapshot_ask_user_question_expanded.svg
new file mode 100644
index 0000000..d6c6954
--- /dev/null
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_ask_user_question/test_snapshot_ask_user_question_expanded.svg
@@ -0,0 +1,180 @@
+
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_basic_conversation/test_snapshot_shows_basic_conversation.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_basic_conversation/test_snapshot_shows_basic_conversation.svg
index 5b55d8b..8222082 100644
--- a/tests/snapshots/__snapshots__/test_ui_snapshot_basic_conversation/test_snapshot_shows_basic_conversation.svg
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_basic_conversation/test_snapshot_shows_basic_conversation.svg
@@ -162,7 +162,7 @@
-
+
╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
@@ -193,7 +193,7 @@
-⏵ default mode (shift+tab to cycle)
+⏵ default agent (shift+tab to cycle)
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
>Ask anything...
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_code_block_horizontal_scrolling/test_snapshot_allows_horizontal_scrolling_for_long_code_blocks.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_code_block_horizontal_scrolling/test_snapshot_allows_horizontal_scrolling_for_long_code_blocks.svg
index 3a93efa..da5f054 100644
--- a/tests/snapshots/__snapshots__/test_ui_snapshot_code_block_horizontal_scrolling/test_snapshot_allows_horizontal_scrolling_for_long_code_blocks.svg
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_code_block_horizontal_scrolling/test_snapshot_allows_horizontal_scrolling_for_long_code_blocks.svg
@@ -170,7 +170,7 @@
-
+
╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
@@ -201,7 +201,7 @@
-⏵ default mode (shift+tab to cycle)
+⏵ default agent (shift+tab to cycle)
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
>Ask anything...
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_accept_edits_mode.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_accept_edits_mode.svg
index 204ca26..88d00f8 100644
--- a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_accept_edits_mode.svg
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_accept_edits_mode.svg
@@ -160,7 +160,7 @@
-
+
╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
@@ -191,7 +191,7 @@
-⏵⏵ accept edits mode (shift+tab to cycle)
+⏵⏵ accept edits agent (shift+tab to cycle)
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
>Ask anything...
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_auto_approve_mode.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_auto_approve_mode.svg
index 486cb7c..c2eb202 100644
--- a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_auto_approve_mode.svg
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_auto_approve_mode.svg
@@ -160,7 +160,7 @@
-
+
╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
@@ -191,7 +191,7 @@
-⏵⏵⏵ auto approve mode (shift+tab to cycle)
+⏵⏵⏵ auto approve agent (shift+tab to cycle)
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
>Ask anything...
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_plan_mode.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_plan_mode.svg
index 1333d07..0b1f5f5 100644
--- a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_plan_mode.svg
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_plan_mode.svg
@@ -160,7 +160,7 @@
-
+
╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
@@ -191,7 +191,7 @@
-⏸︎ plan mode (shift+tab to cycle)
+⏸ plan agent (shift+tab to cycle)
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
>Ask anything...
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_wraps_to_default.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_wraps_to_default.svg
index b99155c..b380245 100644
--- a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_wraps_to_default.svg
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_wraps_to_default.svg
@@ -161,7 +161,7 @@
-
+
╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
@@ -192,7 +192,7 @@
-⏵ default mode (shift+tab to cycle)
+⏵ default agent (shift+tab to cycle)
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
>Ask anything...
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_default_mode.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_default_mode.svg
index b99155c..b380245 100644
--- a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_default_mode.svg
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_default_mode.svg
@@ -161,7 +161,7 @@
-
+
╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
@@ -192,7 +192,7 @@
-⏵ default mode (shift+tab to cycle)
+⏵ default agent (shift+tab to cycle)
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
>Ask anything...
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_plan_offer/test_snapshot_shows_no_plan_offer.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_plan_offer/test_snapshot_shows_no_plan_offer.svg
new file mode 100644
index 0000000..8c5588c
--- /dev/null
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_plan_offer/test_snapshot_shows_no_plan_offer.svg
@@ -0,0 +1,204 @@
+
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_plan_offer/test_snapshot_shows_switch_key_plan_offer.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_plan_offer/test_snapshot_shows_switch_key_plan_offer.svg
new file mode 100644
index 0000000..45ff424
--- /dev/null
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_plan_offer/test_snapshot_shows_switch_key_plan_offer.svg
@@ -0,0 +1,206 @@
+
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_plan_offer/test_snapshot_shows_upgrade_plan_offer.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_plan_offer/test_snapshot_shows_upgrade_plan_offer.svg
new file mode 100644
index 0000000..493bcdb
--- /dev/null
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_plan_offer/test_snapshot_shows_upgrade_plan_offer.svg
@@ -0,0 +1,206 @@
+
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_plan_offer/test_snapshot_shows_whats_new_and_plan_offer.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_plan_offer/test_snapshot_shows_whats_new_and_plan_offer.svg
new file mode 100644
index 0000000..12b7f75
--- /dev/null
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_plan_offer/test_snapshot_shows_whats_new_and_plan_offer.svg
@@ -0,0 +1,207 @@
+
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_answer_first_advance.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_answer_first_advance.svg
new file mode 100644
index 0000000..8ad566d
--- /dev/null
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_answer_first_advance.svg
@@ -0,0 +1,137 @@
+
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_first_answered_checkmark.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_first_answered_checkmark.svg
new file mode 100644
index 0000000..8ad566d
--- /dev/null
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_first_answered_checkmark.svg
@@ -0,0 +1,137 @@
+
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_initial.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_initial.svg
new file mode 100644
index 0000000..1ef74ba
--- /dev/null
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_initial.svg
@@ -0,0 +1,137 @@
+
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_navigate_left_wraps.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_navigate_left_wraps.svg
new file mode 100644
index 0000000..469de27
--- /dev/null
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_navigate_left_wraps.svg
@@ -0,0 +1,137 @@
+
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_navigate_right.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_navigate_right.svg
new file mode 100644
index 0000000..469de27
--- /dev/null
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_navigate_right.svg
@@ -0,0 +1,137 @@
+
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_tab_to_second.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_tab_to_second.svg
new file mode 100644
index 0000000..1ef74ba
--- /dev/null
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_tab_to_second.svg
@@ -0,0 +1,137 @@
+
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_initial.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_initial.svg
new file mode 100644
index 0000000..e3eeb65
--- /dev/null
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_initial.svg
@@ -0,0 +1,136 @@
+
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_mixed_selection.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_mixed_selection.svg
new file mode 100644
index 0000000..172fb71
--- /dev/null
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_mixed_selection.svg
@@ -0,0 +1,138 @@
+
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_navigate_to_submit.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_navigate_to_submit.svg
new file mode 100644
index 0000000..afe89ba
--- /dev/null
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_navigate_to_submit.svg
@@ -0,0 +1,137 @@
+
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_other_with_text.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_other_with_text.svg
new file mode 100644
index 0000000..a064076
--- /dev/null
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_other_with_text.svg
@@ -0,0 +1,138 @@
+
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_toggle_first.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_toggle_first.svg
new file mode 100644
index 0000000..b3fb453
--- /dev/null
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_toggle_first.svg
@@ -0,0 +1,136 @@
+
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_toggle_multiple.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_toggle_multiple.svg
new file mode 100644
index 0000000..6ee8754
--- /dev/null
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_toggle_multiple.svg
@@ -0,0 +1,136 @@
+
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_untoggle.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_untoggle.svg
new file mode 100644
index 0000000..e3eeb65
--- /dev/null
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_untoggle.svg
@@ -0,0 +1,136 @@
+
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_initial.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_initial.svg
new file mode 100644
index 0000000..464defb
--- /dev/null
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_initial.svg
@@ -0,0 +1,136 @@
+
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_navigate_down.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_navigate_down.svg
new file mode 100644
index 0000000..f721210
--- /dev/null
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_navigate_down.svg
@@ -0,0 +1,136 @@
+
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_navigate_to_other.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_navigate_to_other.svg
new file mode 100644
index 0000000..a9a21e4
--- /dev/null
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_navigate_to_other.svg
@@ -0,0 +1,139 @@
+
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_navigate_to_third_option.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_navigate_to_third_option.svg
new file mode 100644
index 0000000..312f57b
--- /dev/null
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_navigate_to_third_option.svg
@@ -0,0 +1,136 @@
+
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_navigate_up_wraps.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_navigate_up_wraps.svg
new file mode 100644
index 0000000..a9a21e4
--- /dev/null
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_navigate_up_wraps.svg
@@ -0,0 +1,139 @@
+
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_other_typing.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_other_typing.svg
new file mode 100644
index 0000000..8198c59
--- /dev/null
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_other_typing.svg
@@ -0,0 +1,138 @@
+
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_buffered_reasoning_yields_before_content.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_buffered_reasoning_yields_before_content.svg
index ec49a36..40825f3 100644
--- a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_buffered_reasoning_yields_before_content.svg
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_buffered_reasoning_yields_before_content.svg
@@ -164,7 +164,7 @@
-
+
╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
@@ -195,7 +195,7 @@
-⏵ default mode (shift+tab to cycle)
+⏵ default agent (shift+tab to cycle)
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
>Ask anything...
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_interleaved_reasoning.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_interleaved_reasoning.svg
index db42343..68f7abf 100644
--- a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_interleaved_reasoning.svg
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_interleaved_reasoning.svg
@@ -164,7 +164,7 @@
-
+
╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
@@ -195,7 +195,7 @@
-⏵ default mode (shift+tab to cycle)
+⏵ default agent (shift+tab to cycle)
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
>Ask anything...
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content.svg
index e73514f..801838a 100644
--- a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content.svg
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content.svg
@@ -164,7 +164,7 @@
-
+
╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
@@ -195,7 +195,7 @@
-⏵ default mode (shift+tab to cycle)
+⏵ default agent (shift+tab to cycle)
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
>Ask anything...
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content_expanded.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content_expanded.svg
index c56e915..2e11cb6 100644
--- a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content_expanded.svg
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content_expanded.svg
@@ -164,7 +164,7 @@
-
+
╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
@@ -195,7 +195,7 @@
-⏵ default mode (shift+tab to cycle)
+⏵ default agent (shift+tab to cycle)
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
>Ask anything...
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_release_update_notification/test_snapshot_shows_release_update_notification.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_release_update_notification/test_snapshot_shows_release_update_notification.svg
index 6239650..30006a2 100644
--- a/tests/snapshots/__snapshots__/test_ui_snapshot_release_update_notification/test_snapshot_shows_release_update_notification.svg
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_release_update_notification/test_snapshot_shows_release_update_notification.svg
@@ -35,13 +35,15 @@
.terminal-r1 { fill: #c5c8c6 }
.terminal-r2 { fill: #3c3836 }
.terminal-r3 { fill: #fbf1c7 }
-.terminal-r4 { fill: #b7bb26 }
-.terminal-r5 { fill: #d0d26f;font-weight: bold }
-.terminal-r6 { fill: #a9a9a9 }
-.terminal-r7 { fill: #a6a087 }
-.terminal-r8 { fill: #85a598;font-weight: bold }
-.terminal-r9 { fill: #7e7e7e }
-.terminal-r10 { fill: #85a598 }
+.terminal-r4 { fill: #fd8019 }
+.terminal-r5 { fill: #85a598;font-weight: bold }
+.terminal-r6 { fill: #aec3bb }
+.terminal-r7 { fill: #b7bb26 }
+.terminal-r8 { fill: #d0d26f;font-weight: bold }
+.terminal-r9 { fill: #a9a9a9 }
+.terminal-r10 { fill: #a6a087 }
+.terminal-r11 { fill: #7e7e7e }
+.terminal-r12 { fill: #85a598 }
@@ -163,7 +165,7 @@
-
+
╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
@@ -174,13 +176,13 @@
││
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
-
-
-
-
-
-
-
+│
+│
+│What's New
+│
+│• Feature 1
+│• Feature 2
+│
@@ -190,16 +192,16 @@
-▌
-▌Update available
-▌1.0.4 => 1000.2.0
-▌Run "uv tool upgrade mistral-vibe" to update
-▌e)
+▌
+▌Update available
+▌1.0.4 => 1000.2.0
+▌Please update mistral-vibe with your package manager
+▌e)
-────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
->Ask anything...
-────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
-/test/workdir0% of 200k tokens
+────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
+>Ask anything...
+────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
+/test/workdir0% of 200k tokens
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_session_resume/test_snapshot_shows_resumed_session_messages.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_session_resume/test_snapshot_shows_resumed_session_messages.svg
new file mode 100644
index 0000000..572238a
--- /dev/null
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_session_resume/test_snapshot_shows_resumed_session_messages.svg
@@ -0,0 +1,206 @@
+
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_whats_new_message.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_whats_new_message.svg
new file mode 100644
index 0000000..114a6f5
--- /dev/null
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_whats_new_message.svg
@@ -0,0 +1,206 @@
+
diff --git a/tests/snapshots/base_snapshot_test_app.py b/tests/snapshots/base_snapshot_test_app.py
index 3bf011a..d7dc5df 100644
--- a/tests/snapshots/base_snapshot_test_app.py
+++ b/tests/snapshots/base_snapshot_test_app.py
@@ -3,10 +3,12 @@ from __future__ import annotations
from rich.style import Style
from textual.widgets.text_area import TextAreaTheme
+from tests.cli.plan_offer.adapters.fake_whoami_gateway import FakeWhoAmIGateway
from tests.stubs.fake_backend import FakeBackend
from vibe.cli.textual_ui.app import VibeApp
from vibe.cli.textual_ui.widgets.chat_input import ChatTextArea
-from vibe.core.agent import Agent
+from vibe.core.agent_loop import AgentLoop
+from vibe.core.agents.models import BuiltinAgentName
from vibe.core.config import SessionLoggingConfig, VibeConfig
@@ -29,17 +31,27 @@ def default_config() -> VibeConfig:
class BaseSnapshotTestApp(VibeApp):
CSS_PATH = "../../vibe/cli/textual_ui/app.tcss"
+ _current_agent_name: str = BuiltinAgentName.DEFAULT
- def __init__(self, config: VibeConfig | None = None, **kwargs):
+ def __init__(
+ self,
+ config: VibeConfig | None = None,
+ backend: FakeBackend | None = None,
+ **kwargs,
+ ):
config = config or default_config()
- super().__init__(config=config, **kwargs)
-
- self.agent = Agent(
+ agent_loop = AgentLoop(
config,
- mode=self._current_agent_mode,
- enable_streaming=self.enable_streaming,
- backend=FakeBackend(),
+ agent_name=self._current_agent_name,
+ enable_streaming=kwargs.get("enable_streaming", False),
+ backend=backend or FakeBackend(),
+ )
+
+ plan_offer_gateway = kwargs.pop("plan_offer_gateway", FakeWhoAmIGateway())
+
+ super().__init__(
+ agent_loop=agent_loop, plan_offer_gateway=plan_offer_gateway, **kwargs
)
async def on_mount(self) -> None:
diff --git a/tests/snapshots/test_ui_snapshot_ask_user_question.py b/tests/snapshots/test_ui_snapshot_ask_user_question.py
new file mode 100644
index 0000000..b54d1a3
--- /dev/null
+++ b/tests/snapshots/test_ui_snapshot_ask_user_question.py
@@ -0,0 +1,80 @@
+from __future__ import annotations
+
+from textual.pilot import Pilot
+
+from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp
+from tests.snapshots.snap_compare import SnapCompare
+from vibe.cli.textual_ui.widgets.tools import ToolResultMessage
+from vibe.core.tools.builtins.ask_user_question import (
+ Answer,
+ AskUserQuestion,
+ AskUserQuestionResult,
+)
+from vibe.core.types import ToolResultEvent
+
+
+class AskUserQuestionResultApp(BaseSnapshotTestApp):
+ """Test app that displays an AskUserQuestion tool result."""
+
+ async def on_mount(self) -> None:
+ await super().on_mount()
+
+ result = AskUserQuestionResult(
+ answers=[
+ Answer(
+ question="What programming language are you currently working with?",
+ answer="Rust",
+ is_other=False,
+ ),
+ Answer(
+ question="What type of project are you building?",
+ answer="Web Application",
+ is_other=False,
+ ),
+ Answer(
+ question="What editor or IDE do you prefer?",
+ answer="VS Code",
+ is_other=True,
+ ),
+ ],
+ cancelled=False,
+ )
+
+ event = ToolResultEvent(
+ tool_name="ask_user_question",
+ tool_class=AskUserQuestion,
+ result=result,
+ tool_call_id="test_call_id",
+ )
+
+ messages_area = self.query_one("#messages")
+ tool_result = ToolResultMessage(event, collapsed=True)
+ await messages_area.mount(tool_result)
+
+
+def test_snapshot_ask_user_question_collapsed(snap_compare: SnapCompare) -> None:
+ """Test collapsed AskUserQuestion result shows summary."""
+
+ async def run_before(pilot: Pilot) -> None:
+ await pilot.pause(0.1)
+
+ assert snap_compare(
+ "test_ui_snapshot_ask_user_question.py:AskUserQuestionResultApp",
+ terminal_size=(120, 20),
+ run_before=run_before,
+ )
+
+
+def test_snapshot_ask_user_question_expanded(snap_compare: SnapCompare) -> None:
+ """Test expanded AskUserQuestion result shows formatted Q&A pairs."""
+
+ async def run_before(pilot: Pilot) -> None:
+ await pilot.pause(0.1)
+ await pilot.press("ctrl+o")
+ await pilot.pause(0.1)
+
+ assert snap_compare(
+ "test_ui_snapshot_ask_user_question.py:AskUserQuestionResultApp",
+ terminal_size=(120, 30),
+ run_before=run_before,
+ )
diff --git a/tests/snapshots/test_ui_snapshot_basic_conversation.py b/tests/snapshots/test_ui_snapshot_basic_conversation.py
index 99a5d51..71b210d 100644
--- a/tests/snapshots/test_ui_snapshot_basic_conversation.py
+++ b/tests/snapshots/test_ui_snapshot_basic_conversation.py
@@ -3,15 +3,13 @@ from __future__ import annotations
from textual.pilot import Pilot
from tests.mock.utils import mock_llm_chunk
-from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp, default_config
+from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp
from tests.snapshots.snap_compare import SnapCompare
from tests.stubs.fake_backend import FakeBackend
-from vibe.core.agent import Agent
class SnapshotTestAppWithConversation(BaseSnapshotTestApp):
def __init__(self) -> None:
- config = default_config()
fake_backend = FakeBackend(
mock_llm_chunk(
content="I'm the Vibe agent and I'm ready to help.",
@@ -19,13 +17,7 @@ class SnapshotTestAppWithConversation(BaseSnapshotTestApp):
completion_tokens=2_500,
)
)
- super().__init__(config=config)
- self.agent = Agent(
- config,
- mode=self._current_agent_mode,
- enable_streaming=self.enable_streaming,
- backend=fake_backend,
- )
+ super().__init__(backend=fake_backend)
def test_snapshot_shows_basic_conversation(snap_compare: SnapCompare) -> None:
diff --git a/tests/snapshots/test_ui_snapshot_plan_offer.py b/tests/snapshots/test_ui_snapshot_plan_offer.py
new file mode 100644
index 0000000..4e941ad
--- /dev/null
+++ b/tests/snapshots/test_ui_snapshot_plan_offer.py
@@ -0,0 +1,132 @@
+from __future__ import annotations
+
+import os
+from pathlib import Path
+import time
+from unittest.mock import patch
+
+from textual.pilot import Pilot
+
+from tests.cli.plan_offer.adapters.fake_whoami_gateway import FakeWhoAmIGateway
+from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp, default_config
+from tests.snapshots.snap_compare import SnapCompare
+from tests.update_notifier.adapters.fake_update_cache_repository import (
+ FakeUpdateCacheRepository,
+)
+from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIResponse
+from vibe.cli.update_notifier import UpdateCache
+
+
+class PlanOfferSnapshotApp(BaseSnapshotTestApp):
+ def __init__(self, gateway: FakeWhoAmIGateway):
+ self._previous_api_key = os.environ.get("MISTRAL_API_KEY")
+ os.environ["MISTRAL_API_KEY"] = "snapshot-api-key"
+ super().__init__(
+ config=default_config(),
+ plan_offer_gateway=gateway,
+ update_cache_repository=FakeUpdateCacheRepository(),
+ )
+
+ def on_unmount(self) -> None:
+ if self._previous_api_key is None:
+ os.environ.pop("MISTRAL_API_KEY", None)
+ else:
+ os.environ["MISTRAL_API_KEY"] = self._previous_api_key
+ return None
+
+
+class SnapshotAppPlanOfferUpgrade(PlanOfferSnapshotApp):
+ def __init__(self) -> None:
+ gateway = FakeWhoAmIGateway(
+ WhoAmIResponse(
+ is_pro_plan=False,
+ advertise_pro_plan=True,
+ prompt_switching_to_pro_plan=False,
+ )
+ )
+ super().__init__(gateway=gateway)
+
+
+class SnapshotAppPlanOfferSwitchKey(PlanOfferSnapshotApp):
+ def __init__(self) -> None:
+ gateway = FakeWhoAmIGateway(
+ WhoAmIResponse(
+ is_pro_plan=False,
+ advertise_pro_plan=False,
+ prompt_switching_to_pro_plan=True,
+ )
+ )
+ super().__init__(gateway=gateway)
+
+
+class SnapshotAppPlanOfferNone(PlanOfferSnapshotApp):
+ def __init__(self) -> None:
+ gateway = FakeWhoAmIGateway(
+ WhoAmIResponse(
+ is_pro_plan=True,
+ advertise_pro_plan=False,
+ prompt_switching_to_pro_plan=False,
+ )
+ )
+ super().__init__(gateway=gateway)
+
+
+class SnapshotAppWhatsNewAndPlanOffer(PlanOfferSnapshotApp):
+ def __init__(self) -> None:
+ gateway = FakeWhoAmIGateway(
+ WhoAmIResponse(
+ is_pro_plan=False,
+ advertise_pro_plan=True,
+ prompt_switching_to_pro_plan=False,
+ )
+ )
+ super().__init__(gateway=gateway)
+ cache = UpdateCache(
+ latest_version="1.0.0",
+ stored_at_timestamp=int(time.time()),
+ seen_whats_new_version=None,
+ )
+ self._update_cache_repository = FakeUpdateCacheRepository(update_cache=cache)
+ self._current_version = "1.0.0"
+
+
+async def _pause_for_plan_offer_task(pilot: Pilot) -> None:
+ await pilot.pause(0.1)
+
+
+def test_snapshot_shows_upgrade_plan_offer(snap_compare: SnapCompare) -> None:
+ assert snap_compare(
+ "test_ui_snapshot_plan_offer.py:SnapshotAppPlanOfferUpgrade",
+ terminal_size=(120, 36),
+ run_before=_pause_for_plan_offer_task,
+ )
+
+
+def test_snapshot_shows_switch_key_plan_offer(snap_compare: SnapCompare) -> None:
+ assert snap_compare(
+ "test_ui_snapshot_plan_offer.py:SnapshotAppPlanOfferSwitchKey",
+ terminal_size=(120, 36),
+ run_before=_pause_for_plan_offer_task,
+ )
+
+
+def test_snapshot_shows_no_plan_offer(snap_compare: SnapCompare) -> None:
+ assert snap_compare(
+ "test_ui_snapshot_plan_offer.py:SnapshotAppPlanOfferNone",
+ terminal_size=(120, 36),
+ run_before=_pause_for_plan_offer_task,
+ )
+
+
+def test_snapshot_shows_whats_new_and_plan_offer(
+ snap_compare: SnapCompare, tmp_path: Path
+) -> None:
+ whats_new_file = tmp_path / "whats_new.md"
+ whats_new_file.write_text("# What's New\n\n- Feature 1\n- Feature 2")
+
+ with patch("vibe.cli.update_notifier.whats_new.VIBE_ROOT", tmp_path):
+ assert snap_compare(
+ "test_ui_snapshot_plan_offer.py:SnapshotAppWhatsNewAndPlanOffer",
+ terminal_size=(120, 36),
+ run_before=_pause_for_plan_offer_task,
+ )
diff --git a/tests/snapshots/test_ui_snapshot_question_app.py b/tests/snapshots/test_ui_snapshot_question_app.py
new file mode 100644
index 0000000..077bb94
--- /dev/null
+++ b/tests/snapshots/test_ui_snapshot_question_app.py
@@ -0,0 +1,365 @@
+from __future__ import annotations
+
+from textual.app import App, ComposeResult
+from textual.containers import Container
+from textual.pilot import Pilot
+
+from tests.snapshots.snap_compare import SnapCompare
+from vibe.cli.textual_ui.widgets.question_app import QuestionApp
+from vibe.core.tools.builtins.ask_user_question import (
+ AskUserQuestionArgs,
+ Choice,
+ Question,
+)
+
+
+def single_question_args() -> AskUserQuestionArgs:
+ return AskUserQuestionArgs(
+ questions=[
+ Question(
+ question="Which database should we use for this project?",
+ header="Database",
+ options=[
+ Choice(label="PostgreSQL", description="Relational database"),
+ Choice(label="MongoDB", description="Document database"),
+ Choice(label="Redis", description="In-memory store"),
+ ],
+ )
+ ]
+ )
+
+
+def multi_question_args() -> AskUserQuestionArgs:
+ return AskUserQuestionArgs(
+ questions=[
+ Question(
+ question="Which database?",
+ header="DB",
+ options=[Choice(label="PostgreSQL"), Choice(label="MongoDB")],
+ ),
+ Question(
+ question="Which framework?",
+ header="Framework",
+ options=[Choice(label="FastAPI"), Choice(label="Django")],
+ ),
+ ]
+ )
+
+
+def multi_select_args() -> AskUserQuestionArgs:
+ return AskUserQuestionArgs(
+ questions=[
+ Question(
+ question="Which features do you want to enable?",
+ header="Features",
+ options=[
+ Choice(label="Authentication", description="User login/logout"),
+ Choice(label="Caching", description="Redis caching layer"),
+ Choice(label="Logging", description="Structured logging"),
+ ],
+ multi_select=True,
+ )
+ ]
+ )
+
+
+class QuestionAppTestApp(App):
+ CSS_PATH = "../../vibe/cli/textual_ui/app.tcss"
+
+ def __init__(self, args: AskUserQuestionArgs):
+ super().__init__()
+ self.question_args = args
+
+ def compose(self) -> ComposeResult:
+ with Container(id="bottom-app-container"):
+ yield QuestionApp(args=self.question_args)
+
+
+class SingleQuestionApp(QuestionAppTestApp):
+ def __init__(self):
+ super().__init__(single_question_args())
+
+
+class MultiQuestionApp(QuestionAppTestApp):
+ def __init__(self):
+ super().__init__(multi_question_args())
+
+
+class MultiSelectApp(QuestionAppTestApp):
+ def __init__(self):
+ super().__init__(multi_select_args())
+
+
+# Single question tests
+
+
+def test_snapshot_question_app_initial(snap_compare: SnapCompare) -> None:
+ async def run_before(pilot: Pilot) -> None:
+ await pilot.pause(0.1)
+
+ assert snap_compare(
+ "test_ui_snapshot_question_app.py:SingleQuestionApp",
+ terminal_size=(80, 20),
+ run_before=run_before,
+ )
+
+
+def test_snapshot_question_app_navigate_down(snap_compare: SnapCompare) -> None:
+ async def run_before(pilot: Pilot) -> None:
+ await pilot.pause(0.1)
+ await pilot.press("down")
+ await pilot.pause(0.1)
+
+ assert snap_compare(
+ "test_ui_snapshot_question_app.py:SingleQuestionApp",
+ terminal_size=(80, 20),
+ run_before=run_before,
+ )
+
+
+def test_snapshot_question_app_navigate_to_third_option(
+ snap_compare: SnapCompare,
+) -> None:
+ async def run_before(pilot: Pilot) -> None:
+ await pilot.pause(0.1)
+ await pilot.press("down")
+ await pilot.press("down")
+ await pilot.pause(0.1)
+
+ assert snap_compare(
+ "test_ui_snapshot_question_app.py:SingleQuestionApp",
+ terminal_size=(80, 20),
+ run_before=run_before,
+ )
+
+
+def test_snapshot_question_app_navigate_to_other(snap_compare: SnapCompare) -> None:
+ async def run_before(pilot: Pilot) -> None:
+ await pilot.pause(0.1)
+ await pilot.press("down")
+ await pilot.press("down")
+ await pilot.press("down")
+ await pilot.pause(0.1)
+
+ assert snap_compare(
+ "test_ui_snapshot_question_app.py:SingleQuestionApp",
+ terminal_size=(80, 20),
+ run_before=run_before,
+ )
+
+
+def test_snapshot_question_app_navigate_up_wraps(snap_compare: SnapCompare) -> None:
+ async def run_before(pilot: Pilot) -> None:
+ await pilot.pause(0.1)
+ await pilot.press("up")
+ await pilot.pause(0.1)
+
+ assert snap_compare(
+ "test_ui_snapshot_question_app.py:SingleQuestionApp",
+ terminal_size=(80, 20),
+ run_before=run_before,
+ )
+
+
+def test_snapshot_question_app_other_typing(snap_compare: SnapCompare) -> None:
+ async def run_before(pilot: Pilot) -> None:
+ await pilot.pause(0.1)
+ await pilot.press("down", "down", "down")
+ await pilot.press("enter")
+ await pilot.pause(0.1)
+ await pilot.press(*"SQLite")
+ await pilot.pause(0.1)
+
+ assert snap_compare(
+ "test_ui_snapshot_question_app.py:SingleQuestionApp",
+ terminal_size=(80, 20),
+ run_before=run_before,
+ )
+
+
+# Multi-question tests
+
+
+def test_snapshot_multi_question_initial(snap_compare: SnapCompare) -> None:
+ async def run_before(pilot: Pilot) -> None:
+ await pilot.pause(0.1)
+
+ assert snap_compare(
+ "test_ui_snapshot_question_app.py:MultiQuestionApp",
+ terminal_size=(80, 20),
+ run_before=run_before,
+ )
+
+
+def test_snapshot_multi_question_tab_to_second(snap_compare: SnapCompare) -> None:
+ async def run_before(pilot: Pilot) -> None:
+ await pilot.pause(0.1)
+ await pilot.press("tab")
+ await pilot.pause(0.1)
+
+ assert snap_compare(
+ "test_ui_snapshot_question_app.py:MultiQuestionApp",
+ terminal_size=(80, 20),
+ run_before=run_before,
+ )
+
+
+def test_snapshot_multi_question_answer_first_advance(
+ snap_compare: SnapCompare,
+) -> None:
+ async def run_before(pilot: Pilot) -> None:
+ await pilot.pause(0.1)
+ await pilot.press("enter")
+ await pilot.pause(0.1)
+
+ assert snap_compare(
+ "test_ui_snapshot_question_app.py:MultiQuestionApp",
+ terminal_size=(80, 20),
+ run_before=run_before,
+ )
+
+
+def test_snapshot_multi_question_navigate_right(snap_compare: SnapCompare) -> None:
+ async def run_before(pilot: Pilot) -> None:
+ await pilot.pause(0.1)
+ await pilot.press("right")
+ await pilot.pause(0.1)
+
+ assert snap_compare(
+ "test_ui_snapshot_question_app.py:MultiQuestionApp",
+ terminal_size=(80, 20),
+ run_before=run_before,
+ )
+
+
+def test_snapshot_multi_question_navigate_left_wraps(snap_compare: SnapCompare) -> None:
+ async def run_before(pilot: Pilot) -> None:
+ await pilot.pause(0.1)
+ await pilot.press("left")
+ await pilot.pause(0.1)
+
+ assert snap_compare(
+ "test_ui_snapshot_question_app.py:MultiQuestionApp",
+ terminal_size=(80, 20),
+ run_before=run_before,
+ )
+
+
+def test_snapshot_multi_question_first_answered_checkmark(
+ snap_compare: SnapCompare,
+) -> None:
+ async def run_before(pilot: Pilot) -> None:
+ await pilot.pause(0.1)
+ await pilot.press("down")
+ await pilot.press("enter")
+ await pilot.pause(0.1)
+
+ assert snap_compare(
+ "test_ui_snapshot_question_app.py:MultiQuestionApp",
+ terminal_size=(80, 20),
+ run_before=run_before,
+ )
+
+
+# Multi-select tests
+
+
+def test_snapshot_multi_select_initial(snap_compare: SnapCompare) -> None:
+ async def run_before(pilot: Pilot) -> None:
+ await pilot.pause(0.1)
+
+ assert snap_compare(
+ "test_ui_snapshot_question_app.py:MultiSelectApp",
+ terminal_size=(80, 20),
+ run_before=run_before,
+ )
+
+
+def test_snapshot_multi_select_toggle_first(snap_compare: SnapCompare) -> None:
+ async def run_before(pilot: Pilot) -> None:
+ await pilot.pause(0.1)
+ await pilot.press("enter")
+ await pilot.pause(0.1)
+
+ assert snap_compare(
+ "test_ui_snapshot_question_app.py:MultiSelectApp",
+ terminal_size=(80, 20),
+ run_before=run_before,
+ )
+
+
+def test_snapshot_multi_select_toggle_multiple(snap_compare: SnapCompare) -> None:
+ async def run_before(pilot: Pilot) -> None:
+ await pilot.pause(0.1)
+ await pilot.press("enter")
+ await pilot.press("down", "down")
+ await pilot.press("enter")
+ await pilot.pause(0.1)
+
+ assert snap_compare(
+ "test_ui_snapshot_question_app.py:MultiSelectApp",
+ terminal_size=(80, 20),
+ run_before=run_before,
+ )
+
+
+def test_snapshot_multi_select_navigate_to_submit(snap_compare: SnapCompare) -> None:
+ async def run_before(pilot: Pilot) -> None:
+ await pilot.pause(0.1)
+ await pilot.press("down", "down", "down", "down")
+ await pilot.pause(0.1)
+
+ assert snap_compare(
+ "test_ui_snapshot_question_app.py:MultiSelectApp",
+ terminal_size=(80, 20),
+ run_before=run_before,
+ )
+
+
+def test_snapshot_multi_select_other_with_text(snap_compare: SnapCompare) -> None:
+ async def run_before(pilot: Pilot) -> None:
+ await pilot.pause(0.1)
+ await pilot.press("down", "down", "down")
+ await pilot.press("enter")
+ await pilot.pause(0.1)
+ await pilot.press(*"Custom feature")
+ await pilot.pause(0.1)
+
+ assert snap_compare(
+ "test_ui_snapshot_question_app.py:MultiSelectApp",
+ terminal_size=(80, 20),
+ run_before=run_before,
+ )
+
+
+def test_snapshot_multi_select_mixed_selection(snap_compare: SnapCompare) -> None:
+ async def run_before(pilot: Pilot) -> None:
+ await pilot.pause(0.1)
+ await pilot.press("enter")
+ await pilot.press("down", "down")
+ await pilot.press("enter")
+ await pilot.press("down")
+ await pilot.press("enter")
+ await pilot.pause(0.1)
+ await pilot.press(*"Extra")
+ await pilot.pause(0.1)
+
+ assert snap_compare(
+ "test_ui_snapshot_question_app.py:MultiSelectApp",
+ terminal_size=(80, 20),
+ run_before=run_before,
+ )
+
+
+def test_snapshot_multi_select_untoggle(snap_compare: SnapCompare) -> None:
+ async def run_before(pilot: Pilot) -> None:
+ await pilot.pause(0.1)
+ await pilot.press("enter")
+ await pilot.press("enter")
+ await pilot.pause(0.1)
+
+ assert snap_compare(
+ "test_ui_snapshot_question_app.py:MultiSelectApp",
+ terminal_size=(80, 20),
+ run_before=run_before,
+ )
diff --git a/tests/snapshots/test_ui_snapshot_reasoning_content.py b/tests/snapshots/test_ui_snapshot_reasoning_content.py
index 533dac0..52b0396 100644
--- a/tests/snapshots/test_ui_snapshot_reasoning_content.py
+++ b/tests/snapshots/test_ui_snapshot_reasoning_content.py
@@ -7,7 +7,7 @@ from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp, default_
from tests.snapshots.snap_compare import SnapCompare
from tests.stubs.fake_backend import FakeBackend
from vibe.cli.textual_ui.widgets.messages import ReasoningMessage
-from vibe.core.agent import Agent
+from vibe.core.agent_loop import AgentLoop
class SnapshotTestAppWithReasoningContent(BaseSnapshotTestApp):
@@ -31,9 +31,9 @@ class SnapshotTestAppWithReasoningContent(BaseSnapshotTestApp):
]
)
super().__init__(config=config)
- self.agent = Agent(
+ self.agent_loop = AgentLoop(
config,
- mode=self._current_agent_mode,
+ agent_name=self._current_agent_name,
enable_streaming=True,
backend=fake_backend,
)
@@ -57,9 +57,9 @@ class SnapshotTestAppWithInterleavedReasoning(BaseSnapshotTestApp):
]
)
super().__init__(config=config)
- self.agent = Agent(
+ self.agent_loop = AgentLoop(
config,
- mode=self._current_agent_mode,
+ agent_name=self._current_agent_name,
enable_streaming=True,
backend=fake_backend,
)
@@ -123,9 +123,9 @@ class SnapshotTestAppWithBufferedReasoningTransition(BaseSnapshotTestApp):
]
)
super().__init__(config=config)
- self.agent = Agent(
+ self.agent_loop = AgentLoop(
config,
- mode=self._current_agent_mode,
+ agent_name=self._current_agent_name,
enable_streaming=True,
backend=fake_backend,
)
diff --git a/tests/snapshots/test_ui_snapshot_release_update_notification.py b/tests/snapshots/test_ui_snapshot_release_update_notification.py
index f15fe4e..cf105f5 100644
--- a/tests/snapshots/test_ui_snapshot_release_update_notification.py
+++ b/tests/snapshots/test_ui_snapshot_release_update_notification.py
@@ -1,5 +1,8 @@
from __future__ import annotations
+from pathlib import Path
+from unittest.mock import patch
+
from textual.pilot import Pilot
from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp, default_config
@@ -7,34 +10,36 @@ from tests.snapshots.snap_compare import SnapCompare
from tests.update_notifier.adapters.fake_update_cache_repository import (
FakeUpdateCacheRepository,
)
-from tests.update_notifier.adapters.fake_version_update_gateway import (
- FakeVersionUpdateGateway,
-)
-from vibe.cli.update_notifier import VersionUpdate
+from tests.update_notifier.adapters.fake_update_gateway import FakeUpdateGateway
+from vibe.cli.update_notifier import Update
class SnapshotTestAppWithUpdate(BaseSnapshotTestApp):
def __init__(self):
config = default_config()
config.enable_update_checks = True
- version_update_notifier = FakeVersionUpdateGateway(
- update=VersionUpdate(latest_version="1000.2.0")
- )
+ update_notifier = FakeUpdateGateway(update=Update(latest_version="1000.2.0"))
update_cache_repository = FakeUpdateCacheRepository()
super().__init__(
config=config,
- version_update_notifier=version_update_notifier,
+ update_notifier=update_notifier,
update_cache_repository=update_cache_repository,
current_version="1.0.4",
)
-def test_snapshot_shows_release_update_notification(snap_compare: SnapCompare) -> None:
+def test_snapshot_shows_release_update_notification(
+ snap_compare: SnapCompare, tmp_path: Path
+) -> None:
+ whats_new_file = tmp_path / "whats_new.md"
+ whats_new_file.write_text("# What's New\n\n- Feature 1\n- Feature 2")
+
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.2)
- assert snap_compare(
- "test_ui_snapshot_release_update_notification.py:SnapshotTestAppWithUpdate",
- terminal_size=(120, 36),
- run_before=run_before,
- )
+ with patch("vibe.cli.update_notifier.whats_new.VIBE_ROOT", tmp_path):
+ assert snap_compare(
+ "test_ui_snapshot_release_update_notification.py:SnapshotTestAppWithUpdate",
+ terminal_size=(120, 36),
+ run_before=run_before,
+ )
diff --git a/tests/snapshots/test_ui_snapshot_session_resume.py b/tests/snapshots/test_ui_snapshot_session_resume.py
new file mode 100644
index 0000000..6e25022
--- /dev/null
+++ b/tests/snapshots/test_ui_snapshot_session_resume.py
@@ -0,0 +1,47 @@
+from __future__ import annotations
+
+from textual.pilot import Pilot
+
+from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp
+from tests.snapshots.snap_compare import SnapCompare
+from vibe.core.types import FunctionCall, LLMMessage, Role, ToolCall
+
+
+class SnapshotTestAppWithResumedSession(BaseSnapshotTestApp):
+ def __init__(self) -> None:
+ super().__init__()
+ # Simulate a previous session with messages
+ user_msg = LLMMessage(role=Role.user, content="Hello, how are you?")
+ assistant_msg = LLMMessage(
+ role=Role.assistant,
+ content="I'm doing well, thank you! Let me read that file for you.",
+ tool_calls=[
+ ToolCall(
+ id="tool_call_1",
+ index=0,
+ function=FunctionCall(
+ name="read_file", arguments='{"path": "test.txt"}'
+ ),
+ )
+ ],
+ )
+ tool_result_msg = LLMMessage(
+ role=Role.tool,
+ content="File content: This is a test file with some content.",
+ name="read_file",
+ tool_call_id="tool_call_1",
+ )
+
+ self.agent_loop.messages.extend([user_msg, assistant_msg, tool_result_msg])
+
+
+def test_snapshot_shows_resumed_session_messages(snap_compare: SnapCompare) -> None:
+ async def run_before(pilot: Pilot) -> None:
+ # Wait for the app to initialize and rebuild history
+ await pilot.pause(0.5)
+
+ assert snap_compare(
+ "test_ui_snapshot_session_resume.py:SnapshotTestAppWithResumedSession",
+ terminal_size=(120, 36),
+ run_before=run_before,
+ )
diff --git a/tests/snapshots/test_ui_snapshot_whats_new.py b/tests/snapshots/test_ui_snapshot_whats_new.py
new file mode 100644
index 0000000..cbff1ca
--- /dev/null
+++ b/tests/snapshots/test_ui_snapshot_whats_new.py
@@ -0,0 +1,52 @@
+from __future__ import annotations
+
+from pathlib import Path
+import time
+from unittest.mock import patch
+
+from textual.pilot import Pilot
+
+from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp, default_config
+from tests.snapshots.snap_compare import SnapCompare
+from tests.update_notifier.adapters.fake_update_cache_repository import (
+ FakeUpdateCacheRepository,
+)
+from tests.update_notifier.adapters.fake_update_gateway import FakeUpdateGateway
+from vibe.cli.update_notifier import UpdateCache
+
+
+class SnapshotTestAppWithWhatsNew(BaseSnapshotTestApp):
+ def __init__(self):
+ config = default_config()
+ config.enable_update_checks = False
+ update_notifier = FakeUpdateGateway(update=None)
+ cache = UpdateCache(
+ latest_version="1.0.0",
+ stored_at_timestamp=int(time.time()),
+ seen_whats_new_version=None,
+ )
+ update_cache_repository = FakeUpdateCacheRepository(update_cache=cache)
+ super().__init__(
+ config=config,
+ update_notifier=update_notifier,
+ update_cache_repository=update_cache_repository,
+ current_version="1.0.0",
+ )
+
+
+def test_snapshot_shows_whats_new_message(
+ snap_compare: SnapCompare, tmp_path: Path
+) -> None:
+ # Create whats_new.md file before the app starts
+ whats_new_file = tmp_path / "whats_new.md"
+ whats_new_file.write_text("# What's New\n\n- Feature 1\n- Feature 2\n- Feature 3")
+
+ async def run_before(pilot: Pilot) -> None:
+ await pilot.pause(0.5)
+
+ with patch("vibe.cli.update_notifier.whats_new.VIBE_ROOT", tmp_path):
+ assert snap_compare(
+ "test_ui_snapshot_whats_new.py:SnapshotTestAppWithWhatsNew",
+ terminal_size=(120, 36),
+ run_before=run_before,
+ )
diff --git a/tests/stubs/fake_client.py b/tests/stubs/fake_client.py
new file mode 100644
index 0000000..a93e29a
--- /dev/null
+++ b/tests/stubs/fake_client.py
@@ -0,0 +1,130 @@
+from __future__ import annotations
+
+from typing import Any
+
+from acp import (
+ Agent as AcpAgent,
+ Client,
+ KillTerminalCommandResponse,
+ ReadTextFileResponse,
+ ReleaseTerminalResponse,
+ RequestPermissionResponse,
+ SessionNotification,
+ TerminalHandle,
+ TerminalOutputResponse,
+ WaitForTerminalExitResponse,
+ WriteTextFileResponse,
+)
+from acp.schema import (
+ AgentMessageChunk,
+ AgentPlanUpdate,
+ AgentThoughtChunk,
+ AvailableCommandsUpdate,
+ CurrentModeUpdate,
+ EnvVariable,
+ PermissionOption,
+ SessionInfoUpdate,
+ ToolCallProgress,
+ ToolCallStart,
+ ToolCallUpdate,
+ UserMessageChunk,
+)
+
+
+class FakeClient(Client):
+ agent: AcpAgent
+
+ def __init__(self) -> None:
+ self._session_updates = []
+
+ async def session_update(
+ self,
+ session_id: str,
+ update: UserMessageChunk
+ | AgentMessageChunk
+ | AgentThoughtChunk
+ | ToolCallStart
+ | ToolCallProgress
+ | AgentPlanUpdate
+ | AvailableCommandsUpdate
+ | CurrentModeUpdate
+ | SessionInfoUpdate,
+ **kwargs: Any,
+ ) -> None:
+ self._session_updates.append(
+ SessionNotification(session_id=session_id, update=update)
+ )
+
+ async def request_permission(
+ self,
+ options: list[PermissionOption],
+ session_id: str,
+ tool_call: ToolCallUpdate,
+ **kwargs: Any,
+ ) -> RequestPermissionResponse:
+ raise NotImplementedError()
+
+ async def read_text_file(
+ self,
+ path: str,
+ session_id: str,
+ limit: int | None = None,
+ line: int | None = None,
+ **kwargs: Any,
+ ) -> ReadTextFileResponse:
+ raise NotImplementedError()
+
+ async def write_text_file(
+ self, content: str, path: str, session_id: str, **kwargs: Any
+ ) -> WriteTextFileResponse | None:
+ raise NotImplementedError()
+
+ async def create_terminal(
+ self,
+ command: str,
+ session_id: str,
+ args: list[str] | None = None,
+ cwd: str | None = None,
+ env: list[EnvVariable] | None = None,
+ output_byte_limit: int | None = None,
+ **kwargs: Any,
+ ) -> TerminalHandle:
+ raise NotImplementedError()
+
+ async def terminal_output(
+ self, session_id: str, terminal_id: str, **kwargs: Any
+ ) -> TerminalOutputResponse:
+ raise NotImplementedError()
+
+ async def release_terminal(
+ self, session_id: str, terminal_id: str, **kwargs: Any
+ ) -> ReleaseTerminalResponse | None:
+ raise NotImplementedError()
+
+ async def wait_for_terminal_exit(
+ self, session_id: str, terminal_id: str, **kwargs: Any
+ ) -> WaitForTerminalExitResponse:
+ raise NotImplementedError()
+
+ async def kill_terminal(
+ self, session_id: str, terminal_id: str, **kwargs: Any
+ ) -> KillTerminalCommandResponse | None:
+ raise NotImplementedError()
+
+ async def ext_method(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
+ raise NotImplementedError()
+
+ async def ext_notification(self, method: str, params: dict[str, Any]) -> None:
+ raise NotImplementedError()
+
+ async def close(self) -> None:
+ raise NotImplementedError()
+
+ def on_connect(self, conn: AcpAgent) -> None:
+ self.agent = conn
+
+ async def __aenter__(self) -> FakeClient:
+ return self
+
+ async def __aexit__(self, exc_type, exc, tb) -> None:
+ await self.close()
diff --git a/tests/stubs/fake_connection.py b/tests/stubs/fake_connection.py
deleted file mode 100644
index c4ad663..0000000
--- a/tests/stubs/fake_connection.py
+++ /dev/null
@@ -1,86 +0,0 @@
-from __future__ import annotations
-
-from collections.abc import Callable
-from typing import Any
-
-from acp import (
- Agent,
- AgentSideConnection,
- CreateTerminalRequest,
- KillTerminalCommandRequest,
- KillTerminalCommandResponse,
- ReadTextFileRequest,
- ReadTextFileResponse,
- ReleaseTerminalRequest,
- ReleaseTerminalResponse,
- RequestPermissionRequest,
- RequestPermissionResponse,
- SessionNotification,
- TerminalHandle,
- TerminalOutputRequest,
- TerminalOutputResponse,
- WaitForTerminalExitRequest,
- WaitForTerminalExitResponse,
- WriteTextFileRequest,
- WriteTextFileResponse,
-)
-
-
-class FakeAgentSideConnection(AgentSideConnection):
- def __init__(self, to_agent: Callable[[AgentSideConnection], Agent]) -> None:
- self._session_updates = []
- to_agent(self)
-
- async def sessionUpdate(self, params: SessionNotification) -> None:
- self._session_updates.append(params)
-
- async def requestPermission(
- self, params: RequestPermissionRequest
- ) -> RequestPermissionResponse:
- raise NotImplementedError()
-
- async def readTextFile(self, params: ReadTextFileRequest) -> ReadTextFileResponse:
- raise NotImplementedError()
-
- async def writeTextFile(
- self, params: WriteTextFileRequest
- ) -> WriteTextFileResponse | None:
- raise NotImplementedError()
-
- async def createTerminal(self, params: CreateTerminalRequest) -> TerminalHandle:
- raise NotImplementedError()
-
- async def terminalOutput(
- self, params: TerminalOutputRequest
- ) -> TerminalOutputResponse:
- raise NotImplementedError()
-
- async def releaseTerminal(
- self, params: ReleaseTerminalRequest
- ) -> ReleaseTerminalResponse | None:
- raise NotImplementedError()
-
- async def waitForTerminalExit(
- self, params: WaitForTerminalExitRequest
- ) -> WaitForTerminalExitResponse:
- raise NotImplementedError()
-
- async def killTerminal(
- self, params: KillTerminalCommandRequest
- ) -> KillTerminalCommandResponse | None:
- raise NotImplementedError()
-
- async def extMethod(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
- raise NotImplementedError()
-
- async def extNotification(self, method: str, params: dict[str, Any]) -> None:
- raise NotImplementedError()
-
- async def close(self) -> None:
- raise NotImplementedError()
-
- async def __aenter__(self) -> AgentSideConnection:
- return self
-
- async def __aexit__(self, exc_type, exc, tb) -> None:
- await self.close()
diff --git a/tests/stubs/fake_tool.py b/tests/stubs/fake_tool.py
index 4c40b56..677e69c 100644
--- a/tests/stubs/fake_tool.py
+++ b/tests/stubs/fake_tool.py
@@ -1,8 +1,11 @@
from __future__ import annotations
+from collections.abc import AsyncGenerator
+
from pydantic import BaseModel
-from vibe.core.tools.base import BaseTool, BaseToolConfig, BaseToolState
+from vibe.core.tools.base import BaseTool, BaseToolConfig, BaseToolState, InvokeContext
+from vibe.core.types import ToolStreamEvent
class FakeToolArgs(BaseModel):
@@ -24,7 +27,9 @@ class FakeTool(BaseTool[FakeToolArgs, FakeToolResult, BaseToolConfig, FakeToolSt
def get_name(cls) -> str:
return "stub_tool"
- async def run(self, args: FakeToolArgs) -> FakeToolResult:
+ async def run(
+ self, args: FakeToolArgs, ctx: InvokeContext | None = None
+ ) -> AsyncGenerator[ToolStreamEvent | FakeToolResult, None]:
if self._exception_to_raise:
raise self._exception_to_raise
- return FakeToolResult()
+ yield FakeToolResult()
diff --git a/tests/test_agent_auto_compact.py b/tests/test_agent_auto_compact.py
index 9efc674..9511d69 100644
--- a/tests/test_agent_auto_compact.py
+++ b/tests/test_agent_auto_compact.py
@@ -4,7 +4,7 @@ import pytest
from tests.mock.utils import mock_llm_chunk
from tests.stubs.fake_backend import FakeBackend
-from vibe.core.agent import Agent
+from vibe.core.agent_loop import AgentLoop
from vibe.core.config import SessionLoggingConfig, VibeConfig
from vibe.core.types import (
AssistantEvent,
@@ -12,6 +12,7 @@ from vibe.core.types import (
CompactStartEvent,
LLMMessage,
Role,
+ UserMessageEvent,
)
@@ -29,18 +30,19 @@ async def test_auto_compact_triggers_and_batches_observer() -> None:
cfg = VibeConfig(
session_logging=SessionLoggingConfig(enabled=False), auto_compact_threshold=1
)
- agent = Agent(cfg, message_observer=observer, backend=backend)
+ agent = AgentLoop(cfg, message_observer=observer, backend=backend)
agent.stats.context_tokens = 2
events = [ev async for ev in agent.act("Hello")]
- assert len(events) == 3
- assert isinstance(events[0], CompactStartEvent)
- assert isinstance(events[1], CompactEndEvent)
- assert isinstance(events[2], AssistantEvent)
- start: CompactStartEvent = events[0]
- end: CompactEndEvent = events[1]
- final: AssistantEvent = events[2]
+ assert len(events) == 4
+ assert isinstance(events[0], UserMessageEvent)
+ assert isinstance(events[1], CompactStartEvent)
+ assert isinstance(events[2], CompactEndEvent)
+ assert isinstance(events[3], AssistantEvent)
+ start: CompactStartEvent = events[1]
+ end: CompactEndEvent = events[2]
+ final: AssistantEvent = events[3]
assert start.current_context_tokens == 2
assert start.threshold == 1
assert end.old_context_tokens == 2
@@ -49,8 +51,5 @@ async def test_auto_compact_triggers_and_batches_observer() -> None:
roles = [r for r, _ in observed]
assert roles == [Role.system, Role.user, Role.assistant]
- assert (
- observed[1][1] is not None
- and "Last request from user was: Hello" in observed[1][1]
- )
+ assert observed[1][1] is not None and "" in observed[1][1]
assert observed[2][1] == ""
diff --git a/tests/test_agent_backend.py b/tests/test_agent_backend.py
index 7460185..aab8992 100644
--- a/tests/test_agent_backend.py
+++ b/tests/test_agent_backend.py
@@ -4,7 +4,7 @@ import pytest
from tests.mock.utils import mock_llm_chunk
from tests.stubs.fake_backend import FakeBackend
-from vibe.core.agent import Agent
+from vibe.core.agent_loop import AgentLoop
from vibe.core.config import SessionLoggingConfig, VibeConfig
@@ -16,7 +16,7 @@ def vibe_config() -> VibeConfig:
@pytest.mark.asyncio
async def test_passes_x_affinity_header_when_asking_an_answer(vibe_config: VibeConfig):
backend = FakeBackend([mock_llm_chunk(content="Response")])
- agent = Agent(vibe_config, backend=backend)
+ agent = AgentLoop(vibe_config, backend=backend)
[_ async for _ in agent.act("Hello")]
@@ -32,7 +32,7 @@ async def test_passes_x_affinity_header_when_asking_an_answer_streaming(
vibe_config: VibeConfig,
):
backend = FakeBackend([mock_llm_chunk(content="Response")])
- agent = Agent(vibe_config, backend=backend, enable_streaming=True)
+ agent = AgentLoop(vibe_config, backend=backend, enable_streaming=True)
[_ async for _ in agent.act("Hello")]
@@ -47,7 +47,7 @@ async def test_passes_x_affinity_header_when_asking_an_answer_streaming(
async def test_updates_tokens_stats_based_on_backend_response(vibe_config: VibeConfig):
chunk = mock_llm_chunk(content="Response", prompt_tokens=100, completion_tokens=50)
backend = FakeBackend([chunk])
- agent = Agent(vibe_config, backend=backend)
+ agent = AgentLoop(vibe_config, backend=backend)
[_ async for _ in agent.act("Hello")]
@@ -62,7 +62,7 @@ async def test_updates_tokens_stats_based_on_backend_response_streaming(
content="Complete", prompt_tokens=200, completion_tokens=75
)
backend = FakeBackend([final_chunk])
- agent = Agent(vibe_config, backend=backend, enable_streaming=True)
+ agent = AgentLoop(vibe_config, backend=backend, enable_streaming=True)
[_ async for _ in agent.act("Hello")]
diff --git a/tests/test_agent_observer_streaming.py b/tests/test_agent_observer_streaming.py
index 55bae29..6655b19 100644
--- a/tests/test_agent_observer_streaming.py
+++ b/tests/test_agent_observer_streaming.py
@@ -8,7 +8,8 @@ import pytest
from tests.mock.utils import mock_llm_chunk
from tests.stubs.fake_backend import FakeBackend
-from vibe.core.agent import Agent
+from vibe.core.agent_loop import AgentLoop
+from vibe.core.agents.models import BuiltinAgentName
from vibe.core.config import SessionLoggingConfig, VibeConfig
from vibe.core.middleware import (
ConversationContext,
@@ -17,7 +18,6 @@ from vibe.core.middleware import (
MiddlewareResult,
ResetReason,
)
-from vibe.core.modes import AgentMode
from vibe.core.tools.base import BaseToolConfig, ToolPermission
from vibe.core.tools.builtins.todo import TodoArgs
from vibe.core.types import (
@@ -30,17 +30,18 @@ from vibe.core.types import (
ToolCall,
ToolCallEvent,
ToolResultEvent,
+ UserMessageEvent,
)
from vibe.core.utils import CancellationReason, get_user_cancellation_message
class InjectBeforeMiddleware:
- injectedMessage = ""
+ injected_message = ""
async def before_turn(self, context: ConversationContext) -> MiddlewareResult:
"Inject a message just before the current step executes."
return MiddlewareResult(
- action=MiddlewareAction.INJECT_MESSAGE, message=self.injectedMessage
+ action=MiddlewareAction.INJECT_MESSAGE, message=self.injected_message
)
async def after_turn(self, context: ConversationContext) -> MiddlewareResult:
@@ -89,7 +90,7 @@ async def test_act_flushes_batched_messages_with_injection_middleware(
observed, observer = observer_capture
backend = FakeBackend([mock_llm_chunk(content="I can write very efficient code.")])
- agent = Agent(make_config(), message_observer=observer, backend=backend)
+ agent = AgentLoop(make_config(), message_observer=observer, backend=backend)
agent.middleware_pipeline.add(InjectBeforeMiddleware())
async for _ in agent.act("How can you help?"):
@@ -101,7 +102,7 @@ async def test_act_flushes_batched_messages_with_injection_middleware(
# injected content should be appended to the user's message before emission
assert (
observed[1][1]
- == f"How can you help?\n\n{InjectBeforeMiddleware.injectedMessage}"
+ == f"How can you help?\n\n{InjectBeforeMiddleware.injected_message}"
)
assert observed[2][1] == "I can write very efficient code."
@@ -114,7 +115,7 @@ async def test_stop_action_flushes_user_msg_before_returning(observer_capture) -
backend = FakeBackend([
mock_llm_chunk(content="My response will never reach you...")
])
- agent = Agent(
+ agent = AgentLoop(
make_config(), message_observer=observer, max_turns=0, backend=backend
)
@@ -133,7 +134,7 @@ async def test_act_emits_user_and_assistant_msgs(observer_capture) -> None:
observed, observer = observer_capture
backend = FakeBackend([mock_llm_chunk(content="Pong!")])
- agent = Agent(make_config(), message_observer=observer, backend=backend)
+ agent = AgentLoop(make_config(), message_observer=observer, backend=backend)
async for _ in agent.act("Ping?"):
pass
@@ -155,12 +156,13 @@ async def test_act_streams_batched_chunks_in_order() -> None:
mock_llm_chunk(content=" and"),
mock_llm_chunk(content=" end"),
])
- agent = Agent(make_config(), backend=backend, enable_streaming=True)
+ agent = AgentLoop(make_config(), backend=backend, enable_streaming=True)
events = [event async for event in agent.act("Stream, please.")]
- assert len(events) == 2
- assert [event.content for event in events if isinstance(event, AssistantEvent)] == [
+ assistant_events = [e for e in events if isinstance(e, AssistantEvent)]
+ assert len(assistant_events) == 2
+ assert [event.content for event in assistant_events] == [
"Hello from Vibe! More",
" and end",
]
@@ -182,33 +184,35 @@ async def test_act_handles_streaming_with_tool_call_events_in_sequence() -> None
],
[mock_llm_chunk(content="Done reviewing todos.")],
])
- agent = Agent(
+ agent = AgentLoop(
make_config(
enabled_tools=["todo"],
tools={"todo": BaseToolConfig(permission=ToolPermission.ALWAYS)},
),
backend=backend,
- mode=AgentMode.AUTO_APPROVE,
+ agent_name=BuiltinAgentName.AUTO_APPROVE,
enable_streaming=True,
)
events = [event async for event in agent.act("What about my todos?")]
assert [type(event) for event in events] == [
+ UserMessageEvent,
AssistantEvent,
ToolCallEvent,
ToolResultEvent,
AssistantEvent,
]
- assert isinstance(events[0], AssistantEvent)
- assert events[0].content == "Checking your todos."
- assert isinstance(events[1], ToolCallEvent)
- assert events[1].tool_name == "todo"
- assert isinstance(events[2], ToolResultEvent)
- assert events[2].error is None
- assert events[2].skipped is False
- assert isinstance(events[3], AssistantEvent)
- assert events[3].content == "Done reviewing todos."
+ assert isinstance(events[0], UserMessageEvent)
+ assert isinstance(events[1], AssistantEvent)
+ assert events[1].content == "Checking your todos."
+ assert isinstance(events[2], ToolCallEvent)
+ assert events[2].tool_name == "todo"
+ assert isinstance(events[3], ToolResultEvent)
+ assert events[3].error is None
+ assert events[3].skipped is False
+ assert isinstance(events[4], AssistantEvent)
+ assert events[4].content == "Done reviewing todos."
assert agent.messages[-1].content == "Done reviewing todos."
@@ -224,25 +228,27 @@ async def test_act_handles_tool_call_chunk_with_content() -> None:
mock_llm_chunk(content="todo request", tool_calls=[todo_tool_call]),
mock_llm_chunk(content=" complete"),
])
- agent = Agent(
+ agent = AgentLoop(
make_config(
enabled_tools=["todo"],
tools={"todo": BaseToolConfig(permission=ToolPermission.ALWAYS)},
),
backend=backend,
- mode=AgentMode.AUTO_APPROVE,
+ agent_name=BuiltinAgentName.AUTO_APPROVE,
enable_streaming=True,
)
events = [event async for event in agent.act("Check todos with content.")]
assert [type(event) for event in events] == [
+ UserMessageEvent,
AssistantEvent,
ToolCallEvent,
ToolResultEvent,
]
- assert isinstance(events[0], AssistantEvent)
- assert events[0].content == "Preparing todo request complete"
+ assert isinstance(events[0], UserMessageEvent)
+ assert isinstance(events[1], AssistantEvent)
+ assert events[1].content == "Preparing todo request complete"
assert any(
m.role == Role.assistant and m.content == "Preparing todo request complete"
for m in agent.messages
@@ -266,31 +272,33 @@ async def test_act_merges_streamed_tool_call_arguments() -> None:
mock_llm_chunk(content="", tool_calls=[tool_call_part_one]),
mock_llm_chunk(content="", tool_calls=[tool_call_part_two]),
])
- agent = Agent(
+ agent = AgentLoop(
make_config(
enabled_tools=["todo"],
tools={"todo": BaseToolConfig(permission=ToolPermission.ALWAYS)},
),
backend=backend,
- mode=AgentMode.AUTO_APPROVE,
+ agent_name=BuiltinAgentName.AUTO_APPROVE,
enable_streaming=True,
)
events = [event async for event in agent.act("Merge streamed tool call args.")]
assert [type(event) for event in events] == [
+ UserMessageEvent,
AssistantEvent,
ToolCallEvent,
ToolResultEvent,
]
- call_event = events[1]
+ assert isinstance(events[0], UserMessageEvent)
+ call_event = events[2]
assert isinstance(call_event, ToolCallEvent)
assert call_event.tool_call_id == "tc_merge"
call_args = cast(TodoArgs, call_event.args)
assert call_args.action == "read"
- assert isinstance(events[2], ToolResultEvent)
- assert events[2].error is None
- assert events[2].skipped is False
+ assert isinstance(events[3], ToolResultEvent)
+ assert events[3].error is None
+ assert events[3].skipped is False
assistant_with_calls = next(
m for m in agent.messages if m.role == Role.assistant and m.tool_calls
)
@@ -328,13 +336,13 @@ async def test_act_handles_user_cancellation_during_streaming() -> None:
mock_llm_chunk(content="Preparing "),
mock_llm_chunk(content="todo request", tool_calls=[todo_tool_call]),
])
- agent = Agent(
+ agent = AgentLoop(
make_config(
enabled_tools=["todo"],
tools={"todo": BaseToolConfig(permission=ToolPermission.ASK)},
),
backend=backend,
- mode=AgentMode.DEFAULT,
+ agent_name=BuiltinAgentName.DEFAULT,
enable_streaming=True,
)
middleware = CountingMiddleware()
@@ -345,11 +353,12 @@ async def test_act_handles_user_cancellation_during_streaming() -> None:
str(get_user_cancellation_message(CancellationReason.OPERATION_CANCELLED)),
)
)
- agent.interaction_logger.save_interaction = AsyncMock(return_value=None)
+ agent.session_logger.save_interaction = AsyncMock(return_value=None)
events = [event async for event in agent.act("Cancel mid stream?")]
assert [type(event) for event in events] == [
+ UserMessageEvent,
AssistantEvent,
ToolCallEvent,
ToolResultEvent,
@@ -360,23 +369,23 @@ async def test_act_handles_user_cancellation_during_streaming() -> None:
assert events[-1].skipped is True
assert events[-1].skip_reason is not None
assert "" in events[-1].skip_reason
- assert agent.interaction_logger.save_interaction.await_count == 1
+ assert agent.session_logger.save_interaction.await_count == 1
@pytest.mark.asyncio
async def test_act_flushes_and_logs_when_streaming_errors(observer_capture) -> None:
observed, observer = observer_capture
backend = FakeBackend(exception_to_raise=RuntimeError("boom in streaming"))
- agent = Agent(
+ agent = AgentLoop(
make_config(), backend=backend, message_observer=observer, enable_streaming=True
)
- agent.interaction_logger.save_interaction = AsyncMock(return_value=None)
+ agent.session_logger.save_interaction = AsyncMock(return_value=None)
with pytest.raises(RuntimeError, match="boom in streaming"):
[_ async for _ in agent.act("Trigger stream failure")]
assert [role for role, _ in observed] == [Role.system, Role.user]
- assert agent.interaction_logger.save_interaction.await_count == 1
+ assert agent.session_logger.save_interaction.await_count == 1
def _snapshot_events(events: list) -> list[tuple[str, str]]:
@@ -395,7 +404,7 @@ async def test_reasoning_buffer_yields_before_content_on_transition() -> None:
mock_llm_chunk(content="", reasoning_content=" problem..."),
mock_llm_chunk(content="The answer is 42."),
])
- agent = Agent(make_config(), backend=backend, enable_streaming=True)
+ agent = AgentLoop(make_config(), backend=backend, enable_streaming=True)
events = [event async for event in agent.act("What's the answer?")]
@@ -417,7 +426,7 @@ async def test_reasoning_buffer_yields_before_content_with_batching() -> None:
mock_llm_chunk(content="", reasoning_content=", Final"),
mock_llm_chunk(content="Done thinking!"),
])
- agent = Agent(make_config(), backend=backend, enable_streaming=True)
+ agent = AgentLoop(make_config(), backend=backend, enable_streaming=True)
events = [event async for event in agent.act("Think step by step")]
@@ -438,7 +447,7 @@ async def test_content_buffer_yields_before_reasoning_on_transition() -> None:
mock_llm_chunk(content="", reasoning_content=" this approach..."),
mock_llm_chunk(content="Actually, the final answer."),
])
- agent = Agent(make_config(), backend=backend, enable_streaming=True)
+ agent = AgentLoop(make_config(), backend=backend, enable_streaming=True)
events = [event async for event in agent.act("Give me an answer")]
@@ -459,7 +468,7 @@ async def test_interleaved_reasoning_content_preserves_order() -> None:
mock_llm_chunk(content="", reasoning_content="Think 3"),
mock_llm_chunk(content="Answer 3"),
])
- agent = Agent(make_config(), backend=backend, enable_streaming=True)
+ agent = AgentLoop(make_config(), backend=backend, enable_streaming=True)
events = [event async for event in agent.act("Interleaved test")]
@@ -483,7 +492,7 @@ async def test_only_reasoning_chunks_yields_reasoning_event() -> None:
mock_llm_chunk(content="", reasoning_content="Just thinking..."),
mock_llm_chunk(content="", reasoning_content=" nothing to say yet."),
])
- agent = Agent(make_config(), backend=backend, enable_streaming=True)
+ agent = AgentLoop(make_config(), backend=backend, enable_streaming=True)
events = [event async for event in agent.act("Silent thinking")]
@@ -498,7 +507,7 @@ async def test_final_buffers_flush_in_correct_order() -> None:
mock_llm_chunk(content="", reasoning_content="Final thought"),
mock_llm_chunk(content="Final words"),
])
- agent = Agent(make_config(), backend=backend, enable_streaming=True)
+ agent = AgentLoop(make_config(), backend=backend, enable_streaming=True)
events = [event async for event in agent.act("End buffers test")]
@@ -516,7 +525,7 @@ async def test_empty_content_chunks_do_not_trigger_false_yields() -> None:
mock_llm_chunk(content="", reasoning_content=" more reasoning"),
mock_llm_chunk(content="Actual content"),
])
- agent = Agent(make_config(), backend=backend, enable_streaming=True)
+ agent = AgentLoop(make_config(), backend=backend, enable_streaming=True)
events = [event async for event in agent.act("Empty content test")]
diff --git a/tests/test_agent_stats.py b/tests/test_agent_stats.py
index c4cfcd3..e9ca8e4 100644
--- a/tests/test_agent_stats.py
+++ b/tests/test_agent_stats.py
@@ -6,7 +6,8 @@ import pytest
from tests.mock.utils import mock_llm_chunk
from tests.stubs.fake_backend import FakeBackend
-from vibe.core.agent import Agent
+from vibe.core.agent_loop import AgentLoop
+from vibe.core.agents.models import BuiltinAgentName
from vibe.core.config import (
Backend,
ModelConfig,
@@ -14,7 +15,6 @@ from vibe.core.config import (
SessionLoggingConfig,
VibeConfig,
)
-from vibe.core.modes import AgentMode
from vibe.core.tools.base import BaseToolConfig, ToolPermission
from vibe.core.types import (
AgentStats,
@@ -25,6 +25,7 @@ from vibe.core.types import (
LLMMessage,
Role,
ToolCall,
+ UserMessageEvent,
)
@@ -160,7 +161,7 @@ class TestReloadPreservesStats:
@pytest.mark.asyncio
async def test_reload_preserves_session_tokens(self) -> None:
backend = FakeBackend(mock_llm_chunk(content="First response"))
- agent = Agent(make_config(), backend=backend)
+ agent = AgentLoop(make_config(), backend=backend)
async for _ in agent.act("Hello"):
pass
@@ -193,7 +194,9 @@ class TestReloadPreservesStats:
mock_llm_chunk(content="Done"),
])
config = make_config(enabled_tools=["todo"])
- agent = Agent(config, mode=AgentMode.AUTO_APPROVE, backend=backend)
+ agent = AgentLoop(
+ config, agent_name=BuiltinAgentName.AUTO_APPROVE, backend=backend
+ )
async for _ in agent.act("Check todos"):
pass
@@ -212,7 +215,7 @@ class TestReloadPreservesStats:
[mock_llm_chunk(content="R1")],
[mock_llm_chunk(content="R2")],
])
- agent = Agent(make_config(), backend=backend)
+ agent = AgentLoop(make_config(), backend=backend)
async for _ in agent.act("First"):
pass
@@ -231,7 +234,7 @@ class TestReloadPreservesStats:
self,
) -> None:
backend = FakeBackend(mock_llm_chunk(content="Response"))
- agent = Agent(make_config(), backend=backend)
+ agent = AgentLoop(make_config(), backend=backend)
[_ async for _ in agent.act("Hello")]
assert agent.stats.context_tokens > 0
initial_context_tokens = agent.stats.context_tokens
@@ -245,7 +248,7 @@ class TestReloadPreservesStats:
@pytest.mark.asyncio
async def test_reload_resets_context_tokens_when_no_messages(self) -> None:
backend = FakeBackend([])
- agent = Agent(make_config(), backend=backend)
+ agent = AgentLoop(make_config(), backend=backend)
assert len(agent.messages) == 1
assert agent.stats.context_tokens == 0
@@ -261,13 +264,13 @@ class TestReloadPreservesStats:
backend = FakeBackend(mock_llm_chunk(content="Response"))
config1 = make_config(system_prompt_id="tests")
config2 = make_config(system_prompt_id="cli")
- agent = Agent(config1, backend=backend)
+ agent = AgentLoop(config1, backend=backend)
[_ async for _ in agent.act("Hello")]
original_context_tokens = agent.stats.context_tokens
assert original_context_tokens > 0
assert len(agent.messages) > 1
- await agent.reload_with_initial_messages(config=config2)
+ await agent.reload_with_initial_messages(base_config=config2)
assert len(agent.messages) > 1
assert agent.stats.context_tokens == original_context_tokens
@@ -278,7 +281,7 @@ class TestReloadPreservesStats:
backend = FakeBackend(mock_llm_chunk(content="Response"))
config_mistral = make_config(active_model="devstral-latest")
- agent = Agent(config_mistral, backend=backend)
+ agent = AgentLoop(config_mistral, backend=backend)
async for _ in agent.act("Hello"):
pass
@@ -287,7 +290,7 @@ class TestReloadPreservesStats:
assert agent.stats.output_price_per_million == 2.0
config_other = make_config(active_model="strawberry")
- await agent.reload_with_initial_messages(config=config_other)
+ await agent.reload_with_initial_messages(base_config=config_other)
assert agent.stats.input_price_per_million == 2.5
assert agent.stats.output_price_per_million == 10.0
@@ -301,7 +304,7 @@ class TestReloadPreservesStats:
[mock_llm_chunk(content="After reload")],
])
config1 = make_config(active_model="devstral-latest")
- agent = Agent(config1, backend=backend)
+ agent = AgentLoop(config1, backend=backend)
async for _ in agent.act("Hello"):
pass
@@ -311,7 +314,7 @@ class TestReloadPreservesStats:
)
config2 = make_config(active_model="strawberry")
- await agent.reload_with_initial_messages(config=config2)
+ await agent.reload_with_initial_messages(base_config=config2)
async for _ in agent.act("Continue"):
pass
@@ -326,7 +329,7 @@ class TestReloadPreservesMessages:
@pytest.mark.asyncio
async def test_reload_preserves_conversation_messages(self) -> None:
backend = FakeBackend(mock_llm_chunk(content="Response"))
- agent = Agent(make_config(), backend=backend)
+ agent = AgentLoop(make_config(), backend=backend)
async for _ in agent.act("Hello"):
pass
@@ -348,7 +351,7 @@ class TestReloadPreservesMessages:
async def test_reload_updates_system_prompt_preserves_rest(self) -> None:
backend = FakeBackend(mock_llm_chunk(content="Response"))
config1 = make_config(system_prompt_id="tests")
- agent = Agent(config1, backend=backend)
+ agent = AgentLoop(config1, backend=backend)
async for _ in agent.act("Hello"):
pass
@@ -357,7 +360,7 @@ class TestReloadPreservesMessages:
old_user = agent.messages[1].content
config2 = make_config(system_prompt_id="cli")
- await agent.reload_with_initial_messages(config=config2)
+ await agent.reload_with_initial_messages(base_config=config2)
assert agent.messages[0].content != old_system
assert agent.messages[1].content == old_user
@@ -365,7 +368,7 @@ class TestReloadPreservesMessages:
@pytest.mark.asyncio
async def test_reload_with_no_messages_stays_empty(self) -> None:
backend = FakeBackend([])
- agent = Agent(make_config(), backend=backend)
+ agent = AgentLoop(make_config(), backend=backend)
assert len(agent.messages) == 1
@@ -380,7 +383,7 @@ class TestReloadPreservesMessages:
) -> None:
observed, observer = observer_capture
backend = FakeBackend(mock_llm_chunk(content="Response"))
- agent = Agent(make_config(), message_observer=observer, backend=backend)
+ agent = AgentLoop(make_config(), message_observer=observer, backend=backend)
async for _ in agent.act("Hello"):
pass
@@ -402,7 +405,7 @@ class TestCompactStatsHandling:
[mock_llm_chunk(content="First response")],
[mock_llm_chunk(content="")],
])
- agent = Agent(make_config(), backend=backend)
+ agent = AgentLoop(make_config(), backend=backend)
async for _ in agent.act("Build something"):
pass
@@ -424,7 +427,7 @@ class TestCompactStatsHandling:
[mock_llm_chunk(content="Long response " * 100)],
[mock_llm_chunk(content="")],
])
- agent = Agent(make_config(), backend=backend)
+ agent = AgentLoop(make_config(), backend=backend)
async for _ in agent.act("Do something complex"):
pass
@@ -456,7 +459,9 @@ class TestCompactStatsHandling:
[mock_llm_chunk(content="")],
])
config = make_config(enabled_tools=["todo"])
- agent = Agent(config, mode=AgentMode.AUTO_APPROVE, backend=backend)
+ agent = AgentLoop(
+ config, agent_name=BuiltinAgentName.AUTO_APPROVE, backend=backend
+ )
async for _ in agent.act("Check todos"):
pass
@@ -473,10 +478,10 @@ class TestCompactStatsHandling:
[mock_llm_chunk(content="Long response " * 100)],
[mock_llm_chunk(content="")],
])
- agent = Agent(make_config(disable_logging=False), backend=backend)
+ agent = AgentLoop(make_config(disable_logging=False), backend=backend)
original_session_id = agent.session_id
- original_logger_session_id = agent.interaction_logger.session_id
+ original_logger_session_id = agent.session_logger.session_id
assert agent.session_id == original_logger_session_id
@@ -486,7 +491,7 @@ class TestCompactStatsHandling:
await agent.compact()
assert agent.session_id != original_session_id
- assert agent.session_id == agent.interaction_logger.session_id
+ assert agent.session_id == agent.session_logger.session_id
class TestAutoCompactIntegration:
@@ -505,19 +510,20 @@ class TestAutoCompactIntegration:
session_logging=SessionLoggingConfig(enabled=False),
auto_compact_threshold=1,
)
- agent = Agent(cfg, message_observer=observer, backend=backend)
+ agent = AgentLoop(cfg, message_observer=observer, backend=backend)
agent.stats.context_tokens = 2
events = [ev async for ev in agent.act("Hello")]
- assert len(events) == 3
- assert isinstance(events[0], CompactStartEvent)
- assert isinstance(events[1], CompactEndEvent)
- assert isinstance(events[2], AssistantEvent)
+ assert len(events) == 4
+ assert isinstance(events[0], UserMessageEvent)
+ assert isinstance(events[1], CompactStartEvent)
+ assert isinstance(events[2], CompactEndEvent)
+ assert isinstance(events[3], AssistantEvent)
- start: CompactStartEvent = events[0]
- end: CompactEndEvent = events[1]
- final: AssistantEvent = events[2]
+ start: CompactStartEvent = events[1]
+ end: CompactEndEvent = events[2]
+ final: AssistantEvent = events[3]
assert start.current_context_tokens == 2
assert start.threshold == 1
@@ -527,17 +533,14 @@ class TestAutoCompactIntegration:
roles = [r for r, _ in observed]
assert roles == [Role.system, Role.user, Role.assistant]
- assert (
- observed[1][1] is not None
- and "Last request from user was: Hello" in observed[1][1]
- )
+ assert observed[1][1] is not None and "" in observed[1][1]
class TestClearHistoryFullReset:
@pytest.mark.asyncio
async def test_clear_history_fully_resets_stats(self) -> None:
backend = FakeBackend(mock_llm_chunk(content="Response"))
- agent = Agent(make_config(), backend=backend)
+ agent = AgentLoop(make_config(), backend=backend)
async for _ in agent.act("Hello"):
pass
@@ -555,7 +558,7 @@ class TestClearHistoryFullReset:
async def test_clear_history_preserves_pricing(self) -> None:
backend = FakeBackend(mock_llm_chunk(content="Response"))
config = make_config(input_price=0.4, output_price=2.0)
- agent = Agent(config, backend=backend)
+ agent = AgentLoop(config, backend=backend)
async for _ in agent.act("Hello"):
pass
@@ -568,7 +571,7 @@ class TestClearHistoryFullReset:
@pytest.mark.asyncio
async def test_clear_history_removes_messages(self) -> None:
backend = FakeBackend(mock_llm_chunk(content="Response"))
- agent = Agent(make_config(), backend=backend)
+ agent = AgentLoop(make_config(), backend=backend)
async for _ in agent.act("Hello"):
pass
@@ -583,10 +586,10 @@ class TestClearHistoryFullReset:
@pytest.mark.asyncio
async def test_clear_history_resets_session_id(self) -> None:
backend = FakeBackend(mock_llm_chunk(content="Response"))
- agent = Agent(make_config(disable_logging=False), backend=backend)
+ agent = AgentLoop(make_config(disable_logging=False), backend=backend)
original_session_id = agent.session_id
- original_logger_session_id = agent.interaction_logger.session_id
+ original_logger_session_id = agent.session_logger.session_id
assert agent.session_id == original_logger_session_id
@@ -596,7 +599,7 @@ class TestClearHistoryFullReset:
await agent.clear_history()
assert agent.session_id != original_session_id
- assert agent.session_id == agent.interaction_logger.session_id
+ assert agent.session_id == agent.session_logger.session_id
class TestStatsEdgeCases:
@@ -608,7 +611,7 @@ class TestStatsEdgeCases:
backend = FakeBackend(mock_llm_chunk(content="Response"))
config1 = make_config(active_model="devstral-latest")
- agent = Agent(config1, backend=backend)
+ agent = AgentLoop(config1, backend=backend)
async for _ in agent.act("Hello"):
pass
@@ -616,7 +619,7 @@ class TestStatsEdgeCases:
cost_before = agent.stats.session_cost
config2 = make_config(active_model="strawberry")
- await agent.reload_with_initial_messages(config=config2)
+ await agent.reload_with_initial_messages(base_config=config2)
cost_after = agent.stats.session_cost
@@ -629,7 +632,7 @@ class TestStatsEdgeCases:
[mock_llm_chunk(content="R2")],
[mock_llm_chunk(content="R3")],
])
- agent = Agent(make_config(), backend=backend)
+ agent = AgentLoop(make_config(), backend=backend)
async for _ in agent.act("First"):
pass
@@ -654,7 +657,7 @@ class TestStatsEdgeCases:
[mock_llm_chunk(content="")],
[mock_llm_chunk(content="After reload")],
])
- agent = Agent(make_config(), backend=backend)
+ agent = AgentLoop(make_config(), backend=backend)
async for _ in agent.act("Build something"):
pass
@@ -675,9 +678,9 @@ class TestStatsEdgeCases:
async def test_reload_without_config_preserves_current(self) -> None:
backend = FakeBackend([])
original_config = make_config(active_model="devstral-latest")
- agent = Agent(original_config, backend=backend)
+ agent = AgentLoop(original_config, backend=backend)
- await agent.reload_with_initial_messages(config=None)
+ await agent.reload_with_initial_messages(base_config=None)
assert agent.config.active_model == "devstral-latest"
@@ -685,9 +688,9 @@ class TestStatsEdgeCases:
async def test_reload_with_new_config_updates_it(self) -> None:
backend = FakeBackend([])
original_config = make_config(active_model="devstral-latest")
- agent = Agent(original_config, backend=backend)
+ agent = AgentLoop(original_config, backend=backend)
new_config = make_config(active_model="devstral-small")
- await agent.reload_with_initial_messages(config=new_config)
+ await agent.reload_with_initial_messages(base_config=new_config)
assert agent.config.active_model == "devstral-small"
diff --git a/tests/test_agent_tool_call.py b/tests/test_agent_tool_call.py
index 45794b9..223401a 100644
--- a/tests/test_agent_tool_call.py
+++ b/tests/test_agent_tool_call.py
@@ -9,9 +9,9 @@ import pytest
from tests.mock.utils import mock_llm_chunk
from tests.stubs.fake_backend import FakeBackend
from tests.stubs.fake_tool import FakeTool
-from vibe.core.agent import Agent
+from vibe.core.agent_loop import AgentLoop
+from vibe.core.agents.models import BuiltinAgentName
from vibe.core.config import SessionLoggingConfig, VibeConfig
-from vibe.core.modes import AgentMode
from vibe.core.tools.base import BaseToolConfig, ToolPermission
from vibe.core.tools.builtins.todo import TodoItem
from vibe.core.types import (
@@ -25,11 +25,12 @@ from vibe.core.types import (
ToolCall,
ToolCallEvent,
ToolResultEvent,
+ UserMessageEvent,
)
-async def act_and_collect_events(agent: Agent, prompt: str) -> list[BaseEvent]:
- return [ev async for ev in agent.act(prompt)]
+async def act_and_collect_events(agent_loop: AgentLoop, prompt: str) -> list[BaseEvent]:
+ return [ev async for ev in agent_loop.act(prompt)]
def make_config(todo_permission: ToolPermission = ToolPermission.ALWAYS) -> VibeConfig:
@@ -53,20 +54,24 @@ def make_todo_tool_call(
)
-def make_agent(
+def make_agent_loop(
*,
auto_approve: bool = True,
todo_permission: ToolPermission = ToolPermission.ALWAYS,
backend: FakeBackend,
approval_callback: SyncApprovalCallback | None = None,
-) -> Agent:
- mode = AgentMode.AUTO_APPROVE if auto_approve else AgentMode.DEFAULT
- agent = Agent(
- make_config(todo_permission=todo_permission), mode=mode, backend=backend
+) -> AgentLoop:
+ agent_name = (
+ BuiltinAgentName.AUTO_APPROVE if auto_approve else BuiltinAgentName.DEFAULT
+ )
+ agent_loop = AgentLoop(
+ make_config(todo_permission=todo_permission),
+ agent_name=agent_name,
+ backend=backend,
)
if approval_callback:
- agent.set_approval_callback(approval_callback)
- return agent
+ agent_loop.set_approval_callback(approval_callback)
+ return agent_loop
@pytest.mark.asyncio
@@ -77,28 +82,30 @@ async def test_single_tool_call_executes_under_auto_approve() -> None:
[mock_llm_chunk(content="Let me check your todos.", tool_calls=[tool_call])],
[mock_llm_chunk(content="I retrieved 0 todos.")],
])
- agent = make_agent(auto_approve=True, backend=backend)
+ agent_loop = make_agent_loop(auto_approve=True, backend=backend)
- events = await act_and_collect_events(agent, "What's my todo list?")
+ events = await act_and_collect_events(agent_loop, "What's my todo list?")
assert [type(e) for e in events] == [
+ UserMessageEvent,
AssistantEvent,
ToolCallEvent,
ToolResultEvent,
AssistantEvent,
]
- assert isinstance(events[0], AssistantEvent)
- assert events[0].content == "Let me check your todos."
- assert isinstance(events[1], ToolCallEvent)
- assert events[1].tool_name == "todo"
- assert isinstance(events[2], ToolResultEvent)
- assert events[2].error is None
- assert events[2].skipped is False
- assert events[2].result is not None
- assert isinstance(events[3], AssistantEvent)
- assert events[3].content == "I retrieved 0 todos."
+ assert isinstance(events[0], UserMessageEvent)
+ assert isinstance(events[1], AssistantEvent)
+ assert events[1].content == "Let me check your todos."
+ assert isinstance(events[2], ToolCallEvent)
+ assert events[2].tool_name == "todo"
+ assert isinstance(events[3], ToolResultEvent)
+ assert events[3].error is None
+ assert events[3].skipped is False
+ assert events[3].result is not None
+ assert isinstance(events[4], AssistantEvent)
+ assert events[4].content == "I retrieved 0 todos."
# check conversation history
- tool_msgs = [m for m in agent.messages if m.role == Role.tool]
+ tool_msgs = [m for m in agent_loop.messages if m.role == Role.tool]
assert len(tool_msgs) == 1
assert tool_msgs[-1].tool_call_id == mocked_tool_call_id
assert "total_count" in (tool_msgs[-1].content or "")
@@ -106,7 +113,7 @@ async def test_single_tool_call_executes_under_auto_approve() -> None:
@pytest.mark.asyncio
async def test_tool_call_requires_approval_if_not_auto_approved() -> None:
- agent = make_agent(
+ agent_loop = make_agent_loop(
auto_approve=False,
todo_permission=ToolPermission.ASK,
backend=FakeBackend([
@@ -120,21 +127,23 @@ async def test_tool_call_requires_approval_if_not_auto_approved() -> None:
]),
)
- events = await act_and_collect_events(agent, "What's my todo list?")
+ events = await act_and_collect_events(agent_loop, "What's my todo list?")
- assert isinstance(events[1], ToolCallEvent)
- assert events[1].tool_name == "todo"
- assert isinstance(events[2], ToolResultEvent)
- assert events[2].skipped is True
- assert events[2].error is None
- assert events[2].result is None
- assert events[2].skip_reason is not None
- assert "not permitted" in events[2].skip_reason.lower()
- assert isinstance(events[3], AssistantEvent)
- assert events[3].content == "I cannot execute the tool without approval."
- assert agent.stats.tool_calls_rejected == 1
- assert agent.stats.tool_calls_agreed == 0
- assert agent.stats.tool_calls_succeeded == 0
+ assert isinstance(events[0], UserMessageEvent)
+ assert isinstance(events[1], AssistantEvent)
+ assert isinstance(events[2], ToolCallEvent)
+ assert events[2].tool_name == "todo"
+ assert isinstance(events[3], ToolResultEvent)
+ assert events[3].skipped is True
+ assert events[3].error is None
+ assert events[3].result is None
+ assert events[3].skip_reason is not None
+ assert "not permitted" in events[3].skip_reason.lower()
+ assert isinstance(events[4], AssistantEvent)
+ assert events[4].content == "I cannot execute the tool without approval."
+ assert agent_loop.stats.tool_calls_rejected == 1
+ assert agent_loop.stats.tool_calls_agreed == 0
+ assert agent_loop.stats.tool_calls_succeeded == 0
@pytest.mark.asyncio
@@ -144,7 +153,7 @@ async def test_tool_call_approved_by_callback() -> None:
) -> tuple[ApprovalResponse, str | None]:
return (ApprovalResponse.YES, None)
- agent = make_agent(
+ agent_loop = make_agent_loop(
auto_approve=False,
todo_permission=ToolPermission.ASK,
approval_callback=approval_callback,
@@ -159,15 +168,16 @@ async def test_tool_call_approved_by_callback() -> None:
]),
)
- events = await act_and_collect_events(agent, "What's my todo list?")
+ events = await act_and_collect_events(agent_loop, "What's my todo list?")
- assert isinstance(events[2], ToolResultEvent)
- assert events[2].skipped is False
- assert events[2].error is None
- assert events[2].result is not None
- assert agent.stats.tool_calls_agreed == 1
- assert agent.stats.tool_calls_rejected == 0
- assert agent.stats.tool_calls_succeeded == 1
+ assert isinstance(events[0], UserMessageEvent)
+ assert isinstance(events[3], ToolResultEvent)
+ assert events[3].skipped is False
+ assert events[3].error is None
+ assert events[3].result is not None
+ assert agent_loop.stats.tool_calls_agreed == 1
+ assert agent_loop.stats.tool_calls_rejected == 0
+ assert agent_loop.stats.tool_calls_succeeded == 1
@pytest.mark.asyncio
@@ -181,7 +191,7 @@ async def test_tool_call_rejected_when_auto_approve_disabled_and_rejected_by_cal
) -> tuple[ApprovalResponse, str | None]:
return (ApprovalResponse.NO, custom_feedback)
- agent = make_agent(
+ agent_loop = make_agent_loop(
auto_approve=False,
todo_permission=ToolPermission.ASK,
approval_callback=approval_callback,
@@ -196,21 +206,22 @@ async def test_tool_call_rejected_when_auto_approve_disabled_and_rejected_by_cal
]),
)
- events = await act_and_collect_events(agent, "What's my todo list?")
+ events = await act_and_collect_events(agent_loop, "What's my todo list?")
- assert isinstance(events[2], ToolResultEvent)
- assert events[2].skipped is True
- assert events[2].error is None
- assert events[2].result is None
- assert events[2].skip_reason == custom_feedback
- assert agent.stats.tool_calls_rejected == 1
- assert agent.stats.tool_calls_agreed == 0
- assert agent.stats.tool_calls_succeeded == 0
+ assert isinstance(events[0], UserMessageEvent)
+ assert isinstance(events[3], ToolResultEvent)
+ assert events[3].skipped is True
+ assert events[3].error is None
+ assert events[3].result is None
+ assert events[3].skip_reason == custom_feedback
+ assert agent_loop.stats.tool_calls_rejected == 1
+ assert agent_loop.stats.tool_calls_agreed == 0
+ assert agent_loop.stats.tool_calls_succeeded == 0
@pytest.mark.asyncio
async def test_tool_call_skipped_when_permission_is_never() -> None:
- agent = make_agent(
+ agent_loop = make_agent_loop(
auto_approve=False,
todo_permission=ToolPermission.NEVER,
backend=FakeBackend([
@@ -224,27 +235,30 @@ async def test_tool_call_skipped_when_permission_is_never() -> None:
]),
)
- events = await act_and_collect_events(agent, "What's my todo list?")
+ events = await act_and_collect_events(agent_loop, "What's my todo list?")
- assert isinstance(events[2], ToolResultEvent)
- assert events[2].skipped is True
- assert events[2].error is None
- assert events[2].result is None
- assert events[2].skip_reason is not None
- assert "permanently disabled" in events[2].skip_reason.lower()
- tool_msgs = [m for m in agent.messages if m.role == Role.tool and m.name == "todo"]
+ assert isinstance(events[0], UserMessageEvent)
+ assert isinstance(events[3], ToolResultEvent)
+ assert events[3].skipped is True
+ assert events[3].error is None
+ assert events[3].result is None
+ assert events[3].skip_reason is not None
+ assert "permanently disabled" in events[3].skip_reason.lower()
+ tool_msgs = [
+ m for m in agent_loop.messages if m.role == Role.tool and m.name == "todo"
+ ]
assert len(tool_msgs) == 1
assert tool_msgs[0].name == "todo"
- assert events[2].skip_reason in (tool_msgs[-1].content or "")
- assert agent.stats.tool_calls_rejected == 1
- assert agent.stats.tool_calls_agreed == 0
- assert agent.stats.tool_calls_succeeded == 0
+ assert events[3].skip_reason in (tool_msgs[-1].content or "")
+ assert agent_loop.stats.tool_calls_rejected == 1
+ assert agent_loop.stats.tool_calls_agreed == 0
+ assert agent_loop.stats.tool_calls_succeeded == 0
@pytest.mark.asyncio
async def test_approval_always_sets_tool_permission_for_subsequent_calls() -> None:
callback_invocations = []
- agent_ref: Agent | None = None
+ agent_ref: AgentLoop | None = None
def approval_callback(
tool_name: str, _args: BaseModel, _tool_call_id: str
@@ -257,7 +271,7 @@ async def test_approval_always_sets_tool_permission_for_subsequent_calls() -> No
agent_ref.config.tools[tool_name].permission = ToolPermission.ALWAYS
return (ApprovalResponse.YES, None)
- agent = make_agent(
+ agent_loop = make_agent_loop(
auto_approve=False,
todo_permission=ToolPermission.ASK,
approval_callback=approval_callback,
@@ -278,32 +292,34 @@ async def test_approval_always_sets_tool_permission_for_subsequent_calls() -> No
[mock_llm_chunk(content="Second done.")],
]),
)
- agent_ref = agent
+ agent_ref = agent_loop
- events1 = await act_and_collect_events(agent, "First request")
- events2 = await act_and_collect_events(agent, "Second request")
+ events1 = await act_and_collect_events(agent_loop, "First request")
+ events2 = await act_and_collect_events(agent_loop, "Second request")
- tool_config_todo = agent.tool_manager.get_tool_config("todo")
+ tool_config_todo = agent_loop.tool_manager.get_tool_config("todo")
assert tool_config_todo.permission is ToolPermission.ALWAYS
- tool_config_help = agent.tool_manager.get_tool_config("bash")
+ tool_config_help = agent_loop.tool_manager.get_tool_config("bash")
assert tool_config_help.permission is not ToolPermission.ALWAYS
- assert agent.auto_approve is False
+ assert agent_loop.auto_approve is False
assert len(callback_invocations) == 1
assert callback_invocations[0] == "todo"
- assert isinstance(events1[2], ToolResultEvent)
- assert events1[2].skipped is False
- assert events1[2].result is not None
- assert isinstance(events2[2], ToolResultEvent)
- assert events2[2].skipped is False
- assert events2[2].result is not None
- assert agent.stats.tool_calls_rejected == 0
- assert agent.stats.tool_calls_succeeded == 2
+ assert isinstance(events1[0], UserMessageEvent)
+ assert isinstance(events1[3], ToolResultEvent)
+ assert events1[3].skipped is False
+ assert events1[3].result is not None
+ assert isinstance(events2[0], UserMessageEvent)
+ assert isinstance(events2[3], ToolResultEvent)
+ assert events2[3].skipped is False
+ assert events2[3].result is not None
+ assert agent_loop.stats.tool_calls_rejected == 0
+ assert agent_loop.stats.tool_calls_succeeded == 2
@pytest.mark.asyncio
async def test_tool_call_with_invalid_action() -> None:
tool_call = make_todo_tool_call("call_5", arguments='{"action": "invalid_action"}')
- agent = make_agent(
+ agent_loop = make_agent_loop(
auto_approve=True,
backend=FakeBackend([
[
@@ -315,13 +331,14 @@ async def test_tool_call_with_invalid_action() -> None:
]),
)
- events = await act_and_collect_events(agent, "What's my todo list?")
+ events = await act_and_collect_events(agent_loop, "What's my todo list?")
- assert isinstance(events[2], ToolResultEvent)
- assert events[2].error is not None
- assert events[2].result is None
- assert "tool_error" in events[2].error.lower()
- assert agent.stats.tool_calls_failed == 1
+ assert isinstance(events[0], UserMessageEvent)
+ assert isinstance(events[3], ToolResultEvent)
+ assert events[3].error is not None
+ assert events[3].result is None
+ assert "tool_error" in events[3].error.lower()
+ assert agent_loop.stats.tool_calls_failed == 1
@pytest.mark.asyncio
@@ -337,7 +354,7 @@ async def test_tool_call_with_duplicate_todo_ids() -> None:
"todos": [t.model_dump() for t in duplicate_todos],
}),
)
- agent = make_agent(
+ agent_loop = make_agent_loop(
auto_approve=True,
backend=FakeBackend([
[mock_llm_chunk(content="Let me write todos.", tool_calls=[tool_call])],
@@ -345,13 +362,14 @@ async def test_tool_call_with_duplicate_todo_ids() -> None:
]),
)
- events = await act_and_collect_events(agent, "Add todos")
+ events = await act_and_collect_events(agent_loop, "Add todos")
- assert isinstance(events[2], ToolResultEvent)
- assert events[2].error is not None
- assert events[2].result is None
- assert "unique" in events[2].error.lower()
- assert agent.stats.tool_calls_failed == 1
+ assert isinstance(events[0], UserMessageEvent)
+ assert isinstance(events[3], ToolResultEvent)
+ assert events[3].error is not None
+ assert events[3].result is None
+ assert "unique" in events[3].error.lower()
+ assert agent_loop.stats.tool_calls_failed == 1
@pytest.mark.asyncio
@@ -364,7 +382,7 @@ async def test_tool_call_with_exceeding_max_todos() -> None:
"todos": [t.model_dump() for t in many_todos],
}),
)
- agent = make_agent(
+ agent_loop = make_agent_loop(
auto_approve=True,
backend=FakeBackend([
[mock_llm_chunk(content="Let me write todos.", tool_calls=[tool_call])],
@@ -372,26 +390,23 @@ async def test_tool_call_with_exceeding_max_todos() -> None:
]),
)
- events = await act_and_collect_events(agent, "Add todos")
+ events = await act_and_collect_events(agent_loop, "Add todos")
- assert isinstance(events[2], ToolResultEvent)
- assert events[2].error is not None
- assert events[2].result is None
- assert "100" in events[2].error
- assert agent.stats.tool_calls_failed == 1
+ assert isinstance(events[0], UserMessageEvent)
+ assert isinstance(events[3], ToolResultEvent)
+ assert events[3].error is not None
+ assert events[3].result is None
+ assert "100" in events[3].error
+ assert agent_loop.stats.tool_calls_failed == 1
@pytest.mark.asyncio
-@pytest.mark.parametrize(
- "exception_class",
- [
- pytest.param(KeyboardInterrupt, id="keyboard_interrupt"),
- pytest.param(asyncio.CancelledError, id="asyncio_cancelled"),
- ],
-)
-async def test_tool_call_can_be_interrupted(
- exception_class: type[BaseException],
-) -> None:
+async def test_tool_call_can_be_interrupted() -> None:
+ """Test that tool calls can be interrupted via asyncio.CancelledError.
+
+ Note: KeyboardInterrupt is no longer handled here as ctrl+C now quits the app directly.
+ """
+ exception_class = asyncio.CancelledError
tool_call = ToolCall(
id="call_8", index=0, function=FunctionCall(name="stub_tool", arguments="{}")
)
@@ -400,23 +415,23 @@ async def test_tool_call_can_be_interrupted(
auto_compact_threshold=0,
enabled_tools=["stub_tool"],
)
- agent = Agent(
+ agent_loop = AgentLoop(
config,
- mode=AgentMode.AUTO_APPROVE,
+ agent_name=BuiltinAgentName.AUTO_APPROVE,
backend=FakeBackend([
[mock_llm_chunk(content="Let me use the tool.", tool_calls=[tool_call])],
[mock_llm_chunk(content="Tool execution completed.")],
]),
)
# no dependency injection available => monkey patch
- agent.tool_manager._available["stub_tool"] = FakeTool
- stub_tool_instance = agent.tool_manager.get("stub_tool")
+ agent_loop.tool_manager._available["stub_tool"] = FakeTool
+ stub_tool_instance = agent_loop.tool_manager.get("stub_tool")
assert isinstance(stub_tool_instance, FakeTool)
stub_tool_instance._exception_to_raise = exception_class()
events: list[BaseEvent] = []
with pytest.raises(exception_class):
- async for ev in agent.act("Execute tool"):
+ async for ev in agent_loop.act("Execute tool"):
events.append(ev)
tool_result_event = next(
@@ -429,9 +444,9 @@ async def test_tool_call_can_be_interrupted(
@pytest.mark.asyncio
async def test_fill_missing_tool_responses_inserts_placeholders() -> None:
- agent = Agent(
+ agent_loop = AgentLoop(
make_config(),
- mode=AgentMode.AUTO_APPROVE,
+ agent_name=BuiltinAgentName.AUTO_APPROVE,
backend=FakeBackend(mock_llm_chunk(content="ok")),
)
tool_calls_messages = [
@@ -441,8 +456,8 @@ async def test_fill_missing_tool_responses_inserts_placeholders() -> None:
assistant_msg = LLMMessage(
role=Role.assistant, content="Calling tools...", tool_calls=tool_calls_messages
)
- agent.messages = [
- agent.messages[0],
+ agent_loop.messages = [
+ agent_loop.messages[0],
assistant_msg,
# only one tool responded: the second is missing
LLMMessage(
@@ -450,9 +465,9 @@ async def test_fill_missing_tool_responses_inserts_placeholders() -> None:
),
]
- await act_and_collect_events(agent, "Proceed")
+ await act_and_collect_events(agent_loop, "Proceed")
- tool_msgs = [m for m in agent.messages if m.role == Role.tool]
+ tool_msgs = [m for m in agent_loop.messages if m.role == Role.tool]
assert any(m.tool_call_id == "tc2" for m in tool_msgs)
# find placeholder message for tc2
placeholder = next(m for m in tool_msgs if m.tool_call_id == "tc2")
@@ -465,19 +480,19 @@ async def test_fill_missing_tool_responses_inserts_placeholders() -> None:
@pytest.mark.asyncio
async def test_ensure_assistant_after_tool_appends_understood() -> None:
- agent = Agent(
+ agent_loop = AgentLoop(
make_config(),
- mode=AgentMode.AUTO_APPROVE,
+ agent_name=BuiltinAgentName.AUTO_APPROVE,
backend=FakeBackend(mock_llm_chunk(content="ok")),
)
tool_msg = LLMMessage(
role=Role.tool, tool_call_id="tc_z", name="todo", content="Done"
)
- agent.messages = [agent.messages[0], tool_msg]
+ agent_loop.messages = [agent_loop.messages[0], tool_msg]
- await act_and_collect_events(agent, "Next")
+ await act_and_collect_events(agent_loop, "Next")
# find the seeded tool message and ensure the next message is "Understood."
- idx = next(i for i, m in enumerate(agent.messages) if m.role == Role.tool)
- assert agent.messages[idx + 1].role == Role.assistant
- assert agent.messages[idx + 1].content == "Understood."
+ idx = next(i for i, m in enumerate(agent_loop.messages) if m.role == Role.tool)
+ assert agent_loop.messages[idx + 1].role == Role.assistant
+ assert agent_loop.messages[idx + 1].content == "Understood."
diff --git a/tests/test_agents.py b/tests/test_agents.py
new file mode 100644
index 0000000..38467c2
--- /dev/null
+++ b/tests/test_agents.py
@@ -0,0 +1,531 @@
+from __future__ import annotations
+
+import pytest
+
+from tests.mock.utils import mock_llm_chunk
+from tests.stubs.fake_backend import FakeBackend
+from vibe.core.agent_loop import AgentLoop
+from vibe.core.agents.manager import AgentManager
+from vibe.core.agents.models import (
+ BUILTIN_AGENTS,
+ PLAN_AGENT_TOOLS,
+ AgentProfile,
+ AgentSafety,
+ AgentType,
+ BuiltinAgentName,
+ _deep_merge,
+)
+from vibe.core.config import SessionLoggingConfig, VibeConfig
+from vibe.core.tools.base import ToolPermission
+from vibe.core.types import (
+ FunctionCall,
+ LLMChunk,
+ LLMMessage,
+ LLMUsage,
+ Role,
+ ToolCall,
+ ToolResultEvent,
+)
+
+
+class TestDeepMerge:
+ def test_simple_merge(self) -> None:
+ base = {"a": 1, "b": 2}
+ override = {"c": 3}
+ result = _deep_merge(base, override)
+ assert result == {"a": 1, "b": 2, "c": 3}
+
+ def test_override_existing_key(self) -> None:
+ base = {"a": 1, "b": 2}
+ override = {"b": 3}
+ result = _deep_merge(base, override)
+ assert result == {"a": 1, "b": 3}
+
+ def test_nested_dict_merge(self) -> None:
+ base = {"a": {"x": 1, "y": 2}}
+ override = {"a": {"y": 3, "z": 4}}
+ result = _deep_merge(base, override)
+ assert result == {"a": {"x": 1, "y": 3, "z": 4}}
+
+ def test_deeply_nested_merge(self) -> None:
+ base = {"a": {"b": {"c": 1}}}
+ override = {"a": {"b": {"d": 2}}}
+ result = _deep_merge(base, override)
+ assert result == {"a": {"b": {"c": 1, "d": 2}}}
+
+ def test_override_dict_with_non_dict(self) -> None:
+ base = {"a": {"x": 1}}
+ override = {"a": "replaced"}
+ result = _deep_merge(base, override)
+ assert result == {"a": "replaced"}
+
+ def test_override_non_dict_with_dict(self) -> None:
+ base = {"a": "string"}
+ override = {"a": {"x": 1}}
+ result = _deep_merge(base, override)
+ assert result == {"a": {"x": 1}}
+
+ def test_preserves_original_base(self) -> None:
+ base = {"a": 1, "b": {"c": 2}}
+ override = {"b": {"d": 3}}
+ _deep_merge(base, override)
+ assert base == {"a": 1, "b": {"c": 2}}
+
+ def test_empty_override(self) -> None:
+ base = {"a": 1, "b": 2}
+ override: dict = {}
+ result = _deep_merge(base, override)
+ assert result == {"a": 1, "b": 2}
+
+ def test_empty_base(self) -> None:
+ base: dict = {}
+ override = {"a": 1}
+ result = _deep_merge(base, override)
+ assert result == {"a": 1}
+
+ def test_lists_are_overridden_not_merged(self) -> None:
+ """Lists should be replaced entirely, not merged element-by-element."""
+ base = {"tools": ["read_file", "grep", "bash"]}
+ override = {"tools": ["write_file"]}
+ result = _deep_merge(base, override)
+ assert result == {"tools": ["write_file"]}
+
+ def test_nested_lists_are_overridden_not_merged(self) -> None:
+ """Nested lists in dicts should also be replaced, not merged."""
+ base = {"config": {"enabled_tools": ["a", "b", "c"], "other": 1}}
+ override = {"config": {"enabled_tools": ["x", "y"]}}
+ result = _deep_merge(base, override)
+ assert result == {"config": {"enabled_tools": ["x", "y"], "other": 1}}
+
+
+class TestAgentSafety:
+ def test_safety_enum_values(self) -> None:
+ assert AgentSafety.SAFE == "safe"
+ assert AgentSafety.NEUTRAL == "neutral"
+ assert AgentSafety.DESTRUCTIVE == "destructive"
+ assert AgentSafety.YOLO == "yolo"
+
+ def test_default_agent_is_neutral(self) -> None:
+ assert BUILTIN_AGENTS[BuiltinAgentName.DEFAULT].safety == AgentSafety.NEUTRAL
+
+ def test_auto_approve_agent_is_yolo(self) -> None:
+ assert BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE].safety == AgentSafety.YOLO
+
+ def test_plan_agent_is_safe(self) -> None:
+ assert BUILTIN_AGENTS[BuiltinAgentName.PLAN].safety == AgentSafety.SAFE
+
+ def test_accept_edits_agent_is_destructive(self) -> None:
+ assert (
+ BUILTIN_AGENTS[BuiltinAgentName.ACCEPT_EDITS].safety
+ == AgentSafety.DESTRUCTIVE
+ )
+
+
+class TestAgentProfile:
+ def test_all_builtin_agents_exist(self) -> None:
+ assert set(BUILTIN_AGENTS.keys()) == set(BuiltinAgentName)
+
+ def test_display_name_property(self) -> None:
+ assert BUILTIN_AGENTS[BuiltinAgentName.DEFAULT].display_name == "Default"
+ assert (
+ BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE].display_name == "Auto Approve"
+ )
+ assert BUILTIN_AGENTS[BuiltinAgentName.PLAN].display_name == "Plan"
+ assert (
+ BUILTIN_AGENTS[BuiltinAgentName.ACCEPT_EDITS].display_name == "Accept Edits"
+ )
+
+ def test_description_property(self) -> None:
+ assert (
+ "approval" in BUILTIN_AGENTS[BuiltinAgentName.DEFAULT].description.lower()
+ )
+ assert (
+ "auto" in BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE].description.lower()
+ )
+ assert "read-only" in BUILTIN_AGENTS[BuiltinAgentName.PLAN].description.lower()
+ assert (
+ "edits" in BUILTIN_AGENTS[BuiltinAgentName.ACCEPT_EDITS].description.lower()
+ )
+
+ def test_explore_is_subagent(self) -> None:
+ assert BUILTIN_AGENTS[BuiltinAgentName.EXPLORE].agent_type == AgentType.SUBAGENT
+
+ def test_agents(self) -> None:
+ agents = [
+ name
+ for name, profile in BUILTIN_AGENTS.items()
+ if profile.agent_type == AgentType.AGENT
+ ]
+ assert set(agents) == {
+ BuiltinAgentName.DEFAULT,
+ BuiltinAgentName.PLAN,
+ BuiltinAgentName.ACCEPT_EDITS,
+ BuiltinAgentName.AUTO_APPROVE,
+ }
+
+
+class TestAgentProfileOverrides:
+ def test_default_agent_has_no_overrides(self) -> None:
+ assert BUILTIN_AGENTS[BuiltinAgentName.DEFAULT].overrides == {}
+
+ def test_auto_approve_agent_sets_auto_approve(self) -> None:
+ overrides = BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE].overrides
+ assert overrides.get("auto_approve") is True
+
+ def test_plan_agent_restricts_tools(self) -> None:
+ overrides = BUILTIN_AGENTS[BuiltinAgentName.PLAN].overrides
+ assert "enabled_tools" in overrides
+ assert overrides["enabled_tools"] == PLAN_AGENT_TOOLS
+
+ def test_accept_edits_agent_sets_tool_permissions(self) -> None:
+ overrides = BUILTIN_AGENTS[BuiltinAgentName.ACCEPT_EDITS].overrides
+ assert "tools" in overrides
+ tools_config = overrides["tools"]
+ assert "write_file" in tools_config
+ assert "search_replace" in tools_config
+ assert tools_config["write_file"]["permission"] == "always"
+ assert tools_config["search_replace"]["permission"] == "always"
+
+
+class TestAgentManagerCycling:
+ @pytest.fixture
+ def base_config(self) -> VibeConfig:
+ return VibeConfig(
+ session_logging=SessionLoggingConfig(enabled=False),
+ auto_compact_threshold=0,
+ include_project_context=False,
+ include_prompt_detail=False,
+ )
+
+ @pytest.fixture
+ def backend(self) -> FakeBackend:
+ return FakeBackend([
+ LLMChunk(
+ message=LLMMessage(role=Role.assistant, content="Test response"),
+ usage=LLMUsage(prompt_tokens=10, completion_tokens=5),
+ )
+ ])
+
+ def test_get_agent_order_includes_primary_agents(
+ self, base_config: VibeConfig, backend: FakeBackend
+ ) -> None:
+ agent = AgentLoop(
+ base_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend
+ )
+ order = agent.agent_manager.get_agent_order()
+ assert len(order) == 4
+ assert BuiltinAgentName.DEFAULT in order
+ assert BuiltinAgentName.AUTO_APPROVE in order
+ assert BuiltinAgentName.PLAN in order
+ assert BuiltinAgentName.ACCEPT_EDITS in order
+
+ def test_next_agent_cycles_through_all(
+ self, base_config: VibeConfig, backend: FakeBackend
+ ) -> None:
+ agent = AgentLoop(
+ base_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend
+ )
+ order = agent.agent_manager.get_agent_order()
+ current = agent.agent_manager.active_profile
+ visited = [current.name]
+ for _ in range(len(order) - 1):
+ current = agent.agent_manager.next_agent(current)
+ visited.append(current.name)
+ assert len(set(visited)) == len(order)
+
+ def test_next_agent_wraps_around(
+ self, base_config: VibeConfig, backend: FakeBackend
+ ) -> None:
+ agent = AgentLoop(
+ base_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend
+ )
+ order = agent.agent_manager.get_agent_order()
+ last_profile = agent.agent_manager.get_agent(order[-1])
+ first_profile = agent.agent_manager.get_agent(order[0])
+ assert agent.agent_manager.next_agent(last_profile).name == first_profile.name
+
+
+class TestAgentProfileConfig:
+ def test_agent_profile_frozen(self) -> None:
+ profile = AgentProfile(
+ name="test",
+ display_name="Test",
+ description="Test agent",
+ safety=AgentSafety.NEUTRAL,
+ )
+ with pytest.raises(AttributeError):
+ profile.name = "changed" # pyright: ignore[reportAttributeAccessIssue]
+
+
+class TestAgentSwitchAgent:
+ @pytest.fixture
+ def base_config(self) -> VibeConfig:
+ return VibeConfig(
+ session_logging=SessionLoggingConfig(enabled=False),
+ auto_compact_threshold=0,
+ include_project_context=False,
+ include_prompt_detail=False,
+ )
+
+ @pytest.fixture
+ def backend(self) -> FakeBackend:
+ return FakeBackend([
+ LLMChunk(
+ message=LLMMessage(role=Role.assistant, content="Test response"),
+ usage=LLMUsage(prompt_tokens=10, completion_tokens=5),
+ )
+ ])
+
+ @pytest.mark.asyncio
+ async def test_switch_to_plan_agent_restricts_tools(
+ self, base_config: VibeConfig, backend: FakeBackend
+ ) -> None:
+ agent = AgentLoop(
+ base_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend
+ )
+ initial_tool_names = set(agent.tool_manager.available_tools.keys())
+ assert len(initial_tool_names) > len(PLAN_AGENT_TOOLS)
+
+ await agent.switch_agent(BuiltinAgentName.PLAN)
+
+ plan_tool_names = set(agent.tool_manager.available_tools.keys())
+ assert plan_tool_names == set(PLAN_AGENT_TOOLS)
+ assert agent.agent_profile.name == BuiltinAgentName.PLAN
+
+ @pytest.mark.asyncio
+ async def test_switch_from_plan_to_default_restores_tools(
+ self, base_config: VibeConfig, backend: FakeBackend
+ ) -> None:
+ agent = AgentLoop(
+ base_config, agent_name=BuiltinAgentName.PLAN, backend=backend
+ )
+ assert len(agent.tool_manager.available_tools) == len(PLAN_AGENT_TOOLS)
+
+ await agent.switch_agent(BuiltinAgentName.DEFAULT)
+
+ assert len(agent.tool_manager.available_tools) > len(PLAN_AGENT_TOOLS)
+ assert agent.agent_profile.name == BuiltinAgentName.DEFAULT
+
+ @pytest.mark.asyncio
+ async def test_switch_agent_preserves_conversation_history(
+ self, base_config: VibeConfig, backend: FakeBackend
+ ) -> None:
+ agent = AgentLoop(
+ base_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend
+ )
+ user_msg = LLMMessage(role=Role.user, content="Hello")
+ assistant_msg = LLMMessage(role=Role.assistant, content="Hi there")
+ agent.messages.append(user_msg)
+ agent.messages.append(assistant_msg)
+
+ await agent.switch_agent(BuiltinAgentName.PLAN)
+
+ assert len(agent.messages) == 3 # system + user + assistant
+ assert agent.messages[1].content == "Hello"
+ assert agent.messages[2].content == "Hi there"
+
+ @pytest.mark.asyncio
+ async def test_switch_to_same_agent_is_noop(
+ self, base_config: VibeConfig, backend: FakeBackend
+ ) -> None:
+ agent = AgentLoop(
+ base_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend
+ )
+ original_config = agent.config
+
+ await agent.switch_agent(BuiltinAgentName.DEFAULT)
+
+ assert agent.config is original_config
+ assert agent.agent_profile.name == BuiltinAgentName.DEFAULT
+
+
+class TestAcceptEditsAgent:
+ def test_accept_edits_config_sets_write_file_always(self) -> None:
+ overrides = BUILTIN_AGENTS[BuiltinAgentName.ACCEPT_EDITS].overrides
+ assert overrides["tools"]["write_file"]["permission"] == "always"
+
+ def test_accept_edits_config_sets_search_replace_always(self) -> None:
+ overrides = BUILTIN_AGENTS[BuiltinAgentName.ACCEPT_EDITS].overrides
+ assert overrides["tools"]["search_replace"]["permission"] == "always"
+
+ @pytest.mark.asyncio
+ async def test_accept_edits_agent_auto_approves_write_file(self) -> None:
+ backend = FakeBackend([])
+
+ config = VibeConfig(
+ session_logging=SessionLoggingConfig(enabled=False),
+ auto_compact_threshold=0,
+ enabled_tools=["write_file"],
+ )
+ agent = AgentLoop(
+ config, agent_name=BuiltinAgentName.ACCEPT_EDITS, backend=backend
+ )
+
+ perm = agent.tool_manager.get_tool_config("write_file").permission
+ assert perm == ToolPermission.ALWAYS
+
+ @pytest.mark.asyncio
+ async def test_accept_edits_agent_requires_approval_for_other_tools(self) -> None:
+ backend = FakeBackend([])
+
+ config = VibeConfig(
+ session_logging=SessionLoggingConfig(enabled=False),
+ auto_compact_threshold=0,
+ enabled_tools=["bash"],
+ )
+ agent = AgentLoop(
+ config, agent_name=BuiltinAgentName.ACCEPT_EDITS, backend=backend
+ )
+
+ perm = agent.tool_manager.get_tool_config("bash").permission
+ assert perm == ToolPermission.ASK
+
+
+class TestPlanAgentToolRestriction:
+ @pytest.mark.asyncio
+ async def test_plan_agent_only_exposes_read_tools_to_llm(self) -> None:
+ backend = FakeBackend([
+ LLMChunk(
+ message=LLMMessage(role=Role.assistant, content="ok"),
+ usage=LLMUsage(prompt_tokens=10, completion_tokens=5),
+ )
+ ])
+ config = VibeConfig(
+ session_logging=SessionLoggingConfig(enabled=False),
+ auto_compact_threshold=0,
+ )
+ agent = AgentLoop(config, agent_name=BuiltinAgentName.PLAN, backend=backend)
+
+ tool_names = set(agent.tool_manager.available_tools.keys())
+
+ assert "bash" not in tool_names
+ assert "write_file" not in tool_names
+ assert "search_replace" not in tool_names
+ for plan_tool in PLAN_AGENT_TOOLS:
+ assert plan_tool in tool_names
+
+ @pytest.mark.asyncio
+ async def test_plan_agent_rejects_non_plan_tool_call(self) -> None:
+ tool_call = ToolCall(
+ id="call_1",
+ index=0,
+ function=FunctionCall(name="bash", arguments='{"command": "ls"}'),
+ )
+ backend = FakeBackend([
+ mock_llm_chunk(content="Let me run bash", tool_calls=[tool_call]),
+ mock_llm_chunk(content="Tool not available"),
+ ])
+
+ config = VibeConfig(
+ session_logging=SessionLoggingConfig(enabled=False),
+ auto_compact_threshold=0,
+ )
+ agent = AgentLoop(config, agent_name=BuiltinAgentName.PLAN, backend=backend)
+
+ events = [ev async for ev in agent.act("Run ls")]
+
+ tool_result = next((e for e in events if isinstance(e, ToolResultEvent)), None)
+ assert tool_result is not None
+ assert tool_result.error is not None
+ assert (
+ "not found" in tool_result.error.lower()
+ or "error" in tool_result.error.lower()
+ )
+
+
+class TestAgentManagerFiltering:
+ def test_enabled_agents_filters_to_only_enabled(self) -> None:
+ config = VibeConfig(
+ include_project_context=False,
+ include_prompt_detail=False,
+ enabled_agents=["default", "plan"],
+ )
+ manager = AgentManager(lambda: config)
+
+ agents = manager.available_agents
+ assert len(agents) < len(manager._available)
+ assert "default" in agents
+ assert "plan" in agents
+ assert "auto-approve" not in agents
+ assert "accept-edits" not in agents
+
+ def test_disabled_agents_excludes_disabled(self) -> None:
+ config = VibeConfig(
+ include_project_context=False,
+ include_prompt_detail=False,
+ disabled_agents=["auto-approve", "accept-edits"],
+ )
+ manager = AgentManager(lambda: config)
+
+ agents = manager.available_agents
+ assert len(agents) < len(manager._available)
+ assert "default" in agents
+ assert "plan" in agents
+ assert "auto-approve" not in agents
+ assert "accept-edits" not in agents
+
+ def test_enabled_agents_takes_precedence_over_disabled(self) -> None:
+ config = VibeConfig(
+ include_project_context=False,
+ include_prompt_detail=False,
+ enabled_agents=["default"],
+ disabled_agents=["default"], # Should be ignored
+ )
+ manager = AgentManager(lambda: config)
+
+ agents = manager.available_agents
+ assert len(agents) == 1
+ assert "default" in agents
+
+ def test_glob_pattern_matching(self) -> None:
+ config = VibeConfig(
+ include_project_context=False,
+ include_prompt_detail=False,
+ disabled_agents=["auto-*", "accept-*"],
+ )
+ manager = AgentManager(lambda: config)
+
+ agents = manager.available_agents
+ assert "default" in agents
+ assert "plan" in agents
+ assert "auto-approve" not in agents
+ assert "accept-edits" not in agents
+
+ def test_regex_pattern_matching(self) -> None:
+ config = VibeConfig(
+ include_project_context=False,
+ include_prompt_detail=False,
+ enabled_agents=["re:^(default|plan)$"],
+ )
+ manager = AgentManager(lambda: config)
+
+ agents = manager.available_agents
+ assert len(agents) == 2
+ assert "default" in agents
+ assert "plan" in agents
+
+ def test_empty_enabled_agents_returns_all(self) -> None:
+ config = VibeConfig(
+ include_project_context=False,
+ include_prompt_detail=False,
+ enabled_agents=[],
+ )
+ manager = AgentManager(lambda: config)
+
+ agents = manager.available_agents
+ assert "default" in agents
+ assert "plan" in agents
+ assert "auto-approve" in agents
+ assert "explore" in agents
+
+ def test_get_subagents_respects_filtering(self) -> None:
+ config = VibeConfig(
+ include_project_context=False,
+ include_prompt_detail=False,
+ disabled_agents=["explore"],
+ )
+ manager = AgentManager(lambda: config)
+
+ subagents = manager.get_subagents()
+ names = [a.name for a in subagents]
+ assert "explore" not in names
diff --git a/tests/test_message_id.py b/tests/test_message_id.py
new file mode 100644
index 0000000..f379782
--- /dev/null
+++ b/tests/test_message_id.py
@@ -0,0 +1,162 @@
+from __future__ import annotations
+
+import json
+from uuid import UUID
+
+from vibe.core.types import (
+ AssistantEvent,
+ LLMMessage,
+ ReasoningEvent,
+ Role,
+ UserMessageEvent,
+)
+
+
+class TestLLMMessageId:
+ def test_user_message_gets_message_id(self) -> None:
+ msg = LLMMessage(role=Role.user, content="Hello")
+ assert msg.message_id is not None
+ UUID(msg.message_id) # Validates it's a valid UUID
+
+ def test_assistant_message_gets_message_id(self) -> None:
+ msg = LLMMessage(role=Role.assistant, content="Hi there")
+ assert msg.message_id is not None
+ UUID(msg.message_id)
+
+ def test_system_message_gets_message_id(self) -> None:
+ msg = LLMMessage(role=Role.system, content="You are helpful")
+ assert msg.message_id is not None
+ UUID(msg.message_id)
+
+ def test_tool_message_does_not_get_message_id(self) -> None:
+ msg = LLMMessage(role=Role.tool, content="result", tool_call_id="tc_123")
+ assert msg.message_id is None
+
+ def test_each_message_gets_unique_id(self) -> None:
+ msg1 = LLMMessage(role=Role.user, content="First")
+ msg2 = LLMMessage(role=Role.user, content="Second")
+ assert msg1.message_id != msg2.message_id
+
+ def test_message_id_preserved_from_dict(self) -> None:
+ expected_id = "custom-message-id-123"
+ msg = LLMMessage.model_validate({
+ "role": "user",
+ "content": "Hello",
+ "message_id": expected_id,
+ })
+ assert msg.message_id == expected_id
+
+ def test_message_id_preserved_for_tool_from_dict(self) -> None:
+ expected_id = "tool-message-id"
+ msg = LLMMessage.model_validate({
+ "role": "tool",
+ "content": "result",
+ "tool_call_id": "tc_123",
+ "message_id": expected_id,
+ })
+ assert msg.message_id == expected_id
+
+ def test_tool_message_no_id_from_dict_without_id(self) -> None:
+ msg = LLMMessage.model_validate({
+ "role": "tool",
+ "content": "result",
+ "tool_call_id": "tc_123",
+ })
+ assert msg.message_id is None
+
+
+class TestLLMMessageAccumulation:
+ def test_message_id_preserved_on_add(self) -> None:
+ msg1 = LLMMessage(role=Role.assistant, content="Hello")
+ msg2 = LLMMessage(role=Role.assistant, content=" world")
+
+ result = msg1 + msg2
+
+ assert result.message_id == msg1.message_id
+ assert result.content == "Hello world"
+
+ def test_message_id_preserved_after_multiple_adds(self) -> None:
+ msg1 = LLMMessage(role=Role.assistant, content="A")
+ msg2 = LLMMessage(role=Role.assistant, content="B")
+ msg3 = LLMMessage(role=Role.assistant, content="C")
+
+ result = msg1 + msg2 + msg3
+
+ assert result.message_id == msg1.message_id
+ assert result.content == "ABC"
+
+
+class TestEventMessageId:
+ def test_user_message_event_has_message_id(self) -> None:
+ event = UserMessageEvent(content="Hello", message_id="user-msg-id")
+ assert event.message_id == "user-msg-id"
+ assert event.content == "Hello"
+
+ def test_assistant_event_has_message_id(self) -> None:
+ event = AssistantEvent(content="test", message_id="test-id")
+ assert event.message_id == "test-id"
+
+ def test_assistant_event_message_id_optional(self) -> None:
+ event = AssistantEvent(content="test")
+ assert event.message_id is None
+
+ def test_reasoning_event_has_message_id(self) -> None:
+ event = ReasoningEvent(content="thinking...", message_id="reason-id")
+ assert event.message_id == "reason-id"
+
+ def test_reasoning_event_message_id_optional(self) -> None:
+ event = ReasoningEvent(content="thinking...")
+ assert event.message_id is None
+
+ def test_assistant_event_add_preserves_message_id(self) -> None:
+ event1 = AssistantEvent(content="Hello", message_id="first-id")
+ event2 = AssistantEvent(content=" world", message_id="second-id")
+
+ result = event1 + event2
+
+ assert result.message_id == "first-id"
+ assert result.content == "Hello world"
+
+
+class TestMessageIdExcludedFromAPI:
+ def test_message_id_excluded_with_exclude_param(self) -> None:
+ msg = LLMMessage(role=Role.user, content="Hello")
+ dumped = msg.model_dump(exclude_none=True, exclude={"message_id"})
+
+ assert "message_id" not in dumped
+ assert dumped["role"] == "user"
+ assert dumped["content"] == "Hello"
+
+ def test_message_id_included_in_normal_dump(self) -> None:
+ msg = LLMMessage(role=Role.user, content="Hello")
+ dumped = msg.model_dump(exclude_none=True)
+
+ assert "message_id" in dumped
+ assert dumped["message_id"] == msg.message_id
+
+
+class TestMessageIdInLogs:
+ def test_message_id_in_json_dump(self) -> None:
+ msg = LLMMessage(role=Role.assistant, content="Response")
+ dumped = msg.model_dump(exclude_none=True)
+
+ json_str = json.dumps(dumped)
+ loaded = json.loads(json_str)
+
+ assert "message_id" in loaded
+ assert loaded["message_id"] == msg.message_id
+
+ def test_message_id_roundtrip(self) -> None:
+ original = LLMMessage(role=Role.user, content="Test")
+ original_id = original.message_id
+
+ dumped = original.model_dump(exclude_none=True)
+ restored = LLMMessage.model_validate(dumped)
+
+ assert restored.message_id == original_id
+
+ def test_tool_message_id_none_in_json(self) -> None:
+ msg = LLMMessage(role=Role.tool, content="result", tool_call_id="tc_1")
+ dumped = msg.model_dump(exclude_none=True)
+
+ assert "message_id" not in dumped
diff --git a/tests/test_middleware.py b/tests/test_middleware.py
index cd7b76d..eb84b04 100644
--- a/tests/test_middleware.py
+++ b/tests/test_middleware.py
@@ -2,15 +2,15 @@ from __future__ import annotations
import pytest
+from vibe.core.agents.models import BUILTIN_AGENTS, AgentProfile, BuiltinAgentName
from vibe.core.config import SessionLoggingConfig, VibeConfig
from vibe.core.middleware import (
- PLAN_MODE_REMINDER,
+ PLAN_AGENT_REMINDER,
ConversationContext,
MiddlewareAction,
MiddlewarePipeline,
- PlanModeMiddleware,
+ PlanAgentMiddleware,
)
-from vibe.core.modes import AgentMode
from vibe.core.types import AgentStats
@@ -19,20 +19,22 @@ def make_context() -> ConversationContext:
return ConversationContext(messages=[], stats=AgentStats(), config=config)
-class TestPlanModeMiddleware:
+class TestPlanAgentMiddleware:
@pytest.mark.asyncio
- async def test_injects_reminder_when_plan_mode_active(self) -> None:
- middleware = PlanModeMiddleware(lambda: AgentMode.PLAN)
+ async def test_injects_reminder_when_plan_agent_active(self) -> None:
+ middleware = PlanAgentMiddleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN])
ctx = make_context()
result = await middleware.before_turn(ctx)
assert result.action == MiddlewareAction.INJECT_MESSAGE
- assert result.message == PLAN_MODE_REMINDER
+ assert result.message == PLAN_AGENT_REMINDER
@pytest.mark.asyncio
- async def test_does_not_inject_when_default_mode(self) -> None:
- middleware = PlanModeMiddleware(lambda: AgentMode.DEFAULT)
+ async def test_does_not_inject_when_default_agent(self) -> None:
+ middleware = PlanAgentMiddleware(
+ lambda: BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
+ )
ctx = make_context()
result = await middleware.before_turn(ctx)
@@ -41,8 +43,10 @@ class TestPlanModeMiddleware:
assert result.message is None
@pytest.mark.asyncio
- async def test_does_not_inject_when_auto_approve_mode(self) -> None:
- middleware = PlanModeMiddleware(lambda: AgentMode.AUTO_APPROVE)
+ async def test_does_not_inject_when_auto_approve_agent(self) -> None:
+ middleware = PlanAgentMiddleware(
+ lambda: BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE]
+ )
ctx = make_context()
result = await middleware.before_turn(ctx)
@@ -51,8 +55,10 @@ class TestPlanModeMiddleware:
assert result.message is None
@pytest.mark.asyncio
- async def test_does_not_inject_when_accept_edits_mode(self) -> None:
- middleware = PlanModeMiddleware(lambda: AgentMode.ACCEPT_EDITS)
+ async def test_does_not_inject_when_accept_edits_agent(self) -> None:
+ middleware = PlanAgentMiddleware(
+ lambda: BUILTIN_AGENTS[BuiltinAgentName.ACCEPT_EDITS]
+ )
ctx = make_context()
result = await middleware.before_turn(ctx)
@@ -62,7 +68,7 @@ class TestPlanModeMiddleware:
@pytest.mark.asyncio
async def test_after_turn_always_continues(self) -> None:
- middleware = PlanModeMiddleware(lambda: AgentMode.PLAN)
+ middleware = PlanAgentMiddleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN])
ctx = make_context()
result = await middleware.after_turn(ctx)
@@ -70,27 +76,27 @@ class TestPlanModeMiddleware:
assert result.action == MiddlewareAction.CONTINUE
@pytest.mark.asyncio
- async def test_dynamically_checks_mode(self) -> None:
- current_mode = AgentMode.DEFAULT
- middleware = PlanModeMiddleware(lambda: current_mode)
+ async def test_dynamically_checks_agent(self) -> None:
+ current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
+ middleware = PlanAgentMiddleware(lambda: current_profile)
ctx = make_context()
result = await middleware.before_turn(ctx)
assert result.action == MiddlewareAction.CONTINUE
- current_mode = AgentMode.PLAN
+ current_profile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
result = await middleware.before_turn(ctx)
assert result.action == MiddlewareAction.INJECT_MESSAGE
- current_mode = AgentMode.AUTO_APPROVE
+ current_profile = BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE]
result = await middleware.before_turn(ctx)
assert result.action == MiddlewareAction.CONTINUE
@pytest.mark.asyncio
async def test_custom_reminder(self) -> None:
- custom_reminder = "Custom plan mode reminder"
- middleware = PlanModeMiddleware(
- lambda: AgentMode.PLAN, reminder=custom_reminder
+ custom_reminder = "Custom plan agent reminder"
+ middleware = PlanAgentMiddleware(
+ lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN], reminder=custom_reminder
)
ctx = make_context()
@@ -99,26 +105,28 @@ class TestPlanModeMiddleware:
assert result.message == custom_reminder
def test_reset_does_nothing(self) -> None:
- middleware = PlanModeMiddleware(lambda: AgentMode.PLAN)
+ middleware = PlanAgentMiddleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN])
middleware.reset()
-class TestMiddlewarePipelineWithPlanMode:
+class TestMiddlewarePipelineWithPlanAgent:
@pytest.mark.asyncio
- async def test_pipeline_includes_plan_mode_injection(self) -> None:
+ async def test_pipeline_includes_plan_agent_injection(self) -> None:
pipeline = MiddlewarePipeline()
- pipeline.add(PlanModeMiddleware(lambda: AgentMode.PLAN))
+ pipeline.add(PlanAgentMiddleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN]))
ctx = make_context()
result = await pipeline.run_before_turn(ctx)
assert result.action == MiddlewareAction.INJECT_MESSAGE
- assert PLAN_MODE_REMINDER in (result.message or "")
+ assert PLAN_AGENT_REMINDER in (result.message or "")
@pytest.mark.asyncio
- async def test_pipeline_skips_injection_when_not_plan_mode(self) -> None:
+ async def test_pipeline_skips_injection_when_not_plan_agent(self) -> None:
pipeline = MiddlewarePipeline()
- pipeline.add(PlanModeMiddleware(lambda: AgentMode.DEFAULT))
+ pipeline.add(
+ PlanAgentMiddleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.DEFAULT])
+ )
ctx = make_context()
result = await pipeline.run_before_turn(ctx)
diff --git a/tests/test_modes.py b/tests/test_modes.py
deleted file mode 100644
index 7ccd086..0000000
--- a/tests/test_modes.py
+++ /dev/null
@@ -1,322 +0,0 @@
-from __future__ import annotations
-
-import pytest
-
-from tests.mock.utils import mock_llm_chunk
-from tests.stubs.fake_backend import FakeBackend
-from vibe.core.agent import Agent
-from vibe.core.config import SessionLoggingConfig, VibeConfig
-from vibe.core.llm.format import get_active_tool_classes
-from vibe.core.modes import (
- MODE_CONFIGS,
- PLAN_MODE_TOOLS,
- AgentMode,
- ModeConfig,
- ModeSafety,
- get_mode_order,
- next_mode,
-)
-from vibe.core.tools.base import ToolPermission
-from vibe.core.types import (
- FunctionCall,
- LLMChunk,
- LLMMessage,
- LLMUsage,
- Role,
- ToolCall,
- ToolResultEvent,
-)
-
-
-class TestModeSafety:
- def test_safety_enum_values(self) -> None:
- assert ModeSafety.SAFE == "safe"
- assert ModeSafety.NEUTRAL == "neutral"
- assert ModeSafety.DESTRUCTIVE == "destructive"
- assert ModeSafety.YOLO == "yolo"
-
- def test_default_mode_is_neutral(self) -> None:
- assert AgentMode.DEFAULT.safety == ModeSafety.NEUTRAL
-
- def test_auto_approve_mode_is_yolo(self) -> None:
- assert AgentMode.AUTO_APPROVE.safety == ModeSafety.YOLO
-
- def test_plan_mode_is_safe(self) -> None:
- assert AgentMode.PLAN.safety == ModeSafety.SAFE
-
- def test_accept_edits_mode_is_destructive(self) -> None:
- assert AgentMode.ACCEPT_EDITS.safety == ModeSafety.DESTRUCTIVE
-
-
-class TestAgentMode:
- def test_all_modes_have_configs(self) -> None:
- for mode in AgentMode:
- assert mode in MODE_CONFIGS
-
- def test_display_name_property(self) -> None:
- assert AgentMode.DEFAULT.display_name == "Default"
- assert AgentMode.AUTO_APPROVE.display_name == "Auto Approve"
- assert AgentMode.PLAN.display_name == "Plan"
- assert AgentMode.ACCEPT_EDITS.display_name == "Accept Edits"
-
- def test_description_property(self) -> None:
- assert "approval" in AgentMode.DEFAULT.description.lower()
- assert "auto" in AgentMode.AUTO_APPROVE.description.lower()
- assert "read-only" in AgentMode.PLAN.description.lower()
- assert "edits" in AgentMode.ACCEPT_EDITS.description.lower()
-
- def test_auto_approve_property(self) -> None:
- assert AgentMode.DEFAULT.auto_approve is False
- assert AgentMode.AUTO_APPROVE.auto_approve is True
- assert AgentMode.PLAN.auto_approve is True
- assert AgentMode.ACCEPT_EDITS.auto_approve is False
-
- def test_from_string_valid(self) -> None:
- assert AgentMode.from_string("default") == AgentMode.DEFAULT
- assert AgentMode.from_string("AUTO_APPROVE") == AgentMode.AUTO_APPROVE
- assert AgentMode.from_string("Plan") == AgentMode.PLAN
- assert AgentMode.from_string("accept_edits") == AgentMode.ACCEPT_EDITS
-
- def test_from_string_invalid(self) -> None:
- assert AgentMode.from_string("invalid") is None
- assert AgentMode.from_string("") is None
-
-
-class TestModeConfigOverrides:
- def test_default_mode_has_no_overrides(self) -> None:
- assert AgentMode.DEFAULT.config_overrides == {}
-
- def test_auto_approve_mode_has_no_overrides(self) -> None:
- assert AgentMode.AUTO_APPROVE.config_overrides == {}
-
- def test_plan_mode_restricts_tools(self) -> None:
- overrides = AgentMode.PLAN.config_overrides
- assert "enabled_tools" in overrides
- assert overrides["enabled_tools"] == PLAN_MODE_TOOLS
-
- def test_accept_edits_mode_sets_tool_permissions(self) -> None:
- overrides = AgentMode.ACCEPT_EDITS.config_overrides
- assert "tools" in overrides
- tools_config = overrides["tools"]
- assert "write_file" in tools_config
- assert "search_replace" in tools_config
- assert tools_config["write_file"]["permission"] == "always"
- assert tools_config["search_replace"]["permission"] == "always"
-
-
-class TestModeCycling:
- def test_get_mode_order_includes_all_modes(self) -> None:
- order = get_mode_order()
- assert len(order) == 4
- assert AgentMode.DEFAULT in order
- assert AgentMode.AUTO_APPROVE in order
- assert AgentMode.PLAN in order
- assert AgentMode.ACCEPT_EDITS in order
-
- def test_next_mode_cycles_through_all(self) -> None:
- order = get_mode_order()
- current = order[0]
- visited = [current]
- for _ in range(len(order) - 1):
- current = next_mode(current)
- visited.append(current)
- assert len(set(visited)) == len(order)
-
- def test_next_mode_wraps_around(self) -> None:
- order = get_mode_order()
- last_mode = order[-1]
- first_mode = order[0]
- assert next_mode(last_mode) == first_mode
-
-
-class TestModeConfig:
- def test_mode_config_defaults(self) -> None:
- config = ModeConfig(display_name="Test", description="Test mode")
- assert config.safety == ModeSafety.NEUTRAL
- assert config.auto_approve is False
- assert config.config_overrides == {}
-
- def test_mode_config_frozen(self) -> None:
- config = ModeConfig(display_name="Test", description="Test mode")
- with pytest.raises(AttributeError):
- config.display_name = "Changed" # pyright: ignore[reportAttributeAccessIssue]
-
-
-class TestAgentSwitchMode:
- @pytest.fixture
- def base_config(self) -> VibeConfig:
- return VibeConfig(
- session_logging=SessionLoggingConfig(enabled=False),
- auto_compact_threshold=0,
- include_project_context=False,
- include_prompt_detail=False,
- )
-
- @pytest.fixture
- def backend(self) -> FakeBackend:
- return FakeBackend([
- LLMChunk(
- message=LLMMessage(role=Role.assistant, content="Test response"),
- usage=LLMUsage(prompt_tokens=10, completion_tokens=5),
- )
- ])
-
- @pytest.mark.asyncio
- async def test_switch_to_plan_mode_restricts_tools(
- self, base_config: VibeConfig, backend: FakeBackend
- ) -> None:
- agent = Agent(base_config, mode=AgentMode.DEFAULT, backend=backend)
- initial_tools = get_active_tool_classes(agent.tool_manager, agent.config)
- initial_tool_names = {t.get_name() for t in initial_tools}
- assert len(initial_tool_names) > len(PLAN_MODE_TOOLS)
-
- await agent.switch_mode(AgentMode.PLAN)
-
- plan_tools = get_active_tool_classes(agent.tool_manager, agent.config)
- plan_tool_names = {t.get_name() for t in plan_tools}
- assert plan_tool_names == set(PLAN_MODE_TOOLS)
- assert agent.mode == AgentMode.PLAN
-
- @pytest.mark.asyncio
- async def test_switch_from_plan_to_normal_restores_tools(
- self, base_config: VibeConfig, backend: FakeBackend
- ) -> None:
- plan_config = VibeConfig.model_validate({
- **base_config.model_dump(),
- **AgentMode.PLAN.config_overrides,
- })
- agent = Agent(plan_config, mode=AgentMode.PLAN, backend=backend)
- plan_tools = get_active_tool_classes(agent.tool_manager, agent.config)
- assert len(plan_tools) == len(PLAN_MODE_TOOLS)
-
- await agent.switch_mode(AgentMode.DEFAULT)
-
- normal_tools = get_active_tool_classes(agent.tool_manager, agent.config)
- assert len(normal_tools) > len(PLAN_MODE_TOOLS)
- assert agent.mode == AgentMode.DEFAULT
-
- @pytest.mark.asyncio
- async def test_switch_mode_preserves_conversation_history(
- self, base_config: VibeConfig, backend: FakeBackend
- ) -> None:
- agent = Agent(base_config, mode=AgentMode.DEFAULT, backend=backend)
- user_msg = LLMMessage(role=Role.user, content="Hello")
- assistant_msg = LLMMessage(role=Role.assistant, content="Hi there")
- agent.messages.append(user_msg)
- agent.messages.append(assistant_msg)
-
- await agent.switch_mode(AgentMode.PLAN)
-
- assert len(agent.messages) == 3 # system + user + assistant
- assert agent.messages[1].content == "Hello"
- assert agent.messages[2].content == "Hi there"
-
- @pytest.mark.asyncio
- async def test_switch_to_same_mode_is_noop(
- self, base_config: VibeConfig, backend: FakeBackend
- ) -> None:
- agent = Agent(base_config, mode=AgentMode.DEFAULT, backend=backend)
- original_config = agent.config
-
- await agent.switch_mode(AgentMode.DEFAULT)
-
- assert agent.config is original_config
- assert agent.mode == AgentMode.DEFAULT
-
-
-class TestAcceptEditsMode:
- def test_accept_edits_config_sets_write_file_always(self) -> None:
- overrides = AgentMode.ACCEPT_EDITS.config_overrides
- assert overrides["tools"]["write_file"]["permission"] == "always"
-
- def test_accept_edits_config_sets_search_replace_always(self) -> None:
- overrides = AgentMode.ACCEPT_EDITS.config_overrides
- assert overrides["tools"]["search_replace"]["permission"] == "always"
-
- def test_accept_edits_mode_not_auto_approve(self) -> None:
- assert AgentMode.ACCEPT_EDITS.auto_approve is False
-
- @pytest.mark.asyncio
- async def test_accept_edits_mode_auto_approves_write_file(self) -> None:
- backend = FakeBackend([])
-
- config = VibeConfig(
- session_logging=SessionLoggingConfig(enabled=False),
- auto_compact_threshold=0,
- enabled_tools=["write_file"],
- **AgentMode.ACCEPT_EDITS.config_overrides,
- )
- agent = Agent(config, mode=AgentMode.ACCEPT_EDITS, backend=backend)
-
- perm = agent.tool_manager.get_tool_config("write_file").permission
- assert perm == ToolPermission.ALWAYS
-
- @pytest.mark.asyncio
- async def test_accept_edits_mode_requires_approval_for_other_tools(self) -> None:
- backend = FakeBackend([])
-
- config = VibeConfig(
- session_logging=SessionLoggingConfig(enabled=False),
- auto_compact_threshold=0,
- enabled_tools=["bash"],
- **AgentMode.ACCEPT_EDITS.config_overrides,
- )
- agent = Agent(config, mode=AgentMode.ACCEPT_EDITS, backend=backend)
-
- perm = agent.tool_manager.get_tool_config("bash").permission
- assert perm == ToolPermission.ASK
-
-
-class TestPlanModeToolRestriction:
- @pytest.mark.asyncio
- async def test_plan_mode_only_exposes_read_tools_to_llm(self) -> None:
- backend = FakeBackend([
- LLMChunk(
- message=LLMMessage(role=Role.assistant, content="ok"),
- usage=LLMUsage(prompt_tokens=10, completion_tokens=5),
- )
- ])
- config = VibeConfig(
- session_logging=SessionLoggingConfig(enabled=False),
- auto_compact_threshold=0,
- **AgentMode.PLAN.config_overrides,
- )
- agent = Agent(config, mode=AgentMode.PLAN, backend=backend)
-
- active_tools = get_active_tool_classes(agent.tool_manager, agent.config)
- tool_names = {t.get_name() for t in active_tools}
-
- assert "bash" not in tool_names
- assert "write_file" not in tool_names
- assert "search_replace" not in tool_names
- for plan_tool in PLAN_MODE_TOOLS:
- assert plan_tool in tool_names
-
- @pytest.mark.asyncio
- async def test_plan_mode_rejects_non_plan_tool_call(self) -> None:
- tool_call = ToolCall(
- id="call_1",
- index=0,
- function=FunctionCall(name="bash", arguments='{"command": "ls"}'),
- )
- backend = FakeBackend([
- mock_llm_chunk(content="Let me run bash", tool_calls=[tool_call]),
- mock_llm_chunk(content="Tool not available"),
- ])
-
- config = VibeConfig(
- session_logging=SessionLoggingConfig(enabled=False),
- auto_compact_threshold=0,
- **AgentMode.PLAN.config_overrides,
- )
- agent = Agent(config, mode=AgentMode.PLAN, backend=backend)
-
- events = [ev async for ev in agent.act("Run ls")]
-
- tool_result = next((e for e in events if isinstance(e, ToolResultEvent)), None)
- assert tool_result is not None
- assert tool_result.error is not None
- assert (
- "not found" in tool_result.error.lower()
- or "error" in tool_result.error.lower()
- )
diff --git a/tests/test_reasoning_content.py b/tests/test_reasoning_content.py
index 697eb31..3154b5f 100644
--- a/tests/test_reasoning_content.py
+++ b/tests/test_reasoning_content.py
@@ -9,7 +9,7 @@ import respx
from tests.mock.utils import mock_llm_chunk
from tests.stubs.fake_backend import FakeBackend
-from vibe.core.agent import Agent
+from vibe.core.agent_loop import AgentLoop
from vibe.core.config import (
ModelConfig,
ProviderConfig,
@@ -294,7 +294,7 @@ class TestAPIToolFormatHandlerReasoningContent:
assert result.reasoning_content is None
-class TestAgentStreamingReasoningEvents:
+class TestAgentLoopStreamingReasoningEvents:
@pytest.mark.asyncio
async def test_streaming_accumulates_reasoning_in_message(self):
backend = FakeBackend([
@@ -302,7 +302,7 @@ class TestAgentStreamingReasoningEvents:
mock_llm_chunk(content="", reasoning_content="Second thought."),
mock_llm_chunk(content="Final answer."),
])
- agent = Agent(make_config(), backend=backend, enable_streaming=True)
+ agent = AgentLoop(make_config(), backend=backend, enable_streaming=True)
[_ async for _ in agent.act("Think and answer")]
@@ -316,7 +316,7 @@ class TestAgentStreamingReasoningEvents:
mock_llm_chunk(content="Hello "),
mock_llm_chunk(content="world!"),
])
- agent = Agent(make_config(), backend=backend, enable_streaming=True)
+ agent = AgentLoop(make_config(), backend=backend, enable_streaming=True)
events = [event async for event in agent.act("Say hello")]
diff --git a/tests/test_system_prompt.py b/tests/test_system_prompt.py
index b0bd51e..7dcc6ed 100644
--- a/tests/test_system_prompt.py
+++ b/tests/test_system_prompt.py
@@ -4,7 +4,9 @@ import sys
import pytest
+from vibe.core.agents import AgentManager
from vibe.core.config import VibeConfig
+from vibe.core.skills.manager import SkillManager
from vibe.core.system_prompt import get_universal_system_prompt
from vibe.core.tools.manager import ToolManager
@@ -23,8 +25,12 @@ def test_get_universal_system_prompt_includes_windows_prompt_on_windows(
include_commit_signature=False,
)
tool_manager = ToolManager(lambda: config)
+ skill_manager = SkillManager(lambda: config)
+ agent_manager = AgentManager(lambda: config)
- prompt = get_universal_system_prompt(tool_manager, config)
+ prompt = get_universal_system_prompt(
+ tool_manager, config, skill_manager, agent_manager
+ )
assert "You are Vibe, a super useful programming assistant." in prompt
assert (
diff --git a/tests/test_ui_external_editor.py b/tests/test_ui_external_editor.py
new file mode 100644
index 0000000..4d9a543
--- /dev/null
+++ b/tests/test_ui_external_editor.py
@@ -0,0 +1,82 @@
+"""Tests for the external editor UI integration (Ctrl+G keybind)."""
+
+from __future__ import annotations
+
+from contextlib import contextmanager
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from vibe.cli.textual_ui.app import VibeApp
+from vibe.cli.textual_ui.widgets.chat_input.container import ChatInputContainer
+from vibe.core.agent_loop import AgentLoop
+from vibe.core.config import SessionLoggingConfig, VibeConfig
+
+
+@pytest.fixture
+def vibe_config() -> VibeConfig:
+ return VibeConfig(session_logging=SessionLoggingConfig(enabled=False))
+
+
+@pytest.fixture
+def vibe_app(vibe_config: VibeConfig) -> VibeApp:
+ agent_loop = AgentLoop(vibe_config)
+ return VibeApp(agent_loop=agent_loop)
+
+
+@contextmanager
+def mock_suspend():
+ """Mock context manager to replace app.suspend()."""
+ yield
+
+
+@pytest.mark.asyncio
+async def test_ctrl_g_opens_external_editor_and_updates_input(
+ vibe_app: VibeApp,
+) -> None:
+ """Test that Ctrl+G triggers external editor and updates input with result."""
+ with patch(
+ "vibe.cli.textual_ui.widgets.chat_input.text_area.ExternalEditor"
+ ) as MockEditor:
+ mock_instance = MagicMock()
+ mock_instance.is_available.return_value = True
+ mock_instance.edit.return_value = "edited content"
+ MockEditor.return_value = mock_instance
+
+ async with vibe_app.run_test() as pilot:
+ chat_input = vibe_app.query_one(ChatInputContainer)
+ chat_input.value = "original"
+ chat_input.focus_input()
+ await pilot.pause()
+
+ with patch.object(vibe_app, "suspend", mock_suspend):
+ await pilot.press("ctrl+g")
+ await pilot.pause()
+
+ mock_instance.edit.assert_called_once_with("original")
+ assert chat_input.value == "edited content"
+
+
+@pytest.mark.asyncio
+async def test_ctrl_g_works_with_empty_input(vibe_app: VibeApp) -> None:
+ """Test that Ctrl+G works when input is empty."""
+ with patch(
+ "vibe.cli.textual_ui.widgets.chat_input.text_area.ExternalEditor"
+ ) as MockEditor:
+ mock_instance = MagicMock()
+ mock_instance.is_available.return_value = True
+ mock_instance.edit.return_value = "new content"
+ MockEditor.return_value = mock_instance
+
+ async with vibe_app.run_test() as pilot:
+ chat_input = vibe_app.query_one(ChatInputContainer)
+ assert chat_input.value == ""
+ chat_input.focus_input()
+ await pilot.pause()
+
+ with patch.object(vibe_app, "suspend", mock_suspend):
+ await pilot.press("ctrl+g")
+ await pilot.pause()
+
+ mock_instance.edit.assert_called_once_with("")
+ assert chat_input.value == "new content"
diff --git a/tests/test_ui_input_history.py b/tests/test_ui_input_history.py
index 854ba23..cc2c4bb 100644
--- a/tests/test_ui_input_history.py
+++ b/tests/test_ui_input_history.py
@@ -9,6 +9,7 @@ from vibe.cli.history_manager import HistoryManager
from vibe.cli.textual_ui.app import VibeApp
from vibe.cli.textual_ui.widgets.chat_input.body import ChatInputBody
from vibe.cli.textual_ui.widgets.chat_input.container import ChatInputContainer
+from vibe.core.agent_loop import AgentLoop
from vibe.core.config import SessionLoggingConfig, VibeConfig
@@ -18,8 +19,9 @@ def vibe_config() -> VibeConfig:
@pytest.fixture
-def vibe_app(vibe_config: VibeConfig, tmp_path: Path) -> VibeApp:
- return VibeApp(config=vibe_config)
+def vibe_app(vibe_config: VibeConfig) -> VibeApp:
+ agent_loop = AgentLoop(vibe_config)
+ return VibeApp(agent_loop=agent_loop)
@pytest.fixture
diff --git a/tests/test_ui_pending_user_message.py b/tests/test_ui_pending_user_message.py
deleted file mode 100644
index dcf0a02..0000000
--- a/tests/test_ui_pending_user_message.py
+++ /dev/null
@@ -1,161 +0,0 @@
-from __future__ import annotations
-
-import asyncio
-from collections.abc import AsyncGenerator, Callable
-import time
-from types import SimpleNamespace
-
-import pytest
-
-from vibe.cli.textual_ui.app import VibeApp
-from vibe.cli.textual_ui.widgets.chat_input.container import ChatInputContainer
-from vibe.cli.textual_ui.widgets.messages import InterruptMessage, UserMessage
-from vibe.core.agent import Agent
-from vibe.core.config import SessionLoggingConfig, VibeConfig
-from vibe.core.types import BaseEvent
-
-
-async def _wait_for(
- pilot, condition: Callable[[], object | None], timeout: float = 3.0
-) -> object | None:
- deadline = time.monotonic() + timeout
- while time.monotonic() < deadline:
- result = condition()
- if result:
- return result
- await pilot.pause(0.05)
- return None
-
-
-class StubAgent(Agent):
- def __init__(self) -> None:
- self.messages: list = []
- self.stats = SimpleNamespace(context_tokens=0)
- self.approval_callback = None
-
- async def initialize(self) -> None:
- return
-
- async def act(self, msg: str) -> AsyncGenerator[BaseEvent]:
- if False:
- yield msg
-
-
-@pytest.fixture
-def vibe_config() -> VibeConfig:
- return VibeConfig(
- session_logging=SessionLoggingConfig(enabled=False), enable_update_checks=False
- )
-
-
-@pytest.fixture
-def vibe_app(vibe_config: VibeConfig) -> VibeApp:
- return VibeApp(config=vibe_config)
-
-
-def _patch_delayed_init(
- monkeypatch: pytest.MonkeyPatch, init_event: asyncio.Event
-) -> None:
- async def _fake_initialize(self: VibeApp) -> None:
- if self.agent or self._agent_initializing:
- return
-
- self._agent_initializing = True
- try:
- await init_event.wait()
- self.agent = StubAgent()
- except asyncio.CancelledError:
- self.agent = None
- return
- finally:
- self._agent_initializing = False
- self._agent_init_task = None
-
- monkeypatch.setattr(VibeApp, "_initialize_agent", _fake_initialize, raising=True)
-
-
-@pytest.mark.asyncio
-async def test_shows_user_message_as_pending_until_agent_is_initialized(
- vibe_app: VibeApp, monkeypatch: pytest.MonkeyPatch
-) -> None:
- init_event = asyncio.Event()
- _patch_delayed_init(monkeypatch, init_event)
-
- async with vibe_app.run_test() as pilot:
- chat_input = vibe_app.query_one(ChatInputContainer)
- chat_input.value = "Hello"
-
- press_task = asyncio.create_task(pilot.press("enter"))
-
- user_message = await _wait_for(
- pilot, lambda: next(iter(vibe_app.query(UserMessage)), None)
- )
- assert isinstance(user_message, UserMessage)
- assert user_message.has_class("pending")
- init_event.set()
- await press_task
- assert not user_message.has_class("pending")
-
-
-@pytest.mark.asyncio
-async def test_can_interrupt_pending_message_during_initialization(
- vibe_app: VibeApp, monkeypatch: pytest.MonkeyPatch
-) -> None:
- init_event = asyncio.Event()
- _patch_delayed_init(monkeypatch, init_event)
-
- async with vibe_app.run_test() as pilot:
- chat_input = vibe_app.query_one(ChatInputContainer)
- chat_input.value = "Hello"
-
- press_task = asyncio.create_task(pilot.press("enter"))
-
- user_message = await _wait_for(
- pilot, lambda: next(iter(vibe_app.query(UserMessage)), None)
- )
- assert isinstance(user_message, UserMessage)
- assert user_message.has_class("pending")
-
- await pilot.press("escape")
- await press_task
- assert not user_message.has_class("pending")
- assert vibe_app.query(InterruptMessage)
- assert vibe_app.agent is None
-
-
-@pytest.mark.asyncio
-async def test_retry_initialization_after_interrupt(
- vibe_app: VibeApp, monkeypatch: pytest.MonkeyPatch
-) -> None:
- init_event = asyncio.Event()
- _patch_delayed_init(monkeypatch, init_event)
-
- async with vibe_app.run_test() as pilot:
- chat_input = vibe_app.query_one(ChatInputContainer)
- chat_input.value = "First Message"
- press_task = asyncio.create_task(pilot.press("enter"))
-
- await _wait_for(pilot, lambda: next(iter(vibe_app.query(UserMessage)), None))
- await pilot.press("escape")
- await press_task
- assert vibe_app.agent is None
- assert vibe_app._agent_init_task is None
-
- chat_input.value = "Second Message"
- press_task_2 = asyncio.create_task(pilot.press("enter"))
-
- def get_second_message():
- messages = list(vibe_app.query(UserMessage))
- if len(messages) >= 2:
- return messages[-1]
- return None
-
- user_message_2 = await _wait_for(pilot, get_second_message)
- assert isinstance(user_message_2, UserMessage)
- assert user_message_2.has_class("pending")
- assert vibe_app.agent is None
-
- init_event.set()
- await press_task_2
- assert not user_message_2.has_class("pending")
- assert vibe_app.agent is not None
diff --git a/tests/tools/test_ask_user_question.py b/tests/tools/test_ask_user_question.py
new file mode 100644
index 0000000..3315e42
--- /dev/null
+++ b/tests/tools/test_ask_user_question.py
@@ -0,0 +1,188 @@
+from __future__ import annotations
+
+import pytest
+
+from vibe.core.tools.base import BaseToolState, ToolError
+from vibe.core.tools.builtins.ask_user_question import (
+ Answer,
+ AskUserQuestion,
+ AskUserQuestionArgs,
+ AskUserQuestionConfig,
+ AskUserQuestionResult,
+ Choice,
+ Question,
+)
+from vibe.core.types import ToolCallEvent, ToolResultEvent
+
+
+@pytest.fixture
+def tool():
+ config = AskUserQuestionConfig()
+ return AskUserQuestion(config=config, state=BaseToolState())
+
+
+@pytest.fixture
+def single_question_args():
+ return AskUserQuestionArgs(
+ questions=[
+ Question(
+ question="Which database?",
+ header="DB",
+ options=[
+ Choice(label="PostgreSQL", description="Relational DB"),
+ Choice(label="MongoDB", description="Document DB"),
+ ],
+ )
+ ]
+ )
+
+
+@pytest.fixture
+def multi_question_args():
+ return AskUserQuestionArgs(
+ questions=[
+ Question(
+ question="Which database?",
+ header="DB",
+ options=[Choice(label="PostgreSQL"), Choice(label="MongoDB")],
+ ),
+ Question(
+ question="Which framework?",
+ header="Framework",
+ options=[Choice(label="FastAPI"), Choice(label="Django")],
+ ),
+ ]
+ )
+
+
+async def run_tool_with_callback(tool, args, callback):
+ from vibe.core.tools.base import InvokeContext
+
+ ctx = InvokeContext(user_input_callback=callback, tool_call_id="123")
+ result = None
+ async for item in tool.run(args, ctx):
+ result = item
+ return result
+
+
+@pytest.mark.asyncio
+async def test_raises_error_without_callback(tool, single_question_args):
+ with pytest.raises(ToolError) as err:
+ async for _ in tool.run(single_question_args, ctx=None):
+ pass
+
+ assert "interactive UI" in str(err.value)
+
+
+@pytest.mark.asyncio
+async def test_calls_callback_and_returns_result(tool, single_question_args):
+ expected_result = AskUserQuestionResult(
+ answers=[
+ Answer(question="Which database?", answer="PostgreSQL", is_other=False)
+ ],
+ cancelled=False,
+ )
+
+ async def mock_callback(args):
+ assert args == single_question_args
+ return expected_result
+
+ result = await run_tool_with_callback(tool, single_question_args, mock_callback)
+
+ assert result is not None
+ assert result == expected_result
+ assert result.answers[0].answer == "PostgreSQL"
+
+
+@pytest.mark.asyncio
+async def test_handles_cancelled_result(tool, single_question_args):
+ expected_result = AskUserQuestionResult(answers=[], cancelled=True)
+
+ async def mock_callback(args):
+ return expected_result
+
+ result = await run_tool_with_callback(tool, single_question_args, mock_callback)
+
+ assert result is not None
+ assert result.cancelled is True
+ assert len(result.answers) == 0
+
+
+@pytest.mark.asyncio
+async def test_handles_other_response(tool, single_question_args):
+ expected_result = AskUserQuestionResult(
+ answers=[Answer(question="Which database?", answer="SQLite", is_other=True)],
+ cancelled=False,
+ )
+
+ async def mock_callback(args):
+ return expected_result
+
+ result = await run_tool_with_callback(tool, single_question_args, mock_callback)
+
+ assert result is not None
+ assert result.answers[0].is_other is True
+ assert result.answers[0].answer == "SQLite"
+
+
+class TestToolUIDisplay:
+ def test_get_call_display_single_question(self, single_question_args):
+ event = ToolCallEvent(
+ tool_name="ask_user_question",
+ tool_class=AskUserQuestion,
+ args=single_question_args,
+ tool_call_id="123",
+ )
+ display = AskUserQuestion.get_call_display(event)
+ assert "Which database?" in display.summary
+
+ def test_get_call_display_multiple_questions(self, multi_question_args):
+ event = ToolCallEvent(
+ tool_name="ask_user_question",
+ tool_class=AskUserQuestion,
+ args=multi_question_args,
+ tool_call_id="123",
+ )
+ display = AskUserQuestion.get_call_display(event)
+ assert "2 questions" in display.summary
+
+ def test_get_result_display_success(self):
+ result = AskUserQuestionResult(
+ answers=[Answer(question="Q?", answer="A", is_other=False)], cancelled=False
+ )
+ event = ToolResultEvent(
+ tool_name="ask_user_question",
+ tool_class=AskUserQuestion,
+ result=result,
+ tool_call_id="123",
+ )
+ display = AskUserQuestion.get_result_display(event)
+ assert display.success is True
+ assert "A" in display.message
+
+ def test_get_result_display_cancelled(self):
+ result = AskUserQuestionResult(answers=[], cancelled=True)
+ event = ToolResultEvent(
+ tool_name="ask_user_question",
+ tool_class=AskUserQuestion,
+ result=result,
+ tool_call_id="123",
+ )
+ display = AskUserQuestion.get_result_display(event)
+ assert display.success is False
+ assert "cancelled" in display.message.lower()
+
+ def test_get_result_display_other(self):
+ result = AskUserQuestionResult(
+ answers=[Answer(question="Q?", answer="Custom", is_other=True)],
+ cancelled=False,
+ )
+ event = ToolResultEvent(
+ tool_name="ask_user_question",
+ tool_class=AskUserQuestion,
+ result=result,
+ tool_call_id="123",
+ )
+ display = AskUserQuestion.get_result_display(event)
+ assert display.success is True
+ assert "(Other)" in display.message
diff --git a/tests/tools/test_bash.py b/tests/tools/test_bash.py
index 2ba04da..b3a2b7b 100644
--- a/tests/tools/test_bash.py
+++ b/tests/tools/test_bash.py
@@ -2,19 +2,21 @@ from __future__ import annotations
import pytest
+from tests.mock.utils import collect_result
from vibe.core.tools.base import BaseToolState, ToolError, ToolPermission
from vibe.core.tools.builtins.bash import Bash, BashArgs, BashToolConfig
@pytest.fixture
-def bash(tmp_path):
- config = BashToolConfig(workdir=tmp_path)
+def bash(tmp_path, monkeypatch):
+ monkeypatch.chdir(tmp_path)
+ config = BashToolConfig()
return Bash(config=config, state=BaseToolState())
@pytest.mark.asyncio
async def test_runs_echo_successfully(bash):
- result = await bash.run(BashArgs(command="echo hello"))
+ result = await collect_result(bash.run(BashArgs(command="echo hello")))
assert result.returncode == 0
assert result.stdout == "hello\n"
@@ -24,7 +26,7 @@ async def test_runs_echo_successfully(bash):
@pytest.mark.asyncio
async def test_fails_cat_command_with_missing_file(bash):
with pytest.raises(ToolError) as err:
- await bash.run(BashArgs(command="cat missing_file.txt"))
+ await collect_result(bash.run(BashArgs(command="cat missing_file.txt")))
message = str(err.value)
assert "Command failed" in message
@@ -33,11 +35,12 @@ async def test_fails_cat_command_with_missing_file(bash):
@pytest.mark.asyncio
-async def test_uses_effective_workdir(tmp_path):
- config = BashToolConfig(workdir=tmp_path)
+async def test_uses_effective_workdir(tmp_path, monkeypatch):
+ monkeypatch.chdir(tmp_path)
+ config = BashToolConfig()
bash_tool = Bash(config=config, state=BaseToolState())
- result = await bash_tool.run(BashArgs(command="pwd"))
+ result = await collect_result(bash_tool.run(BashArgs(command="pwd")))
assert result.stdout.strip() == str(tmp_path)
@@ -45,17 +48,19 @@ async def test_uses_effective_workdir(tmp_path):
@pytest.mark.asyncio
async def test_handles_timeout(bash):
with pytest.raises(ToolError) as err:
- await bash.run(BashArgs(command="sleep 2", timeout=1))
+ await collect_result(bash.run(BashArgs(command="sleep 2", timeout=1)))
assert "Command timed out after 1s" in str(err.value)
@pytest.mark.asyncio
async def test_truncates_output_to_max_bytes(bash):
- config = BashToolConfig(workdir=None, max_output_bytes=5)
+ config = BashToolConfig(max_output_bytes=5)
bash_tool = Bash(config=config, state=BaseToolState())
- result = await bash_tool.run(BashArgs(command="printf 'abcdefghij'"))
+ result = await collect_result(
+ bash_tool.run(BashArgs(command="printf 'abcdefghij'"))
+ )
assert result.stdout == "abcde"
assert result.stderr == ""
@@ -64,7 +69,7 @@ async def test_truncates_output_to_max_bytes(bash):
@pytest.mark.asyncio
async def test_decodes_non_utf8_bytes(bash):
- result = await bash.run(BashArgs(command="printf '\\xff\\xfe'"))
+ result = await collect_result(bash.run(BashArgs(command="printf '\\xff\\xfe'")))
# accept both possible encodings, as some shells emit escaped bytes as literal strings
assert result.stdout in {"��", "\xff\xfe", r"\xff\xfe"}
diff --git a/tests/tools/test_grep.py b/tests/tools/test_grep.py
index 6b8738a..668210f 100644
--- a/tests/tools/test_grep.py
+++ b/tests/tools/test_grep.py
@@ -4,6 +4,7 @@ import shutil
import pytest
+from tests.mock.utils import collect_result
from vibe.core.tools.base import ToolError
from vibe.core.tools.builtins.grep import (
Grep,
@@ -15,13 +16,15 @@ from vibe.core.tools.builtins.grep import (
@pytest.fixture
-def grep(tmp_path):
- config = GrepToolConfig(workdir=tmp_path)
+def grep(tmp_path, monkeypatch):
+ monkeypatch.chdir(tmp_path)
+ config = GrepToolConfig()
return Grep(config=config, state=GrepState())
@pytest.fixture
def grep_gnu_only(tmp_path, monkeypatch):
+ monkeypatch.chdir(tmp_path)
original_which = shutil.which
def mock_which(cmd):
@@ -30,7 +33,7 @@ def grep_gnu_only(tmp_path, monkeypatch):
return original_which(cmd)
monkeypatch.setattr("shutil.which", mock_which)
- config = GrepToolConfig(workdir=tmp_path)
+ config = GrepToolConfig()
return Grep(config=config, state=GrepState())
@@ -66,7 +69,7 @@ def test_raises_error_if_no_grep_available(grep, monkeypatch):
async def test_finds_pattern_in_file(grep, tmp_path):
(tmp_path / "test.py").write_text("def hello():\n print('world')\n")
- result = await grep.run(GrepArgs(pattern="hello"))
+ result = await collect_result(grep.run(GrepArgs(pattern="hello")))
assert result.match_count == 1
assert "hello" in result.matches
@@ -78,7 +81,7 @@ async def test_finds_pattern_in_file(grep, tmp_path):
async def test_finds_multiple_matches(grep, tmp_path):
(tmp_path / "test.py").write_text("foo\nbar\nfoo\nbaz\nfoo\n")
- result = await grep.run(GrepArgs(pattern="foo"))
+ result = await collect_result(grep.run(GrepArgs(pattern="foo")))
assert result.match_count == 3
assert result.matches.count("foo") == 3
@@ -89,7 +92,7 @@ async def test_finds_multiple_matches(grep, tmp_path):
async def test_returns_empty_on_no_matches(grep, tmp_path):
(tmp_path / "test.py").write_text("def hello():\n pass\n")
- result = await grep.run(GrepArgs(pattern="nonexistent"))
+ result = await collect_result(grep.run(GrepArgs(pattern="nonexistent")))
assert result.match_count == 0
assert result.matches == ""
@@ -99,7 +102,7 @@ async def test_returns_empty_on_no_matches(grep, tmp_path):
@pytest.mark.asyncio
async def test_fails_with_empty_pattern(grep):
with pytest.raises(ToolError) as err:
- await grep.run(GrepArgs(pattern=""))
+ await collect_result(grep.run(GrepArgs(pattern="")))
assert "Empty search pattern" in str(err.value)
@@ -107,7 +110,7 @@ async def test_fails_with_empty_pattern(grep):
@pytest.mark.asyncio
async def test_fails_with_nonexistent_path(grep):
with pytest.raises(ToolError) as err:
- await grep.run(GrepArgs(pattern="test", path="nonexistent"))
+ await collect_result(grep.run(GrepArgs(pattern="test", path="nonexistent")))
assert "Path does not exist" in str(err.value)
@@ -119,7 +122,7 @@ async def test_searches_in_specific_path(grep, tmp_path):
(subdir / "test.py").write_text("match here\n")
(tmp_path / "other.py").write_text("match here too\n")
- result = await grep.run(GrepArgs(pattern="match", path="subdir"))
+ result = await collect_result(grep.run(GrepArgs(pattern="match", path="subdir")))
assert result.match_count == 1
assert "subdir" in result.matches and "test.py" in result.matches
@@ -130,19 +133,20 @@ async def test_searches_in_specific_path(grep, tmp_path):
async def test_truncates_to_max_matches(grep, tmp_path):
(tmp_path / "test.py").write_text("\n".join(f"line {i}" for i in range(200)))
- result = await grep.run(GrepArgs(pattern="line", max_matches=50))
+ result = await collect_result(grep.run(GrepArgs(pattern="line", max_matches=50)))
assert result.match_count == 50
assert result.was_truncated
@pytest.mark.asyncio
-async def test_truncates_to_max_output_bytes(grep, tmp_path):
- config = GrepToolConfig(workdir=tmp_path, max_output_bytes=100)
+async def test_truncates_to_max_output_bytes(grep, tmp_path, monkeypatch):
+ monkeypatch.chdir(tmp_path)
+ config = GrepToolConfig(max_output_bytes=100)
grep_tool = Grep(config=config, state=GrepState())
(tmp_path / "test.py").write_text("\n".join("x" * 100 for _ in range(10)))
- result = await grep_tool.run(GrepArgs(pattern="x"))
+ result = await collect_result(grep_tool.run(GrepArgs(pattern="x")))
assert len(result.matches) <= 100
assert result.was_truncated
@@ -155,7 +159,7 @@ async def test_respects_default_ignore_patterns(grep, tmp_path):
node_modules.mkdir()
(node_modules / "excluded.js").write_text("match\n")
- result = await grep.run(GrepArgs(pattern="match"))
+ result = await collect_result(grep.run(GrepArgs(pattern="match")))
assert "included.py" in result.matches
assert "excluded.js" not in result.matches
@@ -170,7 +174,7 @@ async def test_respects_vibeignore_file(grep, tmp_path):
(tmp_path / "excluded.tmp").write_text("match\n")
(tmp_path / "included.py").write_text("match\n")
- result = await grep.run(GrepArgs(pattern="match"))
+ result = await collect_result(grep.run(GrepArgs(pattern="match")))
assert "included.py" in result.matches
assert "excluded.py" not in result.matches
@@ -182,7 +186,7 @@ async def test_ignores_comments_in_vibeignore(grep, tmp_path):
(tmp_path / ".vibeignore").write_text("# comment\npattern/\n# another comment\n")
(tmp_path / "file.py").write_text("match\n")
- result = await grep.run(GrepArgs(pattern="match"))
+ result = await collect_result(grep.run(GrepArgs(pattern="match")))
assert result.match_count >= 1
@@ -191,20 +195,21 @@ async def test_ignores_comments_in_vibeignore(grep, tmp_path):
async def test_tracks_search_history(grep, tmp_path):
(tmp_path / "test.py").write_text("content\n")
- await grep.run(GrepArgs(pattern="first"))
- await grep.run(GrepArgs(pattern="second"))
- await grep.run(GrepArgs(pattern="third"))
+ await collect_result(grep.run(GrepArgs(pattern="first")))
+ await collect_result(grep.run(GrepArgs(pattern="second")))
+ await collect_result(grep.run(GrepArgs(pattern="third")))
assert grep.state.search_history == ["first", "second", "third"]
@pytest.mark.asyncio
-async def test_uses_effective_workdir(tmp_path):
- config = GrepToolConfig(workdir=tmp_path)
+async def test_uses_effective_workdir(tmp_path, monkeypatch):
+ monkeypatch.chdir(tmp_path)
+ config = GrepToolConfig()
grep_tool = Grep(config=config, state=GrepState())
(tmp_path / "test.py").write_text("match\n")
- result = await grep_tool.run(GrepArgs(pattern="match", path="."))
+ result = await collect_result(grep_tool.run(GrepArgs(pattern="match", path=".")))
assert result.match_count == 1
assert "test.py" in result.matches
@@ -216,7 +221,7 @@ class TestGnuGrepBackend:
async def test_finds_pattern_in_file(self, grep_gnu_only, tmp_path):
(tmp_path / "test.py").write_text("def hello():\n print('world')\n")
- result = await grep_gnu_only.run(GrepArgs(pattern="hello"))
+ result = await collect_result(grep_gnu_only.run(GrepArgs(pattern="hello")))
assert result.match_count == 1
assert "hello" in result.matches
@@ -226,7 +231,7 @@ class TestGnuGrepBackend:
async def test_finds_multiple_matches(self, grep_gnu_only, tmp_path):
(tmp_path / "test.py").write_text("foo\nbar\nfoo\nbaz\nfoo\n")
- result = await grep_gnu_only.run(GrepArgs(pattern="foo"))
+ result = await collect_result(grep_gnu_only.run(GrepArgs(pattern="foo")))
assert result.match_count == 3
assert result.matches.count("foo") == 3
@@ -235,7 +240,9 @@ class TestGnuGrepBackend:
async def test_returns_empty_on_no_matches(self, grep_gnu_only, tmp_path):
(tmp_path / "test.py").write_text("def hello():\n pass\n")
- result = await grep_gnu_only.run(GrepArgs(pattern="nonexistent"))
+ result = await collect_result(
+ grep_gnu_only.run(GrepArgs(pattern="nonexistent"))
+ )
assert result.match_count == 0
assert result.matches == ""
@@ -246,7 +253,7 @@ class TestGnuGrepBackend:
):
(tmp_path / "test.py").write_text("Hello\nHELLO\nhello\n")
- result = await grep_gnu_only.run(GrepArgs(pattern="hello"))
+ result = await collect_result(grep_gnu_only.run(GrepArgs(pattern="hello")))
assert result.match_count == 3
@@ -254,7 +261,7 @@ class TestGnuGrepBackend:
async def test_case_sensitive_for_mixed_case_pattern(self, grep_gnu_only, tmp_path):
(tmp_path / "test.py").write_text("Hello\nHELLO\nhello\n")
- result = await grep_gnu_only.run(GrepArgs(pattern="Hello"))
+ result = await collect_result(grep_gnu_only.run(GrepArgs(pattern="Hello")))
assert result.match_count == 1
@@ -265,7 +272,7 @@ class TestGnuGrepBackend:
node_modules.mkdir()
(node_modules / "excluded.js").write_text("match\n")
- result = await grep_gnu_only.run(GrepArgs(pattern="match"))
+ result = await collect_result(grep_gnu_only.run(GrepArgs(pattern="match")))
assert "included.py" in result.matches
assert "excluded.js" not in result.matches
@@ -277,7 +284,9 @@ class TestGnuGrepBackend:
(subdir / "test.py").write_text("match here\n")
(tmp_path / "other.py").write_text("match here too\n")
- result = await grep_gnu_only.run(GrepArgs(pattern="match", path="subdir"))
+ result = await collect_result(
+ grep_gnu_only.run(GrepArgs(pattern="match", path="subdir"))
+ )
assert result.match_count == 1
assert "other.py" not in result.matches
@@ -291,7 +300,7 @@ class TestGnuGrepBackend:
(tmp_path / "excluded.tmp").write_text("match\n")
(tmp_path / "included.py").write_text("match\n")
- result = await grep_gnu_only.run(GrepArgs(pattern="match"))
+ result = await collect_result(grep_gnu_only.run(GrepArgs(pattern="match")))
assert "included.py" in result.matches
assert "excluded.py" not in result.matches
@@ -301,7 +310,9 @@ class TestGnuGrepBackend:
async def test_truncates_to_max_matches(self, grep_gnu_only, tmp_path):
(tmp_path / "test.py").write_text("\n".join(f"line {i}" for i in range(200)))
- result = await grep_gnu_only.run(GrepArgs(pattern="line", max_matches=50))
+ result = await collect_result(
+ grep_gnu_only.run(GrepArgs(pattern="line", max_matches=50))
+ )
assert result.match_count == 50
assert result.was_truncated
@@ -313,7 +324,7 @@ class TestRipgrepBackend:
async def test_smart_case_lowercase_pattern(self, grep, tmp_path):
(tmp_path / "test.py").write_text("Hello\nHELLO\nhello\n")
- result = await grep.run(GrepArgs(pattern="hello"))
+ result = await collect_result(grep.run(GrepArgs(pattern="hello")))
assert result.match_count == 3
@@ -321,7 +332,7 @@ class TestRipgrepBackend:
async def test_smart_case_mixed_case_pattern(self, grep, tmp_path):
(tmp_path / "test.py").write_text("Hello\nHELLO\nhello\n")
- result = await grep.run(GrepArgs(pattern="Hello"))
+ result = await collect_result(grep.run(GrepArgs(pattern="Hello")))
assert result.match_count == 1
@@ -336,12 +347,12 @@ class TestRipgrepBackend:
(ignored_dir / "file.py").write_text("match\n")
(tmp_path / "included.py").write_text("match\n")
- result_with_ignore = await grep.run(GrepArgs(pattern="match"))
+ result_with_ignore = await collect_result(grep.run(GrepArgs(pattern="match")))
assert "included.py" in result_with_ignore.matches
assert "ignored_by_rg" not in result_with_ignore.matches
- result_without_ignore = await grep.run(
- GrepArgs(pattern="match", use_default_ignore=False)
+ result_without_ignore = await collect_result(
+ grep.run(GrepArgs(pattern="match", use_default_ignore=False))
)
assert "included.py" in result_without_ignore.matches
assert "ignored_by_rg/file.py" in result_without_ignore.matches
diff --git a/tests/tools/test_invoke_context.py b/tests/tools/test_invoke_context.py
new file mode 100644
index 0000000..28e0f17
--- /dev/null
+++ b/tests/tools/test_invoke_context.py
@@ -0,0 +1,106 @@
+from __future__ import annotations
+
+from collections.abc import AsyncGenerator
+
+from pydantic import BaseModel
+import pytest
+
+from tests.mock.utils import collect_result
+from vibe.core.tools.base import BaseTool, BaseToolConfig, BaseToolState, InvokeContext
+from vibe.core.types import ApprovalCallback, ApprovalResponse, ToolStreamEvent
+
+
+class SimpleArgs(BaseModel):
+ value: str
+
+
+class SimpleResult(BaseModel):
+ output: str
+ had_context: bool
+ approval_callback_present: bool
+
+
+class SimpleTool(BaseTool[SimpleArgs, SimpleResult, BaseToolConfig, BaseToolState]):
+ description = "A simple test tool"
+
+ async def run(
+ self, args: SimpleArgs, ctx: InvokeContext | None = None
+ ) -> AsyncGenerator[ToolStreamEvent | SimpleResult, None]:
+ yield SimpleResult(
+ output=f"processed: {args.value}",
+ had_context=ctx is not None,
+ approval_callback_present=ctx is not None
+ and ctx.approval_callback is not None,
+ )
+
+
+@pytest.fixture
+def simple_tool() -> SimpleTool:
+ return SimpleTool(config=BaseToolConfig(), state=BaseToolState())
+
+
+class TestInvokeContext:
+ def test_default_approval_callback_is_none(self) -> None:
+ ctx = InvokeContext(tool_call_id="test-call-id")
+ assert ctx.approval_callback is None
+
+ def test_approval_callback_can_be_set(self) -> None:
+ def dummy_callback(
+ _tool_name: str, _args: BaseModel, _tool_call_id: str
+ ) -> tuple[ApprovalResponse, str | None]:
+ return ApprovalResponse.YES, None
+
+ callback: ApprovalCallback = dummy_callback
+ ctx = InvokeContext(tool_call_id="test-call-id", approval_callback=callback)
+ assert ctx.approval_callback is callback
+
+
+class TestToolInvokeWithContext:
+ @pytest.mark.asyncio
+ async def test_invoke_without_context(self, simple_tool: SimpleTool) -> None:
+ result = await collect_result(simple_tool.invoke(value="test"))
+
+ assert result.output == "processed: test"
+ assert result.had_context is False
+ assert result.approval_callback_present is False
+
+ @pytest.mark.asyncio
+ async def test_invoke_with_empty_context(self, simple_tool: SimpleTool) -> None:
+ ctx = InvokeContext(tool_call_id="test-call-id")
+ result = await collect_result(simple_tool.invoke(ctx=ctx, value="test"))
+
+ assert result.output == "processed: test"
+ assert result.had_context is True
+ assert result.approval_callback_present is False
+
+ @pytest.mark.asyncio
+ async def test_invoke_with_approval_callback(self, simple_tool: SimpleTool) -> None:
+ def dummy_callback(
+ _tool_name: str, _args: BaseModel, _tool_call_id: str
+ ) -> tuple[ApprovalResponse, str | None]:
+ return ApprovalResponse.YES, None
+
+ callback: ApprovalCallback = dummy_callback
+ ctx = InvokeContext(tool_call_id="test-call-id", approval_callback=callback)
+ result = await collect_result(simple_tool.invoke(ctx=ctx, value="test"))
+
+ assert result.output == "processed: test"
+ assert result.had_context is True
+ assert result.approval_callback_present is True
+
+ @pytest.mark.asyncio
+ async def test_run_receives_context(self, simple_tool: SimpleTool) -> None:
+ ctx = InvokeContext(tool_call_id="test-call-id")
+ result = await collect_result(
+ simple_tool.run(SimpleArgs(value="direct"), ctx=ctx)
+ )
+
+ assert result.had_context is True
+
+ @pytest.mark.asyncio
+ async def test_run_without_context_defaults_to_none(
+ self, simple_tool: SimpleTool
+ ) -> None:
+ result = await collect_result(simple_tool.run(SimpleArgs(value="direct")))
+
+ assert result.had_context is False
diff --git a/tests/tools/test_manager_get_tool_config.py b/tests/tools/test_manager_get_tool_config.py
index 6f2de19..2099b3a 100644
--- a/tests/tools/test_manager_get_tool_config.py
+++ b/tests/tools/test_manager_get_tool_config.py
@@ -1,5 +1,7 @@
from __future__ import annotations
+from pathlib import Path
+
import pytest
from vibe.core.config import SessionLoggingConfig, VibeConfig
@@ -27,7 +29,7 @@ def test_returns_default_config_when_no_overrides(tool_manager):
assert (
type(config).__name__ == "BashToolConfig"
) # due to vibe's discover system isinstance would fail
- assert config.default_timeout == 30 # type: ignore[attr-defined]
+ assert config.default_timeout == 300 # type: ignore[attr-defined]
assert config.max_output_bytes == 16000 # type: ignore[attr-defined]
assert config.permission == ToolPermission.ASK
@@ -47,7 +49,7 @@ def test_merges_user_overrides_with_defaults():
type(config).__name__ == "BashToolConfig"
) # due to vibe's discover system isinstance would fail
assert config.permission == ToolPermission.ALWAYS
- assert config.default_timeout == 30 # type: ignore[attr-defined]
+ assert config.default_timeout == 300 # type: ignore[attr-defined]
def test_preserves_tool_specific_fields_from_overrides():
@@ -73,16 +75,364 @@ def test_falls_back_to_base_config_for_unknown_tool(tool_manager):
assert config.permission == ToolPermission.ASK
-def test_applies_workdir_from_vibe_config(tmp_path):
- vibe_config = VibeConfig(
- session_logging=SessionLoggingConfig(enabled=False),
- system_prompt_id="tests",
- include_project_context=False,
- workdir=tmp_path,
- )
- manager = ToolManager(lambda: vibe_config)
+class TestToolManagerFiltering:
+ def test_enabled_tools_filters_to_only_enabled(self):
+ vibe_config = VibeConfig(
+ session_logging=SessionLoggingConfig(enabled=False),
+ system_prompt_id="tests",
+ include_project_context=False,
+ enabled_tools=["bash", "grep"],
+ )
+ manager = ToolManager(lambda: vibe_config)
- config = manager.get_tool_config("bash")
+ tools = manager.available_tools
+ assert len(tools) < len(manager._available)
+ assert "bash" in tools
+ assert "grep" in tools
+ assert "read_file" not in tools
+ assert "write_file" not in tools
- assert config.workdir == tmp_path
- assert config.effective_workdir == tmp_path
+ def test_disabled_tools_excludes_disabled(self):
+ vibe_config = VibeConfig(
+ session_logging=SessionLoggingConfig(enabled=False),
+ system_prompt_id="tests",
+ include_project_context=False,
+ disabled_tools=["bash", "write_file"],
+ )
+ manager = ToolManager(lambda: vibe_config)
+
+ tools = manager.available_tools
+ assert len(tools) < len(manager._available)
+ assert "bash" not in tools
+ assert "write_file" not in tools
+ assert "grep" in tools
+ assert "read_file" in tools
+
+ def test_enabled_tools_takes_precedence_over_disabled(self):
+ vibe_config = VibeConfig(
+ session_logging=SessionLoggingConfig(enabled=False),
+ system_prompt_id="tests",
+ include_project_context=False,
+ enabled_tools=["bash"],
+ disabled_tools=["bash"], # Should be ignored
+ )
+ manager = ToolManager(lambda: vibe_config)
+
+ tools = manager.available_tools
+ assert len(tools) == 1
+ assert "bash" in tools
+
+ def test_glob_pattern_matching(self):
+ vibe_config = VibeConfig(
+ session_logging=SessionLoggingConfig(enabled=False),
+ system_prompt_id="tests",
+ include_project_context=False,
+ disabled_tools=["*_file"], # Matches read_file, write_file
+ )
+ manager = ToolManager(lambda: vibe_config)
+
+ tools = manager.available_tools
+ assert "read_file" not in tools
+ assert "write_file" not in tools
+ assert "bash" in tools
+ assert "grep" in tools
+
+ def test_regex_pattern_matching(self):
+ vibe_config = VibeConfig(
+ session_logging=SessionLoggingConfig(enabled=False),
+ system_prompt_id="tests",
+ include_project_context=False,
+ enabled_tools=["re:^(bash|grep)$"],
+ )
+ manager = ToolManager(lambda: vibe_config)
+
+ tools = manager.available_tools
+ assert len(tools) == 2
+ assert "bash" in tools
+ assert "grep" in tools
+
+ def test_case_insensitive_matching(self):
+ vibe_config = VibeConfig(
+ session_logging=SessionLoggingConfig(enabled=False),
+ system_prompt_id="tests",
+ include_project_context=False,
+ enabled_tools=["BASH", "GREP"],
+ )
+ manager = ToolManager(lambda: vibe_config)
+
+ tools = manager.available_tools
+ assert "bash" in tools
+ assert "grep" in tools
+
+ def test_empty_enabled_tools_returns_all(self):
+ vibe_config = VibeConfig(
+ session_logging=SessionLoggingConfig(enabled=False),
+ system_prompt_id="tests",
+ include_project_context=False,
+ enabled_tools=[],
+ )
+ manager = ToolManager(lambda: vibe_config)
+
+ tools = manager.available_tools
+ assert "bash" in tools
+ assert "grep" in tools
+ assert "read_file" in tools
+
+ def test_tool_paths_with_file_and_directory(self, tmp_path: Path):
+ """Should handle a mix of file and directory paths in tool_paths."""
+ import sys
+
+ # Create a directory with a tool
+ tool_dir = tmp_path / "tools"
+ tool_dir.mkdir()
+ (tool_dir / "dir_tool.py").write_text("""
+from vibe.core.tools.base import BaseTool, BaseToolConfig, BaseToolState
+from pydantic import BaseModel
+from collections.abc import AsyncGenerator
+
+class DirToolArgs(BaseModel):
+ pass
+
+class DirToolResult(BaseModel):
+ pass
+
+class DirTool(BaseTool[DirToolArgs, DirToolResult, BaseToolConfig, BaseToolState]):
+ description = "Tool from directory"
+
+ async def run(self, args, ctx=None):
+ yield DirToolResult()
+""")
+
+ # Create a standalone tool file
+ file_tool = tmp_path / "file_tool.py"
+ file_tool.write_text("""
+from vibe.core.tools.base import BaseTool, BaseToolConfig, BaseToolState
+from pydantic import BaseModel
+from collections.abc import AsyncGenerator
+
+class FileToolArgs(BaseModel):
+ pass
+
+class FileToolResult(BaseModel):
+ pass
+
+class FileTool(BaseTool[FileToolArgs, FileToolResult, BaseToolConfig, BaseToolState]):
+ description = "Tool from file path"
+
+ async def run(self, args, ctx=None):
+ yield FileToolResult()
+""")
+
+ # Clean up any previously loaded modules
+ to_remove = [k for k in sys.modules if "dir_tool" in k or "file_tool" in k]
+ for k in to_remove:
+ del sys.modules[k]
+
+ vibe_config = VibeConfig(
+ session_logging=SessionLoggingConfig(enabled=False),
+ system_prompt_id="tests",
+ include_project_context=False,
+ tool_paths=[tool_dir, file_tool],
+ )
+ manager = ToolManager(lambda: vibe_config)
+
+ tools = manager.available_tools
+ assert "dir_tool" in tools
+ assert "file_tool" in tools
+
+
+class TestToolManagerModuleReuse:
+ """Tests for module reuse across ToolManager instances.
+
+ When multiple ToolManager instances are created (e.g., main agent + subagent),
+ they should reuse the same tool modules from sys.modules to preserve class identity.
+ This prevents Pydantic validation errors when tool results from one agent
+ are validated against types from another.
+ """
+
+ def test_multiple_managers_share_tool_classes(self):
+ """Tool classes should be identical across multiple ToolManager instances."""
+ vibe_config = VibeConfig(
+ session_logging=SessionLoggingConfig(enabled=False),
+ system_prompt_id="tests",
+ include_project_context=False,
+ )
+
+ manager1 = ToolManager(lambda: vibe_config)
+ manager2 = ToolManager(lambda: vibe_config)
+
+ # Get the same tool class from both managers
+ todo_class1 = manager1.available_tools.get("todo")
+ todo_class2 = manager2.available_tools.get("todo")
+
+ assert todo_class1 is not None
+ assert todo_class2 is not None
+ # Class objects should be identical (same id), not just equal
+ assert todo_class1 is todo_class2
+
+ def test_tool_state_classes_are_identical(self):
+ """Tool state classes should be identical across managers."""
+ vibe_config = VibeConfig(
+ session_logging=SessionLoggingConfig(enabled=False),
+ system_prompt_id="tests",
+ include_project_context=False,
+ )
+
+ manager1 = ToolManager(lambda: vibe_config)
+ manager2 = ToolManager(lambda: vibe_config)
+
+ todo_class1 = manager1.available_tools["todo"]
+ todo_class2 = manager2.available_tools["todo"]
+
+ state_class1 = todo_class1._get_tool_state_class()
+ state_class2 = todo_class2._get_tool_state_class()
+
+ assert state_class1 is state_class2
+
+ def test_tool_args_results_classes_are_identical(self):
+ """Tool args and result classes should be identical across managers."""
+ vibe_config = VibeConfig(
+ session_logging=SessionLoggingConfig(enabled=False),
+ system_prompt_id="tests",
+ include_project_context=False,
+ )
+
+ manager1 = ToolManager(lambda: vibe_config)
+ manager2 = ToolManager(lambda: vibe_config)
+
+ todo_class1 = manager1.available_tools["todo"]
+ todo_class2 = manager2.available_tools["todo"]
+
+ args1, result1 = todo_class1._get_tool_args_results()
+ args2, result2 = todo_class2._get_tool_args_results()
+
+ assert args1 is args2
+ assert result1 is result2
+
+ def test_tool_instances_are_isolated(self):
+ """Tool instances should be separate even though classes are shared.
+
+ This ensures subagents have isolated state (e.g., separate todo lists)
+ while still sharing class definitions for Pydantic validation.
+ """
+ vibe_config = VibeConfig(
+ session_logging=SessionLoggingConfig(enabled=False),
+ system_prompt_id="tests",
+ include_project_context=False,
+ )
+
+ manager1 = ToolManager(lambda: vibe_config)
+ manager2 = ToolManager(lambda: vibe_config)
+
+ # Get tool instances from each manager
+ tool1 = manager1.get("todo")
+ tool2 = manager2.get("todo")
+
+ # Instances should be different objects
+ assert tool1 is not tool2
+
+ # State should be different objects
+ assert tool1.state is not tool2.state
+
+ # Verify state is truly isolated by modifying one
+ from vibe.core.tools.builtins.todo import TodoItem
+
+ tool1.state.todos = [TodoItem(id="1", content="test")]
+ assert len(tool1.state.todos) == 1
+ assert len(tool2.state.todos) == 0 # Unaffected!
+
+ def test_class_shared_but_instances_isolated(self):
+ """Classes must be shared (for validation) but instances isolated (for state)."""
+ vibe_config = VibeConfig(
+ session_logging=SessionLoggingConfig(enabled=False),
+ system_prompt_id="tests",
+ include_project_context=False,
+ )
+
+ manager1 = ToolManager(lambda: vibe_config)
+ manager2 = ToolManager(lambda: vibe_config)
+
+ tool1 = manager1.get("todo")
+ tool2 = manager2.get("todo")
+
+ # Classes are shared (same object)
+ assert type(tool1) is type(tool2)
+ assert type(tool1.state) is type(tool2.state)
+
+ # But instances are different
+ assert tool1 is not tool2
+ assert tool1.state is not tool2.state
+
+ def test_different_files_same_stem_get_different_modules(self, tmp_path: Path):
+ """Tools with same stem but different paths should be separate modules.
+
+ This ensures user tools can override builtins - a custom todo.py in
+ ~/.config/vibe/tools/ should override the builtin todo.py.
+ """
+ import sys
+
+ # Create two tool files with the same stem but different content
+ dir1 = tmp_path / "tools1"
+ dir2 = tmp_path / "tools2"
+ dir1.mkdir()
+ dir2.mkdir()
+
+ tool_code_v1 = """
+from vibe.core.tools.base import BaseTool, BaseToolConfig, BaseToolState
+from pydantic import BaseModel
+from collections.abc import AsyncGenerator
+
+class DummyArgs(BaseModel):
+ value: str
+
+class DummyResult(BaseModel):
+ version: str = "v1"
+
+class DummyTool(BaseTool[DummyArgs, DummyResult, BaseToolConfig, BaseToolState]):
+ description = "Dummy tool v1"
+
+ async def run(self, args: DummyArgs, ctx=None) -> AsyncGenerator[DummyResult, None]:
+ yield DummyResult(version="v1")
+"""
+
+ tool_code_v2 = """
+from vibe.core.tools.base import BaseTool, BaseToolConfig, BaseToolState
+from pydantic import BaseModel
+from collections.abc import AsyncGenerator
+
+class DummyArgs(BaseModel):
+ value: str
+
+class DummyResult(BaseModel):
+ version: str = "v2"
+
+class DummyTool(BaseTool[DummyArgs, DummyResult, BaseToolConfig, BaseToolState]):
+ description = "Dummy tool v2"
+
+ async def run(self, args: DummyArgs, ctx=None) -> AsyncGenerator[DummyResult, None]:
+ yield DummyResult(version="v2")
+"""
+
+ (dir1 / "dummy.py").write_text(tool_code_v1)
+ (dir2 / "dummy.py").write_text(tool_code_v2)
+
+ # Clean up any previously loaded dummy modules
+ to_remove = [k for k in sys.modules if "dummy" in k]
+ for k in to_remove:
+ del sys.modules[k]
+
+ # Load tools from both directories (dir2 comes after, should override)
+ classes = list(ToolManager._iter_tool_classes([dir1, dir2]))
+ dummy_classes = [c for c in classes if "dummy" in c.get_name().lower()]
+
+ # Should have 2 separate classes (from different modules)
+ assert len(dummy_classes) == 2
+
+ # They should be different class objects
+ assert dummy_classes[0] is not dummy_classes[1]
+
+ # When put in a dict (like _available), the second one wins
+ available = {c.get_name(): c for c in classes}
+ final_class = available.get("dummy_tool")
+ assert final_class is not None
+ assert final_class.description == "Dummy tool v2"
diff --git a/tests/tools/test_mcp.py b/tests/tools/test_mcp.py
new file mode 100644
index 0000000..908b50f
--- /dev/null
+++ b/tests/tools/test_mcp.py
@@ -0,0 +1,308 @@
+from __future__ import annotations
+
+from unittest.mock import MagicMock
+
+from pydantic import ValidationError
+import pytest
+
+from vibe.core.config import MCPHttp, MCPStdio, MCPStreamableHttp
+from vibe.core.tools.mcp import (
+ MCPToolResult,
+ RemoteTool,
+ _parse_call_result,
+ create_mcp_http_proxy_tool_class,
+ create_mcp_stdio_proxy_tool_class,
+)
+
+
+class TestRemoteTool:
+ def test_creates_remote_tool_with_valid_data(self):
+ tool = RemoteTool.model_validate({
+ "name": "test_tool",
+ "description": "A test tool",
+ "inputSchema": {
+ "type": "object",
+ "properties": {"arg": {"type": "string"}},
+ },
+ })
+
+ assert tool.name == "test_tool"
+ assert tool.description == "A test tool"
+ assert tool.input_schema == {
+ "type": "object",
+ "properties": {"arg": {"type": "string"}},
+ }
+
+ def test_uses_default_schema_when_none_provided(self):
+ tool = RemoteTool(name="test_tool")
+
+ assert tool.input_schema == {"type": "object", "properties": {}}
+
+ def test_rejects_empty_name(self):
+ with pytest.raises(ValueError, match="MCP tool missing valid 'name'"):
+ RemoteTool(name="")
+
+ def test_rejects_whitespace_only_name(self):
+ with pytest.raises(ValueError, match="MCP tool missing valid 'name'"):
+ RemoteTool(name=" ")
+
+ def test_normalizes_schema_from_object_with_model_dump(self):
+ mock_schema = MagicMock()
+ mock_schema.model_dump.return_value = {"type": "string"}
+
+ tool = RemoteTool.model_validate({"name": "test", "inputSchema": mock_schema})
+
+ assert tool.input_schema == {"type": "string"}
+
+ def test_rejects_invalid_input_schema(self):
+ with pytest.raises(ValueError, match="inputSchema must be a dict"):
+ RemoteTool.model_validate({"name": "test", "inputSchema": 12345})
+
+
+class TestMCPToolResult:
+ def test_creates_result_with_text(self):
+ result = MCPToolResult(server="test_server", tool="test_tool", text="output")
+
+ assert result.ok is True
+ assert result.server == "test_server"
+ assert result.tool == "test_tool"
+ assert result.text == "output"
+ assert result.structured is None
+
+ def test_creates_result_with_structured_content(self):
+ result = MCPToolResult(
+ server="test_server", tool="test_tool", structured={"key": "value"}
+ )
+
+ assert result.structured == {"key": "value"}
+ assert result.text is None
+
+
+class TestParseCallResult:
+ def test_parses_text_content(self):
+ mock_result = MagicMock()
+ mock_result.structuredContent = None
+ mock_result.content = [MagicMock(text="Hello world")]
+
+ result = _parse_call_result("server", "tool", mock_result)
+
+ assert result.server == "server"
+ assert result.tool == "tool"
+ assert result.text == "Hello world"
+ assert result.structured is None
+
+ def test_parses_structured_content(self):
+ mock_result = MagicMock()
+ mock_result.structuredContent = {"data": "value"}
+ mock_result.content = None
+
+ result = _parse_call_result("server", "tool", mock_result)
+
+ assert result.structured == {"data": "value"}
+ assert result.text is None
+
+ def test_prefers_structured_over_text(self):
+ mock_result = MagicMock()
+ mock_result.structuredContent = {"data": "value"}
+ mock_result.content = [MagicMock(text="text content")]
+
+ result = _parse_call_result("server", "tool", mock_result)
+
+ assert result.structured == {"data": "value"}
+ assert result.text is None
+
+ def test_joins_multiple_text_blocks(self):
+ mock_result = MagicMock()
+ mock_result.structuredContent = None
+ mock_result.content = [MagicMock(text="line1"), MagicMock(text="line2")]
+
+ result = _parse_call_result("server", "tool", mock_result)
+
+ assert result.text == "line1\nline2"
+
+
+class TestCreateMCPHttpProxyToolClass:
+ def test_creates_tool_class_with_correct_name(self):
+ remote = RemoteTool(name="my_tool", description="Test tool")
+ tool_cls = create_mcp_http_proxy_tool_class(
+ url="http://localhost:8080", remote=remote, alias="test_server"
+ )
+
+ assert tool_cls.get_name() == "test_server_my_tool"
+
+ def test_creates_tool_class_with_url_based_alias(self):
+ remote = RemoteTool(name="my_tool")
+ tool_cls = create_mcp_http_proxy_tool_class(
+ url="http://localhost:8080", remote=remote
+ )
+
+ assert tool_cls.get_name() == "localhost_8080_my_tool"
+
+ def test_includes_description_with_hint(self):
+ remote = RemoteTool(name="my_tool", description="Base description")
+ tool_cls = create_mcp_http_proxy_tool_class(
+ url="http://localhost:8080",
+ remote=remote,
+ alias="test",
+ server_hint="Use this for testing",
+ )
+
+ assert "[test]" in tool_cls.description
+ assert "Base description" in tool_cls.description
+ assert "Hint: Use this for testing" in tool_cls.description
+
+ def test_stores_timeout_settings(self):
+ remote = RemoteTool(name="my_tool")
+ tool_cls = create_mcp_http_proxy_tool_class(
+ url="http://localhost:8080",
+ remote=remote,
+ startup_timeout_sec=30.0,
+ tool_timeout_sec=120.0,
+ )
+
+ assert tool_cls._startup_timeout_sec == 30.0 # type: ignore[attr-defined]
+ assert tool_cls._tool_timeout_sec == 120.0 # type: ignore[attr-defined]
+
+ def test_returns_correct_parameters(self):
+ remote = RemoteTool.model_validate({
+ "name": "my_tool",
+ "inputSchema": {
+ "type": "object",
+ "properties": {"arg": {"type": "string"}},
+ },
+ })
+ tool_cls = create_mcp_http_proxy_tool_class(
+ url="http://localhost:8080", remote=remote
+ )
+
+ params = tool_cls.get_parameters()
+
+ assert params == {"type": "object", "properties": {"arg": {"type": "string"}}}
+
+
+class TestCreateMCPStdioProxyToolClass:
+ def test_creates_tool_class_with_alias(self):
+ remote = RemoteTool(name="my_tool")
+ tool_cls = create_mcp_stdio_proxy_tool_class(
+ command=["python", "-m", "mcp_server"], remote=remote, alias="my_server"
+ )
+
+ assert tool_cls.get_name() == "my_server_my_tool"
+
+ def test_creates_tool_class_with_command_based_alias(self):
+ remote = RemoteTool(name="my_tool")
+ tool_cls = create_mcp_stdio_proxy_tool_class(
+ command=["python", "-m", "mcp_server"], remote=remote
+ )
+
+ name = tool_cls.get_name()
+ assert name.startswith("python_")
+ assert name.endswith("_my_tool")
+
+ def test_stores_env_settings(self):
+ remote = RemoteTool(name="my_tool")
+ tool_cls = create_mcp_stdio_proxy_tool_class(
+ command=["python", "-m", "mcp_server"],
+ remote=remote,
+ env={"API_KEY": "secret"},
+ )
+
+ assert tool_cls._env == {"API_KEY": "secret"} # type: ignore[attr-defined]
+
+ def test_stores_timeout_settings(self):
+ remote = RemoteTool(name="my_tool")
+ tool_cls = create_mcp_stdio_proxy_tool_class(
+ command=["python", "-m", "mcp_server"],
+ remote=remote,
+ startup_timeout_sec=15.0,
+ tool_timeout_sec=90.0,
+ )
+
+ assert tool_cls._startup_timeout_sec == 15.0 # type: ignore[attr-defined]
+ assert tool_cls._tool_timeout_sec == 90.0 # type: ignore[attr-defined]
+
+ def test_includes_hint_in_description(self):
+ remote = RemoteTool(name="my_tool", description="Base description")
+ tool_cls = create_mcp_stdio_proxy_tool_class(
+ command=["python"],
+ remote=remote,
+ alias="test",
+ server_hint="For testing only",
+ )
+
+ assert "Hint: For testing only" in tool_cls.description
+
+
+class TestMCPConfigModels:
+ def test_mcp_base_default_timeouts(self):
+ config = MCPStdio(
+ name="test", transport="stdio", command="python -m test_server"
+ )
+
+ assert config.startup_timeout_sec == 10.0
+ assert config.tool_timeout_sec == 60.0
+
+ def test_mcp_base_custom_timeouts(self):
+ config = MCPStdio(
+ name="test",
+ transport="stdio",
+ command="python -m test_server",
+ startup_timeout_sec=30.0,
+ tool_timeout_sec=120.0,
+ )
+
+ assert config.startup_timeout_sec == 30.0
+ assert config.tool_timeout_sec == 120.0
+
+ def test_mcp_base_rejects_non_positive_timeout(self):
+ with pytest.raises(ValidationError):
+ MCPStdio(
+ name="test", transport="stdio", command="python", startup_timeout_sec=0
+ )
+
+ def test_mcp_stdio_with_env(self):
+ config = MCPStdio(
+ name="test",
+ transport="stdio",
+ command="python -m server",
+ env={"API_KEY": "secret", "DEBUG": "1"},
+ )
+
+ assert config.env == {"API_KEY": "secret", "DEBUG": "1"}
+
+ def test_mcp_stdio_argv_with_string_command(self):
+ config = MCPStdio(
+ name="test", transport="stdio", command="python -m server --port 8080"
+ )
+
+ assert config.argv() == ["python", "-m", "server", "--port", "8080"]
+
+ def test_mcp_stdio_argv_with_list_command(self):
+ config = MCPStdio(
+ name="test",
+ transport="stdio",
+ command=["python", "-m", "server"],
+ args=["--port", "8080"],
+ )
+
+ assert config.argv() == ["python", "-m", "server", "--port", "8080"]
+
+ def test_mcp_http_default_timeouts(self):
+ config = MCPHttp(name="test", transport="http", url="http://localhost:8080")
+
+ assert config.startup_timeout_sec == 10.0
+ assert config.tool_timeout_sec == 60.0
+
+ def test_mcp_streamable_http_default_timeouts(self):
+ config = MCPStreamableHttp(
+ name="test", transport="streamable-http", url="http://localhost:8080"
+ )
+
+ assert config.startup_timeout_sec == 10.0
+ assert config.tool_timeout_sec == 60.0
+
+ def test_mcp_name_normalization(self):
+ config = MCPStdio(name="my server!@#$%", transport="stdio", command="python")
+
+ # Trailing special chars become underscores which are then stripped
+ assert config.name == "my_server"
diff --git a/tests/tools/test_task.py b/tests/tools/test_task.py
new file mode 100644
index 0000000..83abdfd
--- /dev/null
+++ b/tests/tools/test_task.py
@@ -0,0 +1,164 @@
+from __future__ import annotations
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from tests.mock.utils import collect_result
+from vibe.core.agents.manager import AgentManager
+from vibe.core.agents.models import BUILTIN_AGENTS, AgentType
+from vibe.core.config import VibeConfig
+from vibe.core.tools.base import BaseToolState, InvokeContext, ToolError
+from vibe.core.tools.builtins.task import Task, TaskArgs, TaskResult, TaskToolConfig
+from vibe.core.types import AssistantEvent, LLMMessage, Role
+
+
+@pytest.fixture
+def task_tool() -> Task:
+ return Task(config=TaskToolConfig(), state=BaseToolState())
+
+
+class TestTaskArgs:
+ def test_default_agent_is_explore(self) -> None:
+ args = TaskArgs(task="do something")
+ assert args.agent == "explore"
+
+ def test_custom_values(self) -> None:
+ args = TaskArgs(task="do something", agent="explore")
+ assert args.task == "do something"
+ assert args.agent == "explore"
+
+
+class TestTaskToolValidation:
+ @pytest.fixture
+ def ctx(self) -> InvokeContext:
+ config = VibeConfig(include_project_context=False, include_prompt_detail=False)
+ manager = AgentManager(lambda: config)
+ return InvokeContext(tool_call_id="test-call-id", agent_manager=manager)
+
+ @pytest.mark.asyncio
+ async def test_rejects_primary_agent(
+ self, task_tool: Task, ctx: InvokeContext
+ ) -> None:
+ args = TaskArgs(task="do something", agent="default")
+
+ with pytest.raises(ToolError) as exc_info:
+ await collect_result(task_tool.run(args, ctx))
+
+ assert "agent" in str(exc_info.value).lower()
+ assert "subagent" in str(exc_info.value).lower()
+
+ @pytest.mark.asyncio
+ async def test_rejects_nonexistent_agent(
+ self, task_tool: Task, ctx: InvokeContext
+ ) -> None:
+ args = TaskArgs(task="do something", agent="nonexistent")
+
+ with pytest.raises(ToolError) as exc_info:
+ await collect_result(task_tool.run(args, ctx))
+
+ assert "Unknown agent" in str(exc_info.value)
+
+ @pytest.mark.asyncio
+ async def test_requires_agent_manager_in_context(self, task_tool: Task) -> None:
+ args = TaskArgs(task="do something", agent="explore")
+ ctx = InvokeContext(tool_call_id="test-call-id") # No agent_manager
+
+ with pytest.raises(ToolError) as exc_info:
+ await collect_result(task_tool.run(args, ctx))
+
+ assert "agent_manager" in str(exc_info.value).lower()
+
+ def test_explore_agent_is_valid_subagent(self) -> None:
+ agent = BUILTIN_AGENTS["explore"]
+ assert agent.agent_type == AgentType.SUBAGENT
+
+
+class TestTaskToolExecution:
+ @pytest.fixture
+ def ctx(self) -> InvokeContext:
+ config = VibeConfig(include_project_context=False, include_prompt_detail=False)
+ manager = AgentManager(lambda: config)
+ return InvokeContext(tool_call_id="test-call-id", agent_manager=manager)
+
+ @pytest.mark.asyncio
+ async def test_happy_path_returns_subagent_response(
+ self, task_tool: Task, ctx: InvokeContext
+ ) -> None:
+ """Test that task tool successfully runs a subagent and returns its response."""
+ mock_messages = [
+ LLMMessage(role=Role.system, content="system"),
+ LLMMessage(role=Role.user, content="task"),
+ LLMMessage(role=Role.assistant, content="response 1"),
+ LLMMessage(role=Role.assistant, content="response 2"),
+ ]
+
+ async def mock_act(task: str):
+ yield AssistantEvent(content="Hello from subagent!")
+ yield AssistantEvent(content=" More content.")
+
+ with patch("vibe.core.tools.builtins.task.AgentLoop") as mock_agent_loop_class:
+ mock_agent_loop = MagicMock()
+ mock_agent_loop.act = mock_act
+ mock_agent_loop.messages = mock_messages
+ mock_agent_loop.set_approval_callback = MagicMock()
+ mock_agent_loop_class.return_value = mock_agent_loop
+
+ args = TaskArgs(task="explore the codebase", agent="explore")
+ result = await collect_result(task_tool.run(args, ctx))
+
+ assert isinstance(result, TaskResult)
+ assert result.response == "Hello from subagent! More content."
+ assert result.turns_used == 2 # 2 assistant messages in mock_messages
+ assert result.completed is True
+
+ @pytest.mark.asyncio
+ async def test_handles_stopped_by_middleware(
+ self, task_tool: Task, ctx: InvokeContext
+ ) -> None:
+ """Test that task tool reports incomplete when stopped by middleware."""
+ mock_messages = [
+ LLMMessage(role=Role.system, content="system"),
+ LLMMessage(role=Role.assistant, content="partial"),
+ ]
+
+ async def mock_act(task: str):
+ yield AssistantEvent(content="Partial response", stopped_by_middleware=True)
+
+ with patch("vibe.core.tools.builtins.task.AgentLoop") as mock_agent_loop_class:
+ mock_agent_loop = MagicMock()
+ mock_agent_loop.act = mock_act
+ mock_agent_loop.messages = mock_messages
+ mock_agent_loop.set_approval_callback = MagicMock()
+ mock_agent_loop_class.return_value = mock_agent_loop
+
+ args = TaskArgs(task="do something", agent="explore")
+ result = await collect_result(task_tool.run(args, ctx))
+
+ assert isinstance(result, TaskResult)
+ assert result.completed is False
+
+ @pytest.mark.asyncio
+ async def test_handles_subagent_exception(
+ self, task_tool: Task, ctx: InvokeContext
+ ) -> None:
+ """Test that task tool gracefully handles exceptions from subagent."""
+ mock_messages = [LLMMessage(role=Role.system, content="system")]
+
+ async def mock_act(task: str):
+ yield AssistantEvent(content="Starting...")
+ raise RuntimeError("Simulated error")
+
+ with patch("vibe.core.tools.builtins.task.AgentLoop") as mock_agent_loop_class:
+ mock_agent_loop = MagicMock()
+ mock_agent_loop.act = mock_act
+ mock_agent_loop.messages = mock_messages
+ mock_agent_loop.set_approval_callback = MagicMock()
+ mock_agent_loop_class.return_value = mock_agent_loop
+
+ args = TaskArgs(task="do something", agent="explore")
+ result = await collect_result(task_tool.run(args, ctx))
+
+ assert isinstance(result, TaskResult)
+ assert result.completed is False
+ assert "Simulated error" in result.response
diff --git a/tests/tools/test_ui_bash_execution.py b/tests/tools/test_ui_bash_execution.py
index ba564ba..53e09bd 100644
--- a/tests/tools/test_ui_bash_execution.py
+++ b/tests/tools/test_ui_bash_execution.py
@@ -9,19 +9,20 @@ from textual.widgets import Static
from vibe.cli.textual_ui.app import VibeApp
from vibe.cli.textual_ui.widgets.chat_input.container import ChatInputContainer
from vibe.cli.textual_ui.widgets.messages import BashOutputMessage, ErrorMessage
+from vibe.core.agent_loop import AgentLoop
from vibe.core.config import SessionLoggingConfig, VibeConfig
@pytest.fixture
-def vibe_config(tmp_path: Path) -> VibeConfig:
- return VibeConfig(
- session_logging=SessionLoggingConfig(enabled=False), workdir=tmp_path
- )
+def vibe_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> VibeConfig:
+ monkeypatch.chdir(tmp_path)
+ return VibeConfig(session_logging=SessionLoggingConfig(enabled=False))
@pytest.fixture
def vibe_app(vibe_config: VibeConfig) -> VibeApp:
- return VibeApp(config=vibe_config)
+ agent_loop = AgentLoop(vibe_config)
+ return VibeApp(agent_loop=agent_loop)
async def _wait_for_bash_output_message(
diff --git a/tests/update_notifier/adapters/fake_update_gateway.py b/tests/update_notifier/adapters/fake_update_gateway.py
new file mode 100644
index 0000000..2f36eaf
--- /dev/null
+++ b/tests/update_notifier/adapters/fake_update_gateway.py
@@ -0,0 +1,22 @@
+from __future__ import annotations
+
+from vibe.cli.update_notifier.ports.update_gateway import (
+ Update,
+ UpdateGateway,
+ UpdateGatewayError,
+)
+
+
+class FakeUpdateGateway(UpdateGateway):
+ def __init__(
+ self, update: Update | None = None, error: UpdateGatewayError | None = None
+ ) -> None:
+ self._update: Update | None = update
+ self._error = error
+ self.fetch_update_calls = 0
+
+ async def fetch_update(self) -> Update | None:
+ self.fetch_update_calls += 1
+ if self._error is not None:
+ raise self._error
+ return self._update
diff --git a/tests/update_notifier/adapters/fake_version_update_gateway.py b/tests/update_notifier/adapters/fake_version_update_gateway.py
deleted file mode 100644
index 957bd7b..0000000
--- a/tests/update_notifier/adapters/fake_version_update_gateway.py
+++ /dev/null
@@ -1,24 +0,0 @@
-from __future__ import annotations
-
-from vibe.cli.update_notifier.ports.version_update_gateway import (
- VersionUpdate,
- VersionUpdateGateway,
- VersionUpdateGatewayError,
-)
-
-
-class FakeVersionUpdateGateway(VersionUpdateGateway):
- def __init__(
- self,
- update: VersionUpdate | None = None,
- error: VersionUpdateGatewayError | None = None,
- ) -> None:
- self._update: VersionUpdate | None = update
- self._error = error
- self.fetch_update_calls = 0
-
- async def fetch_update(self) -> VersionUpdate | None:
- self.fetch_update_calls += 1
- if self._error is not None:
- raise self._error
- return self._update
diff --git a/tests/update_notifier/test_do_update.py b/tests/update_notifier/test_do_update.py
new file mode 100644
index 0000000..1053801
--- /dev/null
+++ b/tests/update_notifier/test_do_update.py
@@ -0,0 +1,74 @@
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from vibe.cli.update_notifier.update import do_update
+
+
+@pytest.mark.asyncio
+async def test_do_update_returns_true_when_first_command_succeeds() -> None:
+ mock_process = MagicMock()
+ mock_process.wait = AsyncMock(return_value=None)
+ mock_process.returncode = 0
+
+ with patch(
+ "vibe.cli.update_notifier.update.UPDATE_COMMANDS", ["command_1", "command_2"]
+ ):
+ with patch(
+ "vibe.cli.update_notifier.update.asyncio.create_subprocess_shell"
+ ) as mock_create:
+ mock_create.return_value = mock_process
+
+ result = await do_update()
+
+ assert result is True
+ mock_create.assert_called_once()
+ assert "command_1" in mock_create.call_args[0][0]
+
+
+@pytest.mark.asyncio
+async def test_do_update_returns_true_when_second_command_succeeds() -> None:
+ mock_process_fail = MagicMock()
+ mock_process_fail.wait = AsyncMock(return_value=None)
+ mock_process_fail.returncode = 1
+
+ mock_process_success = MagicMock()
+ mock_process_success.wait = AsyncMock(return_value=None)
+ mock_process_success.returncode = 0
+
+ with patch(
+ "vibe.cli.update_notifier.update.UPDATE_COMMANDS", ["command_1", "command_2"]
+ ):
+ with patch(
+ "vibe.cli.update_notifier.update.asyncio.create_subprocess_shell"
+ ) as mock_create:
+ mock_create.side_effect = [mock_process_fail, mock_process_success]
+
+ result = await do_update()
+
+ assert result is True
+ assert mock_create.call_count == 2
+ assert "command_1" in mock_create.call_args_list[0][0][0]
+ assert "command_2" in mock_create.call_args_list[1][0][0]
+
+
+@pytest.mark.asyncio
+async def test_do_update_returns_false_when_all_commands_fail() -> None:
+ mock_process = MagicMock()
+ mock_process.wait = AsyncMock(return_value=None)
+ mock_process.returncode = 1
+
+ with patch(
+ "vibe.cli.update_notifier.update.UPDATE_COMMANDS", ["command_1", "command_2"]
+ ):
+ with patch(
+ "vibe.cli.update_notifier.update.asyncio.create_subprocess_shell"
+ ) as mock_create:
+ mock_create.return_value = mock_process
+
+ result = await do_update()
+
+ assert result is False
+ assert mock_create.call_count == 2
diff --git a/tests/update_notifier/test_filesystem_update_cache_repository.py b/tests/update_notifier/test_filesystem_update_cache_repository.py
index fa6c511..4187243 100644
--- a/tests/update_notifier/test_filesystem_update_cache_repository.py
+++ b/tests/update_notifier/test_filesystem_update_cache_repository.py
@@ -24,6 +24,7 @@ async def test_reads_cache_from_file_when_present(tmp_path: Path) -> None:
assert cache is not None
assert cache.latest_version == "1.2.3"
assert cache.stored_at_timestamp == 1_700_000_000
+ assert cache.seen_whats_new_version is None
@pytest.mark.asyncio
@@ -62,6 +63,46 @@ async def test_overwrites_existing_cache(tmp_path: Path) -> None:
content = json.loads(cache_file.read_text())
assert content["latest_version"] == "1.1.0"
assert content["stored_at_timestamp"] == 1_700_200_000
+ assert content.get("seen_whats_new_version") is None
+
+
+@pytest.mark.asyncio
+async def test_reads_cache_with_seen_whats_new_version(tmp_path: Path) -> None:
+ cache_file = tmp_path / "update_cache.json"
+ cache_file.write_text(
+ json.dumps({
+ "latest_version": "1.2.3",
+ "stored_at_timestamp": 1_700_000_000,
+ "seen_whats_new_version": "1.2.0",
+ })
+ )
+ repository = FileSystemUpdateCacheRepository(base_path=tmp_path)
+
+ cache = await repository.get()
+
+ assert cache is not None
+ assert cache.latest_version == "1.2.3"
+ assert cache.stored_at_timestamp == 1_700_000_000
+ assert cache.seen_whats_new_version == "1.2.0"
+
+
+@pytest.mark.asyncio
+async def test_writes_cache_with_seen_whats_new_version(tmp_path: Path) -> None:
+ repository = FileSystemUpdateCacheRepository(base_path=tmp_path)
+
+ await repository.set(
+ UpdateCache(
+ latest_version="1.1.0",
+ stored_at_timestamp=1_700_200_000,
+ seen_whats_new_version="1.1.0",
+ )
+ )
+
+ cache_file = tmp_path / "update_cache.json"
+ content = json.loads(cache_file.read_text())
+ assert content["latest_version"] == "1.1.0"
+ assert content["stored_at_timestamp"] == 1_700_200_000
+ assert content["seen_whats_new_version"] == "1.1.0"
@pytest.mark.asyncio
diff --git a/tests/update_notifier/test_github_version_update_gateway.py b/tests/update_notifier/test_github_update_gateway.py
similarity index 85%
rename from tests/update_notifier/test_github_version_update_gateway.py
rename to tests/update_notifier/test_github_update_gateway.py
index cf59668..04007bf 100644
--- a/tests/update_notifier/test_github_version_update_gateway.py
+++ b/tests/update_notifier/test_github_update_gateway.py
@@ -6,9 +6,9 @@ import httpx
import pytest
from vibe.cli.update_notifier import (
- GitHubVersionUpdateGateway,
- VersionUpdateGatewayCause,
- VersionUpdateGatewayError,
+ GitHubUpdateGateway,
+ UpdateGatewayCause,
+ UpdateGatewayError,
)
Handler = Callable[[httpx.Request], httpx.Response]
@@ -33,9 +33,7 @@ async def test_retrieves_latest_version_when_available() -> None:
async with httpx.AsyncClient(
transport=transport, base_url=GITHUB_API_URL
) as client:
- notifier = GitHubVersionUpdateGateway(
- "owner", "repo", token="token", client=client
- )
+ notifier = GitHubUpdateGateway("owner", "repo", token="token", client=client)
update = await notifier.fetch_update()
assert update is not None
@@ -54,7 +52,7 @@ async def test_strips_uppercase_prefix_from_tag_name() -> None:
async with httpx.AsyncClient(
transport=transport, base_url=GITHUB_API_URL
) as client:
- notifier = GitHubVersionUpdateGateway("owner", "repo", client=client)
+ notifier = GitHubUpdateGateway("owner", "repo", client=client)
update = await notifier.fetch_update()
assert update is not None
@@ -77,7 +75,7 @@ async def test_considers_no_update_available_when_no_releases_are_found() -> Non
async with httpx.AsyncClient(
transport=transport, base_url=GITHUB_API_URL
) as client:
- notifier = GitHubVersionUpdateGateway("owner", "repo", client=client)
+ notifier = GitHubUpdateGateway("owner", "repo", client=client)
update = await notifier.fetch_update()
assert update is None
@@ -100,7 +98,7 @@ async def test_considers_no_update_available_when_only_drafts_and_prereleases_ar
async with httpx.AsyncClient(
transport=transport, base_url=GITHUB_API_URL
) as client:
- notifier = GitHubVersionUpdateGateway("owner", "repo", client=client)
+ notifier = GitHubUpdateGateway("owner", "repo", client=client)
update = await notifier.fetch_update()
assert update is None
@@ -149,7 +147,7 @@ async def test_picks_the_most_recently_published_non_prerelease_and_non_draft()
async with httpx.AsyncClient(
transport=transport, base_url=GITHUB_API_URL
) as client:
- notifier = GitHubVersionUpdateGateway("owner", "repo", client=client)
+ notifier = GitHubUpdateGateway("owner", "repo", client=client)
update = await notifier.fetch_update()
assert update is not None
@@ -174,7 +172,7 @@ async def test_ignores_draft_releases_and_prereleases(
async with httpx.AsyncClient(
transport=transport, base_url=GITHUB_API_URL
) as client:
- notifier = GitHubVersionUpdateGateway("owner", "repo", client=client)
+ notifier = GitHubUpdateGateway("owner", "repo", client=client)
update = await notifier.fetch_update()
assert update is None
@@ -185,7 +183,7 @@ async def test_ignores_draft_releases_and_prereleases(
[
(
lambda _: httpx.Response(status_code=httpx.codes.NOT_FOUND),
- VersionUpdateGatewayCause.NOT_FOUND,
+ UpdateGatewayCause.NOT_FOUND,
"Unable to fetch the GitHub releases. Did you export a GITHUB_TOKEN environment variable?",
),
(
@@ -193,30 +191,30 @@ async def test_ignores_draft_releases_and_prereleases(
status_code=httpx.codes.FORBIDDEN,
headers={"X-RateLimit-Remaining": "0"},
),
- VersionUpdateGatewayCause.TOO_MANY_REQUESTS,
+ UpdateGatewayCause.TOO_MANY_REQUESTS,
None,
),
(
lambda _: httpx.Response(status_code=httpx.codes.TOO_MANY_REQUESTS),
- VersionUpdateGatewayCause.TOO_MANY_REQUESTS,
+ UpdateGatewayCause.TOO_MANY_REQUESTS,
None,
),
(
lambda _: httpx.Response(status_code=httpx.codes.FORBIDDEN),
- VersionUpdateGatewayCause.FORBIDDEN,
+ UpdateGatewayCause.FORBIDDEN,
None,
),
(
lambda _: httpx.Response(status_code=httpx.codes.INTERNAL_SERVER_ERROR),
- VersionUpdateGatewayCause.ERROR_RESPONSE,
+ UpdateGatewayCause.ERROR_RESPONSE,
None,
),
(
lambda _: httpx.Response(status_code=httpx.codes.OK, text="not json"),
- VersionUpdateGatewayCause.INVALID_RESPONSE,
+ UpdateGatewayCause.INVALID_RESPONSE,
None,
),
- (_raise_connect_timeout, VersionUpdateGatewayCause.REQUEST_FAILED, None),
+ (_raise_connect_timeout, UpdateGatewayCause.REQUEST_FAILED, None),
],
ids=[
"not_found",
@@ -231,15 +229,15 @@ async def test_ignores_draft_releases_and_prereleases(
@pytest.mark.asyncio
async def test_retrieves_nothing_when_fetching_update_fails(
handler: Handler,
- expected_cause: VersionUpdateGatewayCause,
+ expected_cause: UpdateGatewayCause,
expected_custom_message: str | None,
) -> None:
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(
transport=transport, base_url=GITHUB_API_URL
) as client:
- notifier = GitHubVersionUpdateGateway("owner", "repo", client=client)
- with pytest.raises(VersionUpdateGatewayError) as excinfo:
+ notifier = GitHubUpdateGateway("owner", "repo", client=client)
+ with pytest.raises(UpdateGatewayError) as excinfo:
await notifier.fetch_update()
assert excinfo.value.cause == expected_cause
diff --git a/tests/update_notifier/test_pypi_version_update_gateway.py b/tests/update_notifier/test_pypi_update_gateway.py
similarity index 79%
rename from tests/update_notifier/test_pypi_version_update_gateway.py
rename to tests/update_notifier/test_pypi_update_gateway.py
index 1bd3cc4..fd84e12 100644
--- a/tests/update_notifier/test_pypi_version_update_gateway.py
+++ b/tests/update_notifier/test_pypi_update_gateway.py
@@ -5,13 +5,11 @@ from collections.abc import Callable
import httpx
import pytest
-from vibe.cli.update_notifier.adapters.pypi_version_update_gateway import (
- PyPIVersionUpdateGateway,
-)
-from vibe.cli.update_notifier.ports.version_update_gateway import (
- VersionUpdate,
- VersionUpdateGatewayCause,
- VersionUpdateGatewayError,
+from vibe.cli.update_notifier.adapters.pypi_update_gateway import PyPIUpdateGateway
+from vibe.cli.update_notifier.ports.update_gateway import (
+ Update,
+ UpdateGatewayCause,
+ UpdateGatewayError,
)
Handler = Callable[[httpx.Request], httpx.Response]
@@ -28,7 +26,7 @@ async def test_retrieves_nothing_when_no_versions_are_available() -> None:
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, base_url=PYPI_API_URL) as client:
- gateway = PyPIVersionUpdateGateway(project_name="mistral-vibe", client=client)
+ gateway = PyPIUpdateGateway(project_name="mistral-vibe", client=client)
update = await gateway.fetch_update()
assert update is None
@@ -59,10 +57,10 @@ async def test_retrieves_the_latest_non_yanked_version() -> None:
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, base_url=PYPI_API_URL) as client:
- gateway = PyPIVersionUpdateGateway(project_name="mistral-vibe", client=client)
+ gateway = PyPIUpdateGateway(project_name="mistral-vibe", client=client)
update = await gateway.fetch_update()
- assert update == VersionUpdate(latest_version="1.0.2")
+ assert update == Update(latest_version="1.0.2")
@pytest.mark.asyncio
@@ -80,7 +78,7 @@ async def test_retrieves_nothing_when_only_yanked_versions_are_available() -> No
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, base_url=PYPI_API_URL) as client:
- gateway = PyPIVersionUpdateGateway(project_name="mistral-vibe", client=client)
+ gateway = PyPIUpdateGateway(project_name="mistral-vibe", client=client)
update = await gateway.fetch_update()
assert update is None
@@ -104,7 +102,7 @@ async def test_does_not_match_versions_by_substring() -> None:
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, base_url=PYPI_API_URL) as client:
- gateway = PyPIVersionUpdateGateway(project_name="mistral-vibe", client=client)
+ gateway = PyPIUpdateGateway(project_name="mistral-vibe", client=client)
update = await gateway.fetch_update()
assert update is None
@@ -119,37 +117,37 @@ def _raise_connect_timeout(request: httpx.Request) -> httpx.Response:
[
(
lambda _: httpx.Response(status_code=httpx.codes.NOT_FOUND),
- VersionUpdateGatewayCause.NOT_FOUND,
+ UpdateGatewayCause.NOT_FOUND,
None,
),
(
lambda _: httpx.Response(status_code=httpx.codes.FORBIDDEN),
- VersionUpdateGatewayCause.FORBIDDEN,
+ UpdateGatewayCause.FORBIDDEN,
None,
),
(
lambda _: httpx.Response(status_code=httpx.codes.INTERNAL_SERVER_ERROR),
- VersionUpdateGatewayCause.ERROR_RESPONSE,
+ UpdateGatewayCause.ERROR_RESPONSE,
None,
),
(
lambda _: httpx.Response(status_code=httpx.codes.OK, content=b"{not-json"),
- VersionUpdateGatewayCause.INVALID_RESPONSE,
+ UpdateGatewayCause.INVALID_RESPONSE,
None,
),
- (_raise_connect_timeout, VersionUpdateGatewayCause.REQUEST_FAILED, None),
+ (_raise_connect_timeout, UpdateGatewayCause.REQUEST_FAILED, None),
],
)
@pytest.mark.asyncio
async def test_retrieves_nothing_when_fetching_update_fails(
handler: Callable[[httpx.Request], httpx.Response],
- expected_cause: VersionUpdateGatewayCause,
+ expected_cause: UpdateGatewayCause,
expected_message: str | None,
) -> None:
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, base_url=PYPI_API_URL) as client:
- gateway = PyPIVersionUpdateGateway(project_name="mistral-vibe", client=client)
- with pytest.raises(VersionUpdateGatewayError) as excinfo:
+ gateway = PyPIUpdateGateway(project_name="mistral-vibe", client=client)
+ with pytest.raises(UpdateGatewayError) as excinfo:
await gateway.fetch_update()
assert excinfo.value.cause == expected_cause
diff --git a/tests/update_notifier/test_ui_update_notification.py b/tests/update_notifier/test_ui_update_notification.py
new file mode 100644
index 0000000..996bda4
--- /dev/null
+++ b/tests/update_notifier/test_ui_update_notification.py
@@ -0,0 +1,406 @@
+from __future__ import annotations
+
+import asyncio
+from pathlib import Path
+import time
+from typing import Protocol
+from unittest.mock import patch
+
+import pytest
+from textual.app import Notification
+
+from tests.update_notifier.adapters.fake_update_cache_repository import (
+ FakeUpdateCacheRepository,
+)
+from tests.update_notifier.adapters.fake_update_gateway import FakeUpdateGateway
+from vibe.cli.textual_ui.app import VibeApp
+from vibe.cli.textual_ui.widgets.messages import WhatsNewMessage
+from vibe.cli.update_notifier import (
+ Update,
+ UpdateCache,
+ UpdateGatewayCause,
+ UpdateGatewayError,
+)
+from vibe.core.agent_loop import AgentLoop
+from vibe.core.agents.models import BuiltinAgentName
+from vibe.core.config import SessionLoggingConfig, VibeConfig
+
+
+async def _wait_for_notification(
+ app: VibeApp, pilot, *, timeout: float = 1.0, interval: float = 0.05
+) -> Notification:
+ loop = asyncio.get_running_loop()
+ deadline = loop.time() + timeout
+
+ while loop.time() < deadline:
+ notifications = list(app._notifications)
+ if notifications:
+ return notifications[-1]
+ await pilot.pause(interval)
+
+ pytest.fail("Notification not displayed")
+
+
+async def _assert_no_notifications(
+ app: VibeApp, pilot, *, timeout: float = 1.0, interval: float = 0.05
+) -> None:
+ loop = asyncio.get_running_loop()
+ deadline = loop.time() + timeout
+
+ while loop.time() < deadline:
+ if app._notifications:
+ pytest.fail("Notification unexpectedly displayed")
+ await pilot.pause(interval)
+
+ assert not app._notifications
+
+
+@pytest.fixture
+def vibe_config_with_update_checks_enabled() -> VibeConfig:
+ return VibeConfig(
+ session_logging=SessionLoggingConfig(enabled=False), enable_update_checks=True
+ )
+
+
+class VibeAppFactory(Protocol):
+ def __call__(
+ self,
+ *,
+ notifier: FakeUpdateGateway,
+ update_cache_repository: FakeUpdateCacheRepository | None = None,
+ config: VibeConfig | None = None,
+ initial_agent_name: str = BuiltinAgentName.DEFAULT,
+ current_version: str = "0.1.0",
+ ) -> VibeApp: ...
+
+
+@pytest.fixture
+def make_vibe_app(vibe_config_with_update_checks_enabled: VibeConfig) -> VibeAppFactory:
+ update_cache_repository = FakeUpdateCacheRepository()
+
+ def _make_app(
+ *,
+ notifier: FakeUpdateGateway,
+ update_cache_repository: FakeUpdateCacheRepository
+ | None = update_cache_repository,
+ config: VibeConfig | None = None,
+ initial_agent_name: str = BuiltinAgentName.DEFAULT,
+ current_version: str = "0.1.0",
+ ) -> VibeApp:
+ app_config = config or vibe_config_with_update_checks_enabled
+ agent_loop = AgentLoop(
+ app_config, agent_name=initial_agent_name, enable_streaming=False
+ )
+ return VibeApp(
+ agent_loop=agent_loop,
+ update_notifier=notifier,
+ update_cache_repository=update_cache_repository,
+ current_version=current_version,
+ )
+
+ return _make_app
+
+
+@pytest.mark.asyncio
+async def test_ui_displays_update_notification(make_vibe_app: VibeAppFactory) -> None:
+ notifier = FakeUpdateGateway(update=Update(latest_version="0.2.0"))
+ app = make_vibe_app(notifier=notifier)
+
+ async with app.run_test() as pilot:
+ notification = await _wait_for_notification(app, pilot, timeout=0.3)
+
+ assert notification.severity == "information"
+ assert notification.title == "Update available"
+ assert (
+ notification.message
+ == "0.1.0 => 0.2.0\nPlease update mistral-vibe with your package manager"
+ )
+
+
+@pytest.mark.asyncio
+async def test_ui_does_not_display_update_notification_when_not_available(
+ make_vibe_app: VibeAppFactory,
+) -> None:
+ notifier = FakeUpdateGateway(update=None)
+ app = make_vibe_app(notifier=notifier)
+
+ async with app.run_test() as pilot:
+ await _assert_no_notifications(app, pilot, timeout=0.3)
+ assert notifier.fetch_update_calls == 1
+
+
+@pytest.mark.asyncio
+async def test_ui_displays_warning_toast_when_check_fails(
+ make_vibe_app: VibeAppFactory,
+) -> None:
+ notifier = FakeUpdateGateway(
+ error=UpdateGatewayError(cause=UpdateGatewayCause.FORBIDDEN)
+ )
+ app = make_vibe_app(notifier=notifier)
+
+ async with app.run_test() as pilot:
+ await pilot.pause(0.3)
+ notifications = list(app._notifications)
+
+ assert notifications
+ warning = notifications[-1]
+ assert warning.severity == "warning"
+ assert "forbidden" in warning.message.lower()
+
+
+@pytest.mark.asyncio
+async def test_ui_does_not_invoke_gateway_nor_show_error_notification_when_update_checks_are_disabled(
+ vibe_config_with_update_checks_enabled: VibeConfig, make_vibe_app: VibeAppFactory
+) -> None:
+ config = vibe_config_with_update_checks_enabled
+ config.enable_update_checks = False
+ notifier = FakeUpdateGateway(update=Update(latest_version="0.2.0"))
+ app = make_vibe_app(notifier=notifier, config=config)
+
+ async with app.run_test() as pilot:
+ await _assert_no_notifications(app, pilot, timeout=0.3)
+
+ assert notifier.fetch_update_calls == 0
+
+
+@pytest.mark.asyncio
+async def test_ui_does_not_show_toast_when_update_is_known_in_recent_cache_already(
+ make_vibe_app: VibeAppFactory,
+) -> None:
+ timestamp_two_hours_ago = int(time.time()) - 2 * 60 * 60
+ notifier = FakeUpdateGateway(update=Update(latest_version="0.2.0"))
+ update_cache = UpdateCache(
+ latest_version="0.2.0", stored_at_timestamp=timestamp_two_hours_ago
+ )
+ update_cache_repository = FakeUpdateCacheRepository(update_cache=update_cache)
+ app = make_vibe_app(
+ notifier=notifier, update_cache_repository=update_cache_repository
+ )
+
+ async with app.run_test() as pilot:
+ await _assert_no_notifications(app, pilot, timeout=0.3)
+
+ assert notifier.fetch_update_calls == 0
+
+
+@pytest.mark.asyncio
+async def test_ui_does_show_toast_when_cache_entry_is_too_old(
+ make_vibe_app: VibeAppFactory,
+) -> None:
+ timestamp_two_days_ago = int(time.time()) - 2 * 24 * 60 * 60
+ notifier = FakeUpdateGateway(update=Update(latest_version="0.2.0"))
+ update_cache = UpdateCache(
+ latest_version="0.2.0", stored_at_timestamp=timestamp_two_days_ago
+ )
+ update_cache_repository = FakeUpdateCacheRepository(update_cache=update_cache)
+ app = make_vibe_app(
+ notifier=notifier, update_cache_repository=update_cache_repository
+ )
+
+ async with app.run_test() as pilot:
+ await pilot.pause(0.3)
+ notifications = list(app._notifications)
+
+ assert notifications
+ notification = notifications[-1]
+ assert notification.severity == "information"
+ assert notification.title == "Update available"
+ assert (
+ notification.message
+ == "0.1.0 => 0.2.0\nPlease update mistral-vibe with your package manager"
+ )
+ assert notifier.fetch_update_calls == 1
+
+
+async def _wait_for_whats_new_message(
+ app: VibeApp, pilot, *, timeout: float = 1.0, interval: float = 0.05
+) -> WhatsNewMessage:
+ loop = asyncio.get_running_loop()
+ deadline = loop.time() + timeout
+
+ while loop.time() < deadline:
+ try:
+ message = app.query_one(WhatsNewMessage)
+ if message:
+ return message
+ except Exception:
+ pass
+ await pilot.pause(interval)
+
+ pytest.fail("WhatsNewMessage not displayed")
+
+
+@pytest.mark.asyncio
+async def test_ui_displays_whats_new_message_when_content_exists(
+ make_vibe_app: VibeAppFactory, tmp_path: Path
+) -> None:
+ notifier = FakeUpdateGateway(update=None)
+ cache = UpdateCache(
+ latest_version="1.0.0",
+ stored_at_timestamp=int(time.time()),
+ seen_whats_new_version=None,
+ )
+ repository = FakeUpdateCacheRepository(update_cache=cache)
+ app = make_vibe_app(
+ notifier=notifier, update_cache_repository=repository, current_version="1.0.0"
+ )
+
+ whats_new_content = "# What's New\n\n- Feature 1\n- Feature 2"
+ with patch("vibe.cli.update_notifier.whats_new.VIBE_ROOT", tmp_path):
+ whats_new_file = tmp_path / "whats_new.md"
+ whats_new_file.write_text(whats_new_content)
+
+ async with app.run_test() as pilot:
+ await pilot.pause(0.5)
+ message = await _wait_for_whats_new_message(app, pilot, timeout=0.5)
+
+ assert message is not None
+ assert message._content == whats_new_content
+ assert repository.update_cache is not None
+ assert repository.update_cache.seen_whats_new_version == "1.0.0"
+
+
+@pytest.mark.asyncio
+async def test_ui_does_not_display_whats_new_when_seen_whats_new_version_matches(
+ make_vibe_app: VibeAppFactory, tmp_path: Path
+) -> None:
+ notifier = FakeUpdateGateway(update=None)
+ cache = UpdateCache(
+ latest_version="1.0.0",
+ stored_at_timestamp=int(time.time()),
+ seen_whats_new_version="1.0.0",
+ )
+ repository = FakeUpdateCacheRepository(update_cache=cache)
+ app = make_vibe_app(
+ notifier=notifier, update_cache_repository=repository, current_version="1.0.0"
+ )
+
+ with patch("vibe.cli.update_notifier.whats_new.VIBE_ROOT", tmp_path):
+ whats_new_file = tmp_path / "whats_new.md"
+ whats_new_file.write_text("# What's New\n\n- Feature 1")
+
+ async with app.run_test() as pilot:
+ await pilot.pause(0.5)
+
+ try:
+ app.query_one(WhatsNewMessage)
+ pytest.fail("WhatsNewMessage should not be displayed")
+ except Exception:
+ pass
+
+
+@pytest.mark.asyncio
+async def test_ui_does_not_display_whats_new_when_file_is_empty(
+ make_vibe_app: VibeAppFactory, tmp_path: Path
+) -> None:
+ notifier = FakeUpdateGateway(update=None)
+ cache = UpdateCache(
+ latest_version="1.0.0",
+ stored_at_timestamp=int(time.time()),
+ seen_whats_new_version=None,
+ )
+ repository = FakeUpdateCacheRepository(update_cache=cache)
+ app = make_vibe_app(
+ notifier=notifier, update_cache_repository=repository, current_version="1.0.0"
+ )
+
+ with patch("vibe.cli.update_notifier.whats_new.VIBE_ROOT", tmp_path):
+ whats_new_file = tmp_path / "whats_new.md"
+ whats_new_file.write_text("")
+
+ async with app.run_test() as pilot:
+ await pilot.pause(0.5)
+
+ try:
+ app.query_one(WhatsNewMessage)
+ pytest.fail("WhatsNewMessage should not be displayed")
+ except Exception:
+ pass # Expected: message should not exist
+
+ assert repository.update_cache is not None
+ assert repository.update_cache.seen_whats_new_version == "1.0.0"
+
+
+@pytest.mark.asyncio
+async def test_ui_does_not_display_whats_new_when_file_does_not_exist(
+ make_vibe_app: VibeAppFactory, tmp_path: Path
+) -> None:
+ notifier = FakeUpdateGateway(update=None)
+ cache = UpdateCache(
+ latest_version="1.0.0",
+ stored_at_timestamp=int(time.time()),
+ seen_whats_new_version=None,
+ )
+ repository = FakeUpdateCacheRepository(update_cache=cache)
+ app = make_vibe_app(
+ notifier=notifier, update_cache_repository=repository, current_version="1.0.0"
+ )
+
+ with patch("vibe.cli.update_notifier.whats_new.VIBE_ROOT", tmp_path):
+ async with app.run_test() as pilot:
+ await pilot.pause(0.5)
+
+ try:
+ app.query_one(WhatsNewMessage)
+ pytest.fail("WhatsNewMessage should not be displayed")
+ except Exception:
+ pass # Expected: message should not exist
+
+ assert repository.update_cache is not None
+ assert repository.update_cache.seen_whats_new_version == "1.0.0"
+
+
+@pytest.mark.asyncio
+async def test_ui_displays_success_notification_when_auto_update_succeeds(
+ make_vibe_app: VibeAppFactory,
+) -> None:
+ config = VibeConfig(
+ session_logging=SessionLoggingConfig(enabled=False),
+ enable_update_checks=True,
+ enable_auto_update=True,
+ )
+ notifier = FakeUpdateGateway(update=Update(latest_version="0.2.0"))
+
+ with patch("vibe.cli.update_notifier.update.UPDATE_COMMANDS", ["true"]):
+ app = make_vibe_app(notifier=notifier, config=config)
+
+ async with app.run_test() as pilot:
+ await pilot.pause(0.3)
+ notifications = list(app._notifications)
+
+ assert notifications, "No notifications displayed"
+ notification = notifications[-1]
+ assert notification.severity == "information"
+ assert notification.title == "Update successful"
+ assert (
+ notification.message
+ == "0.1.0 => 0.2.0\nVibe was updated successfully. Please restart to use the new version."
+ )
+
+
+@pytest.mark.asyncio
+async def test_ui_displays_update_notification_when_auto_update_fails(
+ make_vibe_app: VibeAppFactory,
+) -> None:
+ config = VibeConfig(
+ session_logging=SessionLoggingConfig(enabled=False),
+ enable_update_checks=True,
+ enable_auto_update=True,
+ )
+ notifier = FakeUpdateGateway(update=Update(latest_version="0.2.0"))
+
+ with patch("vibe.cli.update_notifier.update.UPDATE_COMMANDS", ["false"]):
+ app = make_vibe_app(notifier=notifier, config=config)
+
+ async with app.run_test() as pilot:
+ await pilot.pause(0.3)
+ notifications = list(app._notifications)
+
+ assert notifications
+ notification = notifications[-1]
+ assert notification.severity == "information"
+ assert notification.title == "Update available"
+ assert (
+ notification.message
+ == "0.1.0 => 0.2.0\nPlease update mistral-vibe with your package manager"
+ )
diff --git a/tests/update_notifier/test_ui_version_update_notification.py b/tests/update_notifier/test_ui_version_update_notification.py
deleted file mode 100644
index 71cc953..0000000
--- a/tests/update_notifier/test_ui_version_update_notification.py
+++ /dev/null
@@ -1,222 +0,0 @@
-from __future__ import annotations
-
-import asyncio
-import time
-from typing import Protocol
-
-import pytest
-from textual.app import Notification
-
-from tests.update_notifier.adapters.fake_update_cache_repository import (
- FakeUpdateCacheRepository,
-)
-from tests.update_notifier.adapters.fake_version_update_gateway import (
- FakeVersionUpdateGateway,
-)
-from vibe.cli.textual_ui.app import VibeApp
-from vibe.cli.update_notifier import (
- UpdateCache,
- VersionUpdate,
- VersionUpdateGatewayCause,
- VersionUpdateGatewayError,
-)
-from vibe.core.config import SessionLoggingConfig, VibeConfig
-from vibe.core.modes import AgentMode
-
-
-async def _wait_for_notification(
- app: VibeApp, pilot, *, timeout: float = 1.0, interval: float = 0.05
-) -> Notification:
- loop = asyncio.get_running_loop()
- deadline = loop.time() + timeout
-
- while loop.time() < deadline:
- notifications = list(app._notifications)
- if notifications:
- return notifications[-1]
- await pilot.pause(interval)
-
- pytest.fail("Notification not displayed")
-
-
-async def _assert_no_notifications(
- app: VibeApp, pilot, *, timeout: float = 1.0, interval: float = 0.05
-) -> None:
- loop = asyncio.get_running_loop()
- deadline = loop.time() + timeout
-
- while loop.time() < deadline:
- if app._notifications:
- pytest.fail("Notification unexpectedly displayed")
- await pilot.pause(interval)
-
- assert not app._notifications
-
-
-@pytest.fixture
-def vibe_config_with_update_checks_enabled() -> VibeConfig:
- return VibeConfig(
- session_logging=SessionLoggingConfig(enabled=False), enable_update_checks=True
- )
-
-
-class VibeAppFactory(Protocol):
- def __call__(
- self,
- *,
- notifier: FakeVersionUpdateGateway,
- update_cache_repository: FakeUpdateCacheRepository | None = None,
- config: VibeConfig | None = None,
- initial_mode: AgentMode = AgentMode.DEFAULT,
- current_version: str = "0.1.0",
- ) -> VibeApp: ...
-
-
-@pytest.fixture
-def make_vibe_app(vibe_config_with_update_checks_enabled: VibeConfig) -> VibeAppFactory:
- update_cache_repository = FakeUpdateCacheRepository()
-
- def _make_app(
- *,
- notifier: FakeVersionUpdateGateway,
- update_cache_repository: FakeUpdateCacheRepository
- | None = update_cache_repository,
- config: VibeConfig | None = None,
- initial_mode: AgentMode = AgentMode.DEFAULT,
- current_version: str = "0.1.0",
- ) -> VibeApp:
- return VibeApp(
- config=config or vibe_config_with_update_checks_enabled,
- initial_mode=initial_mode,
- version_update_notifier=notifier,
- update_cache_repository=update_cache_repository,
- current_version=current_version,
- )
-
- return _make_app
-
-
-@pytest.mark.asyncio
-async def test_ui_displays_update_notification(make_vibe_app: VibeAppFactory) -> None:
- notifier = FakeVersionUpdateGateway(update=VersionUpdate(latest_version="0.2.0"))
- app = make_vibe_app(notifier=notifier)
-
- async with app.run_test() as pilot:
- notification = await _wait_for_notification(app, pilot, timeout=0.3)
-
- assert notification.severity == "information"
- assert notification.title == "Update available"
- assert (
- notification.message
- == '0.1.0 => 0.2.0\nRun "uv tool upgrade mistral-vibe" to update'
- )
-
-
-@pytest.mark.asyncio
-async def test_ui_does_not_display_update_notification_when_not_available(
- make_vibe_app: VibeAppFactory,
-) -> None:
- notifier = FakeVersionUpdateGateway(update=None)
- app = make_vibe_app(notifier=notifier)
-
- async with app.run_test() as pilot:
- await _assert_no_notifications(app, pilot, timeout=0.3)
- assert notifier.fetch_update_calls == 1
-
-
-@pytest.mark.asyncio
-async def test_ui_displays_warning_toast_when_check_fails(
- make_vibe_app: VibeAppFactory,
-) -> None:
- notifier = FakeVersionUpdateGateway(
- error=VersionUpdateGatewayError(cause=VersionUpdateGatewayCause.FORBIDDEN)
- )
- app = make_vibe_app(notifier=notifier)
-
- async with app.run_test() as pilot:
- await pilot.pause(0.3)
- notifications = list(app._notifications)
-
- assert notifications
- warning = notifications[-1]
- assert warning.severity == "warning"
- assert "forbidden" in warning.message.lower()
-
-
-@pytest.mark.asyncio
-async def test_ui_does_not_invoke_gateway_nor_show_error_notification_when_update_checks_are_disabled(
- vibe_config_with_update_checks_enabled: VibeConfig, make_vibe_app: VibeAppFactory
-) -> None:
- config = vibe_config_with_update_checks_enabled
- config.enable_update_checks = False
- notifier = FakeVersionUpdateGateway(update=VersionUpdate(latest_version="0.2.0"))
- app = make_vibe_app(notifier=notifier, config=config)
-
- async with app.run_test() as pilot:
- await _assert_no_notifications(app, pilot, timeout=0.3)
-
- assert notifier.fetch_update_calls == 0
-
-
-@pytest.mark.asyncio
-async def test_ui_does_not_invoke_gateway_nor_show_update_notification_when_update_checks_are_disabled(
- vibe_config_with_update_checks_enabled: VibeConfig, make_vibe_app: VibeAppFactory
-) -> None:
- config = vibe_config_with_update_checks_enabled
- config.enable_update_checks = False
- notifier = FakeVersionUpdateGateway(update=VersionUpdate(latest_version="0.2.0"))
- app = make_vibe_app(notifier=notifier, config=config)
-
- async with app.run_test() as pilot:
- await _assert_no_notifications(app, pilot, timeout=0.3)
-
- assert notifier.fetch_update_calls == 0
-
-
-@pytest.mark.asyncio
-async def test_ui_does_not_show_toast_when_update_is_known_in_recent_cache_already(
- make_vibe_app: VibeAppFactory,
-) -> None:
- timestamp_two_hours_ago = int(time.time()) - 2 * 60 * 60
- notifier = FakeVersionUpdateGateway(update=VersionUpdate(latest_version="0.2.0"))
- update_cache = UpdateCache(
- latest_version="0.2.0", stored_at_timestamp=timestamp_two_hours_ago
- )
- update_cache_repository = FakeUpdateCacheRepository(update_cache=update_cache)
- app = make_vibe_app(
- notifier=notifier, update_cache_repository=update_cache_repository
- )
-
- async with app.run_test() as pilot:
- await _assert_no_notifications(app, pilot, timeout=0.3)
-
- assert notifier.fetch_update_calls == 0
-
-
-@pytest.mark.asyncio
-async def test_ui_does_show_toast_when_cache_entry_is_too_old(
- make_vibe_app: VibeAppFactory,
-) -> None:
- timestamp_two_days_ago = int(time.time()) - 2 * 24 * 60 * 60
- notifier = FakeVersionUpdateGateway(update=VersionUpdate(latest_version="0.2.0"))
- update_cache = UpdateCache(
- latest_version="0.2.0", stored_at_timestamp=timestamp_two_days_ago
- )
- update_cache_repository = FakeUpdateCacheRepository(update_cache=update_cache)
- app = make_vibe_app(
- notifier=notifier, update_cache_repository=update_cache_repository
- )
-
- async with app.run_test() as pilot:
- await pilot.pause(0.3)
- notifications = list(app._notifications)
-
- assert notifications
- notification = notifications[-1]
- assert notification.severity == "information"
- assert notification.title == "Update available"
- assert (
- notification.message
- == '0.1.0 => 0.2.0\nRun "uv tool upgrade mistral-vibe" to update'
- )
- assert notifier.fetch_update_calls == 1
diff --git a/tests/update_notifier/test_version_update_use_case.py b/tests/update_notifier/test_update_use_case.py
similarity index 69%
rename from tests/update_notifier/test_version_update_use_case.py
rename to tests/update_notifier/test_update_use_case.py
index 6a2bbb1..7112203 100644
--- a/tests/update_notifier/test_version_update_use_case.py
+++ b/tests/update_notifier/test_update_use_case.py
@@ -5,19 +5,14 @@ import pytest
from tests.update_notifier.adapters.fake_update_cache_repository import (
FakeUpdateCacheRepository,
)
-from tests.update_notifier.adapters.fake_version_update_gateway import (
- FakeVersionUpdateGateway,
-)
+from tests.update_notifier.adapters.fake_update_gateway import FakeUpdateGateway
from vibe.cli.update_notifier import (
+ Update,
UpdateCache,
- VersionUpdate,
- VersionUpdateGatewayCause,
- VersionUpdateGatewayError,
-)
-from vibe.cli.update_notifier.version_update import (
- VersionUpdateError,
- get_update_if_available,
+ UpdateGatewayCause,
+ UpdateGatewayError,
)
+from vibe.cli.update_notifier.update import UpdateError, get_update_if_available
@pytest.fixture
@@ -26,14 +21,12 @@ def current_timestamp() -> int:
@pytest.mark.asyncio
-async def test_retrieves_the_latest_version_update_when_available() -> None:
+async def test_retrieves_the_latest_update_when_available() -> None:
latest_update = "1.0.3"
- version_update_notifier = FakeVersionUpdateGateway(
- update=VersionUpdate(latest_version=latest_update)
- )
+ update_notifier = FakeUpdateGateway(update=Update(latest_version=latest_update))
update = await get_update_if_available(
- version_update_notifier,
+ update_notifier,
current_version="1.0.0",
update_cache_repository=FakeUpdateCacheRepository(),
)
@@ -46,12 +39,10 @@ async def test_retrieves_the_latest_version_update_when_available() -> None:
async def test_retrieves_nothing_when_the_current_version_is_the_latest() -> None:
current_version = "1.0.0"
latest_version = "1.0.0"
- version_update_notifier = FakeVersionUpdateGateway(
- update=VersionUpdate(latest_version=latest_version)
- )
+ update_notifier = FakeUpdateGateway(update=Update(latest_version=latest_version))
update = await get_update_if_available(
- version_update_notifier,
+ update_notifier,
current_version=current_version,
update_cache_repository=FakeUpdateCacheRepository(),
)
@@ -65,12 +56,10 @@ async def test_retrieves_nothing_when_the_current_version_is_greater_than_the_la
):
current_version = "0.2.0"
latest_version = "0.1.2"
- version_update_notifier = FakeVersionUpdateGateway(
- update=VersionUpdate(latest_version=latest_version)
- )
+ update_notifier = FakeUpdateGateway(update=Update(latest_version=latest_version))
update = await get_update_if_available(
- version_update_notifier,
+ update_notifier,
current_version=current_version,
update_cache_repository=FakeUpdateCacheRepository(),
)
@@ -80,10 +69,10 @@ async def test_retrieves_nothing_when_the_current_version_is_greater_than_the_la
@pytest.mark.asyncio
async def test_retrieves_nothing_when_no_version_is_available() -> None:
- version_update_notifier = FakeVersionUpdateGateway(update=None)
+ update_notifier = FakeUpdateGateway(update=None)
update = await get_update_if_available(
- version_update_notifier,
+ update_notifier,
current_version="1.0.0",
update_cache_repository=FakeUpdateCacheRepository(),
)
@@ -93,12 +82,10 @@ async def test_retrieves_nothing_when_no_version_is_available() -> None:
@pytest.mark.asyncio
async def test_retrieves_nothing_when_latest_version_is_invalid() -> None:
- version_update_notifier = FakeVersionUpdateGateway(
- update=VersionUpdate(latest_version="invalid-version")
- )
+ update_notifier = FakeUpdateGateway(update=Update(latest_version="invalid-version"))
update = await get_update_if_available(
- version_update_notifier,
+ update_notifier,
current_version="1.0.0",
update_cache_repository=FakeUpdateCacheRepository(),
)
@@ -110,13 +97,13 @@ async def test_retrieves_nothing_when_latest_version_is_invalid() -> None:
async def test_replaces_hyphens_with_plus_signs_in_latest_version_to_conform_with_PEP_440() -> (
None
):
- version_update_notifier = FakeVersionUpdateGateway(
+ update_notifier = FakeUpdateGateway(
# if we were not replacing hyphens with plus signs, this should fail for PEP 440
- update=VersionUpdate(latest_version="1.6.1-jetbrains")
+ update=Update(latest_version="1.6.1-jetbrains")
)
update = await get_update_if_available(
- version_update_notifier,
+ update_notifier,
current_version="1.0.0",
update_cache_repository=FakeUpdateCacheRepository(),
)
@@ -127,12 +114,10 @@ async def test_replaces_hyphens_with_plus_signs_in_latest_version_to_conform_wit
@pytest.mark.asyncio
async def test_retrieves_nothing_when_current_version_is_invalid() -> None:
- version_update_notifier = FakeVersionUpdateGateway(
- update=VersionUpdate(latest_version="1.0.1")
- )
+ update_notifier = FakeUpdateGateway(update=Update(latest_version="1.0.1"))
update = await get_update_if_available(
- version_update_notifier,
+ update_notifier,
current_version="invalid-version",
update_cache_repository=FakeUpdateCacheRepository(),
)
@@ -143,27 +128,25 @@ async def test_retrieves_nothing_when_current_version_is_invalid() -> None:
@pytest.mark.parametrize(
("cause", "expected_message_substring"),
[
- (VersionUpdateGatewayCause.TOO_MANY_REQUESTS, "Rate limit exceeded"),
- (VersionUpdateGatewayCause.INVALID_RESPONSE, "invalid response"),
+ (UpdateGatewayCause.TOO_MANY_REQUESTS, "Rate limit exceeded"),
+ (UpdateGatewayCause.INVALID_RESPONSE, "invalid response"),
(
- VersionUpdateGatewayCause.NOT_FOUND,
+ UpdateGatewayCause.NOT_FOUND,
"Unable to fetch the releases. Please check your permissions.",
),
- (VersionUpdateGatewayCause.ERROR_RESPONSE, "Unexpected response"),
- (VersionUpdateGatewayCause.REQUEST_FAILED, "Network error"),
+ (UpdateGatewayCause.ERROR_RESPONSE, "Unexpected response"),
+ (UpdateGatewayCause.REQUEST_FAILED, "Network error"),
],
)
@pytest.mark.asyncio
-async def test_raises_version_update_error(
- cause: VersionUpdateGatewayCause, expected_message_substring: str
+async def test_raises_update_error(
+ cause: UpdateGatewayCause, expected_message_substring: str
) -> None:
- version_update_notifier = FakeVersionUpdateGateway(
- error=VersionUpdateGatewayError(cause=cause)
- )
+ update_notifier = FakeUpdateGateway(error=UpdateGatewayError(cause=cause))
- with pytest.raises(VersionUpdateError) as excinfo:
+ with pytest.raises(UpdateError) as excinfo:
await get_update_if_available(
- version_update_notifier,
+ update_notifier,
current_version="1.0.0",
update_cache_repository=FakeUpdateCacheRepository(),
)
@@ -175,13 +158,11 @@ async def test_raises_version_update_error(
async def test_notifies_and_updates_cache_when_repository_is_empty(
current_timestamp: int,
) -> None:
- version_update_notifier = FakeVersionUpdateGateway(
- update=VersionUpdate(latest_version="1.0.1")
- )
+ update_notifier = FakeUpdateGateway(update=Update(latest_version="1.0.1"))
update_cache_repository = FakeUpdateCacheRepository()
update = await get_update_if_available(
- version_update_notifier,
+ update_notifier,
current_version="1.0.0",
update_cache_repository=update_cache_repository,
get_current_timestamp=lambda: current_timestamp,
@@ -190,7 +171,7 @@ async def test_notifies_and_updates_cache_when_repository_is_empty(
assert update is not None
assert update.latest_version == "1.0.1"
assert update.should_notify is True
- assert version_update_notifier.fetch_update_calls == 1
+ assert update_notifier.fetch_update_calls == 1
assert update_cache_repository.update_cache is not None
assert update_cache_repository.update_cache.latest_version == "1.0.1"
assert update_cache_repository.update_cache.stored_at_timestamp == current_timestamp
@@ -200,9 +181,7 @@ async def test_notifies_and_updates_cache_when_repository_is_empty(
async def test_does_not_notify_when_an_available_update_has_been_recently_cached(
current_timestamp: int,
) -> None:
- version_update_notifier = FakeVersionUpdateGateway(
- update=VersionUpdate(latest_version="1.0.1")
- )
+ update_notifier = FakeUpdateGateway(update=Update(latest_version="1.0.1"))
timestamp_twelve_hours_ago = current_timestamp - 12 * 60 * 60
update_cache = UpdateCache(
latest_version="1.0.1", stored_at_timestamp=timestamp_twelve_hours_ago
@@ -210,7 +189,7 @@ async def test_does_not_notify_when_an_available_update_has_been_recently_cached
update_cache_repository = FakeUpdateCacheRepository(update_cache=update_cache)
update = await get_update_if_available(
- version_update_notifier,
+ update_notifier,
current_version="1.0.0",
update_cache_repository=update_cache_repository,
get_current_timestamp=lambda: current_timestamp,
@@ -219,16 +198,14 @@ async def test_does_not_notify_when_an_available_update_has_been_recently_cached
assert update is not None
assert update.latest_version == "1.0.1"
assert update.should_notify is False
- assert version_update_notifier.fetch_update_calls == 0
+ assert update_notifier.fetch_update_calls == 0
@pytest.mark.asyncio
async def test_retrieves_nothing_when_the_recently_cached_update_is_the_one_currently_in_use(
current_timestamp: int,
) -> None:
- version_update_notifier = FakeVersionUpdateGateway(
- update=VersionUpdate(latest_version="1.0.1")
- )
+ update_notifier = FakeUpdateGateway(update=Update(latest_version="1.0.1"))
timestamp_twelve_hours_ago = current_timestamp - 12 * 60 * 60
update_cache = UpdateCache(
latest_version="1.0.1", stored_at_timestamp=timestamp_twelve_hours_ago
@@ -236,23 +213,21 @@ async def test_retrieves_nothing_when_the_recently_cached_update_is_the_one_curr
update_cache_repository = FakeUpdateCacheRepository(update_cache=update_cache)
update = await get_update_if_available(
- version_update_notifier,
+ update_notifier,
current_version="1.0.1",
update_cache_repository=update_cache_repository,
get_current_timestamp=lambda: current_timestamp,
)
assert update is None
- assert version_update_notifier.fetch_update_calls == 0
+ assert update_notifier.fetch_update_calls == 0
@pytest.mark.asyncio
async def test_retrieves_fresh_update_and_notifies_and_updates_cache_when_cache_is_not_fresh(
current_timestamp: int,
) -> None:
- version_update_notifier = FakeVersionUpdateGateway(
- update=VersionUpdate(latest_version="1.0.2")
- )
+ update_notifier = FakeUpdateGateway(update=Update(latest_version="1.0.2"))
timestamp_two_days_ago = current_timestamp - 48 * 60 * 60
update_cache = UpdateCache(
latest_version="1.0.1", stored_at_timestamp=timestamp_two_days_ago
@@ -260,14 +235,14 @@ async def test_retrieves_fresh_update_and_notifies_and_updates_cache_when_cache_
update_cache_repository = FakeUpdateCacheRepository(update_cache=update_cache)
update = await get_update_if_available(
- version_update_notifier,
+ update_notifier,
current_version="1.0.0",
update_cache_repository=update_cache_repository,
get_current_timestamp=lambda: current_timestamp,
)
assert update is not None
- assert version_update_notifier.fetch_update_calls == 1
+ assert update_notifier.fetch_update_calls == 1
assert update.should_notify is True
assert update.latest_version == "1.0.2"
assert update_cache_repository.update_cache is not None
@@ -279,7 +254,7 @@ async def test_retrieves_fresh_update_and_notifies_and_updates_cache_when_cache_
async def test_updates_cache_timestamp_with_current_version_when_no_update_is_available(
current_timestamp: int,
) -> None:
- version_update_notifier = FakeVersionUpdateGateway(update=None)
+ update_notifier = FakeUpdateGateway(update=None)
timestamp_two_days_ago = current_timestamp - 48 * 60 * 60
update_cache = UpdateCache(
latest_version="1.0.0", stored_at_timestamp=timestamp_two_days_ago
@@ -287,14 +262,14 @@ async def test_updates_cache_timestamp_with_current_version_when_no_update_is_av
update_cache_repository = FakeUpdateCacheRepository(update_cache=update_cache)
update = await get_update_if_available(
- version_update_notifier,
+ update_notifier,
current_version="1.0.0",
update_cache_repository=update_cache_repository,
get_current_timestamp=lambda: current_timestamp,
)
assert update is None
- assert version_update_notifier.fetch_update_calls == 1
+ assert update_notifier.fetch_update_calls == 1
assert update_cache_repository.update_cache is not None
assert update_cache_repository.update_cache.latest_version == "1.0.0"
assert update_cache_repository.update_cache.stored_at_timestamp == current_timestamp
@@ -304,8 +279,8 @@ async def test_updates_cache_timestamp_with_current_version_when_no_update_is_av
async def test_updates_cache_timestamp_with_current_version_when_gateway_errors(
current_timestamp: int,
) -> None:
- version_update_notifier = FakeVersionUpdateGateway(
- error=VersionUpdateGatewayError(cause=VersionUpdateGatewayCause.ERROR_RESPONSE)
+ update_notifier = FakeUpdateGateway(
+ error=UpdateGatewayError(cause=UpdateGatewayCause.ERROR_RESPONSE)
)
timestamp_two_days_ago = current_timestamp - 48 * 60 * 60
update_cache = UpdateCache(
@@ -313,15 +288,15 @@ async def test_updates_cache_timestamp_with_current_version_when_gateway_errors(
)
update_cache_repository = FakeUpdateCacheRepository(update_cache=update_cache)
- with pytest.raises(VersionUpdateError):
+ with pytest.raises(UpdateError):
await get_update_if_available(
- version_update_notifier,
+ update_notifier,
current_version="1.0.0",
update_cache_repository=update_cache_repository,
get_current_timestamp=lambda: current_timestamp,
)
- assert version_update_notifier.fetch_update_calls == 1
+ assert update_notifier.fetch_update_calls == 1
assert update_cache_repository.update_cache is not None
assert update_cache_repository.update_cache.latest_version == "1.0.0"
assert update_cache_repository.update_cache.stored_at_timestamp == current_timestamp
diff --git a/tests/update_notifier/test_whats_new.py b/tests/update_notifier/test_whats_new.py
new file mode 100644
index 0000000..a2d75c7
--- /dev/null
+++ b/tests/update_notifier/test_whats_new.py
@@ -0,0 +1,161 @@
+from __future__ import annotations
+
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+from tests.update_notifier.adapters.fake_update_cache_repository import (
+ FakeUpdateCacheRepository,
+)
+from vibe.cli.update_notifier import UpdateCache
+from vibe.cli.update_notifier.whats_new import (
+ load_whats_new_content,
+ mark_version_as_seen,
+ should_show_whats_new,
+)
+
+
+@pytest.mark.asyncio
+async def test_should_show_whats_new_returns_false_when_cache_is_none() -> None:
+ repository = FakeUpdateCacheRepository()
+
+ result = await should_show_whats_new("1.0.0", repository)
+
+ assert result is False
+
+
+@pytest.mark.asyncio
+async def test_should_show_whats_new_returns_true_when_seen_whats_new_version_differs() -> (
+ None
+):
+ cache = UpdateCache(
+ latest_version="1.0.0",
+ stored_at_timestamp=1_700_000_000,
+ seen_whats_new_version="0.9.0",
+ )
+ repository = FakeUpdateCacheRepository(update_cache=cache)
+
+ result = await should_show_whats_new("1.0.0", repository)
+
+ assert result is True
+
+
+@pytest.mark.asyncio
+async def test_should_show_whats_new_returns_false_when_seen_whats_new_version_matches() -> (
+ None
+):
+ cache = UpdateCache(
+ latest_version="1.0.0",
+ stored_at_timestamp=1_700_000_000,
+ seen_whats_new_version="1.0.0",
+ )
+ repository = FakeUpdateCacheRepository(update_cache=cache)
+
+ result = await should_show_whats_new("1.0.0", repository)
+
+ assert result is False
+
+
+@pytest.mark.asyncio
+async def test_should_show_whats_new_returns_true_when_seen_whats_new_version_is_none() -> (
+ None
+):
+ cache = UpdateCache(
+ latest_version="1.0.0",
+ stored_at_timestamp=1_700_000_000,
+ seen_whats_new_version=None,
+ )
+ repository = FakeUpdateCacheRepository(update_cache=cache)
+
+ result = await should_show_whats_new("1.0.0", repository)
+
+ assert result is True
+
+
+def test_load_whats_new_content_returns_none_when_file_does_not_exist(
+ tmp_path: Path,
+) -> None:
+ with patch("vibe.cli.update_notifier.whats_new.VIBE_ROOT", tmp_path):
+ result = load_whats_new_content()
+
+ assert result is None
+
+
+def test_load_whats_new_content_returns_none_when_file_is_empty(tmp_path: Path) -> None:
+ whats_new_file = tmp_path / "whats_new.md"
+ whats_new_file.write_text("")
+
+ with patch("vibe.cli.update_notifier.whats_new.VIBE_ROOT", tmp_path):
+ result = load_whats_new_content()
+
+ assert result is None
+
+
+def test_load_whats_new_content_returns_none_when_file_contains_only_whitespace(
+ tmp_path: Path,
+) -> None:
+ whats_new_file = tmp_path / "whats_new.md"
+ whats_new_file.write_text(" \n\t \n ")
+
+ with patch("vibe.cli.update_notifier.whats_new.VIBE_ROOT", tmp_path):
+ result = load_whats_new_content()
+
+ assert result is None
+
+
+def test_load_whats_new_content_returns_content_when_file_exists(
+ tmp_path: Path,
+) -> None:
+ whats_new_file = tmp_path / "whats_new.md"
+ content = "# What's New\n\n- Feature 1\n- Feature 2"
+ whats_new_file.write_text(content)
+
+ with patch("vibe.cli.update_notifier.whats_new.VIBE_ROOT", tmp_path):
+ result = load_whats_new_content()
+
+ assert result == content
+
+
+def test_load_whats_new_content_handles_os_error(tmp_path: Path) -> None:
+ whats_new_file = tmp_path / "whats_new.md"
+ whats_new_file.write_text("content")
+
+ with patch("vibe.cli.update_notifier.whats_new.VIBE_ROOT", tmp_path):
+ with patch.object(Path, "read_text", side_effect=OSError("Permission denied")):
+ result = load_whats_new_content()
+
+ assert result is None
+
+
+@pytest.mark.asyncio
+async def test_mark_version_as_seen_creates_new_cache_when_repository_is_empty() -> (
+ None
+):
+ repository = FakeUpdateCacheRepository()
+
+ await mark_version_as_seen("1.0.0", repository)
+
+ assert repository.update_cache is not None
+ assert repository.update_cache.latest_version == "1.0.0"
+ assert repository.update_cache.seen_whats_new_version == "1.0.0"
+ assert repository.update_cache.stored_at_timestamp > 0
+
+
+@pytest.mark.asyncio
+async def test_mark_version_as_seen_updates_seen_whats_new_version_preserving_other_fields() -> (
+ None
+):
+ cache = UpdateCache(
+ latest_version="1.2.0",
+ stored_at_timestamp=1_700_000_000,
+ seen_whats_new_version="1.0.0",
+ )
+ repository = FakeUpdateCacheRepository(update_cache=cache)
+
+ await mark_version_as_seen("1.1.0", repository)
+
+ assert repository.update_cache is not None
+ assert repository.update_cache.latest_version == "1.2.0"
+ assert repository.update_cache.stored_at_timestamp == 1_700_000_000
+ assert repository.update_cache.seen_whats_new_version == "1.1.0"
diff --git a/uv.lock b/uv.lock
index d20aa32..823639d 100644
--- a/uv.lock
+++ b/uv.lock
@@ -4,23 +4,14 @@ requires-python = ">=3.12"
[[package]]
name = "agent-client-protocol"
-version = "0.6.3"
+version = "0.7.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/c6/fe/147187918c5ba695db537b3088c441bcace4ac9365fae532bf36b1494769/agent_client_protocol-0.6.3.tar.gz", hash = "sha256:ea01a51d5b55864c606401694dad429d83c5bedb476807d81b8208031d6cf3d8", size = 152382, upload-time = "2025-11-03T20:09:19.027Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/db/7c/12da39be4f73026fd9b02144df5f64d803488cf1439aa221b0edb7c305e3/agent_client_protocol-0.7.1.tar.gz", hash = "sha256:8d7031209e14c3f2f987e3b95e7d9c3286158e7b2af1bf43d6aae5b8a429249f", size = 66226, upload-time = "2025-12-28T13:58:57.012Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/9c/2e/62d1770a489d3356cd75e19cd61583e7e411f1b00ab9859c73048621e4c2/agent_client_protocol-0.6.3-py3-none-any.whl", hash = "sha256:184264bd6988731613a49c9eb89d7ecd23c6afffe905c64f1b604a42a9b20aef", size = 47613, upload-time = "2025-11-03T20:09:17.427Z" },
-]
-
-[[package]]
-name = "aiofiles"
-version = "25.1.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" },
+ { url = "https://files.pythonhosted.org/packages/03/48/48d2fb454f911147432cd779f548e188274e1700f1cbe0a258e78158331a/agent_client_protocol-0.7.1-py3-none-any.whl", hash = "sha256:4ffe999488f2b23db26f09becdfaa2aaae6529f0847a52bca61bc2c628001c0f", size = 53771, upload-time = "2025-12-28T13:58:55.967Z" },
]
[[package]]
@@ -272,6 +263,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" },
]
+[[package]]
+name = "debugpy"
+version = "1.8.19"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/73/75/9e12d4d42349b817cd545b89247696c67917aab907012ae5b64bbfea3199/debugpy-1.8.19.tar.gz", hash = "sha256:eea7e5987445ab0b5ed258093722d5ecb8bb72217c5c9b1e21f64efe23ddebdb", size = 1644590, upload-time = "2025-12-15T21:53:28.044Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4a/15/d762e5263d9e25b763b78be72dc084c7a32113a0bac119e2f7acae7700ed/debugpy-1.8.19-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:bccb1540a49cde77edc7ce7d9d075c1dbeb2414751bc0048c7a11e1b597a4c2e", size = 2549995, upload-time = "2025-12-15T21:53:43.773Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/88/f7d25c68b18873b7c53d7c156ca7a7ffd8e77073aa0eac170a9b679cf786/debugpy-1.8.19-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:e9c68d9a382ec754dc05ed1d1b4ed5bd824b9f7c1a8cd1083adb84b3c93501de", size = 4309891, upload-time = "2025-12-15T21:53:45.26Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/4f/a65e973aba3865794da65f71971dca01ae66666132c7b2647182d5be0c5f/debugpy-1.8.19-cp312-cp312-win32.whl", hash = "sha256:6599cab8a783d1496ae9984c52cb13b7c4a3bd06a8e6c33446832a5d97ce0bee", size = 5286355, upload-time = "2025-12-15T21:53:46.763Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/3a/d3d8b48fec96e3d824e404bf428276fb8419dfa766f78f10b08da1cb2986/debugpy-1.8.19-cp312-cp312-win_amd64.whl", hash = "sha256:66e3d2fd8f2035a8f111eb127fa508469dfa40928a89b460b41fd988684dc83d", size = 5328239, upload-time = "2025-12-15T21:53:48.868Z" },
+ { url = "https://files.pythonhosted.org/packages/71/3d/388035a31a59c26f1ecc8d86af607d0c42e20ef80074147cd07b180c4349/debugpy-1.8.19-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:91e35db2672a0abaf325f4868fcac9c1674a0d9ad9bb8a8c849c03a5ebba3e6d", size = 2538859, upload-time = "2025-12-15T21:53:50.478Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/19/c93a0772d0962294f083dbdb113af1a7427bb632d36e5314297068f55db7/debugpy-1.8.19-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:85016a73ab84dea1c1f1dcd88ec692993bcbe4532d1b49ecb5f3c688ae50c606", size = 4292575, upload-time = "2025-12-15T21:53:51.821Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/56/09e48ab796b0a77e3d7dc250f95251832b8bf6838c9632f6100c98bdf426/debugpy-1.8.19-cp313-cp313-win32.whl", hash = "sha256:b605f17e89ba0ecee994391194285fada89cee111cfcd29d6f2ee11cbdc40976", size = 5286209, upload-time = "2025-12-15T21:53:53.602Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/4e/931480b9552c7d0feebe40c73725dd7703dcc578ba9efc14fe0e6d31cfd1/debugpy-1.8.19-cp313-cp313-win_amd64.whl", hash = "sha256:c30639998a9f9cd9699b4b621942c0179a6527f083c72351f95c6ab1728d5b73", size = 5328206, upload-time = "2025-12-15T21:53:55.433Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/b9/cbec520c3a00508327476c7fce26fbafef98f412707e511eb9d19a2ef467/debugpy-1.8.19-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:1e8c4d1bd230067bf1bbcdbd6032e5a57068638eb28b9153d008ecde288152af", size = 2537372, upload-time = "2025-12-15T21:53:57.318Z" },
+ { url = "https://files.pythonhosted.org/packages/88/5e/cf4e4dc712a141e10d58405c58c8268554aec3c35c09cdcda7535ff13f76/debugpy-1.8.19-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:d40c016c1f538dbf1762936e3aeb43a89b965069d9f60f9e39d35d9d25e6b809", size = 4268729, upload-time = "2025-12-15T21:53:58.712Z" },
+ { url = "https://files.pythonhosted.org/packages/82/a3/c91a087ab21f1047db328c1d3eb5d1ff0e52de9e74f9f6f6fa14cdd93d58/debugpy-1.8.19-cp314-cp314-win32.whl", hash = "sha256:0601708223fe1cd0e27c6cce67a899d92c7d68e73690211e6788a4b0e1903f5b", size = 5286388, upload-time = "2025-12-15T21:54:00.687Z" },
+ { url = "https://files.pythonhosted.org/packages/17/b8/bfdc30b6e94f1eff09f2dc9cc1f9cd1c6cde3d996bcbd36ce2d9a4956e99/debugpy-1.8.19-cp314-cp314-win_amd64.whl", hash = "sha256:8e19a725f5d486f20e53a1dde2ab8bb2c9607c40c00a42ab646def962b41125f", size = 5327741, upload-time = "2025-12-15T21:54:02.148Z" },
+ { url = "https://files.pythonhosted.org/packages/25/3e/e27078370414ef35fafad2c06d182110073daaeb5d3bf734b0b1eeefe452/debugpy-1.8.19-py2.py3-none-any.whl", hash = "sha256:360ffd231a780abbc414ba0f005dad409e71c78637efe8f2bd75837132a41d38", size = 5292321, upload-time = "2025-12-15T21:54:16.024Z" },
+]
+
[[package]]
name = "distlib"
version = "0.4.0"
@@ -661,11 +673,11 @@ wheels = [
[[package]]
name = "mistral-vibe"
-version = "1.3.5"
+version = "2.0.0"
source = { editable = "." }
dependencies = [
{ name = "agent-client-protocol" },
- { name = "aiofiles" },
+ { name = "anyio" },
{ name = "httpx" },
{ name = "mcp" },
{ name = "mistralai" },
@@ -690,6 +702,7 @@ build = [
{ name = "pyinstaller" },
]
dev = [
+ { name = "debugpy" },
{ name = "pre-commit" },
{ name = "pyright" },
{ name = "pytest" },
@@ -706,8 +719,8 @@ dev = [
[package.metadata]
requires-dist = [
- { name = "agent-client-protocol", specifier = "==0.6.3" },
- { name = "aiofiles", specifier = ">=24.1.0" },
+ { name = "agent-client-protocol", specifier = "==0.7.1" },
+ { name = "anyio", specifier = ">=4.12.0" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "mcp", specifier = ">=1.14.0" },
{ name = "mistralai", specifier = "==1.9.11" },
@@ -730,6 +743,7 @@ requires-dist = [
[package.metadata.requires-dev]
build = [{ name = "pyinstaller", specifier = ">=6.17.0" }]
dev = [
+ { name = "debugpy", specifier = ">=1.8.19" },
{ name = "pre-commit", specifier = ">=4.2.0" },
{ name = "pyright", specifier = ">=1.1.403" },
{ name = "pytest", specifier = ">=8.3.5" },
diff --git a/vibe/__init__.py b/vibe/__init__.py
index 73bb6ac..547cc13 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.5"
+__version__ = "2.0.0"
diff --git a/vibe/acp/acp_agent.py b/vibe/acp/acp_agent_loop.py
similarity index 53%
rename from vibe/acp/acp_agent.py
rename to vibe/acp/acp_agent_loop.py
index 213c1f7..6c46e5a 100644
--- a/vibe/acp/acp_agent.py
+++ b/vibe/acp/acp_agent_loop.py
@@ -2,31 +2,23 @@ from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator
+import os
from pathlib import Path
import sys
-from typing import cast, override
+from typing import Any, cast, override
from acp import (
PROTOCOL_VERSION,
Agent as AcpAgent,
- AgentSideConnection,
- AuthenticateRequest,
- CancelNotification,
- InitializeRequest,
+ Client,
InitializeResponse,
- LoadSessionRequest,
- NewSessionRequest,
+ LoadSessionResponse,
NewSessionResponse,
- PromptRequest,
PromptResponse,
RequestError,
- RequestPermissionRequest,
- SessionNotification,
- SetSessionModelRequest,
SetSessionModelResponse,
- SetSessionModeRequest,
SetSessionModeResponse,
- stdio_streams,
+ run_agent,
)
from acp.helpers import ContentBlock, SessionUpdate
from acp.schema import (
@@ -35,14 +27,24 @@ from acp.schema import (
AllowedOutcome,
AuthenticateResponse,
AuthMethod,
+ ClientCapabilities,
+ ContentToolCallContent,
+ ForkSessionResponse,
+ HttpMcpServer,
Implementation,
+ ListSessionsResponse,
+ McpServerStdio,
ModelInfo,
PromptCapabilities,
+ ResumeSessionResponse,
SessionModelState,
SessionModeState,
+ SseMcpServer,
TextContentBlock,
TextResourceContents,
- ToolCall,
+ ToolCallProgress,
+ ToolCallUpdate,
+ UserMessageChunk,
)
from pydantic import BaseModel, ConfigDict
@@ -55,41 +57,53 @@ from vibe.acp.tools.session_update import (
from vibe.acp.utils import (
TOOL_OPTIONS,
ToolOption,
- acp_to_agent_mode,
+ create_compact_end_session_update,
+ create_compact_start_session_update,
get_all_acp_session_modes,
- is_valid_acp_mode,
+ is_valid_acp_agent,
)
-from vibe.core.agent import Agent as VibeAgent
+from vibe.core.agent_loop import AgentLoop
+from vibe.core.agents.models import BuiltinAgentName
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
from vibe.core.config import MissingAPIKeyError, VibeConfig, load_api_keys_from_env
-from vibe.core.modes import AgentMode
from vibe.core.tools.base import BaseToolConfig, ToolPermission
from vibe.core.types import (
ApprovalResponse,
AssistantEvent,
AsyncApprovalCallback,
+ CompactEndEvent,
+ CompactStartEvent,
ToolCallEvent,
ToolResultEvent,
+ ToolStreamEvent,
+ UserMessageEvent,
)
from vibe.core.utils import CancellationReason, get_user_cancellation_message
-class AcpSession(BaseModel):
+class AcpSessionLoop(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
id: str
- agent: VibeAgent
+ agent_loop: AgentLoop
task: asyncio.Task[None] | None = None
-class VibeAcpAgent(AcpAgent):
- def __init__(self, connection: AgentSideConnection) -> None:
- self.sessions: dict[str, AcpSession] = {}
- self.connection = connection
+class VibeAcpAgentLoop(AcpAgent):
+ client: Client
+
+ def __init__(self) -> None:
+ self.sessions: dict[str, AcpSessionLoop] = {}
self.client_capabilities = None
@override
- async def initialize(self, params: InitializeRequest) -> InitializeResponse:
- self.client_capabilities = params.clientCapabilities
+ async def initialize(
+ self,
+ protocol_version: int,
+ client_capabilities: ClientCapabilities | None = None,
+ client_info: Implementation | None = None,
+ **kwargs: Any,
+ ) -> InitializeResponse:
+ self.client_capabilities = client_capabilities
# The ACP Agent process can be launched in 3 different ways, depending on installation
# - dev mode: `uv run vibe-acp`, ran from the project root
@@ -133,91 +147,94 @@ class VibeAcpAgent(AcpAgent):
)
response = InitializeResponse(
- agentCapabilities=AgentCapabilities(
- loadSession=False,
- promptCapabilities=PromptCapabilities(
- audio=False, embeddedContext=True, image=False
+ agent_capabilities=AgentCapabilities(
+ load_session=False,
+ prompt_capabilities=PromptCapabilities(
+ audio=False, embedded_context=True, image=False
),
),
- protocolVersion=PROTOCOL_VERSION,
- agentInfo=Implementation(
+ protocol_version=PROTOCOL_VERSION,
+ agent_info=Implementation(
name="@mistralai/mistral-vibe",
title="Mistral Vibe",
version=__version__,
),
- authMethods=auth_methods,
+ auth_methods=auth_methods,
)
return response
@override
async def authenticate(
- self, params: AuthenticateRequest
+ self, method_id: str, **kwargs: Any
) -> AuthenticateResponse | None:
raise NotImplementedError("Not implemented yet")
@override
- async def newSession(self, params: NewSessionRequest) -> NewSessionResponse:
- capability_disabled_tools = self._get_disabled_tools_from_capabilities()
+ async def new_session(
+ self,
+ cwd: str,
+ mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio],
+ **kwargs: Any,
+ ) -> NewSessionResponse:
load_api_keys_from_env()
+ os.chdir(cwd)
- cwd = Path(params.cwd)
try:
- config = VibeConfig.load(
- workdir=cwd,
- tool_paths=[str(VIBE_ROOT / "acp" / "tools" / "builtins")],
- disabled_tools=capability_disabled_tools,
- )
+ config = VibeConfig.load(disabled_tools=["ask_user_question"])
+ config.tool_paths.extend(self._get_acp_tool_overrides())
except MissingAPIKeyError as e:
raise RequestError.auth_required({
"message": "You must be authenticated before creating a new session"
}) from e
- agent = VibeAgent(config=config, mode=AgentMode.DEFAULT, enable_streaming=True)
- # NOTE: For now, we pin session.id to agent.session_id right after init time.
- # We should just use agent.session_id everywhere, but it can still change during
- # session lifetime (e.g. agent.compact is called).
- # We should refactor agent.session_id to make it immutable in ACP context.
- session = AcpSession(id=agent.session_id, agent=agent)
+ agent_loop = AgentLoop(
+ config=config, agent_name=BuiltinAgentName.DEFAULT, enable_streaming=True
+ )
+ # NOTE: For now, we pin session.id to agent_loop.session_id right after init time.
+ # We should just use agent_loop.session_id everywhere, but it can still change during
+ # session lifetime (e.g. agent_loop.compact is called).
+ # We should refactor agent_loop.session_id to make it immutable in ACP context.
+ session = AcpSessionLoop(id=agent_loop.session_id, agent_loop=agent_loop)
self.sessions[session.id] = session
- if not agent.auto_approve:
- agent.set_approval_callback(
- self._create_approval_callback(agent.session_id)
+ if not agent_loop.auto_approve:
+ agent_loop.set_approval_callback(
+ self._create_approval_callback(agent_loop.session_id)
)
response = NewSessionResponse(
- sessionId=agent.session_id,
+ session_id=agent_loop.session_id,
models=SessionModelState(
- currentModelId=agent.config.active_model,
- availableModels=[
- ModelInfo(modelId=model.alias, name=model.alias)
- for model in agent.config.models
+ current_model_id=agent_loop.config.active_model,
+ available_models=[
+ ModelInfo(model_id=model.alias, name=model.alias)
+ for model in agent_loop.config.models
],
),
modes=SessionModeState(
- currentModeId=session.agent.mode.value,
- availableModes=get_all_acp_session_modes(),
+ current_mode_id=session.agent_loop.agent_profile.name,
+ available_modes=get_all_acp_session_modes(agent_loop.agent_manager),
),
)
return response
- def _get_disabled_tools_from_capabilities(self) -> list[str]:
- if not self.client_capabilities:
- return []
+ def _get_acp_tool_overrides(self) -> list[Path]:
+ overrides = ["todo"]
- disabled: list[str] = []
+ if self.client_capabilities:
+ if self.client_capabilities.terminal:
+ overrides.append("bash")
+ if self.client_capabilities.fs:
+ fs = self.client_capabilities.fs
+ if fs.read_text_file:
+ overrides.append("read_file")
+ if fs.write_text_file:
+ overrides.extend(["write_file", "search_replace"])
- if not self.client_capabilities.terminal:
- disabled.append("bash")
-
- if fs := self.client_capabilities.fs:
- if not fs.readTextFile:
- disabled.append("read_file")
- if not fs.writeTextFile:
- disabled.append("write_file")
- disabled.append("search_replace")
-
- return disabled
+ return [
+ VIBE_ROOT / "acp" / "tools" / "builtins" / f"{override}.py"
+ for override in overrides
+ ]
def _create_approval_callback(self, session_id: str) -> AsyncApprovalCallback:
session = self._get_session(session_id)
@@ -229,9 +246,9 @@ class VibeAcpAgent(AcpAgent):
case ToolOption.ALLOW_ONCE:
return (ApprovalResponse.YES, None)
case ToolOption.ALLOW_ALWAYS:
- if tool_name not in session.agent.config.tools:
- session.agent.config.tools[tool_name] = BaseToolConfig()
- session.agent.config.tools[
+ if tool_name not in session.agent_loop.config.tools:
+ session.agent_loop.config.tools[tool_name] = BaseToolConfig()
+ session.agent_loop.config.tools[
tool_name
].permission = ToolPermission.ALWAYS
return (ApprovalResponse.YES, None)
@@ -247,19 +264,16 @@ class VibeAcpAgent(AcpAgent):
tool_name: str, args: BaseModel, tool_call_id: str
) -> tuple[ApprovalResponse, str | None]:
# Create the tool call update
- tool_call = ToolCall(toolCallId=tool_call_id)
+ tool_call = ToolCallUpdate(tool_call_id=tool_call_id)
- # Request permission from the user
- request = RequestPermissionRequest(
- sessionId=session_id, toolCall=tool_call, options=TOOL_OPTIONS
+ response = await self.client.request_permission(
+ session_id=session_id, tool_call=tool_call, options=TOOL_OPTIONS
)
- response = await self.connection.requestPermission(request)
-
# Parse the response using isinstance for proper type narrowing
if response.outcome.outcome == "selected":
outcome = cast(AllowedOutcome, response.outcome)
- return _handle_permission_selection(outcome.optionId, tool_name)
+ return _handle_permission_selection(outcome.option_id, tool_name)
else:
return (
ApprovalResponse.NO,
@@ -272,102 +286,111 @@ class VibeAcpAgent(AcpAgent):
return approval_callback
- def _get_session(self, session_id: str) -> AcpSession:
+ def _get_session(self, session_id: str) -> AcpSessionLoop:
if session_id not in self.sessions:
raise RequestError.invalid_params({"session": "Not found"})
return self.sessions[session_id]
@override
- async def loadSession(self, params: LoadSessionRequest) -> None:
+ async def load_session(
+ self,
+ cwd: str,
+ mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio],
+ session_id: str,
+ **kwargs: Any,
+ ) -> LoadSessionResponse | None:
raise NotImplementedError()
@override
- async def setSessionMode(
- self, params: SetSessionModeRequest
+ async def set_session_mode(
+ self, mode_id: str, session_id: str, **kwargs: Any
) -> SetSessionModeResponse | None:
- session = self._get_session(params.sessionId)
+ session = self._get_session(session_id)
- if not is_valid_acp_mode(params.modeId):
+ if not is_valid_acp_agent(session.agent_loop.agent_manager, mode_id):
return None
- new_mode = acp_to_agent_mode(params.modeId)
- if new_mode is None:
- return None
+ await session.agent_loop.switch_agent(mode_id)
- await session.agent.switch_mode(new_mode)
-
- if new_mode.auto_approve:
- session.agent.approval_callback = None
+ if session.agent_loop.auto_approve:
+ session.agent_loop.approval_callback = None
else:
- session.agent.set_approval_callback(
+ session.agent_loop.set_approval_callback(
self._create_approval_callback(session.id)
)
return SetSessionModeResponse()
@override
- async def setSessionModel(
- self, params: SetSessionModelRequest
+ async def set_session_model(
+ self, model_id: str, session_id: str, **kwargs: Any
) -> SetSessionModelResponse | None:
- session = self._get_session(params.sessionId)
+ session = self._get_session(session_id)
- model_aliases = [model.alias for model in session.agent.config.models]
- if params.modelId not in model_aliases:
+ model_aliases = [model.alias for model in session.agent_loop.config.models]
+ if model_id not in model_aliases:
return None
- VibeConfig.save_updates({"active_model": params.modelId})
+ VibeConfig.save_updates({"active_model": model_id})
new_config = VibeConfig.load(
- workdir=session.agent.config.workdir,
- tool_paths=session.agent.config.tool_paths,
- disabled_tools=self._get_disabled_tools_from_capabilities(),
+ tool_paths=session.agent_loop.config.tool_paths,
+ disabled_tools=["ask_user_question"],
)
- await session.agent.reload_with_initial_messages(config=new_config)
+ await session.agent_loop.reload_with_initial_messages(base_config=new_config)
return SetSessionModelResponse()
@override
- async def prompt(self, params: PromptRequest) -> PromptResponse:
- session = self._get_session(params.sessionId)
+ async def list_sessions(
+ self, cursor: str | None = None, cwd: str | None = None, **kwargs: Any
+ ) -> ListSessionsResponse:
+ raise NotImplementedError()
+
+ @override
+ async def prompt(
+ self, prompt: list[ContentBlock], session_id: str, **kwargs: Any
+ ) -> PromptResponse:
+ session = self._get_session(session_id)
if session.task is not None:
raise RuntimeError(
- "Concurrent prompts are not supported yet, wait for agent to finish"
+ "Concurrent prompts are not supported yet, wait for agent loop to finish"
)
- text_prompt = self._build_text_prompt(params.prompt)
+ text_prompt = self._build_text_prompt(prompt)
- async def agent_task() -> None:
- async for update in self._run_agent_loop(session, text_prompt):
- await self.connection.sessionUpdate(
- SessionNotification(sessionId=session.id, update=update)
- )
+ temp_user_message_id: str | None = kwargs.get("messageId")
+
+ async def agent_loop_task() -> None:
+ async for update in self._run_agent_loop(
+ session, text_prompt, temp_user_message_id
+ ):
+ await self.client.session_update(session_id=session.id, update=update)
try:
- session.task = asyncio.create_task(agent_task())
+ session.task = asyncio.create_task(agent_loop_task())
await session.task
except asyncio.CancelledError:
- return PromptResponse(stopReason="cancelled")
+ return PromptResponse(stop_reason="cancelled")
except Exception as e:
- await self.connection.sessionUpdate(
- SessionNotification(
- sessionId=params.sessionId,
- update=AgentMessageChunk(
- sessionUpdate="agent_message_chunk",
- content=TextContentBlock(type="text", text=f"Error: {e!s}"),
- ),
- )
+ await self.client.session_update(
+ session_id=session_id,
+ update=AgentMessageChunk(
+ session_update="agent_message_chunk",
+ content=TextContentBlock(type="text", text=f"Error: {e!s}"),
+ ),
)
- return PromptResponse(stopReason="refusal")
+ return PromptResponse(stop_reason="refusal")
finally:
session.task = None
- return PromptResponse(stopReason="end_turn")
+ return PromptResponse(stop_reason="end_turn")
def _build_text_prompt(self, acp_prompt: list[ContentBlock]) -> str:
text_prompt = ""
@@ -400,7 +423,7 @@ class VibeAcpAgent(AcpAgent):
"name": block.name,
"title": block.title,
"description": block.description,
- "mimeType": block.mimeType,
+ "mime_type": block.mime_type,
"size": block.size,
}
parts = [
@@ -415,23 +438,37 @@ class VibeAcpAgent(AcpAgent):
return text_prompt
async def _run_agent_loop(
- self, session: AcpSession, prompt: str
+ self, session: AcpSessionLoop, prompt: str, user_message_id: str | None = None
) -> AsyncGenerator[SessionUpdate]:
- rendered_prompt = render_path_prompt(
- prompt, base_dir=session.agent.config.effective_workdir
- )
- async for event in session.agent.act(rendered_prompt):
- if isinstance(event, AssistantEvent):
+ rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd())
+
+ async for event in session.agent_loop.act(rendered_prompt):
+ if isinstance(event, UserMessageEvent):
+ yield UserMessageChunk(
+ session_update="user_message_chunk",
+ content=TextContentBlock(type="text", text=""),
+ field_meta={
+ "messageId": event.message_id,
+ **(
+ {"previousMessageId": user_message_id}
+ if user_message_id
+ else {}
+ ),
+ },
+ )
+
+ elif isinstance(event, AssistantEvent):
yield AgentMessageChunk(
- sessionUpdate="agent_message_chunk",
+ session_update="agent_message_chunk",
content=TextContentBlock(type="text", text=event.content),
+ field_meta={"messageId": event.message_id},
)
elif isinstance(event, ToolCallEvent):
if issubclass(event.tool_class, BaseAcpTool):
event.tool_class.update_tool_state(
- tool_manager=session.agent.tool_manager,
- connection=self.connection,
+ tool_manager=session.agent_loop.tool_manager,
+ client=self.client,
session_id=session.id,
tool_call_id=event.tool_call_id,
)
@@ -445,32 +482,67 @@ class VibeAcpAgent(AcpAgent):
if session_update:
yield session_update
+ elif isinstance(event, ToolStreamEvent):
+ yield ToolCallProgress(
+ session_update="tool_call_update",
+ tool_call_id=event.tool_call_id,
+ content=[
+ ContentToolCallContent(
+ type="content",
+ content=TextContentBlock(type="text", text=event.message),
+ )
+ ],
+ )
+
+ elif isinstance(event, CompactStartEvent):
+ yield create_compact_start_session_update(event)
+
+ elif isinstance(event, CompactEndEvent):
+ yield create_compact_end_session_update(event)
+
@override
- async def cancel(self, params: CancelNotification) -> None:
- session = self._get_session(params.sessionId)
+ async def cancel(self, session_id: str, **kwargs: Any) -> None:
+ session = self._get_session(session_id)
if session.task and not session.task.done():
session.task.cancel()
session.task = None
@override
- async def extMethod(self, method: str, params: dict) -> dict:
+ async def fork_session(
+ self,
+ cwd: str,
+ session_id: str,
+ mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio] | None = None,
+ **kwargs: Any,
+ ) -> ForkSessionResponse:
raise NotImplementedError()
@override
- async def extNotification(self, method: str, params: dict) -> None:
+ async def resume_session(
+ self,
+ cwd: str,
+ session_id: str,
+ mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio] | None = None,
+ **kwargs: Any,
+ ) -> ResumeSessionResponse:
raise NotImplementedError()
+ @override
+ async def ext_method(self, method: str, params: dict) -> dict:
+ raise NotImplementedError()
-async def _run_acp_server() -> None:
- reader, writer = await stdio_streams()
+ @override
+ async def ext_notification(self, method: str, params: dict) -> None:
+ raise NotImplementedError()
- AgentSideConnection(lambda connection: VibeAcpAgent(connection), writer, reader)
- await asyncio.Event().wait()
+ @override
+ def on_connect(self, conn: Client) -> None:
+ self.client = conn
def run_acp_server() -> None:
try:
- asyncio.run(_run_acp_server())
+ asyncio.run(run_agent(agent=VibeAcpAgentLoop(), use_unstable_protocol=True))
except KeyboardInterrupt:
# This is expected when the server is terminated
pass
diff --git a/vibe/acp/entrypoint.py b/vibe/acp/entrypoint.py
index db41db7..9095baf 100644
--- a/vibe/acp/entrypoint.py
+++ b/vibe/acp/entrypoint.py
@@ -2,10 +2,13 @@ from __future__ import annotations
import argparse
from dataclasses import dataclass
+import os
import sys
from vibe import __version__
-from vibe.core.paths.config_paths import unlock_config_paths
+from vibe.core.config import VibeConfig
+from vibe.core.paths.config_paths import CONFIG_FILE, HISTORY_FILE, unlock_config_paths
+from vibe.core.utils import logger
# Configure line buffering for subprocess communication
sys.stdout.reconfigure(line_buffering=True) # pyright: ignore[reportAttributeAccessIssue]
@@ -28,12 +31,45 @@ def parse_arguments() -> Arguments:
return Arguments(setup=args.setup)
+def bootstrap_config_files() -> None:
+ if not CONFIG_FILE.path.exists():
+ try:
+ VibeConfig.save_updates(VibeConfig.create_default())
+ except Exception as e:
+ logger.error(f"Could not create default config file: {e}")
+ raise
+
+ if not HISTORY_FILE.path.exists():
+ try:
+ HISTORY_FILE.path.parent.mkdir(parents=True, exist_ok=True)
+ HISTORY_FILE.path.write_text("Hello Vibe!\n", "utf-8")
+ except Exception as e:
+ logger.error(f"Could not create history file: {e}")
+ raise
+
+
+def handle_debug_mode() -> None:
+ if os.environ.get("DEBUG_MODE") != "true":
+ return
+
+ try:
+ import debugpy
+ except ImportError:
+ return
+
+ debugpy.listen(("localhost", 5678))
+ # uncomment this to wait for the debugger to attach
+ # debugpy.wait_for_client()
+
+
def main() -> None:
+ handle_debug_mode()
unlock_config_paths()
- from vibe.acp.acp_agent import run_acp_server
+ from vibe.acp.acp_agent_loop import run_acp_server
from vibe.setup.onboarding import run_onboarding
+ bootstrap_config_files()
args = parse_arguments()
if args.setup:
run_onboarding()
diff --git a/vibe/acp/tools/base.py b/vibe/acp/tools/base.py
index f2b7364..3fd817d 100644
--- a/vibe/acp/tools/base.py
+++ b/vibe/acp/tools/base.py
@@ -1,12 +1,12 @@
from __future__ import annotations
from abc import abstractmethod
-from typing import Protocol, cast, runtime_checkable
+from typing import Annotated, Protocol, cast, runtime_checkable
-from acp import AgentSideConnection, SessionNotification
+from acp import Client
from acp.helpers import SessionUpdate, ToolCallContentVariant
from acp.schema import ToolCallProgress
-from pydantic import Field
+from pydantic import BaseModel, ConfigDict, Field, SkipValidation
from vibe.core.tools.base import BaseTool, ToolError
from vibe.core.tools.manager import ToolManager
@@ -28,9 +28,11 @@ class ToolResultSessionUpdateProtocol(Protocol):
) -> SessionUpdate | None: ...
-class AcpToolState:
- connection: AgentSideConnection | None = Field(
- default=None, description="ACP agent-side connection"
+class AcpToolState(BaseModel):
+ model_config = ConfigDict(arbitrary_types_allowed=True)
+
+ client: Annotated[Client | None, SkipValidation] = Field(
+ default=None, description="ACP Client"
)
session_id: str | None = Field(default=None, description="Current ACP session ID")
tool_call_id: str | None = Field(
@@ -52,12 +54,12 @@ class BaseAcpTool[ToolState: AcpToolState](BaseTool):
cls,
*,
tool_manager: ToolManager,
- connection: AgentSideConnection | None,
+ client: Client | None,
session_id: str | None,
tool_call_id: str | None,
) -> None:
tool_instance = cls.get_tool_instance(cls.get_name(), tool_manager)
- tool_instance.state.connection = connection
+ tool_instance.state.client = client
tool_instance.state.session_id = session_id
tool_instance.state.tool_call_id = tool_call_id
@@ -65,36 +67,34 @@ class BaseAcpTool[ToolState: AcpToolState](BaseTool):
@abstractmethod
def _get_tool_state_class(cls) -> type[ToolState]: ...
- def _load_state(self) -> tuple[AgentSideConnection, str, str | None]:
- if self.state.connection is None:
+ def _load_state(self) -> tuple[Client, str, str | None]:
+ if self.state.client is None:
raise ToolError(
- "Connection not available in tool state. This tool can only be used within an ACP session."
+ "Client not available in tool state. This tool can only be used within an ACP session."
)
if self.state.session_id is None:
raise ToolError(
"Session ID not available in tool state. This tool can only be used within an ACP session."
)
- return self.state.connection, self.state.session_id, self.state.tool_call_id
+ return self.state.client, self.state.session_id, self.state.tool_call_id
async def _send_in_progress_session_update(
self, content: list[ToolCallContentVariant] | None = None
) -> None:
- connection, session_id, tool_call_id = self._load_state()
+ client, session_id, tool_call_id = self._load_state()
if tool_call_id is None:
return
try:
- await connection.sessionUpdate(
- SessionNotification(
- sessionId=session_id,
- update=ToolCallProgress(
- sessionUpdate="tool_call_update",
- toolCallId=tool_call_id,
- status="in_progress",
- content=content,
- ),
- )
+ await client.session_update(
+ session_id=session_id,
+ update=ToolCallProgress(
+ session_update="tool_call_update",
+ tool_call_id=tool_call_id,
+ status="in_progress",
+ content=content,
+ ),
)
except Exception as e:
logger.error(f"Failed to update session: {e!r}")
diff --git a/vibe/acp/tools/builtins/bash.py b/vibe/acp/tools/builtins/bash.py
index 0d41cd7..2c57973 100644
--- a/vibe/acp/tools/builtins/bash.py
+++ b/vibe/acp/tools/builtins/bash.py
@@ -1,9 +1,10 @@
from __future__ import annotations
import asyncio
+from collections.abc import AsyncGenerator
+from pathlib import Path
import shlex
-from acp import CreateTerminalRequest, TerminalHandle
from acp.schema import (
EnvVariable,
TerminalToolCallContent,
@@ -14,9 +15,9 @@ from acp.schema import (
from vibe import VIBE_ROOT
from vibe.acp.tools.base import AcpToolState, BaseAcpTool
-from vibe.core.tools.base import BaseToolState, ToolError
+from vibe.core.tools.base import BaseToolState, InvokeContext, ToolError
from vibe.core.tools.builtins.bash import Bash as CoreBashTool, BashArgs, BashResult
-from vibe.core.types import ToolCallEvent, ToolResultEvent
+from vibe.core.types import ToolCallEvent, ToolResultEvent, ToolStreamEvent
from vibe.core.utils import logger
@@ -32,48 +33,54 @@ class Bash(CoreBashTool, BaseAcpTool[AcpBashState]):
def _get_tool_state_class(cls) -> type[AcpBashState]:
return AcpBashState
- async def run(self, args: BashArgs) -> BashResult:
- connection, session_id, _ = self._load_state()
+ async def run(
+ self, args: BashArgs, ctx: InvokeContext | None = None
+ ) -> AsyncGenerator[ToolStreamEvent | BashResult, None]:
+ client, session_id, _ = self._load_state()
timeout = args.timeout or self.config.default_timeout
max_bytes = self.config.max_output_bytes
env, command, cmd_args = self._parse_command(args.command)
- create_request = CreateTerminalRequest(
- sessionId=session_id,
- command=command,
- args=cmd_args,
- env=env,
- cwd=str(self.config.effective_workdir),
- outputByteLimit=max_bytes,
- )
-
try:
- terminal_handle = await connection.createTerminal(create_request)
+ terminal_handle = await client.create_terminal(
+ session_id=session_id,
+ command=command,
+ args=cmd_args,
+ env=env,
+ cwd=str(Path.cwd()),
+ output_byte_limit=max_bytes,
+ )
except Exception as e:
raise ToolError(f"Failed to create terminal: {e!r}") from e
+ terminal_id = terminal_handle.id
+
await self._send_in_progress_session_update([
- TerminalToolCallContent(type="terminal", terminalId=terminal_handle.id)
+ TerminalToolCallContent(type="terminal", terminal_id=terminal_id)
])
try:
exit_response = await self._wait_for_terminal_exit(
- terminal_handle, timeout, args.command
+ terminal_id=terminal_id, timeout=timeout, command=args.command
)
- output_response = await terminal_handle.current_output()
+ output_response = await client.terminal_output(
+ session_id=session_id, terminal_id=terminal_id
+ )
- return self._build_result(
+ yield self._build_result(
command=args.command,
stdout=output_response.output,
stderr="",
- returncode=exit_response.exitCode or 0,
+ returncode=exit_response.exit_code or 0,
)
finally:
try:
- await terminal_handle.release()
+ await client.release_terminal(
+ session_id=session_id, terminal_id=terminal_id
+ )
except Exception as e:
logger.error(f"Failed to release terminal: {e!r}")
@@ -105,15 +112,22 @@ class Bash(CoreBashTool, BaseAcpTool[AcpBashState]):
return summary
async def _wait_for_terminal_exit(
- self, terminal_handle: TerminalHandle, timeout: int, command: str
+ self, terminal_id: str, timeout: int, command: str
) -> WaitForTerminalExitResponse:
+ client, session_id, _ = self._load_state()
+
try:
return await asyncio.wait_for(
- terminal_handle.wait_for_exit(), timeout=timeout
+ client.wait_for_terminal_exit(
+ session_id=session_id, terminal_id=terminal_id
+ ),
+ timeout=timeout,
)
except TimeoutError:
try:
- await terminal_handle.kill()
+ await client.kill_terminal(
+ session_id=session_id, terminal_id=terminal_id
+ )
except Exception as e:
logger.error(f"Failed to kill terminal: {e!r}")
@@ -125,12 +139,12 @@ class Bash(CoreBashTool, BaseAcpTool[AcpBashState]):
raise ValueError(f"Unexpected tool args: {event.args}")
return ToolCallStart(
- sessionUpdate="tool_call",
+ session_update="tool_call",
title=Bash.get_summary(event.args),
content=None,
- toolCallId=event.tool_call_id,
+ tool_call_id=event.tool_call_id,
kind="execute",
- rawInput=event.args.model_dump_json(),
+ raw_input=event.args.model_dump_json(),
)
@classmethod
@@ -138,7 +152,7 @@ class Bash(CoreBashTool, BaseAcpTool[AcpBashState]):
cls, event: ToolResultEvent
) -> ToolCallProgress | None:
return ToolCallProgress(
- sessionUpdate="tool_call_update",
- toolCallId=event.tool_call_id,
+ session_update="tool_call_update",
+ tool_call_id=event.tool_call_id,
status="failed" if event.error else "completed",
)
diff --git a/vibe/acp/tools/builtins/read_file.py b/vibe/acp/tools/builtins/read_file.py
index 137513c..af20697 100644
--- a/vibe/acp/tools/builtins/read_file.py
+++ b/vibe/acp/tools/builtins/read_file.py
@@ -2,8 +2,6 @@ from __future__ import annotations
from pathlib import Path
-from acp import ReadTextFileRequest
-
from vibe import VIBE_ROOT
from vibe.acp.tools.base import AcpToolState, BaseAcpTool
from vibe.core.tools.base import ToolError
@@ -31,19 +29,17 @@ class ReadFile(CoreReadFileTool, BaseAcpTool[AcpReadFileState]):
return AcpReadFileState
async def _read_file(self, args: ReadFileArgs, file_path: Path) -> _ReadResult:
- connection, session_id, _ = self._load_state()
+ client, session_id, _ = self._load_state()
line = args.offset + 1 if args.offset > 0 else None
limit = args.limit
- read_request = ReadTextFileRequest(
- sessionId=session_id, path=str(file_path), line=line, limit=limit
- )
-
await self._send_in_progress_session_update()
try:
- response = await connection.readTextFile(read_request)
+ response = await client.read_text_file(
+ session_id=session_id, path=str(file_path), line=line, limit=limit
+ )
except Exception as e:
raise ToolError(f"Error reading {file_path}: {e}") from e
diff --git a/vibe/acp/tools/builtins/search_replace.py b/vibe/acp/tools/builtins/search_replace.py
index d363eef..f2dde1e 100644
--- a/vibe/acp/tools/builtins/search_replace.py
+++ b/vibe/acp/tools/builtins/search_replace.py
@@ -2,7 +2,6 @@ from __future__ import annotations
from pathlib import Path
-from acp import ReadTextFileRequest, WriteTextFileRequest
from acp.helpers import SessionUpdate
from acp.schema import (
FileEditToolCallContent,
@@ -38,14 +37,14 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
return AcpSearchReplaceState
async def _read_file(self, file_path: Path) -> str:
- connection, session_id, _ = self._load_state()
-
- read_request = ReadTextFileRequest(sessionId=session_id, path=str(file_path))
+ client, session_id, _ = self._load_state()
await self._send_in_progress_session_update()
try:
- response = await connection.readTextFile(read_request)
+ response = await client.read_text_file(
+ session_id=session_id, path=str(file_path)
+ )
except Exception as e:
raise ToolError(f"Unexpected error reading {file_path}: {e}") from e
@@ -62,14 +61,12 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
)
async def _write_file(self, file_path: Path, content: str) -> None:
- connection, session_id, _ = self._load_state()
-
- write_request = WriteTextFileRequest(
- sessionId=session_id, path=str(file_path), content=content
- )
+ client, session_id, _ = self._load_state()
try:
- await connection.writeTextFile(write_request)
+ await client.write_text_file(
+ session_id=session_id, path=str(file_path), content=content
+ )
except Exception as e:
raise ToolError(f"Error writing {file_path}: {e}") from e
@@ -82,29 +79,29 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
blocks = cls._parse_search_replace_blocks(args.content)
return ToolCallStart(
- sessionUpdate="tool_call",
+ session_update="tool_call",
title=cls.get_call_display(event).summary,
- toolCallId=event.tool_call_id,
+ tool_call_id=event.tool_call_id,
kind="edit",
content=[
FileEditToolCallContent(
type="diff",
path=args.file_path,
- oldText=block.search,
- newText=block.replace,
+ old_text=block.search,
+ new_text=block.replace,
)
for block in blocks
],
locations=[ToolCallLocation(path=args.file_path)],
- rawInput=args.model_dump_json(),
+ raw_input=args.model_dump_json(),
)
@classmethod
def tool_result_session_update(cls, event: ToolResultEvent) -> SessionUpdate | None:
if event.error:
return ToolCallProgress(
- sessionUpdate="tool_call_update",
- toolCallId=event.tool_call_id,
+ session_update="tool_call_update",
+ tool_call_id=event.tool_call_id,
status="failed",
)
@@ -115,18 +112,18 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
blocks = cls._parse_search_replace_blocks(result.content)
return ToolCallProgress(
- sessionUpdate="tool_call_update",
- toolCallId=event.tool_call_id,
+ session_update="tool_call_update",
+ tool_call_id=event.tool_call_id,
status="completed",
content=[
FileEditToolCallContent(
type="diff",
path=result.file,
- oldText=block.search,
- newText=block.replace,
+ old_text=block.search,
+ new_text=block.replace,
)
for block in blocks
],
locations=[ToolCallLocation(path=result.file)],
- rawOutput=result.model_dump_json(),
+ raw_output=result.model_dump_json(),
)
diff --git a/vibe/acp/tools/builtins/todo.py b/vibe/acp/tools/builtins/todo.py
index a19fb6b..b45831c 100644
--- a/vibe/acp/tools/builtins/todo.py
+++ b/vibe/acp/tools/builtins/todo.py
@@ -52,7 +52,7 @@ class Todo(CoreTodoTool, BaseAcpTool[AcpTodoState]):
}
update = AgentPlanUpdate(
- sessionUpdate="plan",
+ session_update="plan",
entries=[
PlanEntry(
content=todo.content,
diff --git a/vibe/acp/tools/builtins/write_file.py b/vibe/acp/tools/builtins/write_file.py
index 9612db8..39dc0fb 100644
--- a/vibe/acp/tools/builtins/write_file.py
+++ b/vibe/acp/tools/builtins/write_file.py
@@ -2,7 +2,6 @@ from __future__ import annotations
from pathlib import Path
-from acp import WriteTextFileRequest
from acp.helpers import SessionUpdate
from acp.schema import (
FileEditToolCallContent,
@@ -38,16 +37,14 @@ class WriteFile(CoreWriteFileTool, BaseAcpTool[AcpWriteFileState]):
return AcpWriteFileState
async def _write_file(self, args: WriteFileArgs, file_path: Path) -> None:
- connection, session_id, _ = self._load_state()
-
- write_request = WriteTextFileRequest(
- sessionId=session_id, path=str(file_path), content=args.content
- )
+ client, session_id, _ = self._load_state()
await self._send_in_progress_session_update()
try:
- await connection.writeTextFile(write_request)
+ await client.write_text_file(
+ session_id=session_id, path=str(file_path), content=args.content
+ )
except Exception as e:
raise ToolError(f"Error writing {file_path}: {e}") from e
@@ -58,25 +55,25 @@ class WriteFile(CoreWriteFileTool, BaseAcpTool[AcpWriteFileState]):
return None
return ToolCallStart(
- sessionUpdate="tool_call",
+ session_update="tool_call",
title=cls.get_call_display(event).summary,
- toolCallId=event.tool_call_id,
+ tool_call_id=event.tool_call_id,
kind="edit",
content=[
FileEditToolCallContent(
- type="diff", path=args.path, oldText=None, newText=args.content
+ type="diff", path=args.path, old_text=None, new_text=args.content
)
],
locations=[ToolCallLocation(path=args.path)],
- rawInput=args.model_dump_json(),
+ raw_input=args.model_dump_json(),
)
@classmethod
def tool_result_session_update(cls, event: ToolResultEvent) -> SessionUpdate | None:
if event.error:
return ToolCallProgress(
- sessionUpdate="tool_call_update",
- toolCallId=event.tool_call_id,
+ session_update="tool_call_update",
+ tool_call_id=event.tool_call_id,
status="failed",
)
@@ -85,14 +82,17 @@ class WriteFile(CoreWriteFileTool, BaseAcpTool[AcpWriteFileState]):
return None
return ToolCallProgress(
- sessionUpdate="tool_call_update",
- toolCallId=event.tool_call_id,
+ session_update="tool_call_update",
+ tool_call_id=event.tool_call_id,
status="completed",
content=[
FileEditToolCallContent(
- type="diff", path=result.path, oldText=None, newText=result.content
+ type="diff",
+ path=result.path,
+ old_text=None,
+ new_text=result.content,
)
],
locations=[ToolCallLocation(path=result.path)],
- rawOutput=result.model_dump_json(),
+ raw_output=result.model_dump_json(),
)
diff --git a/vibe/acp/tools/session_update.py b/vibe/acp/tools/session_update.py
index 9b2321f..e8c836f 100644
--- a/vibe/acp/tools/session_update.py
+++ b/vibe/acp/tools/session_update.py
@@ -17,7 +17,14 @@ from vibe.core.tools.ui import ToolUIDataAdapter
from vibe.core.types import ToolCallEvent, ToolResultEvent
from vibe.core.utils import TaggedText, is_user_cancellation_event
-TOOL_KIND: dict[str, ToolKind] = {"read_file": "read", "grep": "search"}
+TOOL_KIND: dict[str, ToolKind] = {
+ "grep": "search",
+ "read_file": "read",
+ # Right now, jetbrains implementation of "edit" tool kind is broken
+ # Leading to the tool not appearing in the chat
+ # "write_file": "edit",
+ # "search_replace": "edit",
+}
def tool_call_session_update(event: ToolCallEvent) -> SessionUpdate | None:
@@ -38,12 +45,12 @@ def tool_call_session_update(event: ToolCallEvent) -> SessionUpdate | None:
)
return ToolCallStart(
- sessionUpdate="tool_call",
+ session_update="tool_call",
title=display.summary,
content=content,
- toolCallId=event.tool_call_id,
+ tool_call_id=event.tool_call_id,
kind=TOOL_KIND.get(event.tool_name, "other"),
- rawInput=event.args.model_dump_json(),
+ raw_input=event.args.model_dump_json(),
)
@@ -66,10 +73,10 @@ def tool_result_session_update(event: ToolResultEvent) -> SessionUpdate | None:
if event.tool_class is None:
return ToolCallProgress(
- sessionUpdate="tool_call_update",
- toolCallId=event.tool_call_id,
+ session_update="tool_call_update",
+ tool_call_id=event.tool_call_id,
status="failed",
- rawOutput=raw_output,
+ raw_output=raw_output,
content=[
ContentToolCallContent(
type="content",
@@ -103,9 +110,9 @@ def tool_result_session_update(event: ToolResultEvent) -> SessionUpdate | None:
)
return ToolCallProgress(
- sessionUpdate="tool_call_update",
- toolCallId=event.tool_call_id,
+ session_update="tool_call_update",
+ tool_call_id=event.tool_call_id,
status=tool_status,
- rawOutput=raw_output,
+ raw_output=raw_output,
content=content,
)
diff --git a/vibe/acp/utils.py b/vibe/acp/utils.py
index 1bcd6f1..8f37f8f 100644
--- a/vibe/acp/utils.py
+++ b/vibe/acp/utils.py
@@ -1,11 +1,23 @@
from __future__ import annotations
from enum import StrEnum
-from typing import Literal, cast
+from typing import TYPE_CHECKING, Literal, cast
-from acp.schema import PermissionOption, SessionMode
+from acp.schema import (
+ ContentToolCallContent,
+ PermissionOption,
+ SessionMode,
+ TextContentBlock,
+ ToolCallProgress,
+ ToolCallStart,
+)
-from vibe.core.modes import MODE_CONFIGS, AgentMode
+from vibe.core.agents.models import AgentProfile, AgentType
+from vibe.core.types import CompactEndEvent, CompactStartEvent
+from vibe.core.utils import compact_reduction_display
+
+if TYPE_CHECKING:
+ from vibe.core.agents.manager import AgentManager
class ToolOption(StrEnum):
@@ -17,37 +29,85 @@ class ToolOption(StrEnum):
TOOL_OPTIONS = [
PermissionOption(
- optionId=ToolOption.ALLOW_ONCE,
+ option_id=ToolOption.ALLOW_ONCE,
name="Allow once",
kind=cast(Literal["allow_once"], ToolOption.ALLOW_ONCE),
),
PermissionOption(
- optionId=ToolOption.ALLOW_ALWAYS,
+ option_id=ToolOption.ALLOW_ALWAYS,
name="Allow always",
kind=cast(Literal["allow_always"], ToolOption.ALLOW_ALWAYS),
),
PermissionOption(
- optionId=ToolOption.REJECT_ONCE,
+ option_id=ToolOption.REJECT_ONCE,
name="Reject once",
kind=cast(Literal["reject_once"], ToolOption.REJECT_ONCE),
),
]
-def agent_mode_to_acp(mode: AgentMode) -> SessionMode:
- config = MODE_CONFIGS[mode]
+def agent_profile_to_acp(profile: AgentProfile) -> SessionMode:
return SessionMode(
- id=mode.value, name=config.display_name, description=config.description
+ id=profile.name, name=profile.display_name, description=profile.description
)
-def acp_to_agent_mode(mode_id: str) -> AgentMode | None:
- return AgentMode.from_string(mode_id)
+def is_valid_acp_agent(agent_manager: AgentManager, agent_name: str) -> bool:
+ return agent_name in agent_manager.available_agents
-def is_valid_acp_mode(mode_id: str) -> bool:
- return AgentMode.from_string(mode_id) is not None
+def get_all_acp_session_modes(agent_manager: AgentManager) -> list[SessionMode]:
+ return [
+ agent_profile_to_acp(profile)
+ for profile in agent_manager.available_agents.values()
+ if profile.agent_type == AgentType.AGENT
+ ]
-def get_all_acp_session_modes() -> list[SessionMode]:
- return [agent_mode_to_acp(mode) for mode in AgentMode]
+def create_compact_start_session_update(event: CompactStartEvent) -> ToolCallStart:
+ # WORKAROUND: Using tool_call to communicate compact events to the client.
+ # This should be revisited when the ACP protocol defines how compact events
+ # should be represented.
+ # [RFD](https://agentclientprotocol.com/rfds/session-usage)
+ return ToolCallStart(
+ session_update="tool_call",
+ tool_call_id=event.tool_call_id,
+ title="Compacting conversation history...",
+ kind="other",
+ status="in_progress",
+ content=[
+ ContentToolCallContent(
+ type="content",
+ content=TextContentBlock(
+ type="text",
+ text="Automatic context management, no approval required. This may take some time...",
+ ),
+ )
+ ],
+ )
+
+
+def create_compact_end_session_update(event: CompactEndEvent) -> ToolCallProgress:
+ # WORKAROUND: Using tool_call_update to communicate compact events to the client.
+ # This should be revisited when the ACP protocol defines how compact events
+ # should be represented.
+ # [RFD](https://agentclientprotocol.com/rfds/session-usage)
+ return ToolCallProgress(
+ session_update="tool_call_update",
+ tool_call_id=event.tool_call_id,
+ title="Compacted conversation history",
+ status="completed",
+ content=[
+ ContentToolCallContent(
+ type="content",
+ content=TextContentBlock(
+ type="text",
+ text=(
+ compact_reduction_display(
+ event.old_context_tokens, event.new_context_tokens
+ )
+ ),
+ ),
+ )
+ ],
+ )
diff --git a/vibe/cli/cli.py b/vibe/cli/cli.py
index 5c439e4..aa21e35 100644
--- a/vibe/cli/cli.py
+++ b/vibe/cli/cli.py
@@ -6,29 +6,26 @@ import sys
from rich import print as rprint
from vibe.cli.textual_ui.app import run_textual_ui
+from vibe.core.agent_loop import AgentLoop
+from vibe.core.agents.models import BuiltinAgentName
from vibe.core.config import (
MissingAPIKeyError,
MissingPromptFileError,
VibeConfig,
load_api_keys_from_env,
)
-from vibe.core.interaction_logger import InteractionLogger
-from vibe.core.modes import AgentMode
-from vibe.core.paths.config_paths import CONFIG_FILE, HISTORY_FILE, INSTRUCTIONS_FILE
+from vibe.core.paths.config_paths import CONFIG_FILE, HISTORY_FILE
from vibe.core.programmatic import run_programmatic
-from vibe.core.types import LLMMessage, OutputFormat
-from vibe.core.utils import ConversationLimitException
+from vibe.core.session.session_loader import SessionLoader
+from vibe.core.types import LLMMessage, OutputFormat, Role
+from vibe.core.utils import ConversationLimitException, logger
from vibe.setup.onboarding import run_onboarding
-def get_initial_mode(args: argparse.Namespace) -> AgentMode:
- if args.plan:
- return AgentMode.PLAN
- if args.auto_approve:
- return AgentMode.AUTO_APPROVE
- if args.prompt is not None:
- return AgentMode.AUTO_APPROVE
- return AgentMode.DEFAULT
+def get_initial_agent_name(args: argparse.Namespace) -> str:
+ if args.prompt is not None and args.agent == BuiltinAgentName.DEFAULT:
+ return BuiltinAgentName.AUTO_APPROVE
+ return args.agent
def get_prompt_from_stdin() -> str | None:
@@ -46,14 +43,12 @@ def get_prompt_from_stdin() -> str | None:
return None
-def load_config_or_exit(
- agent: str | None = None, mode: AgentMode = AgentMode.DEFAULT
-) -> VibeConfig:
+def load_config_or_exit() -> VibeConfig:
try:
- return VibeConfig.load(agent, **mode.config_overrides)
+ return VibeConfig.load()
except MissingAPIKeyError:
run_onboarding()
- return VibeConfig.load(agent, **mode.config_overrides)
+ return VibeConfig.load()
except MissingPromptFileError as e:
rprint(f"[yellow]Invalid system prompt id: {e}[/]")
sys.exit(1)
@@ -69,13 +64,6 @@ def bootstrap_config_files() -> None:
except Exception as e:
rprint(f"[yellow]Could not create default config file: {e}[/]")
- if not INSTRUCTIONS_FILE.path.exists():
- try:
- INSTRUCTIONS_FILE.path.parent.mkdir(parents=True, exist_ok=True)
- INSTRUCTIONS_FILE.path.touch()
- except Exception as e:
- rprint(f"[yellow]Could not create instructions file: {e}[/]")
-
if not HISTORY_FILE.path.exists():
try:
HISTORY_FILE.path.parent.mkdir(parents=True, exist_ok=True)
@@ -99,7 +87,7 @@ def load_session(
session_to_load = None
if args.continue_session:
- session_to_load = InteractionLogger.find_latest_session(config.session_logging)
+ session_to_load = SessionLoader.find_latest_session(config.session_logging)
if not session_to_load:
rprint(
f"[red]No previous sessions found in "
@@ -107,7 +95,7 @@ def load_session(
)
sys.exit(1)
else:
- session_to_load = InteractionLogger.find_session_by_id(
+ session_to_load = SessionLoader.find_session_by_id(
args.resume, config.session_logging
)
if not session_to_load:
@@ -118,25 +106,32 @@ def load_session(
sys.exit(1)
try:
- loaded_messages, _ = InteractionLogger.load_session(session_to_load)
+ loaded_messages, _ = SessionLoader.load_session(session_to_load)
return loaded_messages
except Exception as e:
rprint(f"[red]Failed to load session: {e}[/]")
sys.exit(1)
+def _load_messages_from_previous_session(
+ agent_loop: AgentLoop, loaded_messages: list[LLMMessage]
+) -> None:
+ non_system_messages = [msg for msg in loaded_messages if msg.role != Role.system]
+ agent_loop.messages.extend(non_system_messages)
+ logger.info("Loaded %d messages from previous session", len(non_system_messages))
+
+
def run_cli(args: argparse.Namespace) -> None:
load_api_keys_from_env()
+ bootstrap_config_files()
if args.setup:
run_onboarding()
sys.exit(0)
try:
- bootstrap_config_files()
-
- initial_mode = get_initial_mode(args)
- config = load_config_or_exit(args.agent, initial_mode)
+ initial_agent_name = get_initial_agent_name(args)
+ config = load_config_or_exit()
if args.enabled_tools:
config.enabled_tools = args.enabled_tools
@@ -163,7 +158,7 @@ def run_cli(args: argparse.Namespace) -> None:
max_price=args.max_price,
output_format=output_format,
previous_messages=loaded_messages,
- mode=initial_mode,
+ agent_name=initial_agent_name,
)
if final_response:
print(final_response)
@@ -175,12 +170,16 @@ def run_cli(args: argparse.Namespace) -> None:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
else:
+ agent_loop = AgentLoop(
+ config, agent_name=initial_agent_name, enable_streaming=True
+ )
+
+ if loaded_messages:
+ _load_messages_from_previous_session(agent_loop, loaded_messages)
+
run_textual_ui(
- config,
- initial_mode=initial_mode,
- enable_streaming=True,
+ agent_loop=agent_loop,
initial_prompt=args.initial_prompt or stdin_prompt,
- loaded_messages=loaded_messages,
)
except (KeyboardInterrupt, EOFError):
diff --git a/vibe/cli/clipboard.py b/vibe/cli/clipboard.py
index 81061fc..4136059 100644
--- a/vibe/cli/clipboard.py
+++ b/vibe/cli/clipboard.py
@@ -100,6 +100,7 @@ def copy_selection_to_clipboard(app: App) -> None:
f'"{_shorten_preview(selected_texts)}" copied to clipboard',
severity="information",
timeout=2,
+ markup=False,
)
else:
app.notify(
diff --git a/vibe/cli/commands.py b/vibe/cli/commands.py
index d5f6823..c14f114 100644
--- a/vibe/cli/commands.py
+++ b/vibe/cli/commands.py
@@ -84,6 +84,7 @@ class CommandRegistry:
"- `Ctrl+J` / `Shift+Enter` Insert newline",
"- `Escape` Interrupt agent or close dialogs",
"- `Ctrl+C` Quit (or clear input if text present)",
+ "- `Ctrl+G` Edit input in external editor",
"- `Ctrl+O` Toggle tool output view",
"- `Ctrl+T` Toggle todo view",
"- `Shift+Tab` Toggle auto-approve mode",
diff --git a/vibe/cli/entrypoint.py b/vibe/cli/entrypoint.py
index 6dcd688..7568186 100644
--- a/vibe/cli/entrypoint.py
+++ b/vibe/cli/entrypoint.py
@@ -1,12 +1,14 @@
from __future__ import annotations
import argparse
+import os
from pathlib import Path
import sys
from rich import print as rprint
from vibe import __version__
+from vibe.core.agents.models import BuiltinAgentName
from vibe.core.paths.config_paths import unlock_config_paths
from vibe.core.trusted_folders import has_trustable_content, trusted_folders_manager
from vibe.setup.trusted_folders.trust_folder_dialog import (
@@ -35,18 +37,6 @@ def parse_arguments() -> argparse.Namespace:
help="Run in programmatic mode: send prompt, auto-approve all tools, "
"output response, and exit.",
)
- parser.add_argument(
- "--auto-approve",
- action="store_true",
- default=False,
- help="Start in auto-approve mode: never ask for approval before running tools.",
- )
- parser.add_argument(
- "--plan",
- action="store_true",
- default=False,
- help="Start in plan mode: read-only tools for exploration and planning.",
- )
parser.add_argument(
"--max-turns",
type=int,
@@ -82,10 +72,17 @@ def parse_arguments() -> argparse.Namespace:
parser.add_argument(
"--agent",
metavar="NAME",
- default=None,
- help="Load agent configuration from ~/.vibe/agents/NAME.toml",
+ default=BuiltinAgentName.DEFAULT,
+ help="Agent to use (builtin: default, plan, accept-edits, auto-approve, "
+ "or custom from ~/.vibe/agents/NAME.toml)",
)
parser.add_argument("--setup", action="store_true", help="Setup API key and exit")
+ parser.add_argument(
+ "--workdir",
+ type=Path,
+ metavar="DIR",
+ help="Change to this directory before running",
+ )
continuation_group = parser.add_mutually_exclusive_group()
continuation_group.add_argument(
@@ -104,7 +101,17 @@ def parse_arguments() -> argparse.Namespace:
def check_and_resolve_trusted_folder() -> None:
- cwd = Path.cwd()
+ try:
+ cwd = Path.cwd()
+ except FileNotFoundError:
+ rprint(
+ "[red]Error: Current working directory no longer exists.[/]\n"
+ "[yellow]The directory you started vibe from has been deleted. "
+ "Please change to an existing directory and try again, "
+ "or use --workdir to specify a working directory.[/]"
+ )
+ sys.exit(1)
+
if not has_trustable_content(cwd) or cwd.resolve() == Path.home().resolve():
return
@@ -130,6 +137,15 @@ def check_and_resolve_trusted_folder() -> None:
def main() -> None:
args = parse_arguments()
+ if args.workdir:
+ workdir = args.workdir.expanduser().resolve()
+ if not workdir.is_dir():
+ rprint(
+ f"[red]Error: --workdir does not exist or is not a directory: {workdir}[/]"
+ )
+ sys.exit(1)
+ os.chdir(workdir)
+
is_interactive = args.prompt is None
if is_interactive:
check_and_resolve_trusted_folder()
diff --git a/vibe/cli/history_manager.py b/vibe/cli/history_manager.py
index d2b067c..5d6d3c4 100644
--- a/vibe/cli/history_manager.py
+++ b/vibe/cli/history_manager.py
@@ -38,7 +38,7 @@ class HistoryManager:
self.history_file.parent.mkdir(parents=True, exist_ok=True)
with self.history_file.open("w", encoding="utf-8") as f:
for entry in self._entries:
- f.write(json.dumps(entry) + "\n")
+ f.write(json.dumps(entry, ensure_ascii=False) + "\n")
except OSError:
pass
diff --git a/vibe/cli/plan_offer/adapters/http_whoami_gateway.py b/vibe/cli/plan_offer/adapters/http_whoami_gateway.py
new file mode 100644
index 0000000..a8570ad
--- /dev/null
+++ b/vibe/cli/plan_offer/adapters/http_whoami_gateway.py
@@ -0,0 +1,67 @@
+from __future__ import annotations
+
+from collections.abc import Mapping
+from typing import cast
+
+import httpx
+
+from vibe.cli.plan_offer.ports.whoami_gateway import (
+ WhoAmIGatewayError,
+ WhoAmIGatewayUnauthorized,
+ WhoAmIResponse,
+)
+
+BASE_URL = "https://console.mistral.ai"
+WHOAMI_PATH = "/api/vibe/whoami"
+
+
+class HttpWhoAmIGateway:
+ def __init__(self, base_url: str = BASE_URL) -> None:
+ self._base_url = base_url.rstrip("/")
+
+ async def whoami(self, api_key: str) -> WhoAmIResponse:
+ url = f"{self._base_url}{WHOAMI_PATH}"
+ headers = {"Authorization": f"Bearer {api_key}"}
+ try:
+ async with httpx.AsyncClient() as client:
+ response = await client.get(url, headers=headers)
+ except httpx.RequestError as exc:
+ raise WhoAmIGatewayError() from exc
+
+ if response.status_code in {httpx.codes.UNAUTHORIZED, httpx.codes.FORBIDDEN}:
+ raise WhoAmIGatewayUnauthorized()
+ if not response.is_success:
+ raise WhoAmIGatewayError(f"Unexpected status {response.status_code}")
+
+ payload = _safe_json(response) or {}
+ return WhoAmIResponse(
+ is_pro_plan=_parse_bool(payload.get("is_pro_plan")),
+ advertise_pro_plan=_parse_bool(payload.get("advertise_pro_plan")),
+ prompt_switching_to_pro_plan=_parse_bool(
+ payload.get("prompt_switching_to_pro_plan")
+ ),
+ )
+
+
+def _safe_json(response: httpx.Response) -> Mapping[str, object] | None:
+ try:
+ data = response.json()
+ except ValueError:
+ return None
+ return cast(Mapping[str, object], data) if isinstance(data, dict) else None
+
+
+def _parse_bool(value: object | None) -> bool:
+ if value is None:
+ return False
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, str):
+ match value.strip().lower():
+ case "true":
+ return True
+ case "false":
+ return False
+ case _:
+ raise WhoAmIGatewayError("Invalid boolean string in whoami response")
+ raise WhoAmIGatewayError("Invalid boolean value in whoami response")
diff --git a/vibe/cli/plan_offer/decide_plan_offer.py b/vibe/cli/plan_offer/decide_plan_offer.py
new file mode 100644
index 0000000..59d0ec2
--- /dev/null
+++ b/vibe/cli/plan_offer/decide_plan_offer.py
@@ -0,0 +1,56 @@
+from __future__ import annotations
+
+from enum import StrEnum
+import logging
+
+from vibe.cli.plan_offer.ports.whoami_gateway import (
+ WhoAmIGateway,
+ WhoAmIGatewayError,
+ WhoAmIGatewayUnauthorized,
+ WhoAmIResponse,
+)
+
+logger = logging.getLogger(__name__)
+
+CONSOLE_CLI_URL = "https://console.mistral.ai/codestral/cli"
+UPGRADE_URL = CONSOLE_CLI_URL
+SWITCH_TO_PRO_KEY_URL = CONSOLE_CLI_URL
+
+
+class PlanOfferAction(StrEnum):
+ NONE = "none"
+ UPGRADE = "upgrade"
+ SWITCH_TO_PRO_KEY = "switch_to_pro_key"
+
+
+ACTION_TO_URL: dict[PlanOfferAction, str] = {
+ PlanOfferAction.UPGRADE: UPGRADE_URL,
+ PlanOfferAction.SWITCH_TO_PRO_KEY: SWITCH_TO_PRO_KEY_URL,
+}
+
+
+async def decide_plan_offer(
+ api_key: str | None, gateway: WhoAmIGateway
+) -> PlanOfferAction:
+ if not api_key:
+ return PlanOfferAction.UPGRADE
+ try:
+ response = await gateway.whoami(api_key)
+ except WhoAmIGatewayUnauthorized:
+ return PlanOfferAction.UPGRADE
+ except WhoAmIGatewayError:
+ logger.warning("Failed to fetch plan status.", exc_info=True)
+ return PlanOfferAction.NONE
+ return _action_from_response(response)
+
+
+def _action_from_response(response: WhoAmIResponse) -> PlanOfferAction:
+ match response:
+ case WhoAmIResponse(is_pro_plan=True):
+ return PlanOfferAction.NONE
+ case WhoAmIResponse(prompt_switching_to_pro_plan=True):
+ return PlanOfferAction.SWITCH_TO_PRO_KEY
+ case WhoAmIResponse(advertise_pro_plan=True):
+ return PlanOfferAction.UPGRADE
+ case _:
+ return PlanOfferAction.NONE
diff --git a/vibe/cli/plan_offer/ports/whoami_gateway.py b/vibe/cli/plan_offer/ports/whoami_gateway.py
new file mode 100644
index 0000000..abb2f4f
--- /dev/null
+++ b/vibe/cli/plan_offer/ports/whoami_gateway.py
@@ -0,0 +1,23 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Protocol
+
+
+@dataclass(frozen=True, slots=True)
+class WhoAmIResponse:
+ is_pro_plan: bool
+ advertise_pro_plan: bool
+ prompt_switching_to_pro_plan: bool
+
+
+class WhoAmIGatewayUnauthorized(Exception):
+ pass
+
+
+class WhoAmIGatewayError(Exception):
+ pass
+
+
+class WhoAmIGateway(Protocol):
+ async def whoami(self, api_key: str) -> WhoAmIResponse: ...
diff --git a/vibe/cli/terminal_setup.py b/vibe/cli/terminal_setup.py
index 96f7303..c1730df 100644
--- a/vibe/cli/terminal_setup.py
+++ b/vibe/cli/terminal_setup.py
@@ -7,11 +7,12 @@ import os
from pathlib import Path
import platform
import subprocess
-from typing import Any
+from typing import Any, Literal
class Terminal(Enum):
VSCODE = "vscode"
+ VSCODE_INSIDERS = "vscode_insiders"
CURSOR = "cursor"
ITERM2 = "iterm2"
WEZTERM = "wezterm"
@@ -41,13 +42,21 @@ def _is_cursor() -> bool:
return False
+def _detect_vscode_terminal() -> Literal[Terminal.VSCODE, Terminal.VSCODE_INSIDERS]:
+ term_version = os.environ.get("TERM_PROGRAM_VERSION", "").lower()
+ if term_version.endswith("-insider"):
+ return Terminal.VSCODE_INSIDERS
+
+ return Terminal.VSCODE
+
+
def detect_terminal() -> Terminal:
term_program = os.environ.get("TERM_PROGRAM", "").lower()
if term_program == "vscode":
if _is_cursor():
return Terminal.CURSOR
- return Terminal.VSCODE
+ return _detect_vscode_terminal()
term_map = {
"iterm.app": Terminal.ITERM2,
@@ -65,17 +74,19 @@ def detect_terminal() -> Terminal:
return Terminal.UNKNOWN
-def _get_vscode_keybindings_path() -> Path | None:
+def _get_vscode_keybindings_path(is_stable: bool) -> Path | None:
system = platform.system()
+ app_name = "Code" if is_stable else "Code - Insiders"
+
if system == "Darwin":
- base = Path.home() / "Library" / "Application Support" / "Code" / "User"
+ base = Path.home() / "Library" / "Application Support" / app_name / "User"
elif system == "Linux":
- base = Path.home() / ".config" / "Code" / "User"
+ base = Path.home() / ".config" / app_name / "User"
elif system == "Windows":
appdata = os.environ.get("APPDATA", "")
if appdata:
- base = Path(appdata) / "Code" / "User"
+ base = Path(appdata) / app_name / "User"
else:
return None
else:
@@ -118,13 +129,13 @@ def _parse_keybindings(content: str) -> list[dict[str, Any]]:
def _setup_vscode_like_terminal(terminal: Terminal) -> SetupResult:
- """Setup keybindings for VSCode or Cursor."""
+ """Setup keybindings for VS Code or Cursor."""
if terminal == Terminal.CURSOR:
keybindings_path = _get_cursor_keybindings_path()
editor_name = "Cursor"
else:
- keybindings_path = _get_vscode_keybindings_path()
- editor_name = "VSCode"
+ keybindings_path = _get_vscode_keybindings_path(terminal == Terminal.VSCODE)
+ editor_name = "VS Code" if terminal == Terminal.VSCODE else "VS Code Insiders"
if keybindings_path is None:
return SetupResult(
@@ -151,7 +162,9 @@ def _setup_vscode_like_terminal(terminal: Terminal) -> SetupResult:
)
keybindings.append(new_binding)
- keybindings_path.write_text(json.dumps(keybindings, indent=2) + "\n")
+ keybindings_path.write_text(
+ json.dumps(keybindings, indent=2, ensure_ascii=False) + "\n"
+ )
return SetupResult(
success=True,
@@ -376,10 +389,8 @@ def setup_terminal() -> SetupResult:
terminal = detect_terminal()
match terminal:
- case Terminal.VSCODE:
- return _setup_vscode_like_terminal(Terminal.VSCODE)
- case Terminal.CURSOR:
- return _setup_vscode_like_terminal(Terminal.CURSOR)
+ case Terminal.VSCODE | Terminal.VSCODE_INSIDERS | Terminal.CURSOR:
+ return _setup_vscode_like_terminal(terminal)
case Terminal.ITERM2:
return _setup_iterm2()
case Terminal.WEZTERM:
@@ -391,7 +402,7 @@ def setup_terminal() -> SetupResult:
success=False,
terminal=Terminal.UNKNOWN,
message="Could not detect terminal. Supported terminals:\n"
- "- VSCode\n"
+ "- VS Code\n"
"- Cursor\n"
"- iTerm2\n"
"- WezTerm\n"
diff --git a/vibe/cli/textual_ui/app.py b/vibe/cli/textual_ui/app.py
index 97d9133..3c34504 100644
--- a/vibe/cli/textual_ui/app.py
+++ b/vibe/cli/textual_ui/app.py
@@ -2,9 +2,11 @@ from __future__ import annotations
import asyncio
from enum import StrEnum, auto
+from os import getenv
+from pathlib import Path
import subprocess
import time
-from typing import Any, ClassVar, assert_never
+from typing import Any, ClassVar, assert_never, cast
from pydantic import BaseModel
from textual.app import App, ComposeResult
@@ -17,50 +19,67 @@ from textual.widgets import Static
from vibe import __version__ as CORE_VERSION
from vibe.cli.clipboard import copy_selection_to_clipboard
from vibe.cli.commands import CommandRegistry
+from vibe.cli.plan_offer.adapters.http_whoami_gateway import HttpWhoAmIGateway
+from vibe.cli.plan_offer.decide_plan_offer import (
+ ACTION_TO_URL,
+ PlanOfferAction,
+ decide_plan_offer,
+)
+from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIGateway
from vibe.cli.terminal_setup import setup_terminal
from vibe.cli.textual_ui.handlers.event_handler import EventHandler
from vibe.cli.textual_ui.terminal_theme import (
TERMINAL_THEME_NAME,
capture_terminal_theme,
)
+from vibe.cli.textual_ui.widgets.agent_indicator import AgentIndicator
from vibe.cli.textual_ui.widgets.approval_app import ApprovalApp
from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer
from vibe.cli.textual_ui.widgets.compact import CompactMessage
from vibe.cli.textual_ui.widgets.config_app import ConfigApp
from vibe.cli.textual_ui.widgets.context_progress import ContextProgress, TokenState
-from vibe.cli.textual_ui.widgets.loading import LoadingWidget
+from vibe.cli.textual_ui.widgets.loading import LoadingWidget, paused_timer
from vibe.cli.textual_ui.widgets.messages import (
AssistantMessage,
BashOutputMessage,
ErrorMessage,
InterruptMessage,
+ PlanOfferMessage,
ReasoningMessage,
StreamingMessageBase,
UserCommandMessage,
UserMessage,
WarningMessage,
+ WhatsNewMessage,
)
-from vibe.cli.textual_ui.widgets.mode_indicator import ModeIndicator
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.cli.textual_ui.widgets.path_display import PathDisplay
+from vibe.cli.textual_ui.widgets.question_app import QuestionApp
from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage
from vibe.cli.textual_ui.widgets.welcome import WelcomeBanner
from vibe.cli.update_notifier import (
FileSystemUpdateCacheRepository,
- PyPIVersionUpdateGateway,
+ PyPIUpdateGateway,
UpdateCacheRepository,
- VersionUpdateAvailability,
- VersionUpdateError,
- VersionUpdateGateway,
+ UpdateError,
+ UpdateGateway,
get_update_if_available,
+ load_whats_new_content,
+ mark_version_as_seen,
+ should_show_whats_new,
)
-from vibe.core.agent import Agent
+from vibe.cli.update_notifier.update import do_update
+from vibe.core.agent_loop import AgentLoop
+from vibe.core.agents import AgentProfile
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
from vibe.core.config import VibeConfig
-from vibe.core.modes import AgentMode, next_mode
from vibe.core.paths.config_paths import HISTORY_FILE
-from vibe.core.tools.base import BaseToolConfig, ToolPermission
-from vibe.core.types import ApprovalResponse, LLMMessage, Role
+from vibe.core.tools.base import ToolPermission
+from vibe.core.tools.builtins.ask_user_question import (
+ AskUserQuestionArgs,
+ AskUserQuestionResult,
+)
+from vibe.core.types import AgentStats, ApprovalResponse, LLMMessage, Role
from vibe.core.utils import (
CancellationReason,
get_user_cancellation_message,
@@ -70,9 +89,17 @@ from vibe.core.utils import (
class BottomApp(StrEnum):
+ """Bottom panel app types.
+
+ Convention: Each value must match the widget class name with "App" suffix removed.
+ E.g., ApprovalApp -> Approval, ConfigApp -> Config, QuestionApp -> Question.
+ This allows dynamic lookup via: BottomApp[type(widget).__name__.removesuffix("App")]
+ """
+
Approval = auto()
Config = auto()
Input = auto()
+ Question = auto()
class VibeApp(App): # noqa: PLR0904
@@ -94,35 +121,29 @@ class VibeApp(App): # noqa: PLR0904
def __init__(
self,
- config: VibeConfig,
- initial_mode: AgentMode = AgentMode.DEFAULT,
- enable_streaming: bool = False,
+ agent_loop: AgentLoop,
initial_prompt: str | None = None,
- loaded_messages: list[LLMMessage] | None = None,
- version_update_notifier: VersionUpdateGateway | None = None,
+ update_notifier: UpdateGateway | None = None,
update_cache_repository: UpdateCacheRepository | None = None,
current_version: str = CORE_VERSION,
+ plan_offer_gateway: WhoAmIGateway | None = None,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
- self._config = config
- self._current_agent_mode = initial_mode
- self.enable_streaming = enable_streaming
- self.agent: Agent | None = None
+ self.agent_loop = agent_loop
self._agent_running = False
- self._agent_initializing = False
self._interrupt_requested = False
self._agent_task: asyncio.Task | None = None
self._loading_widget: LoadingWidget | None = None
self._pending_approval: asyncio.Future | None = None
+ self._pending_question: asyncio.Future | None = None
self.event_handler: EventHandler | None = None
self.commands = CommandRegistry()
self._chat_input_container: ChatInputContainer | None = None
- self._mode_indicator: ModeIndicator | None = None
- self._context_progress: ContextProgress | None = None
+ self._agent_indicator: AgentIndicator | None = None
self._current_bottom_app: BottomApp = BottomApp.Input
self.history_file = HISTORY_FILE.path
@@ -131,26 +152,19 @@ class VibeApp(App): # noqa: PLR0904
self._todos_collapsed = False
self._current_streaming_message: AssistantMessage | None = None
self._current_streaming_reasoning: ReasoningMessage | None = None
- self._version_update_notifier = version_update_notifier
+ self._update_notifier = update_notifier
self._update_cache_repository = update_cache_repository
- self._is_update_check_enabled = config.enable_update_checks
self._current_version = current_version
- self._update_notification_task: asyncio.Task | None = None
- self._update_notification_shown = False
-
+ self._plan_offer_gateway = plan_offer_gateway
+ self._plan_offer_shown = False
self._initial_prompt = initial_prompt
- self._loaded_messages = loaded_messages
- self._agent_init_task: asyncio.Task | None = None
- # prevent a race condition where the agent initialization
- # completes exactly at the moment the user interrupts
- self._agent_init_interrupted = False
self._auto_scroll = True
self._last_escape_time: float | None = None
self._terminal_theme = capture_terminal_theme()
@property
def config(self) -> VibeConfig:
- return self.agent.config if self.agent else self._config
+ return self.agent_loop.config
def compose(self) -> ComposeResult:
with VerticalScroll(id="chat"):
@@ -159,7 +173,7 @@ class VibeApp(App): # noqa: PLR0904
with Horizontal(id="loading-area"):
yield Static(id="loading-area-content")
- yield ModeIndicator(mode=self._current_agent_mode)
+ yield AgentIndicator(profile=self.agent_loop.agent_profile)
yield Static(id="todo-area")
@@ -168,13 +182,12 @@ class VibeApp(App): # noqa: PLR0904
history_file=self.history_file,
command_registry=self.commands,
id="input-container",
- safety=self._current_agent_mode.safety,
+ safety=self.agent_loop.agent_profile.safety,
+ skill_entries_getter=self._get_skill_entries,
)
with Horizontal(id="bottom-bar"):
- yield PathDisplay(
- self.config.displayed_workdir or self.config.effective_workdir
- )
+ yield PathDisplay(self.config.displayed_workdir or Path.cwd())
yield NoMarkupStatic(id="spacer")
yield ContextProgress()
@@ -197,26 +210,33 @@ class VibeApp(App): # noqa: PLR0904
)
self._chat_input_container = self.query_one(ChatInputContainer)
- self._mode_indicator = self.query_one(ModeIndicator)
- self._context_progress = self.query_one(ContextProgress)
+ self._agent_indicator = self.query_one(AgentIndicator)
+ context_progress = self.query_one(ContextProgress)
- if self.config.auto_compact_threshold > 0:
- self._context_progress.tokens = TokenState(
- max_tokens=self.config.auto_compact_threshold, current_tokens=0
+ def update_context_progress(stats: AgentStats) -> None:
+ context_progress.tokens = TokenState(
+ max_tokens=self.config.auto_compact_threshold,
+ current_tokens=stats.context_tokens,
)
+ AgentStats.add_listener("context_tokens", update_context_progress)
+ self.agent_loop.stats.trigger_listeners()
+
+ self.agent_loop.set_approval_callback(self._approval_callback)
+ self.agent_loop.set_user_input_callback(self._user_input_callback)
+ self._refresh_profile_widgets()
+
chat_input_container = self.query_one(ChatInputContainer)
chat_input_container.focus_input()
await self._show_dangerous_directory_warning()
+ await self._check_and_show_whats_new()
+ await self._maybe_show_plan_offer()
self._schedule_update_notification()
- if self._loaded_messages:
- await self._rebuild_history_from_messages()
+ await self._rebuild_history_from_messages()
if self._initial_prompt:
self.call_after_refresh(self._process_initial_prompt)
- else:
- self._ensure_agent_init_task()
def _process_initial_prompt(self) -> None:
if self._initial_prompt:
@@ -235,7 +255,7 @@ class VibeApp(App): # noqa: PLR0904
input_widget.value = ""
if self._agent_running:
- await self._interrupt_agent()
+ await self._interrupt_agent_loop()
if value.startswith("!"):
await self._handle_bash_command(value[1:])
@@ -244,6 +264,9 @@ class VibeApp(App): # noqa: PLR0904
if await self._handle_command(value):
return
+ if await self._handle_skill(value):
+ return
+
await self._handle_user_message(value)
async def on_approval_app_approval_granted(
@@ -280,6 +303,21 @@ class VibeApp(App): # noqa: PLR0904
if self._loading_widget and self._loading_widget.parent:
await self._remove_loading_widget()
+ async def on_question_app_answered(self, message: QuestionApp.Answered) -> None:
+ if self._pending_question and not self._pending_question.done():
+ result = AskUserQuestionResult(answers=message.answers, cancelled=False)
+ self._pending_question.set_result(result)
+
+ await self._switch_to_input_app()
+
+ async def on_question_app_cancelled(self, message: QuestionApp.Cancelled) -> None:
+ if self._pending_question and not self._pending_question.done():
+ result = AskUserQuestionResult(answers=[], cancelled=True)
+ self._pending_question.set_result(result)
+
+ await self._switch_to_input_app()
+ await self._interrupt_agent_loop()
+
async def _remove_loading_widget(self) -> None:
if self._loading_widget and self._loading_widget.parent:
await self._loading_widget.remove()
@@ -312,7 +350,7 @@ class VibeApp(App): # noqa: PLR0904
self, message: ConfigApp.ConfigClosed
) -> None:
if message.changes:
- self._save_config_changes(message.changes)
+ VibeConfig.save_updates(message.changes)
await self._reload_config()
else:
await self._mount_and_scroll(
@@ -342,31 +380,9 @@ class VibeApp(App): # noqa: PLR0904
def _set_tool_permission_always(
self, tool_name: str, save_permanently: bool = False
) -> None:
- if save_permanently:
- VibeConfig.save_updates({"tools": {tool_name: {"permission": "always"}}})
-
- if tool_name not in self.config.tools:
- self.config.tools[tool_name] = BaseToolConfig()
-
- self.config.tools[tool_name].permission = ToolPermission.ALWAYS
-
- def _save_config_changes(self, changes: dict[str, str]) -> None:
- if not changes:
- return
-
- updates: dict = {}
-
- for key, value in changes.items():
- match key:
- case "active_model":
- if value != self.config.active_model:
- updates["active_model"] = value
- case "textual_theme":
- if value != self.config.textual_theme:
- updates["textual_theme"] = value
-
- if updates:
- VibeConfig.save_updates(updates)
+ self.agent_loop.set_tool_permission(
+ tool_name, ToolPermission.ALWAYS, save_permanently
+ )
async def _handle_command(self, user_input: str) -> bool:
if command := self.commands.find_command(user_input):
@@ -379,6 +395,40 @@ class VibeApp(App): # noqa: PLR0904
return True
return False
+ def _get_skill_entries(self) -> list[tuple[str, str]]:
+ if not self.agent_loop:
+ return []
+ return [
+ (f"/{name}", info.description)
+ for name, info in self.agent_loop.skill_manager.available_skills.items()
+ if info.user_invocable
+ ]
+
+ async def _handle_skill(self, user_input: str) -> bool:
+ if not user_input.startswith("/"):
+ return False
+
+ if not self.agent_loop:
+ return False
+
+ skill_name = user_input[1:].strip().lower()
+ skill_info = self.agent_loop.skill_manager.get_skill(skill_name)
+ if not skill_info:
+ return False
+
+ try:
+ skill_content = skill_info.skill_path.read_text(encoding="utf-8")
+ except OSError as e:
+ await self._mount_and_scroll(
+ ErrorMessage(
+ f"Failed to read skill file: {e}", collapsed=self._tools_collapsed
+ )
+ )
+ return True
+
+ await self._handle_user_message(skill_content)
+ return True
+
async def _handle_bash_command(self, command: str) -> None:
if not command:
await self._mount_and_scroll(
@@ -390,12 +440,7 @@ class VibeApp(App): # noqa: PLR0904
try:
result = subprocess.run(
- command,
- shell=True,
- capture_output=True,
- text=False,
- timeout=30,
- cwd=self.config.effective_workdir,
+ command, shell=True, capture_output=True, text=False, timeout=30
)
stdout = (
result.stdout.decode("utf-8", errors="replace") if result.stdout else ""
@@ -406,9 +451,7 @@ class VibeApp(App): # noqa: PLR0904
output = stdout or stderr or "(no output)"
exit_code = result.returncode
await self._mount_and_scroll(
- BashOutputMessage(
- command, str(self.config.effective_workdir), output, exit_code
- )
+ BashOutputMessage(command, str(Path.cwd()), output, exit_code)
)
except subprocess.TimeoutExpired:
await self._mount_and_scroll(
@@ -423,106 +466,28 @@ class VibeApp(App): # noqa: PLR0904
)
async def _handle_user_message(self, message: str) -> None:
- init_task = self._ensure_agent_init_task()
- pending_init = bool(init_task and not init_task.done())
- user_message = UserMessage(message, pending=pending_init)
+ user_message = UserMessage(message)
await self._mount_and_scroll(user_message)
- self.run_worker(
- self._process_user_message_after_mount(
- message=message,
- user_message=user_message,
- init_task=init_task,
- pending_init=pending_init,
- ),
- exclusive=False,
- )
-
- async def _process_user_message_after_mount(
- self,
- message: str,
- user_message: UserMessage,
- init_task: asyncio.Task | None,
- pending_init: bool,
- ) -> None:
- try:
- if init_task and not init_task.done():
- loading = LoadingWidget()
- self._loading_widget = loading
- await self.query_one("#loading-area-content").mount(loading)
-
- try:
- await init_task
- finally:
- if self._loading_widget and self._loading_widget.parent:
- await self._loading_widget.remove()
- self._loading_widget = None
- if pending_init:
- await user_message.set_pending(False)
- elif pending_init:
- await user_message.set_pending(False)
-
- if pending_init and self._agent_init_interrupted:
- self._agent_init_interrupted = False
- return
-
- if self.agent and not self._agent_running:
- self._agent_task = asyncio.create_task(self._handle_agent_turn(message))
- except asyncio.CancelledError:
- self._agent_init_interrupted = False
- if pending_init:
- await user_message.set_pending(False)
- return
-
- async def _initialize_agent(self) -> None:
- if self.agent or self._agent_initializing:
- return
-
- self._agent_initializing = True
- try:
- agent = Agent(
- self.config,
- mode=self._current_agent_mode,
- enable_streaming=self.enable_streaming,
+ if not self._agent_running:
+ self._agent_task = asyncio.create_task(
+ self._handle_agent_loop_turn(message)
)
- if not self._current_agent_mode.auto_approve:
- agent.approval_callback = self._approval_callback
-
- if self._loaded_messages:
- non_system_messages = [
- msg
- for msg in self._loaded_messages
- if not (msg.role == Role.system)
- ]
- agent.messages.extend(non_system_messages)
- logger.info(
- "Loaded %d messages from previous session", len(non_system_messages)
- )
-
- self.agent = agent
- except asyncio.CancelledError:
- self.agent = None
- return
- except Exception as e:
- self.agent = None
- await self._mount_and_scroll(
- ErrorMessage(str(e), collapsed=self._tools_collapsed)
- )
- finally:
- self._agent_initializing = False
- self._agent_init_task = None
-
async def _rebuild_history_from_messages(self) -> None:
- if not self._loaded_messages:
+ if all(msg.role == Role.system for msg in self.agent_loop.messages):
return
messages_area = self.query_one("#messages")
+ # Don't rebuild if messages are already displayed
+ if messages_area.children:
+ return
+
tool_call_map: dict[str, str] = {}
with self.batch_update():
- for msg in self._loaded_messages:
+ for msg in self.agent_loop.messages:
if msg.role == Role.system:
continue
@@ -567,35 +532,38 @@ class VibeApp(App): # noqa: PLR0904
await messages_area.mount(ToolCallMessage(tool_name=tool_name))
- def _ensure_agent_init_task(self) -> asyncio.Task | None:
- if self.agent:
- self._agent_init_task = None
- self._agent_init_interrupted = False
- return None
-
- if self._agent_init_task and self._agent_init_task.done():
- if self._agent_init_task.cancelled():
- self._agent_init_task = None
-
- if not self._agent_init_task or self._agent_init_task.done():
- self._agent_init_interrupted = False
- self._agent_init_task = asyncio.create_task(self._initialize_agent())
-
- return self._agent_init_task
+ def _is_tool_enabled_in_main_agent(self, tool: str) -> bool:
+ return tool in self.agent_loop.tool_manager.available_tools
async def _approval_callback(
self, tool: str, args: BaseModel, tool_call_id: str
) -> tuple[ApprovalResponse, str | None]:
+ # Auto-approve only if parent is in auto-approve mode AND tool is enabled
+ # This ensures subagents respect the main agent's tool restrictions
+ if self.agent_loop and self.agent_loop.config.auto_approve:
+ if self._is_tool_enabled_in_main_agent(tool):
+ return (ApprovalResponse.YES, None)
+
self._pending_approval = asyncio.Future()
- await self._switch_to_approval_app(tool, args)
- result = await self._pending_approval
+ with paused_timer(self._loading_widget):
+ await self._switch_to_approval_app(tool, args)
+ result = await self._pending_approval
+
self._pending_approval = None
return result
- async def _handle_agent_turn(self, prompt: str) -> None:
- if not self.agent:
- return
+ async def _user_input_callback(self, args: BaseModel) -> BaseModel:
+ question_args = cast(AskUserQuestionArgs, args)
+ self._pending_question = asyncio.Future()
+ with paused_timer(self._loading_widget):
+ await self._switch_to_question_app(question_args)
+ result = await self._pending_question
+
+ self._pending_question = None
+ return result
+
+ async def _handle_agent_loop_turn(self, prompt: str) -> None:
self._agent_running = True
loading_area = self.query_one("#loading-area-content")
@@ -606,17 +574,8 @@ class VibeApp(App): # noqa: PLR0904
self._show_todo_area()
try:
- rendered_prompt = render_path_prompt(
- prompt, base_dir=self.config.effective_workdir
- )
- async for event in self.agent.act(rendered_prompt):
- if self._context_progress and self.agent:
- current_state = self._context_progress.tokens
- self._context_progress.tokens = TokenState(
- max_tokens=current_state.max_tokens,
- current_tokens=self.agent.stats.context_tokens,
- )
-
+ rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd())
+ async for event in self.agent_loop.act(rendered_prompt):
if self.event_handler:
await self.event_handler.handle_event(
event,
@@ -648,26 +607,12 @@ class VibeApp(App): # noqa: PLR0904
self._hide_todo_area()
await self._finalize_current_streaming_message()
- async def _interrupt_agent(self) -> None:
- interrupting_agent_init = bool(
- self._agent_init_task and not self._agent_init_task.done()
- )
-
- if (
- not self._agent_running and not interrupting_agent_init
- ) or self._interrupt_requested:
+ async def _interrupt_agent_loop(self) -> None:
+ if not self._agent_running or self._interrupt_requested:
return
self._interrupt_requested = True
- if interrupting_agent_init and self._agent_init_task:
- self._agent_init_interrupted = True
- self._agent_init_task.cancel()
- try:
- await self._agent_init_task
- except asyncio.CancelledError:
- pass
-
if self._agent_task and not self._agent_task.done():
self._agent_task.cancel()
try:
@@ -695,16 +640,7 @@ class VibeApp(App): # noqa: PLR0904
await self._mount_and_scroll(UserCommandMessage(help_text))
async def _show_status(self) -> None:
- if self.agent is None:
- await self._mount_and_scroll(
- ErrorMessage(
- "Agent not initialized yet. Send a message first.",
- collapsed=self._tools_collapsed,
- )
- )
- return
-
- stats = self.agent.stats
+ stats = self.agent_loop.stats
status_text = f"""## Agent Statistics
- **Steps**: {stats.steps:,}
@@ -724,23 +660,9 @@ class VibeApp(App): # noqa: PLR0904
async def _reload_config(self) -> None:
try:
- new_config = VibeConfig.load(**self._current_agent_mode.config_overrides)
+ base_config = VibeConfig.load()
- if self.agent:
- await self.agent.reload_with_initial_messages(config=new_config)
- else:
- self._config = new_config
- if self._context_progress:
- if self.config.auto_compact_threshold > 0:
- current_tokens = (
- self.agent.stats.context_tokens if self.agent else 0
- )
- self._context_progress.tokens = TokenState(
- max_tokens=self.config.auto_compact_threshold,
- current_tokens=current_tokens,
- )
- else:
- self._context_progress.tokens = TokenState()
+ await self.agent_loop.reload_with_initial_messages(base_config=base_config)
await self._mount_and_scroll(UserCommandMessage("Configuration reloaded."))
except Exception as e:
@@ -751,32 +673,14 @@ class VibeApp(App): # noqa: PLR0904
)
async def _clear_history(self) -> None:
- if self.agent is None:
- await self._mount_and_scroll(
- ErrorMessage(
- "No conversation history to clear yet.",
- collapsed=self._tools_collapsed,
- )
- )
- return
-
- if not self.agent:
- return
-
try:
- await self.agent.clear_history()
+ await self.agent_loop.clear_history()
await self._finalize_current_streaming_message()
messages_area = self.query_one("#messages")
await messages_area.remove_children()
todo_area = self.query_one("#todo-area")
await todo_area.remove_children()
- if self._context_progress and self.agent:
- current_state = self._context_progress.tokens
- self._context_progress.tokens = TokenState(
- max_tokens=current_state.max_tokens,
- current_tokens=self.agent.stats.context_tokens,
- )
await messages_area.mount(UserMessage("/clear"))
await self._mount_and_scroll(
UserCommandMessage("Conversation history cleared!")
@@ -792,16 +696,7 @@ class VibeApp(App): # noqa: PLR0904
)
async def _show_log_path(self) -> None:
- if self.agent is None:
- await self._mount_and_scroll(
- ErrorMessage(
- "No log file created yet. Send a message first.",
- collapsed=self._tools_collapsed,
- )
- )
- return
-
- if not self.agent.interaction_logger.enabled:
+ if not self.agent_loop.session_logger.enabled:
await self._mount_and_scroll(
ErrorMessage(
"Session logging is disabled in configuration.",
@@ -811,10 +706,10 @@ class VibeApp(App): # noqa: PLR0904
return
try:
- log_path = str(self.agent.interaction_logger.filepath)
+ log_path = str(self.agent_loop.session_logger.session_dir)
await self._mount_and_scroll(
UserCommandMessage(
- f"## Current Log File Path\n\n`{log_path}`\n\nYou can send this file to share your interaction."
+ f"## Current Log Directory\n\n`{log_path}`\n\nYou can send this directory to share your interaction."
)
)
except Exception as e:
@@ -828,13 +723,13 @@ class VibeApp(App): # noqa: PLR0904
if self._agent_running:
await self._mount_and_scroll(
ErrorMessage(
- "Cannot compact while agent is processing. Please wait.",
+ "Cannot compact while agent loop is processing. Please wait.",
collapsed=self._tools_collapsed,
)
)
return
- if self.agent is None:
+ if len(self.agent_loop.messages) <= 1:
await self._mount_and_scroll(
ErrorMessage(
"No conversation history to compact yet.",
@@ -843,19 +738,10 @@ class VibeApp(App): # noqa: PLR0904
)
return
- if len(self.agent.messages) <= 1:
- await self._mount_and_scroll(
- ErrorMessage(
- "No conversation history to compact yet.",
- collapsed=self._tools_collapsed,
- )
- )
+ if not self.event_handler:
return
- if not self.agent or not self.event_handler:
- return
-
- old_tokens = self.agent.stats.context_tokens
+ old_tokens = self.agent_loop.stats.context_tokens
compact_msg = CompactMessage()
self.event_handler.current_compact = compact_msg
await self._mount_and_scroll(compact_msg)
@@ -867,18 +753,10 @@ class VibeApp(App): # noqa: PLR0904
async def _run_compact(self, compact_msg: CompactMessage, old_tokens: int) -> None:
self._agent_running = True
try:
- if not self.agent:
- return
-
- await self.agent.compact()
- new_tokens = self.agent.stats.context_tokens
+ await self.agent_loop.compact()
+ new_tokens = self.agent_loop.stats.context_tokens
compact_msg.set_complete(old_tokens=old_tokens, new_tokens=new_tokens)
- if self._context_progress:
- current_state = self._context_progress.tokens
- self._context_progress.tokens = TokenState(
- max_tokens=current_state.max_tokens, current_tokens=new_tokens
- )
except asyncio.CancelledError:
compact_msg.set_error("Compaction interrupted")
raise
@@ -891,13 +769,11 @@ class VibeApp(App): # noqa: PLR0904
self.event_handler.current_compact = None
def _get_session_resume_info(self) -> str | None:
- if not self.agent:
+ if not self.agent_loop.session_logger.enabled:
return None
- if not self.agent.interaction_logger.enabled:
+ if not self.agent_loop.session_logger.session_id:
return None
- if not self.agent.interaction_logger.session_id:
- return None
- return self.agent.interaction_logger.session_id[:8]
+ return self.agent_loop.session_logger.session_id[:8]
async def _exit_app(self) -> None:
self.exit(result=self._get_session_resume_info())
@@ -923,95 +799,59 @@ class VibeApp(App): # noqa: PLR0904
ErrorMessage(result.message, collapsed=self._tools_collapsed)
)
+ async def _switch_from_input(self, widget: Widget, scroll: bool = False) -> None:
+ bottom_container = self.query_one("#bottom-app-container")
+
+ if self._chat_input_container:
+ self._chat_input_container.display = False
+ self._chat_input_container.disabled = True
+
+ if self._agent_indicator:
+ self._agent_indicator.display = False
+
+ self._current_bottom_app = BottomApp[type(widget).__name__.removesuffix("App")]
+ await bottom_container.mount(widget)
+
+ self.call_after_refresh(widget.focus)
+ if scroll:
+ self.call_after_refresh(self._scroll_to_bottom)
+
async def _switch_to_config_app(self) -> None:
if self._current_bottom_app == BottomApp.Config:
return
- bottom_container = self.query_one("#bottom-app-container")
await self._mount_and_scroll(UserCommandMessage("Configuration opened..."))
-
- try:
- chat_input_container = self.query_one(ChatInputContainer)
- await chat_input_container.remove()
- except Exception:
- pass
-
- if self._mode_indicator:
- self._mode_indicator.display = False
-
- config_app = ConfigApp(
- self.config, has_terminal_theme=self._terminal_theme is not None
+ await self._switch_from_input(
+ ConfigApp(self.config, has_terminal_theme=self._terminal_theme is not None)
)
- await bottom_container.mount(config_app)
- self._current_bottom_app = BottomApp.Config
-
- self.call_after_refresh(config_app.focus)
async def _switch_to_approval_app(
self, tool_name: str, tool_args: BaseModel
) -> None:
- bottom_container = self.query_one("#bottom-app-container")
-
- try:
- chat_input_container = self.query_one(ChatInputContainer)
- await chat_input_container.remove()
- except Exception:
- pass
-
- if self._mode_indicator:
- self._mode_indicator.display = False
-
approval_app = ApprovalApp(
- tool_name=tool_name,
- tool_args=tool_args,
- workdir=str(self.config.effective_workdir),
- config=self.config,
+ tool_name=tool_name, tool_args=tool_args, config=self.config
)
- await bottom_container.mount(approval_app)
- self._current_bottom_app = BottomApp.Approval
+ await self._switch_from_input(approval_app, scroll=True)
- self.call_after_refresh(approval_app.focus)
- self.call_after_refresh(self._scroll_to_bottom)
+ async def _switch_to_question_app(self, args: AskUserQuestionArgs) -> None:
+ await self._switch_from_input(QuestionApp(args=args), scroll=True)
async def _switch_to_input_app(self) -> None:
- bottom_container = self.query_one("#bottom-app-container")
+ for app in BottomApp:
+ if app != BottomApp.Input:
+ try:
+ await self.query_one(f"#{app.value}-app").remove()
+ except Exception:
+ pass
- try:
- config_app = self.query_one("#config-app")
- await config_app.remove()
- except Exception:
- pass
+ if self._agent_indicator:
+ self._agent_indicator.display = True
- try:
- approval_app = self.query_one("#approval-app")
- await approval_app.remove()
- except Exception:
- pass
-
- if self._mode_indicator:
- self._mode_indicator.display = True
-
- try:
- chat_input_container = self.query_one(ChatInputContainer)
- self._chat_input_container = chat_input_container
+ if self._chat_input_container:
+ self._chat_input_container.disabled = False
+ self._chat_input_container.display = True
self._current_bottom_app = BottomApp.Input
- self.call_after_refresh(chat_input_container.focus_input)
- return
- except Exception:
- pass
-
- chat_input_container = ChatInputContainer(
- history_file=self.history_file,
- command_registry=self.commands,
- id="input-container",
- safety=self._current_agent_mode.safety,
- )
- await bottom_container.mount(chat_input_container)
- self._chat_input_container = chat_input_container
-
- self._current_bottom_app = BottomApp.Input
-
- self.call_after_refresh(chat_input_container.focus_input)
+ self.call_after_refresh(self._chat_input_container.focus_input)
def _focus_current_bottom_app(self) -> None:
try:
@@ -1022,6 +862,8 @@ class VibeApp(App): # noqa: PLR0904
self.query_one(ConfigApp).focus()
case BottomApp.Approval:
self.query_one(ApprovalApp).focus()
+ case BottomApp.Question:
+ self.query_one(QuestionApp).focus()
case app:
assert_never(app)
except Exception:
@@ -1048,6 +890,15 @@ class VibeApp(App): # noqa: PLR0904
self._last_escape_time = None
return
+ if self._current_bottom_app == BottomApp.Question:
+ try:
+ question_app = self.query_one(QuestionApp)
+ question_app.action_cancel()
+ except Exception:
+ pass
+ self._last_escape_time = None
+ return
+
if (
self._current_bottom_app == BottomApp.Input
and self._last_escape_time is not None
@@ -1062,18 +913,8 @@ class VibeApp(App): # noqa: PLR0904
except Exception:
pass
- has_pending_user_message = any(
- msg.has_class("pending") for msg in self.query(UserMessage)
- )
-
- interrupt_needed = self._agent_running or (
- self._agent_init_task
- and not self._agent_init_task.done()
- and has_pending_user_message
- )
-
- if interrupt_needed:
- self.run_worker(self._interrupt_agent(), exclusive=False)
+ if self._agent_running:
+ self.run_worker(self._interrupt_agent_loop(), exclusive=False)
self._last_escape_time = current_time
self._scroll_to_bottom()
@@ -1102,43 +943,27 @@ class VibeApp(App): # noqa: PLR0904
def action_cycle_mode(self) -> None:
if self._current_bottom_app != BottomApp.Input:
return
-
- new_mode = next_mode(self._current_agent_mode)
- self._switch_mode(new_mode)
-
- def _switch_mode(self, mode: AgentMode) -> None:
- if mode == self._current_agent_mode:
- return
-
- self._current_agent_mode = mode
-
- if self._mode_indicator:
- self._mode_indicator.set_mode(mode)
- if self._chat_input_container:
- self._chat_input_container.set_safety(mode.safety)
-
- if self.agent:
- if mode.auto_approve:
- self.agent.approval_callback = None
- else:
- self.agent.approval_callback = self._approval_callback
-
- self.run_worker(
- self._do_agent_switch(mode), group="mode_switch", exclusive=True
- )
-
+ self._refresh_profile_widgets()
self._focus_current_bottom_app()
+ self.run_worker(self._cycle_agent(), group="mode_switch", exclusive=True)
- async def _do_agent_switch(self, mode: AgentMode) -> None:
- if self.agent:
- await self.agent.switch_mode(mode)
+ def _refresh_profile_widgets(self) -> None:
+ self._update_profile_widgets(self.agent_loop.agent_profile)
- if self._context_progress:
- current_state = self._context_progress.tokens
- self._context_progress.tokens = TokenState(
- max_tokens=current_state.max_tokens,
- current_tokens=self.agent.stats.context_tokens,
- )
+ def _update_profile_widgets(self, profile: AgentProfile) -> None:
+ if self._agent_indicator:
+ self._agent_indicator.set_profile(profile)
+ if self._chat_input_container:
+ self._chat_input_container.set_safety(profile.safety)
+
+ async def _cycle_agent(self) -> None:
+ new_profile = self.agent_loop.agent_manager.next_agent(
+ self.agent_loop.agent_profile
+ )
+ self._update_profile_widgets(new_profile)
+ await self.agent_loop.switch_agent(new_profile.name)
+ self.agent_loop.set_approval_callback(self._approval_callback)
+ self.agent_loop.set_user_input_callback(self._user_input_callback)
def action_clear_quit(self) -> None:
input_widgets = self.query(ChatInputContainer)
@@ -1181,6 +1006,53 @@ class VibeApp(App): # noqa: PLR0904
)
await self._mount_and_scroll(WarningMessage(warning, show_border=False))
+ async def _check_and_show_whats_new(self) -> None:
+ if self._update_cache_repository is None:
+ return
+
+ if not await should_show_whats_new(
+ self._current_version, self._update_cache_repository
+ ):
+ return
+
+ content = load_whats_new_content()
+ if content is not None:
+ await self._mount_and_scroll(WhatsNewMessage(content))
+ await mark_version_as_seen(self._current_version, self._update_cache_repository)
+
+ async def _maybe_show_plan_offer(self) -> None:
+ if self._plan_offer_shown:
+ return
+ action = await self._resolve_plan_offer_action()
+ if action is PlanOfferAction.NONE:
+ return
+ url = ACTION_TO_URL[action]
+ match action:
+ case PlanOfferAction.UPGRADE:
+ text = f"Upgrade to [Pro]({url})"
+ case PlanOfferAction.SWITCH_TO_PRO_KEY:
+ text = f"Switch to your [Pro API key]({url})"
+ await self._mount_and_scroll(PlanOfferMessage(text))
+ self._plan_offer_shown = True
+
+ async def _resolve_plan_offer_action(self) -> PlanOfferAction:
+ try:
+ active_model = self.config.get_active_model()
+ provider = self.config.get_provider_for_model(active_model)
+ except ValueError:
+ return PlanOfferAction.NONE
+
+ api_key_env = provider.api_key_env_var
+ api_key = getenv(api_key_env) if api_key_env else None
+ gateway = self._plan_offer_gateway or HttpWhoAmIGateway()
+ try:
+ return await decide_plan_offer(api_key, gateway)
+ except Exception as exc:
+ logger.warning(
+ "Plan-offer check failed (%s).", type(exc).__name__, exc_info=True
+ )
+ return PlanOfferAction.NONE
+
async def _finalize_current_streaming_message(self) -> None:
if self._current_streaming_reasoning is not None:
self._current_streaming_reasoning.stop_spinning()
@@ -1283,31 +1155,22 @@ class VibeApp(App): # noqa: PLR0904
pass
def _schedule_update_notification(self) -> None:
- if (
- self._version_update_notifier is None
- or self._update_notification_task
- or not self._is_update_check_enabled
- ):
+ if self._update_notifier is None or not self.config.enable_update_checks:
return
- self._update_notification_task = asyncio.create_task(
- self._check_version_update(), name="version-update-check"
- )
+ asyncio.create_task(self._check_update(), name="version-update-check")
- async def _check_version_update(self) -> None:
+ async def _check_update(self) -> None:
try:
- if (
- self._version_update_notifier is None
- or self._update_cache_repository is None
- ):
+ if self._update_notifier is None or self._update_cache_repository is None:
return
- update = await get_update_if_available(
- version_update_notifier=self._version_update_notifier,
+ update_availability = await get_update_if_available(
+ update_notifier=self._update_notifier,
current_version=self._current_version,
update_cache_repository=self._update_cache_repository,
)
- except VersionUpdateError as error:
+ except UpdateError as error:
self.notify(
error.message,
title="Update check failed",
@@ -1318,24 +1181,28 @@ class VibeApp(App): # noqa: PLR0904
except Exception as exc:
logger.debug("Version update check failed", exc_info=exc)
return
- finally:
- self._update_notification_task = None
- if update is None or not update.should_notify:
+ if update_availability is None or not update_availability.should_notify:
return
- self._display_update_notification(update)
+ update_message_prefix = (
+ f"{self._current_version} => {update_availability.latest_version}"
+ )
- def _display_update_notification(self, update: VersionUpdateAvailability) -> None:
- if self._update_notification_shown:
+ if self.config.enable_auto_update and await do_update():
+ self.notify(
+ f"{update_message_prefix}\nVibe was updated successfully. Please restart to use the new version.",
+ title="Update successful",
+ severity="information",
+ timeout=10,
+ )
return
- message = f'{self._current_version} => {update.latest_version}\nRun "uv tool upgrade mistral-vibe" to update'
+ message = f"{update_message_prefix}\nPlease update mistral-vibe with your package manager"
self.notify(
message, title="Update available", severity="information", timeout=10
)
- self._update_notification_shown = True
def on_mouse_up(self, event: MouseUp) -> None:
copy_selection_to_clipboard(self)
@@ -1358,22 +1225,13 @@ def _print_session_resume_message(session_id: str | None) -> None:
print(f"Or: vibe --resume {session_id}")
-def run_textual_ui(
- config: VibeConfig,
- initial_mode: AgentMode = AgentMode.DEFAULT,
- enable_streaming: bool = False,
- initial_prompt: str | None = None,
- loaded_messages: list[LLMMessage] | None = None,
-) -> None:
- update_notifier = PyPIVersionUpdateGateway(project_name="mistral-vibe")
+def run_textual_ui(agent_loop: AgentLoop, initial_prompt: str | None = None) -> None:
+ update_notifier = PyPIUpdateGateway(project_name="mistral-vibe")
update_cache_repository = FileSystemUpdateCacheRepository()
app = VibeApp(
- config=config,
- initial_mode=initial_mode,
- enable_streaming=enable_streaming,
+ agent_loop=agent_loop,
initial_prompt=initial_prompt,
- loaded_messages=loaded_messages,
- version_update_notifier=update_notifier,
+ update_notifier=update_notifier,
update_cache_repository=update_cache_repository,
)
session_id = app.run()
diff --git a/vibe/cli/textual_ui/app.tcss b/vibe/cli/textual_ui/app.tcss
index 6e75d41..312d315 100644
--- a/vibe/cli/textual_ui/app.tcss
+++ b/vibe/cli/textual_ui/app.tcss
@@ -133,7 +133,7 @@ Screen {
#input {
width: 1fr;
height: auto;
- max-height: 16;
+ max-height: 50vh;
background: transparent;
color: $text;
border: none;
@@ -523,6 +523,18 @@ StatusMessage {
margin-top: 1;
}
+.tool-call-container {
+ width: 100%;
+ height: auto;
+}
+
+.tool-stream-message {
+ width: 100%;
+ height: auto;
+ color: $text-muted;
+ padding-left: 2;
+}
+
.tool-result {
width: 100%;
height: auto;
@@ -852,6 +864,81 @@ WelcomeBanner {
color: $foreground;
}
+#question-app {
+ width: 100%;
+ height: auto;
+ max-height: 16;
+ background: transparent;
+ border: round $foreground-muted;
+ padding: 0 1;
+ margin: 0 0 1 0;
+}
+
+#question-content {
+ width: 100%;
+ height: auto;
+}
+
+.question-tabs {
+ height: auto;
+ color: $primary;
+ margin-bottom: 1;
+}
+
+.question-title {
+ height: auto;
+ text-style: bold;
+ color: $primary;
+ margin-bottom: 1;
+}
+
+.question-option {
+ height: auto;
+ color: $foreground;
+}
+
+.question-option-selected {
+ color: $primary;
+ text-style: bold;
+}
+
+.question-other-row {
+ width: 100%;
+ height: auto;
+}
+
+.question-other-prefix {
+ width: auto;
+ height: auto;
+ color: $foreground;
+}
+
+.question-other-input {
+ width: 1fr;
+ height: auto;
+ background: transparent;
+ border: none;
+ padding: 0;
+}
+
+.question-other-static {
+ width: auto;
+ height: auto;
+ color: $foreground;
+}
+
+.question-submit {
+ height: auto;
+ color: $foreground-muted;
+ margin-top: 1;
+}
+
+.question-help {
+ height: auto;
+ color: $foreground-muted;
+ margin-top: 1;
+}
+
.code-block {
height: auto;
color: $foreground;
@@ -859,6 +946,28 @@ WelcomeBanner {
padding: 1;
}
+.whats-new-message {
+ background: $surface;
+ border-left: solid $warning;
+}
+
+.plan-offer-message {
+ background: $surface;
+ border-left: solid $warning;
+ height: auto;
+ text-align: center;
+ content-align: center middle;
+ padding: 1 2;
+ margin-top: 1;
+
+ Markdown > *:last-child {
+ margin-bottom: 0;
+ link-style: bold underline;
+ link-background-hover: transparent;
+ link-color-hover: $warning;
+ }
+}
+
Horizontal {
width: 100%;
height: auto;
@@ -870,7 +979,7 @@ ExpandingBorder {
content-align: left bottom;
}
-ModeIndicator {
+AgentIndicator {
width: auto;
height: auto;
background: transparent;
@@ -879,19 +988,19 @@ ModeIndicator {
color: $text-muted;
align: left middle;
- &.mode-safe {
+ &.agent-safe {
color: $success;
}
- &.mode-neutral {
+ &.agent-neutral {
color: $text-muted;
}
- &.mode-destructive {
+ &.agent-destructive {
color: $warning;
}
- &.mode-yolo {
+ &.agent-yolo {
color: $error;
}
}
diff --git a/vibe/cli/textual_ui/external_editor.py b/vibe/cli/textual_ui/external_editor.py
new file mode 100644
index 0000000..b4bbbe6
--- /dev/null
+++ b/vibe/cli/textual_ui/external_editor.py
@@ -0,0 +1,32 @@
+from __future__ import annotations
+
+import os
+from pathlib import Path
+import shlex
+import subprocess
+import tempfile
+
+
+class ExternalEditor:
+ """Handles opening an external editor to edit prompt content."""
+
+ @staticmethod
+ def get_editor() -> str:
+ return os.environ.get("VISUAL") or os.environ.get("EDITOR") or "nano"
+
+ def edit(self, initial_content: str = "") -> str | None:
+ editor = self.get_editor()
+ fd, filepath = tempfile.mkstemp(suffix=".md", prefix="vibe_")
+ try:
+ with os.fdopen(fd, "w") as f:
+ f.write(initial_content)
+
+ parts = shlex.split(editor)
+ subprocess.run([*parts, filepath], check=True)
+
+ content = Path(filepath).read_text().rstrip()
+ return content if content != initial_content else None
+ except (OSError, subprocess.CalledProcessError):
+ return
+ finally:
+ Path(filepath).unlink(missing_ok=True)
diff --git a/vibe/cli/textual_ui/handlers/event_handler.py b/vibe/cli/textual_ui/handlers/event_handler.py
index 20727ce..7130b8c 100644
--- a/vibe/cli/textual_ui/handlers/event_handler.py
+++ b/vibe/cli/textual_ui/handlers/event_handler.py
@@ -15,6 +15,8 @@ from vibe.core.types import (
ReasoningEvent,
ToolCallEvent,
ToolResultEvent,
+ ToolStreamEvent,
+ UserMessageEvent,
)
from vibe.core.utils import TaggedText
@@ -51,6 +53,8 @@ class EventHandler:
case ToolResultEvent():
sanitized_event = self._sanitize_event(event)
await self._handle_tool_result(sanitized_event)
+ case ToolStreamEvent():
+ await self._handle_tool_stream(event)
case ReasoningEvent():
await self._handle_reasoning_message(event)
case AssistantEvent():
@@ -59,6 +63,8 @@ class EventHandler:
await self._handle_compact_start()
case CompactEndEvent():
await self._handle_compact_end(event)
+ case UserMessageEvent():
+ pass
case _:
await self._handle_unknown_event(event)
return None
@@ -119,6 +125,10 @@ class EventHandler:
self.current_tool_call = None
+ async def _handle_tool_stream(self, event: ToolStreamEvent) -> None:
+ if self.current_tool_call:
+ self.current_tool_call.set_stream_message(event.message)
+
async def _handle_assistant_message(self, event: AssistantEvent) -> None:
await self.mount_callback(AssistantMessage(event.content))
diff --git a/vibe/cli/textual_ui/widgets/agent_indicator.py b/vibe/cli/textual_ui/widgets/agent_indicator.py
new file mode 100644
index 0000000..852b326
--- /dev/null
+++ b/vibe/cli/textual_ui/widgets/agent_indicator.py
@@ -0,0 +1,41 @@
+from __future__ import annotations
+
+from textual.widgets import Static
+
+from vibe.core.agents import AgentProfile, AgentSafety, BuiltinAgentName
+
+AGENT_ICONS: dict[str, str] = {
+ BuiltinAgentName.DEFAULT: "⏵",
+ BuiltinAgentName.PLAN: "⏸",
+ BuiltinAgentName.ACCEPT_EDITS: "⏵⏵",
+ BuiltinAgentName.AUTO_APPROVE: "⏵⏵⏵",
+}
+
+SAFETY_CLASSES: dict[AgentSafety, str] = {
+ AgentSafety.SAFE: "agent-safe",
+ AgentSafety.NEUTRAL: "agent-neutral",
+ AgentSafety.DESTRUCTIVE: "agent-destructive",
+ AgentSafety.YOLO: "agent-yolo",
+}
+
+
+class AgentIndicator(Static):
+ def __init__(self, profile: AgentProfile) -> None:
+ super().__init__(markup=False)
+ self.can_focus = False
+ self.profile = profile
+ self._update_display()
+
+ def _update_display(self) -> None:
+ icon = AGENT_ICONS.get(self.profile.name, "")
+ name = self.profile.display_name.lower()
+ self.update(f"{icon}{' ' if icon else ''}{name} agent (shift+tab to cycle)")
+
+ for safety_class in SAFETY_CLASSES.values():
+ self.remove_class(safety_class)
+
+ self.add_class(SAFETY_CLASSES[self.profile.safety])
+
+ def set_profile(self, profile: AgentProfile) -> None:
+ self.profile = profile
+ self._update_display()
diff --git a/vibe/cli/textual_ui/widgets/approval_app.py b/vibe/cli/textual_ui/widgets/approval_app.py
index 281e787..8ee2022 100644
--- a/vibe/cli/textual_ui/widgets/approval_app.py
+++ b/vibe/cli/textual_ui/widgets/approval_app.py
@@ -52,12 +52,11 @@ class ApprovalApp(Container):
self.tool_args = tool_args
def __init__(
- self, tool_name: str, tool_args: BaseModel, workdir: str, config: VibeConfig
+ self, tool_name: str, tool_args: BaseModel, config: VibeConfig
) -> None:
super().__init__(id="approval-app")
self.tool_name = tool_name
self.tool_args = tool_args
- self.workdir = workdir
self.config = config
self.selected_option = 0
self.content_container: Vertical | None = None
diff --git a/vibe/cli/textual_ui/widgets/chat_input/container.py b/vibe/cli/textual_ui/widgets/chat_input/container.py
index 2e35d34..747e86e 100644
--- a/vibe/cli/textual_ui/widgets/chat_input/container.py
+++ b/vibe/cli/textual_ui/widgets/chat_input/container.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+from collections.abc import Callable
from pathlib import Path
from typing import Any
@@ -16,13 +17,13 @@ from vibe.cli.textual_ui.widgets.chat_input.completion_manager import (
)
from vibe.cli.textual_ui.widgets.chat_input.completion_popup import CompletionPopup
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea
+from vibe.core.agents import AgentSafety
from vibe.core.autocompletion.completers import CommandCompleter, PathCompleter
-from vibe.core.modes import ModeSafety
-SAFETY_BORDER_CLASSES: dict[ModeSafety, str] = {
- ModeSafety.SAFE: "border-safe",
- ModeSafety.DESTRUCTIVE: "border-warning",
- ModeSafety.YOLO: "border-error",
+SAFETY_BORDER_CLASSES: dict[AgentSafety, str] = {
+ AgentSafety.SAFE: "border-safe",
+ AgentSafety.DESTRUCTIVE: "border-warning",
+ AgentSafety.YOLO: "border-error",
}
@@ -38,27 +39,33 @@ class ChatInputContainer(Vertical):
self,
history_file: Path | None = None,
command_registry: CommandRegistry | None = None,
- safety: ModeSafety = ModeSafety.NEUTRAL,
+ safety: AgentSafety = AgentSafety.NEUTRAL,
+ skill_entries_getter: Callable[[], list[tuple[str, str]]] | None = None,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self._history_file = history_file
self._command_registry = command_registry or CommandRegistry()
self._safety = safety
-
- command_entries = [
- (alias, command.description)
- for command in self._command_registry.commands.values()
- for alias in sorted(command.aliases)
- ]
+ self._skill_entries_getter = skill_entries_getter
self._completion_manager = MultiCompletionManager([
- SlashCommandController(CommandCompleter(command_entries), self),
+ SlashCommandController(CommandCompleter(self._get_slash_entries), self),
PathCompletionController(PathCompleter(), self),
])
self._completion_popup: CompletionPopup | None = None
self._body: ChatInputBody | None = None
+ def _get_slash_entries(self) -> list[tuple[str, str]]:
+ entries = [
+ (alias, command.description)
+ for command in self._command_registry.commands.values()
+ for alias in sorted(command.aliases)
+ ]
+ if self._skill_entries_getter:
+ entries.extend(self._skill_entries_getter())
+ return sorted(entries)
+
def compose(self) -> ComposeResult:
self._completion_popup = CompletionPopup()
yield self._completion_popup
@@ -155,7 +162,7 @@ class ChatInputContainer(Vertical):
event.stop()
self.post_message(self.Submitted(event.value))
- def set_safety(self, safety: ModeSafety) -> None:
+ def set_safety(self, safety: AgentSafety) -> None:
self._safety = safety
try:
diff --git a/vibe/cli/textual_ui/widgets/chat_input/text_area.py b/vibe/cli/textual_ui/widgets/chat_input/text_area.py
index 202433c..6989725 100644
--- a/vibe/cli/textual_ui/widgets/chat_input/text_area.py
+++ b/vibe/cli/textual_ui/widgets/chat_input/text_area.py
@@ -8,6 +8,7 @@ from textual.message import Message
from textual.widgets import TextArea
from vibe.cli.autocompletion.base import CompletionResult
+from vibe.cli.textual_ui.external_editor import ExternalEditor
from vibe.cli.textual_ui.widgets.chat_input.completion_manager import (
MultiCompletionManager,
)
@@ -23,7 +24,8 @@ class ChatTextArea(TextArea):
"New Line",
show=False,
priority=True,
- )
+ ),
+ Binding("ctrl+g", "open_external_editor", "External Editor", show=False),
]
MODE_CHARACTERS: ClassVar[set[Literal["!", "/"]]] = {"!", "/"}
@@ -84,6 +86,17 @@ class ChatTextArea(TextArea):
def action_insert_newline(self) -> None:
self.insert("\n")
+ def action_open_external_editor(self) -> None:
+ editor = ExternalEditor()
+ current_text = self.get_full_text()
+
+ with self.app.suspend():
+ result = editor.edit(current_text)
+
+ if result is not None:
+ self.clear()
+ self.insert(result)
+
def on_text_area_changed(self, event: TextArea.Changed) -> None:
if not self._navigating_history and self.text != self._last_text:
self._reset_prefix()
diff --git a/vibe/cli/textual_ui/widgets/compact.py b/vibe/cli/textual_ui/widgets/compact.py
index 10a18b1..f232b0f 100644
--- a/vibe/cli/textual_ui/widgets/compact.py
+++ b/vibe/cli/textual_ui/widgets/compact.py
@@ -3,6 +3,7 @@ from __future__ import annotations
from textual.message import Message
from vibe.cli.textual_ui.widgets.status_message import StatusMessage
+from vibe.core.utils import compact_reduction_display
class CompactMessage(StatusMessage):
@@ -25,17 +26,7 @@ class CompactMessage(StatusMessage):
if self.error_message:
return f"Error: {self.error_message}"
- if self.old_tokens is not None and self.new_tokens is not None:
- reduction = self.old_tokens - self.new_tokens
- reduction_pct = (
- (reduction / self.old_tokens * 100) if self.old_tokens > 0 else 0
- )
- return (
- f"Compaction complete: {self.old_tokens:,} → "
- f"{self.new_tokens:,} tokens (-{reduction_pct:.1f}%)"
- )
-
- return "Compaction complete"
+ return compact_reduction_display(self.old_tokens, self.new_tokens)
def set_complete(
self, old_tokens: int | None = None, new_tokens: int | None = None
diff --git a/vibe/cli/textual_ui/widgets/config_app.py b/vibe/cli/textual_ui/widgets/config_app.py
index 1c476f1..3086e56 100644
--- a/vibe/cli/textual_ui/widgets/config_app.py
+++ b/vibe/cli/textual_ui/widgets/config_app.py
@@ -26,7 +26,6 @@ class SettingDefinition(TypedDict):
label: str
type: str
options: list[str]
- value: str
class ConfigApp(Container):
@@ -69,14 +68,12 @@ class ConfigApp(Container):
"label": "Model",
"type": "cycle",
"options": [m.alias for m in self.config.models],
- "value": self.config.active_model,
},
{
"key": "textual_theme",
"label": "Theme",
"type": "cycle",
"options": themes,
- "value": self.config.textual_theme,
},
]
@@ -115,7 +112,9 @@ class ConfigApp(Container):
cursor = "› " if is_selected else " "
label: str = setting["label"]
- value: str = self.changes.get(setting["key"], setting["value"])
+ value: str = self.changes.get(
+ setting["key"], getattr(self.config, setting["key"], "")
+ )
text = f"{cursor}{label}: {value}"
@@ -141,7 +140,7 @@ class ConfigApp(Container):
def action_toggle_setting(self) -> None:
setting = self.settings[self.selected_index]
key: str = setting["key"]
- current: str = self.changes.get(key, setting["value"])
+ current: str = self.changes.get(key, getattr(self.config, key)) or ""
options: list[str] = setting["options"]
new_value = ""
diff --git a/vibe/cli/textual_ui/widgets/context_progress.py b/vibe/cli/textual_ui/widgets/context_progress.py
index f9e803e..81909fa 100644
--- a/vibe/cli/textual_ui/widgets/context_progress.py
+++ b/vibe/cli/textual_ui/widgets/context_progress.py
@@ -25,8 +25,6 @@ class ContextProgress(NoMarkupStatic):
self.update("")
return
- percentage = min(
- 100, int((new_state.current_tokens / new_state.max_tokens) * 100)
- )
- text = f"{percentage}% of {new_state.max_tokens // 1000}k tokens"
+ ratio = min(1, new_state.current_tokens / new_state.max_tokens)
+ text = f"{ratio:.0%} of {new_state.max_tokens // 1000}k tokens"
self.update(text)
diff --git a/vibe/cli/textual_ui/widgets/loading.py b/vibe/cli/textual_ui/widgets/loading.py
index 5e018ef..396b0d6 100644
--- a/vibe/cli/textual_ui/widgets/loading.py
+++ b/vibe/cli/textual_ui/widgets/loading.py
@@ -1,5 +1,7 @@
from __future__ import annotations
+from collections.abc import Iterator
+from contextlib import contextmanager
from datetime import datetime
import random
from time import time
@@ -60,6 +62,8 @@ class LoadingWidget(SpinnerMixin, Static):
self.hint_widget: Static | None = None
self.start_time: float | None = None
self._last_elapsed: int = -1
+ self._paused_total: float = 0.0
+ self._pause_start: float | None = None
def _get_easter_egg(self) -> str | None:
EASTER_EGG_PROBABILITY = 0.10
@@ -84,6 +88,15 @@ class LoadingWidget(SpinnerMixin, Static):
def _apply_easter_egg(self, status: str) -> str:
return self._get_easter_egg() or status
+ def pause_timer(self) -> None:
+ if self._pause_start is None:
+ self._pause_start = time()
+
+ def resume_timer(self) -> None:
+ if self._pause_start is not None:
+ self._paused_total += time() - self._pause_start
+ self._pause_start = None
+
def set_status(self, status: str) -> None:
self.status = self._apply_easter_egg(status)
self._update_animation()
@@ -154,7 +167,21 @@ class LoadingWidget(SpinnerMixin, Static):
self.transition_progress = 0
if self.hint_widget and self.start_time is not None:
- elapsed = int(time() - self.start_time)
+ paused = self._paused_total + (
+ time() - self._pause_start if self._pause_start else 0
+ )
+ elapsed = int(time() - self.start_time - paused)
if elapsed != self._last_elapsed:
self._last_elapsed = elapsed
self.hint_widget.update(f"({elapsed}s esc to interrupt)")
+
+
+@contextmanager
+def paused_timer(loading_widget: LoadingWidget | None) -> Iterator[None]:
+ if loading_widget:
+ loading_widget.pause_timer()
+ try:
+ yield
+ finally:
+ if loading_widget:
+ loading_widget.resume_timer()
diff --git a/vibe/cli/textual_ui/widgets/messages.py b/vibe/cli/textual_ui/widgets/messages.py
index 1dce0b4..20c27e5 100644
--- a/vibe/cli/textual_ui/widgets/messages.py
+++ b/vibe/cli/textual_ui/widgets/messages.py
@@ -197,6 +197,29 @@ class UserCommandMessage(Static):
yield Markdown(self._content)
+class WhatsNewMessage(Static):
+ def __init__(self, content: str) -> None:
+ super().__init__()
+ self.add_class("whats-new-message")
+ self._content = content
+
+ def compose(self) -> ComposeResult:
+ yield Markdown(self._content)
+
+
+class PlanOfferMessage(Static):
+ def __init__(self, text: str) -> None:
+ super().__init__()
+ self.add_class("plan-offer-message")
+ self._text = text
+
+ def compose(self) -> ComposeResult:
+ yield Markdown(self._text)
+
+ def get_text(self) -> str:
+ return self._text
+
+
class InterruptMessage(Static):
def __init__(self) -> None:
super().__init__()
diff --git a/vibe/cli/textual_ui/widgets/mode_indicator.py b/vibe/cli/textual_ui/widgets/mode_indicator.py
deleted file mode 100644
index d42f18b..0000000
--- a/vibe/cli/textual_ui/widgets/mode_indicator.py
+++ /dev/null
@@ -1,47 +0,0 @@
-from __future__ import annotations
-
-from textual.widgets import Static
-
-from vibe.core.modes import AgentMode, ModeSafety
-
-MODE_ICONS: dict[AgentMode, str] = {
- AgentMode.DEFAULT: "⏵",
- AgentMode.PLAN: "⏸︎",
- AgentMode.ACCEPT_EDITS: "⏵⏵",
- AgentMode.AUTO_APPROVE: "⏵⏵⏵",
-}
-
-SAFETY_CLASSES: dict[ModeSafety, str] = {
- ModeSafety.SAFE: "mode-safe",
- ModeSafety.NEUTRAL: "mode-neutral",
- ModeSafety.DESTRUCTIVE: "mode-destructive",
- ModeSafety.YOLO: "mode-yolo",
-}
-
-
-class ModeIndicator(Static):
- """Displays the current agent mode with safety-colored indicator."""
-
- def __init__(self, mode: AgentMode = AgentMode.DEFAULT) -> None:
- super().__init__()
- self.can_focus = False
- self._mode = mode
- self._update_display()
-
- def _update_display(self) -> None:
- icon = MODE_ICONS.get(self._mode, "??")
- name = self._mode.display_name.lower()
- self.update(f"{icon} {name} mode (shift+tab to cycle)")
-
- for safety_class in SAFETY_CLASSES.values():
- self.remove_class(safety_class)
-
- self.add_class(SAFETY_CLASSES[self._mode.safety])
-
- @property
- def mode(self) -> AgentMode:
- return self._mode
-
- def set_mode(self, mode: AgentMode) -> None:
- self._mode = mode
- self._update_display()
diff --git a/vibe/cli/textual_ui/widgets/question_app.py b/vibe/cli/textual_ui/widgets/question_app.py
new file mode 100644
index 0000000..742061c
--- /dev/null
+++ b/vibe/cli/textual_ui/widgets/question_app.py
@@ -0,0 +1,479 @@
+from __future__ import annotations
+
+import itertools
+from typing import TYPE_CHECKING, ClassVar
+
+from textual import events
+from textual.app import ComposeResult
+from textual.binding import Binding, BindingType
+from textual.containers import Container, Horizontal, Vertical
+from textual.message import Message
+from textual.reactive import reactive
+from textual.widgets import Input
+
+from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
+
+if TYPE_CHECKING:
+ from vibe.core.tools.builtins.ask_user_question import (
+ AskUserQuestionArgs,
+ Choice,
+ Question,
+ )
+
+from vibe.core.tools.builtins.ask_user_question import Answer
+
+
+class QuestionApp(Container):
+ MAX_OPTIONS: ClassVar[int] = 4
+
+ can_focus = True
+ can_focus_children = False
+
+ current_question_idx: reactive[int] = reactive(0)
+ selected_option: reactive[int] = reactive(0)
+
+ BINDINGS: ClassVar[list[BindingType]] = [
+ Binding("up", "move_up", "Up", show=False),
+ Binding("down", "move_down", "Down", show=False),
+ Binding("enter", "select", "Select", show=False),
+ Binding("escape", "cancel", "Cancel", show=False),
+ ]
+
+ class Answered(Message):
+ def __init__(self, answers: list[Answer]) -> None:
+ super().__init__()
+ self.answers = answers
+
+ class Cancelled(Message):
+ pass
+
+ def __init__(self, args: AskUserQuestionArgs) -> None:
+ super().__init__(id="question-app")
+ self.args = args
+ self.questions = args.questions
+
+ self.answers: dict[int, tuple[str, bool]] = {}
+ self.multi_selections: dict[int, set[int]] = {}
+ self.other_texts: dict[int, str] = {}
+
+ self.option_widgets: list[NoMarkupStatic] = []
+ self.title_widget: NoMarkupStatic | None = None
+ self.other_prefix: NoMarkupStatic | None = None
+ self.other_input: Input | None = None
+ self.other_static: NoMarkupStatic | None = None
+ self.submit_widget: NoMarkupStatic | None = None
+ self.help_widget: NoMarkupStatic | None = None
+ self.tabs_widget: NoMarkupStatic | None = None
+
+ @property
+ def _current_question(self) -> Question:
+ return self.questions[self.current_question_idx]
+
+ @property
+ def _total_options(self) -> int:
+ base = len(self._current_question.options) + 1
+ if self._current_question.multi_select:
+ return base + 1
+ return base
+
+ @property
+ def _other_option_idx(self) -> int:
+ return len(self._current_question.options)
+
+ @property
+ def _submit_option_idx(self) -> int:
+ if not self._current_question.multi_select:
+ return -1
+ return self._other_option_idx + 1
+
+ @property
+ def _is_other_selected(self) -> bool:
+ return self.selected_option == self._other_option_idx
+
+ @property
+ def _is_submit_selected(self) -> bool:
+ return (
+ self._current_question.multi_select
+ and self.selected_option == self._submit_option_idx
+ )
+
+ def compose(self) -> ComposeResult:
+ with Vertical(id="question-content"):
+ if len(self.questions) > 1:
+ self.tabs_widget = NoMarkupStatic("", classes="question-tabs")
+ yield self.tabs_widget
+
+ self.title_widget = NoMarkupStatic("", classes="question-title")
+ yield self.title_widget
+
+ for _ in range(self.MAX_OPTIONS):
+ widget = NoMarkupStatic("", classes="question-option")
+ self.option_widgets.append(widget)
+ yield widget
+
+ with Horizontal(classes="question-other-row"):
+ self.other_prefix = NoMarkupStatic("", classes="question-other-prefix")
+ yield self.other_prefix
+ self.other_input = Input(
+ placeholder="Type your answer...", classes="question-other-input"
+ )
+ yield self.other_input
+ self.other_static = NoMarkupStatic(
+ "Type your answer...", classes="question-other-static"
+ )
+ yield self.other_static
+
+ self.submit_widget = NoMarkupStatic("", classes="question-submit")
+ yield self.submit_widget
+
+ self.help_widget = NoMarkupStatic("", classes="question-help")
+ yield self.help_widget
+
+ async def on_mount(self) -> None:
+ self._update_display()
+ self.focus()
+
+ def _watch_current_question_idx(self) -> None:
+ self._update_display()
+
+ def _watch_selected_option(self) -> None:
+ self._update_display()
+
+ def _update_display(self) -> None:
+ self._update_tabs()
+ self._update_title()
+ self._update_options()
+ self._update_other_row()
+ self._update_submit()
+ self._update_help()
+
+ def _update_tabs(self) -> None:
+ if not self.tabs_widget or len(self.questions) <= 1:
+ return
+ tabs = []
+ for i, question in enumerate(self.questions):
+ header = question.header or f"Q{i + 1}"
+ if i in self.answers:
+ header += " ✓"
+ if i == self.current_question_idx:
+ tabs.append(f"[{header}]")
+ else:
+ tabs.append(f" {header} ")
+ self.tabs_widget.update(" ".join(tabs))
+
+ def _update_title(self) -> None:
+ if self.title_widget:
+ self.title_widget.update(self._current_question.question)
+
+ def _update_options(self) -> None:
+ q = self._current_question
+ options = q.options
+ is_multi = q.multi_select
+ multi_selected = self.multi_selections.get(self.current_question_idx, set())
+
+ for i, widget in enumerate(self.option_widgets):
+ if i < len(options):
+ is_focused = i == self.selected_option
+ is_selected = i in multi_selected
+ self._render_option(
+ widget, i, options[i], is_multi, is_focused, is_selected
+ )
+ else:
+ widget.update("")
+ widget.display = False
+
+ def _format_option_prefix(
+ self, idx: int, is_focused: bool, is_multi: bool, is_selected: bool
+ ) -> str:
+ """Format the prefix for an option line (cursor + number + checkbox if multi)."""
+ cursor = "› " if is_focused else " "
+ if is_multi:
+ check = "[x]" if is_selected else "[ ]"
+ return f"{cursor}{idx + 1}. {check} "
+ return f"{cursor}{idx + 1}. "
+
+ def _render_option(
+ self,
+ widget: NoMarkupStatic,
+ idx: int,
+ opt: Choice,
+ is_multi: bool,
+ is_focused: bool,
+ is_selected: bool,
+ ) -> None:
+ prefix = self._format_option_prefix(idx, is_focused, is_multi, is_selected)
+ text = f"{prefix}{opt.label}"
+
+ if opt.description:
+ text += f" - {opt.description}"
+
+ widget.update(text)
+ widget.display = True
+ widget.remove_class("question-option-selected")
+ if is_focused:
+ widget.add_class("question-option-selected")
+
+ def _update_other_row(self) -> None:
+ if not self.other_prefix or not self.other_input or not self.other_static:
+ return
+
+ q = self._current_question
+ is_multi = q.multi_select
+ multi_selected = self.multi_selections.get(self.current_question_idx, set())
+ other_idx = self._other_option_idx
+ is_focused = self._is_other_selected
+ is_selected = other_idx in multi_selected
+
+ prefix = self._format_option_prefix(
+ other_idx, is_focused, is_multi, is_selected
+ )
+ self.other_prefix.update(prefix)
+
+ stored_text = self.other_texts.get(self.current_question_idx, "")
+ if self.other_input.value != stored_text:
+ self.other_input.value = stored_text
+
+ show_input = is_focused or bool(stored_text)
+
+ self.other_input.display = show_input
+ self.other_static.display = not show_input
+
+ self.other_prefix.remove_class("question-option-selected")
+ if is_focused:
+ self.other_prefix.add_class("question-option-selected")
+
+ if is_focused and show_input:
+ self.other_input.focus()
+ elif not is_focused and not self._is_submit_selected:
+ self.focus()
+
+ def _update_submit(self) -> None:
+ if not self.submit_widget:
+ return
+
+ q = self._current_question
+ if not q.multi_select:
+ self.submit_widget.display = False
+ return
+
+ self.submit_widget.display = True
+ is_focused = self._is_submit_selected
+ cursor = "› " if is_focused else " "
+
+ text = (
+ "Submit"
+ if len(set(self.answers.keys()) | {self.current_question_idx})
+ == len(self.questions)
+ else "Next"
+ )
+ self.submit_widget.update(f"{cursor} {text} →")
+ self.submit_widget.remove_class("question-option-selected")
+ if is_focused:
+ self.submit_widget.add_class("question-option-selected")
+ self.focus()
+
+ def _update_help(self) -> None:
+ if not self.help_widget:
+ return
+ if self._current_question.multi_select:
+ help_text = "↑↓ navigate Enter toggle Esc cancel"
+ else:
+ help_text = "↑↓ navigate Enter select Esc cancel"
+ if len(self.questions) > 1:
+ help_text = "←→ questions " + help_text
+ self.help_widget.update(help_text)
+
+ def _store_other_text(self) -> None:
+ if self.other_input:
+ self.other_texts[self.current_question_idx] = self.other_input.value
+
+ def _get_other_text(self, idx: int) -> str:
+ return self.other_texts.get(idx, "")
+
+ def action_move_up(self) -> None:
+ self.selected_option = (self.selected_option - 1) % self._total_options
+
+ def action_move_down(self) -> None:
+ self.selected_option = (self.selected_option + 1) % self._total_options
+
+ def _switch_question(self, new_idx: int) -> None:
+ self.current_question_idx = new_idx
+ self.selected_option = 0
+
+ def action_next_question(self) -> None:
+ if self._is_other_selected:
+ other_text = self.other_texts.get(self.current_question_idx, "").strip()
+ if not other_text:
+ return
+ new_idx = (self.current_question_idx + 1) % len(self.questions)
+ self._switch_question(new_idx)
+
+ def action_prev_question(self) -> None:
+ new_idx = (self.current_question_idx - 1) % len(self.questions)
+ self._switch_question(new_idx)
+
+ def action_select(self) -> None:
+ if self._current_question.multi_select:
+ self._handle_multi_select_action()
+ else:
+ self._handle_single_select_action()
+
+ def _handle_multi_select_action(self) -> None:
+ """Handle Enter key in multi-select mode: toggle option or submit."""
+ if self._is_submit_selected:
+ self._save_current_answer()
+ self._advance_or_submit()
+ elif self._is_other_selected:
+ if self.other_input:
+ self.other_input.focus()
+ else:
+ self._toggle_selection(self.selected_option)
+
+ def _handle_single_select_action(self) -> None:
+ """Handle Enter key in single-select mode: select and advance."""
+ if self._is_other_selected:
+ if self.other_input:
+ other_text = self.other_texts.get(self.current_question_idx, "").strip()
+ if other_text:
+ self._save_current_answer()
+ self._advance_or_submit()
+ else:
+ self.other_input.focus()
+ else:
+ self._save_current_answer()
+ self._advance_or_submit()
+
+ def _toggle_selection(self, option_idx: int) -> None:
+ """Toggle an option's selection state (multi-select only)."""
+ selections = self.multi_selections.setdefault(self.current_question_idx, set())
+ if option_idx in selections:
+ selections.discard(option_idx)
+ else:
+ selections.add(option_idx)
+ self._update_display()
+
+ def _advance_or_submit(self) -> None:
+ if self._all_answered():
+ self._submit()
+ else:
+ new_idx = next(
+ i
+ for i in itertools.chain(
+ range(self.current_question_idx + 1, len(self.questions)),
+ range(self.current_question_idx),
+ )
+ if i not in self.answers
+ )
+ self._switch_question(new_idx)
+
+ def action_cancel(self) -> None:
+ self.post_message(self.Cancelled())
+
+ def on_input_submitted(self, _event: Input.Submitted) -> None:
+ if not self.other_input or not self.other_input.value.strip():
+ return
+
+ q = self._current_question
+ if q.multi_select:
+ self.selected_option = self._submit_option_idx
+ else:
+ self._save_current_answer()
+ self._advance_or_submit()
+
+ def on_input_changed(self, _event: Input.Changed) -> None:
+ self._store_other_text()
+ self._sync_other_selection_with_text()
+ self._update_display()
+
+ def _sync_other_selection_with_text(self) -> None:
+ """Auto-select/deselect 'Other' option based on whether text is entered (multi-select only)."""
+ if not self._current_question.multi_select or not self.other_input:
+ return
+
+ other_idx = self._other_option_idx
+ selections = self.multi_selections.setdefault(self.current_question_idx, set())
+ has_text = bool(self.other_input.value.strip())
+
+ if has_text and other_idx not in selections:
+ selections.add(other_idx)
+ elif not has_text and other_idx in selections:
+ selections.discard(other_idx)
+
+ def on_key(self, event: events.Key) -> None:
+ if len(self.questions) <= 1:
+ return
+ if self.other_input and self.other_input.has_focus:
+ return
+ if event.key == "left":
+ self.action_prev_question()
+ event.stop()
+ elif event.key == "right":
+ self.action_next_question()
+ event.stop()
+
+ def _save_current_answer(self) -> None:
+ if self._current_question.multi_select:
+ self._save_multi_select_answer()
+ else:
+ self._save_single_select_answer()
+
+ def _save_multi_select_answer(self) -> None:
+ """Save answer for multi-select question (combines all selected options)."""
+ q = self._current_question
+ idx = self.current_question_idx
+ selections = self.multi_selections.get(idx, set())
+
+ if not selections:
+ return
+
+ other_text = self.other_texts.get(idx, "").strip()
+ answers = []
+ has_other = False
+ other_idx = len(q.options)
+
+ for sel_idx in sorted(selections):
+ if sel_idx < len(q.options):
+ answers.append(q.options[sel_idx].label)
+ elif sel_idx == other_idx and other_text:
+ answers.append(other_text)
+ has_other = True
+
+ if answers:
+ self.answers[idx] = (", ".join(answers), has_other)
+
+ def _save_single_select_answer(self) -> None:
+ """Save answer for single-select question."""
+ idx = self.current_question_idx
+
+ if self._is_other_selected:
+ other_text = self.other_texts.get(idx, "").strip()
+ if other_text:
+ self.answers[idx] = (other_text, True)
+ else:
+ self.answers[idx] = (
+ self._current_question.options[self.selected_option].label,
+ False,
+ )
+
+ def _all_answered(self) -> bool:
+ return all(i in self.answers for i in range(len(self.questions)))
+
+ def _submit(self) -> None:
+ result: list[Answer] = []
+ for i, q in enumerate(self.questions):
+ answer_text, is_other = self.answers.get(i, ("", False))
+ result.append(
+ Answer(question=q.question, answer=answer_text, is_other=is_other)
+ )
+ self.post_message(self.Answered(answers=result))
+
+ def on_blur(self, _event: events.Blur) -> None:
+ self.call_after_refresh(self._refocus_if_needed)
+
+ def on_input_blurred(self, _event: Input.Blurred) -> None:
+ self.call_after_refresh(self._refocus_if_needed)
+
+ def _refocus_if_needed(self) -> None:
+ if self.has_focus or (self.other_input and self.other_input.has_focus):
+ return
+ self.focus()
diff --git a/vibe/cli/textual_ui/widgets/tool_widgets.py b/vibe/cli/textual_ui/widgets/tool_widgets.py
index 6f5d4e7..8ac519b 100644
--- a/vibe/cli/textual_ui/widgets/tool_widgets.py
+++ b/vibe/cli/textual_ui/widgets/tool_widgets.py
@@ -10,6 +10,7 @@ from textual.widgets import Markdown, Static
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.cli.textual_ui.widgets.utils import DEFAULT_TOOL_SHORTCUT, TOOL_SHORTCUTS
+from vibe.core.tools.builtins.ask_user_question import AskUserQuestionResult
from vibe.core.tools.builtins.bash import BashArgs, BashResult
from vibe.core.tools.builtins.grep import GrepArgs, GrepResult
from vibe.core.tools.builtins.read_file import ReadFileArgs, ReadFileResult
@@ -321,6 +322,19 @@ class GrepResultWidget(ToolResultWidget[GrepResult]):
yield Markdown(f"```\n{_truncate_lines(self.result.matches, 30)}\n```")
+class AskUserQuestionResultWidget(ToolResultWidget[AskUserQuestionResult]):
+ def compose(self) -> ComposeResult:
+ if self.collapsed or not self.result:
+ yield from self._header()
+ return
+
+ for answer in self.result.answers:
+ if len(self.result.answers) > 1:
+ yield NoMarkupStatic(answer.question, classes="tool-result-detail")
+ prefix = "(Other) " if answer.is_other else ""
+ yield NoMarkupStatic(f"{prefix}{answer.answer}", classes="ask-user-answer")
+
+
APPROVAL_WIDGETS: dict[str, type[ToolApprovalWidget]] = {
"bash": BashApprovalWidget,
"read_file": ReadFileApprovalWidget,
@@ -337,6 +351,7 @@ RESULT_WIDGETS: dict[str, type[ToolResultWidget]] = {
"search_replace": SearchReplaceResultWidget,
"grep": GrepResultWidget,
"todo": TodoResultWidget,
+ "ask_user_question": AskUserQuestionResultWidget,
}
diff --git a/vibe/cli/textual_ui/widgets/tools.py b/vibe/cli/textual_ui/widgets/tools.py
index 0426978..30a4fcd 100644
--- a/vibe/cli/textual_ui/widgets/tools.py
+++ b/vibe/cli/textual_ui/widgets/tools.py
@@ -4,7 +4,7 @@ from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical
from textual.widgets import Static
-from vibe.cli.textual_ui.widgets.messages import ExpandingBorder
+from vibe.cli.textual_ui.widgets.messages import ExpandingBorder, NonSelectableStatic
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.cli.textual_ui.widgets.status_message import StatusMessage
from vibe.cli.textual_ui.widgets.tool_widgets import get_result_widget
@@ -23,6 +23,7 @@ class ToolCallMessage(StatusMessage):
self._event = event
self._tool_name = tool_name or (event.tool_name if event else "unknown")
self._is_history = event is None
+ self._stream_widget: NoMarkupStatic | None = None
super().__init__()
self.add_class("tool-call")
@@ -30,6 +31,19 @@ class ToolCallMessage(StatusMessage):
if self._is_history:
self._is_spinning = False
+ def compose(self) -> ComposeResult:
+ with Vertical(classes="tool-call-container"):
+ with Horizontal():
+ self._indicator_widget = NonSelectableStatic(
+ self._spinner.current_frame(), classes="status-indicator-icon"
+ )
+ yield self._indicator_widget
+ self._text_widget = NoMarkupStatic("", classes="status-indicator-text")
+ yield self._text_widget
+ self._stream_widget = NoMarkupStatic("", classes="tool-stream-message")
+ self._stream_widget.display = False
+ yield self._stream_widget
+
def get_content(self) -> str:
if self._event and self._event.tool_class:
adapter = ToolUIDataAdapter(self._event.tool_class)
@@ -37,6 +51,18 @@ class ToolCallMessage(StatusMessage):
return display.summary
return self._tool_name
+ def set_stream_message(self, message: str) -> None:
+ """Update the stream message displayed below the tool call indicator."""
+ if self._stream_widget:
+ self._stream_widget.update(f"→ {message}")
+ self._stream_widget.display = True
+
+ def stop_spinning(self, success: bool = True) -> None:
+ """Stop the spinner and hide the stream widget."""
+ if self._stream_widget:
+ self._stream_widget.display = False
+ super().stop_spinning(success)
+
class ToolResultMessage(Static):
def __init__(
@@ -80,12 +106,21 @@ class ToolResultMessage(Static):
async def on_mount(self) -> None:
if self._call_widget:
- success = self._event is None or (
- not self._event.error and not self._event.skipped
- )
+ success = self._determine_success()
self._call_widget.stop_spinning(success=success)
await self._render_result()
+ def _determine_success(self) -> bool:
+ if self._event is None:
+ return True
+ if self._event.error or self._event.skipped:
+ return False
+ if self._event.tool_class:
+ adapter = ToolUIDataAdapter(self._event.tool_class)
+ display = adapter.get_result_display(self._event)
+ return display.success
+ return True
+
async def _render_result(self) -> None:
if self._content_container is None:
return
diff --git a/vibe/cli/textual_ui/widgets/welcome.py b/vibe/cli/textual_ui/widgets/welcome.py
index f7b3ee6..b2af828 100644
--- a/vibe/cli/textual_ui/widgets/welcome.py
+++ b/vibe/cli/textual_ui/widgets/welcome.py
@@ -1,6 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
+from pathlib import Path
from time import monotonic
from rich.align import Align
@@ -102,7 +103,7 @@ class WelcomeBanner(Static):
model_count = len(self.config.models)
self._static_line3_suffix = f"{self.LOGO_TEXT_GAP}[dim]{model_count} models · {mcp_count} MCP servers[/]"
self._static_line5_suffix = (
- f"{self.LOGO_TEXT_GAP}[dim]{self.config.effective_workdir}[/]"
+ f"{self.LOGO_TEXT_GAP}[dim]{self.config.displayed_workdir or Path.cwd()}[/]"
)
self._static_line7 = f"[dim]Type[/] [{self.BORDER_TARGET_COLOR}]/help[/] [dim]for more information • [/][{self.BORDER_TARGET_COLOR}]/terminal-setup[/][dim] for shift+enter[/]"
diff --git a/vibe/cli/update_notifier/__init__.py b/vibe/cli/update_notifier/__init__.py
index 804a827..3e894a3 100644
--- a/vibe/cli/update_notifier/__init__.py
+++ b/vibe/cli/update_notifier/__init__.py
@@ -3,41 +3,45 @@ from __future__ import annotations
from vibe.cli.update_notifier.adapters.filesystem_update_cache_repository import (
FileSystemUpdateCacheRepository,
)
-from vibe.cli.update_notifier.adapters.github_version_update_gateway import (
- GitHubVersionUpdateGateway,
-)
-from vibe.cli.update_notifier.adapters.pypi_version_update_gateway import (
- PyPIVersionUpdateGateway,
-)
+from vibe.cli.update_notifier.adapters.github_update_gateway import GitHubUpdateGateway
+from vibe.cli.update_notifier.adapters.pypi_update_gateway import PyPIUpdateGateway
from vibe.cli.update_notifier.ports.update_cache_repository import (
UpdateCache,
UpdateCacheRepository,
)
-from vibe.cli.update_notifier.ports.version_update_gateway import (
+from vibe.cli.update_notifier.ports.update_gateway import (
DEFAULT_GATEWAY_MESSAGES,
- VersionUpdate,
- VersionUpdateGateway,
- VersionUpdateGatewayCause,
- VersionUpdateGatewayError,
+ Update,
+ UpdateGateway,
+ UpdateGatewayCause,
+ UpdateGatewayError,
)
-from vibe.cli.update_notifier.version_update import (
- VersionUpdateAvailability,
- VersionUpdateError,
+from vibe.cli.update_notifier.update import (
+ UpdateAvailability,
+ UpdateError,
get_update_if_available,
)
+from vibe.cli.update_notifier.whats_new import (
+ load_whats_new_content,
+ mark_version_as_seen,
+ should_show_whats_new,
+)
__all__ = [
"DEFAULT_GATEWAY_MESSAGES",
"FileSystemUpdateCacheRepository",
- "GitHubVersionUpdateGateway",
- "PyPIVersionUpdateGateway",
+ "GitHubUpdateGateway",
+ "PyPIUpdateGateway",
+ "Update",
+ "UpdateAvailability",
"UpdateCache",
"UpdateCacheRepository",
- "VersionUpdate",
- "VersionUpdateAvailability",
- "VersionUpdateError",
- "VersionUpdateGateway",
- "VersionUpdateGatewayCause",
- "VersionUpdateGatewayError",
+ "UpdateError",
+ "UpdateGateway",
+ "UpdateGatewayCause",
+ "UpdateGatewayError",
"get_update_if_available",
+ "load_whats_new_content",
+ "mark_version_as_seen",
+ "should_show_whats_new",
]
diff --git a/vibe/cli/update_notifier/adapters/filesystem_update_cache_repository.py b/vibe/cli/update_notifier/adapters/filesystem_update_cache_repository.py
index 3a2333d..28fc9b3 100644
--- a/vibe/cli/update_notifier/adapters/filesystem_update_cache_repository.py
+++ b/vibe/cli/update_notifier/adapters/filesystem_update_cache_repository.py
@@ -26,6 +26,7 @@ class FileSystemUpdateCacheRepository(UpdateCacheRepository):
data = json.loads(content)
latest_version = data.get("latest_version")
stored_at_timestamp = data.get("stored_at_timestamp")
+ seen_whats_new_version = data.get("seen_whats_new_version")
except (TypeError, json.JSONDecodeError):
return None
@@ -34,16 +35,28 @@ class FileSystemUpdateCacheRepository(UpdateCacheRepository):
):
return None
+ if (
+ not isinstance(seen_whats_new_version, str)
+ and seen_whats_new_version is not None
+ ):
+ seen_whats_new_version = None
+
return UpdateCache(
- latest_version=latest_version, stored_at_timestamp=stored_at_timestamp
+ latest_version=latest_version,
+ stored_at_timestamp=stored_at_timestamp,
+ seen_whats_new_version=seen_whats_new_version,
)
async def set(self, update_cache: UpdateCache) -> None:
try:
- payload = json.dumps({
- "latest_version": update_cache.latest_version,
- "stored_at_timestamp": update_cache.stored_at_timestamp,
- })
+ payload = json.dumps(
+ {
+ "latest_version": update_cache.latest_version,
+ "stored_at_timestamp": update_cache.stored_at_timestamp,
+ "seen_whats_new_version": update_cache.seen_whats_new_version,
+ },
+ ensure_ascii=False,
+ )
await asyncio.to_thread(self._cache_file.write_text, payload)
except OSError:
return None
diff --git a/vibe/cli/update_notifier/adapters/github_version_update_gateway.py b/vibe/cli/update_notifier/adapters/github_update_gateway.py
similarity index 72%
rename from vibe/cli/update_notifier/adapters/github_version_update_gateway.py
rename to vibe/cli/update_notifier/adapters/github_update_gateway.py
index 591afe4..edfe1d5 100644
--- a/vibe/cli/update_notifier/adapters/github_version_update_gateway.py
+++ b/vibe/cli/update_notifier/adapters/github_update_gateway.py
@@ -2,15 +2,15 @@ from __future__ import annotations
import httpx
-from vibe.cli.update_notifier.ports.version_update_gateway import (
- VersionUpdate,
- VersionUpdateGateway,
- VersionUpdateGatewayCause,
- VersionUpdateGatewayError,
+from vibe.cli.update_notifier.ports.update_gateway import (
+ Update,
+ UpdateGateway,
+ UpdateGatewayCause,
+ UpdateGatewayError,
)
-class GitHubVersionUpdateGateway(VersionUpdateGateway):
+class GitHubUpdateGateway(UpdateGateway):
def __init__(
self,
owner: str,
@@ -28,7 +28,7 @@ class GitHubVersionUpdateGateway(VersionUpdateGateway):
self._timeout = timeout
self._base_url = base_url.rstrip("/")
- async def fetch_update(self) -> VersionUpdate | None:
+ async def fetch_update(self) -> Update | None:
headers = {
"Accept": "application/vnd.github+json",
"User-Agent": "mistral-vibe-update-notifier",
@@ -51,38 +51,30 @@ class GitHubVersionUpdateGateway(VersionUpdateGateway):
) as client:
response = await client.get(request_path, headers=headers)
except httpx.RequestError as exc:
- raise VersionUpdateGatewayError(
- cause=VersionUpdateGatewayCause.REQUEST_FAILED
- ) from exc
+ raise UpdateGatewayError(cause=UpdateGatewayCause.REQUEST_FAILED) from exc
rate_limit_remaining = response.headers.get("X-RateLimit-Remaining")
if response.status_code == httpx.codes.TOO_MANY_REQUESTS or (
rate_limit_remaining is not None and rate_limit_remaining == "0"
):
- raise VersionUpdateGatewayError(
- cause=VersionUpdateGatewayCause.TOO_MANY_REQUESTS
- )
+ raise UpdateGatewayError(cause=UpdateGatewayCause.TOO_MANY_REQUESTS)
if response.status_code == httpx.codes.FORBIDDEN:
- raise VersionUpdateGatewayError(cause=VersionUpdateGatewayCause.FORBIDDEN)
+ raise UpdateGatewayError(cause=UpdateGatewayCause.FORBIDDEN)
if response.status_code == httpx.codes.NOT_FOUND:
- raise VersionUpdateGatewayError(
- cause=VersionUpdateGatewayCause.NOT_FOUND,
+ raise UpdateGatewayError(
+ cause=UpdateGatewayCause.NOT_FOUND,
message="Unable to fetch the GitHub releases. Did you export a GITHUB_TOKEN environment variable?",
)
if response.is_error:
- raise VersionUpdateGatewayError(
- cause=VersionUpdateGatewayCause.ERROR_RESPONSE
- )
+ raise UpdateGatewayError(cause=UpdateGatewayCause.ERROR_RESPONSE)
try:
data = response.json()
except ValueError as exc:
- raise VersionUpdateGatewayError(
- cause=VersionUpdateGatewayCause.INVALID_RESPONSE
- ) from exc
+ raise UpdateGatewayError(cause=UpdateGatewayCause.INVALID_RESPONSE) from exc
if not data:
return None
@@ -95,7 +87,7 @@ class GitHubVersionUpdateGateway(VersionUpdateGateway):
if release.get("prerelease") or release.get("draft"):
continue
if version := _extract_version(release.get("tag_name")):
- return VersionUpdate(latest_version=version)
+ return Update(latest_version=version)
return None
diff --git a/vibe/cli/update_notifier/adapters/pypi_version_update_gateway.py b/vibe/cli/update_notifier/adapters/pypi_update_gateway.py
similarity index 71%
rename from vibe/cli/update_notifier/adapters/pypi_version_update_gateway.py
rename to vibe/cli/update_notifier/adapters/pypi_update_gateway.py
index d4038d6..88f7495 100644
--- a/vibe/cli/update_notifier/adapters/pypi_version_update_gateway.py
+++ b/vibe/cli/update_notifier/adapters/pypi_update_gateway.py
@@ -4,21 +4,21 @@ import httpx
from packaging.utils import parse_sdist_filename, parse_wheel_filename
from packaging.version import InvalidVersion, Version
-from vibe.cli.update_notifier.ports.version_update_gateway import (
- VersionUpdate,
- VersionUpdateGateway,
- VersionUpdateGatewayCause,
- VersionUpdateGatewayError,
+from vibe.cli.update_notifier.ports.update_gateway import (
+ Update,
+ UpdateGateway,
+ UpdateGatewayCause,
+ UpdateGatewayError,
)
-_STATUS_CAUSES: dict[int, VersionUpdateGatewayCause] = {
- httpx.codes.NOT_FOUND: VersionUpdateGatewayCause.NOT_FOUND,
- httpx.codes.FORBIDDEN: VersionUpdateGatewayCause.FORBIDDEN,
- httpx.codes.TOO_MANY_REQUESTS: VersionUpdateGatewayCause.TOO_MANY_REQUESTS,
+_STATUS_CAUSES: dict[int, UpdateGatewayCause] = {
+ httpx.codes.NOT_FOUND: UpdateGatewayCause.NOT_FOUND,
+ httpx.codes.FORBIDDEN: UpdateGatewayCause.FORBIDDEN,
+ httpx.codes.TOO_MANY_REQUESTS: UpdateGatewayCause.TOO_MANY_REQUESTS,
}
-class PyPIVersionUpdateGateway(VersionUpdateGateway):
+class PyPIUpdateGateway(UpdateGateway):
def __init__(
self,
project_name: str,
@@ -32,16 +32,14 @@ class PyPIVersionUpdateGateway(VersionUpdateGateway):
self._timeout = timeout
self._base_url = base_url.rstrip("/")
- async def fetch_update(self) -> VersionUpdate | None:
+ async def fetch_update(self) -> Update | None:
response = await self._fetch()
self._raise_gateway_error_if_any(response)
try:
data = response.json()
except ValueError as exc:
- raise VersionUpdateGatewayError(
- cause=VersionUpdateGatewayCause.INVALID_RESPONSE
- ) from exc
+ raise UpdateGatewayError(cause=UpdateGatewayCause.INVALID_RESPONSE) from exc
versions = data.get("versions") or []
files = data.get("files") or []
@@ -66,7 +64,7 @@ class PyPIVersionUpdateGateway(VersionUpdateGateway):
for version in sorted(valid_versions, reverse=True):
if version in non_yanked_versions:
- return VersionUpdate(latest_version=str(version))
+ return Update(latest_version=str(version))
return None
@@ -87,18 +85,14 @@ class PyPIVersionUpdateGateway(VersionUpdateGateway):
) as client:
return await client.get(request_path, headers=headers)
except httpx.RequestError as exc:
- raise VersionUpdateGatewayError(
- cause=VersionUpdateGatewayCause.REQUEST_FAILED
- ) from exc
+ raise UpdateGatewayError(cause=UpdateGatewayCause.REQUEST_FAILED) from exc
def _raise_gateway_error_if_any(self, response: httpx.Response) -> None:
if response.status_code in _STATUS_CAUSES:
- raise VersionUpdateGatewayError(cause=_STATUS_CAUSES[response.status_code])
+ raise UpdateGatewayError(cause=_STATUS_CAUSES[response.status_code])
if response.is_error:
- raise VersionUpdateGatewayError(
- cause=VersionUpdateGatewayCause.ERROR_RESPONSE
- )
+ raise UpdateGatewayError(cause=UpdateGatewayCause.ERROR_RESPONSE)
def _parse_filename_version(filename: str) -> Version | None:
diff --git a/vibe/cli/update_notifier/ports/update_cache_repository.py b/vibe/cli/update_notifier/ports/update_cache_repository.py
index 6806746..da0c1bf 100644
--- a/vibe/cli/update_notifier/ports/update_cache_repository.py
+++ b/vibe/cli/update_notifier/ports/update_cache_repository.py
@@ -8,6 +8,7 @@ from typing import Protocol
class UpdateCache:
latest_version: str
stored_at_timestamp: int
+ seen_whats_new_version: str | None = None
class UpdateCacheRepository(Protocol):
diff --git a/vibe/cli/update_notifier/ports/update_gateway.py b/vibe/cli/update_notifier/ports/update_gateway.py
new file mode 100644
index 0000000..60fb42a
--- /dev/null
+++ b/vibe/cli/update_notifier/ports/update_gateway.py
@@ -0,0 +1,53 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from enum import StrEnum, auto
+from typing import Protocol
+
+
+@dataclass(frozen=True, slots=True)
+class Update:
+ latest_version: str
+
+
+class UpdateGatewayCause(StrEnum):
+ @staticmethod
+ def _generate_next_value_(
+ name: str, start: int, count: int, last_values: list[str]
+ ) -> str:
+ return name.lower()
+
+ TOO_MANY_REQUESTS = auto()
+ FORBIDDEN = auto()
+ NOT_FOUND = auto()
+ REQUEST_FAILED = auto()
+ ERROR_RESPONSE = auto()
+ INVALID_RESPONSE = auto()
+ UNKNOWN = auto()
+
+
+DEFAULT_GATEWAY_MESSAGES: dict[UpdateGatewayCause, str] = {
+ UpdateGatewayCause.TOO_MANY_REQUESTS: "Rate limit exceeded while checking for updates.",
+ UpdateGatewayCause.FORBIDDEN: "Request was forbidden while checking for updates.",
+ UpdateGatewayCause.NOT_FOUND: "Unable to fetch the releases. Please check your permissions.",
+ UpdateGatewayCause.REQUEST_FAILED: "Network error while checking for updates.",
+ UpdateGatewayCause.ERROR_RESPONSE: "Unexpected response received while checking for updates.",
+ UpdateGatewayCause.INVALID_RESPONSE: "Received an invalid response while checking for updates.",
+ UpdateGatewayCause.UNKNOWN: "Unable to determine whether an update is available.",
+}
+
+
+class UpdateGatewayError(Exception):
+ def __init__(
+ self, *, cause: UpdateGatewayCause, message: str | None = None
+ ) -> None:
+ self.cause = cause
+ self.user_message = message
+ detail = message or DEFAULT_GATEWAY_MESSAGES.get(
+ cause, DEFAULT_GATEWAY_MESSAGES[UpdateGatewayCause.UNKNOWN]
+ )
+ super().__init__(detail)
+
+
+class UpdateGateway(Protocol):
+ async def fetch_update(self) -> Update | None: ...
diff --git a/vibe/cli/update_notifier/ports/version_update_gateway.py b/vibe/cli/update_notifier/ports/version_update_gateway.py
deleted file mode 100644
index 762546f..0000000
--- a/vibe/cli/update_notifier/ports/version_update_gateway.py
+++ /dev/null
@@ -1,53 +0,0 @@
-from __future__ import annotations
-
-from dataclasses import dataclass
-from enum import StrEnum, auto
-from typing import Protocol
-
-
-@dataclass(frozen=True, slots=True)
-class VersionUpdate:
- latest_version: str
-
-
-class VersionUpdateGatewayCause(StrEnum):
- @staticmethod
- def _generate_next_value_(
- name: str, start: int, count: int, last_values: list[str]
- ) -> str:
- return name.lower()
-
- TOO_MANY_REQUESTS = auto()
- FORBIDDEN = auto()
- NOT_FOUND = auto()
- REQUEST_FAILED = auto()
- ERROR_RESPONSE = auto()
- INVALID_RESPONSE = auto()
- UNKNOWN = auto()
-
-
-DEFAULT_GATEWAY_MESSAGES: dict[VersionUpdateGatewayCause, str] = {
- VersionUpdateGatewayCause.TOO_MANY_REQUESTS: "Rate limit exceeded while checking for updates.",
- VersionUpdateGatewayCause.FORBIDDEN: "Request was forbidden while checking for updates.",
- VersionUpdateGatewayCause.NOT_FOUND: "Unable to fetch the releases. Please check your permissions.",
- VersionUpdateGatewayCause.REQUEST_FAILED: "Network error while checking for updates.",
- VersionUpdateGatewayCause.ERROR_RESPONSE: "Unexpected response received while checking for updates.",
- VersionUpdateGatewayCause.INVALID_RESPONSE: "Received an invalid response while checking for updates.",
- VersionUpdateGatewayCause.UNKNOWN: "Unable to determine whether an update is available.",
-}
-
-
-class VersionUpdateGatewayError(Exception):
- def __init__(
- self, *, cause: VersionUpdateGatewayCause, message: str | None = None
- ) -> None:
- self.cause = cause
- self.user_message = message
- detail = message or DEFAULT_GATEWAY_MESSAGES.get(
- cause, DEFAULT_GATEWAY_MESSAGES[VersionUpdateGatewayCause.UNKNOWN]
- )
- super().__init__(detail)
-
-
-class VersionUpdateGateway(Protocol):
- async def fetch_update(self) -> VersionUpdate | None: ...
diff --git a/vibe/cli/update_notifier/version_update.py b/vibe/cli/update_notifier/update.py
similarity index 66%
rename from vibe/cli/update_notifier/version_update.py
rename to vibe/cli/update_notifier/update.py
index f5a65f9..d4db614 100644
--- a/vibe/cli/update_notifier/version_update.py
+++ b/vibe/cli/update_notifier/update.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import asyncio
from collections.abc import Callable
from dataclasses import dataclass
import time
@@ -10,21 +11,21 @@ from vibe.cli.update_notifier import (
DEFAULT_GATEWAY_MESSAGES,
UpdateCache,
UpdateCacheRepository,
- VersionUpdateGateway,
- VersionUpdateGatewayCause,
- VersionUpdateGatewayError,
+ UpdateGateway,
+ UpdateGatewayCause,
+ UpdateGatewayError,
)
UPDATE_CACHE_TTL_SECONDS = 24 * 60 * 60
@dataclass(frozen=True, slots=True)
-class VersionUpdateAvailability:
+class UpdateAvailability:
latest_version: str
should_notify: bool
-class VersionUpdateError(Exception):
+class UpdateError(Exception):
def __init__(self, message: str) -> None:
self.message = message
super().__init__(message)
@@ -37,17 +38,17 @@ def _parse_version(raw: str) -> Version | None:
return None
-def _describe_gateway_error(error: VersionUpdateGatewayError) -> str:
+def _describe_gateway_error(error: UpdateGatewayError) -> str:
if message := getattr(error, "user_message", None):
return message
- cause = getattr(error, "cause", VersionUpdateGatewayCause.UNKNOWN)
- if isinstance(cause, VersionUpdateGatewayCause):
+ cause = getattr(error, "cause", UpdateGatewayCause.UNKNOWN)
+ if isinstance(cause, UpdateGatewayCause):
return DEFAULT_GATEWAY_MESSAGES.get(
- cause, DEFAULT_GATEWAY_MESSAGES[VersionUpdateGatewayCause.UNKNOWN]
+ cause, DEFAULT_GATEWAY_MESSAGES[UpdateGatewayCause.UNKNOWN]
)
- return DEFAULT_GATEWAY_MESSAGES[VersionUpdateGatewayCause.UNKNOWN]
+ return DEFAULT_GATEWAY_MESSAGES[UpdateGatewayCause.UNKNOWN]
def _is_cache_fresh(
@@ -60,14 +61,12 @@ def _is_cache_fresh(
def _get_cached_update_if_any(
cache: UpdateCache, current: Version
-) -> VersionUpdateAvailability | None:
+) -> UpdateAvailability | None:
latest_version_in_cache = _parse_version(cache.latest_version)
if latest_version_in_cache is None or latest_version_in_cache <= current:
return None
- return VersionUpdateAvailability(
- latest_version=cache.latest_version, should_notify=False
- )
+ return UpdateAvailability(latest_version=cache.latest_version, should_notify=False)
async def _write_update_cache(
@@ -81,11 +80,11 @@ async def _write_update_cache(
async def get_update_if_available(
- version_update_notifier: VersionUpdateGateway,
+ update_notifier: UpdateGateway,
current_version: str,
update_cache_repository: UpdateCacheRepository,
get_current_timestamp: Callable[[], int] = lambda: int(time.time()),
-) -> VersionUpdateAvailability | None:
+) -> UpdateAvailability | None:
if not (current := _parse_version(current_version)):
return None
@@ -94,12 +93,12 @@ async def get_update_if_available(
return _get_cached_update_if_any(update_cache, current)
try:
- update = await version_update_notifier.fetch_update()
- except VersionUpdateGatewayError as error:
+ update = await update_notifier.fetch_update()
+ except UpdateGatewayError as error:
await _write_update_cache(
update_cache_repository, current_version, get_current_timestamp
)
- raise VersionUpdateError(_describe_gateway_error(error)) from error
+ raise UpdateError(_describe_gateway_error(error)) from error
if not update:
await _write_update_cache(
@@ -120,6 +119,21 @@ async def get_update_if_available(
update_cache_repository, update.latest_version, get_current_timestamp
)
- return VersionUpdateAvailability(
- latest_version=update.latest_version, should_notify=True
- )
+ return UpdateAvailability(latest_version=update.latest_version, should_notify=True)
+
+
+UPDATE_COMMANDS = ["uv tool upgrade mistral-vibe", "brew upgrade mistral-vibe"]
+
+
+async def do_update() -> bool:
+ for command in UPDATE_COMMANDS:
+ process = await asyncio.create_subprocess_shell(
+ command,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ stdin=asyncio.subprocess.DEVNULL,
+ )
+ await process.wait()
+ if process.returncode == 0:
+ return True
+ return False
diff --git a/vibe/cli/update_notifier/whats_new.py b/vibe/cli/update_notifier/whats_new.py
new file mode 100644
index 0000000..61ce16a
--- /dev/null
+++ b/vibe/cli/update_notifier/whats_new.py
@@ -0,0 +1,49 @@
+from __future__ import annotations
+
+import time
+
+from vibe import VIBE_ROOT
+from vibe.cli.update_notifier.ports.update_cache_repository import (
+ UpdateCache,
+ UpdateCacheRepository,
+)
+
+
+async def should_show_whats_new(
+ current_version: str, repository: UpdateCacheRepository
+) -> bool:
+ cache = await repository.get()
+ if cache is None:
+ return False
+ return cache.seen_whats_new_version != current_version
+
+
+def load_whats_new_content() -> str | None:
+ whats_new_file = VIBE_ROOT / "whats_new.md"
+ if not whats_new_file.exists():
+ return None
+ try:
+ content = whats_new_file.read_text(encoding="utf-8").strip()
+ return content if content else None
+ except OSError:
+ return None
+
+
+async def mark_version_as_seen(version: str, repository: UpdateCacheRepository) -> None:
+ cache = await repository.get()
+ if cache is None:
+ await repository.set(
+ UpdateCache(
+ latest_version=version,
+ stored_at_timestamp=int(time.time()),
+ seen_whats_new_version=version,
+ )
+ )
+ else:
+ await repository.set(
+ UpdateCache(
+ latest_version=cache.latest_version,
+ stored_at_timestamp=cache.stored_at_timestamp,
+ seen_whats_new_version=version,
+ )
+ )
diff --git a/vibe/core/agent.py b/vibe/core/agent_loop.py
similarity index 77%
rename from vibe/core/agent.py
rename to vibe/core/agent_loop.py
index f6caf36..fea6e5b 100644
--- a/vibe/core/agent.py
+++ b/vibe/core/agent_loop.py
@@ -3,16 +3,18 @@ from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator, Callable
from enum import StrEnum, auto
+from threading import Thread
import time
from typing import cast
from uuid import uuid4
from pydantic import BaseModel
+from vibe.core.agents.manager import AgentManager
+from vibe.core.agents.models import AgentProfile, BuiltinAgentName
from vibe.core.config import VibeConfig
-from vibe.core.interaction_logger import InteractionLogger
from vibe.core.llm.backend.factory import BACKEND_FACTORY
-from vibe.core.llm.format import APIToolFormatHandler, ResolvedMessage
+from vibe.core.llm.format import APIToolFormatHandler, ResolvedMessage, ResolvedToolCall
from vibe.core.llm.types import BackendLike
from vibe.core.middleware import (
AutoCompactMiddleware,
@@ -21,17 +23,20 @@ from vibe.core.middleware import (
MiddlewareAction,
MiddlewarePipeline,
MiddlewareResult,
- PlanModeMiddleware,
+ PlanAgentMiddleware,
PriceLimitMiddleware,
ResetReason,
TurnLimitMiddleware,
)
-from vibe.core.modes import AgentMode
from vibe.core.prompts import UtilityPrompt
+from vibe.core.session.session_logger import SessionLogger
+from vibe.core.session.session_migration import migrate_sessions_entrypoint
from vibe.core.skills.manager import SkillManager
from vibe.core.system_prompt import get_universal_system_prompt
from vibe.core.tools.base import (
BaseTool,
+ BaseToolConfig,
+ InvokeContext,
ToolError,
ToolPermission,
ToolPermissionError,
@@ -54,6 +59,9 @@ from vibe.core.types import (
SyncApprovalCallback,
ToolCallEvent,
ToolResultEvent,
+ ToolStreamEvent,
+ UserInputCallback,
+ UserMessageEvent,
)
from vibe.core.utils import (
TOOL_ERROR_TAG,
@@ -75,35 +83,36 @@ class ToolDecision(BaseModel):
feedback: str | None = None
-class AgentError(Exception):
- """Base exception for Agent errors."""
+class AgentLoopError(Exception):
+ """Base exception for AgentLoop errors."""
-class AgentStateError(AgentError):
- """Raised when agent is in an invalid state."""
+class AgentLoopStateError(AgentLoopError):
+ """Raised when agent loop is in an invalid state."""
-class LLMResponseError(AgentError):
+class AgentLoopLLMResponseError(AgentLoopError):
"""Raised when LLM response is malformed or missing expected data."""
-class Agent:
+class AgentLoop:
def __init__(
self,
config: VibeConfig,
- mode: AgentMode = AgentMode.DEFAULT,
+ agent_name: str = BuiltinAgentName.DEFAULT,
message_observer: Callable[[LLMMessage], None] | None = None,
max_turns: int | None = None,
max_price: float | None = None,
backend: BackendLike | None = None,
enable_streaming: bool = False,
) -> None:
- """Initialize the agent with configuration and mode."""
- self.config = config
- self._mode = mode
+ self._base_config = config
self._max_turns = max_turns
self._max_price = max_price
+ self.agent_manager = AgentManager(
+ lambda: self._base_config, initial_agent=agent_name
+ )
self.tool_manager = ToolManager(lambda: self.config)
self.skill_manager = SkillManager(lambda: self.config)
self.format_handler = APIToolFormatHandler()
@@ -113,12 +122,12 @@ class Agent:
self.message_observer = message_observer
self._last_observed_message_index: int = 0
- self.middleware_pipeline = MiddlewarePipeline()
self.enable_streaming = enable_streaming
+ self.middleware_pipeline = MiddlewarePipeline()
self._setup_middleware()
system_prompt = get_universal_system_prompt(
- self.tool_manager, config, self.skill_manager
+ self.tool_manager, config, self.skill_manager, self.agent_manager
)
self.messages = [LLMMessage(role=Role.system, content=system_prompt)]
@@ -135,23 +144,45 @@ class Agent:
pass
self.approval_callback: ApprovalCallback | None = None
+ self.user_input_callback: UserInputCallback | None = None
self.session_id = str(uuid4())
- self.interaction_logger = InteractionLogger(
- config.session_logging,
- self.session_id,
- self.auto_approve,
- config.effective_workdir,
+ self.session_logger = SessionLogger(config.session_logging, self.session_id)
+
+ thread = Thread(
+ target=migrate_sessions_entrypoint,
+ args=(config.session_logging,),
+ daemon=True,
+ name="migrate_sessions",
)
+ thread.start()
@property
- def mode(self) -> AgentMode:
- return self._mode
+ def agent_profile(self) -> AgentProfile:
+ return self.agent_manager.active_profile
+
+ @property
+ def config(self) -> VibeConfig:
+ return self.agent_manager.config
@property
def auto_approve(self) -> bool:
- return self._mode.auto_approve
+ return self.config.auto_approve
+
+ def set_tool_permission(
+ self, tool_name: str, permission: ToolPermission, save_permanently: bool = False
+ ) -> None:
+ if save_permanently:
+ VibeConfig.save_updates({
+ "tools": {tool_name: {"permission": permission.value}}
+ })
+
+ if tool_name not in self.config.tools:
+ self.config.tools[tool_name] = BaseToolConfig()
+
+ self.config.tools[tool_name].permission = permission
+ self.tool_manager.invalidate_tool(tool_name)
def _select_backend(self) -> BackendLike:
active_model = self.config.get_active_model()
@@ -197,7 +228,7 @@ class Agent:
ContextWarningMiddleware(0.5, self.config.auto_compact_threshold)
)
- self.middleware_pipeline.add(PlanModeMiddleware(lambda: self._mode))
+ self.middleware_pipeline.add(PlanAgentMiddleware(lambda: self.agent_profile))
async def _handle_middleware_result(
self, result: MiddlewareResult
@@ -224,14 +255,18 @@ class Agent:
threshold = result.metadata.get(
"threshold", self.config.auto_compact_threshold
)
+ tool_call_id = str(uuid4())
yield CompactStartEvent(
- current_context_tokens=old_tokens, threshold=threshold
+ tool_call_id=tool_call_id,
+ current_context_tokens=old_tokens,
+ threshold=threshold,
)
summary = await self.compact()
yield CompactEndEvent(
+ tool_call_id=tool_call_id,
old_context_tokens=old_tokens,
new_context_tokens=self.stats.context_tokens,
summary_length=len(summary),
@@ -246,9 +281,15 @@ class Agent:
)
async def _conversation_loop(self, user_msg: str) -> AsyncGenerator[BaseEvent]:
- self.messages.append(LLMMessage(role=Role.user, content=user_msg))
+ user_message = LLMMessage(role=Role.user, content=user_msg)
+ self.messages.append(user_message)
self.stats.steps += 1
+ if user_message.message_id is None:
+ raise AgentLoopError("User message must have a message_id")
+
+ yield UserMessageEvent(content=user_msg, message_id=user_message.message_id)
+
try:
should_break_loop = False
while not should_break_loop:
@@ -287,8 +328,12 @@ class Agent:
finally:
self._flush_new_messages()
- await self.interaction_logger.save_interaction(
- self.messages, self.stats, self.config, self.tool_manager
+ await self.session_logger.save_interaction(
+ self.messages,
+ self.stats,
+ self._base_config,
+ self.tool_manager,
+ self.agent_profile,
)
async def _perform_llm_turn(self) -> AsyncGenerator[BaseEvent, None]:
@@ -303,9 +348,7 @@ class Agent:
last_message = self.messages[-1]
parsed = self.format_handler.parse_message(last_message)
- resolved = self.format_handler.resolve_tool_calls(
- parsed, self.tool_manager, self.config
- )
+ resolved = self.format_handler.resolve_tool_calls(parsed, self.tool_manager)
if not resolved.tool_calls and not resolved.failed_calls:
return
@@ -320,12 +363,16 @@ class Agent:
reasoning_buffer = ""
chunks_with_content = 0
chunks_with_reasoning = 0
+ message_id: str | None = None
BATCH_SIZE = 5
async for chunk in self._chat_streaming():
+ if message_id is None:
+ message_id = chunk.message.message_id
+
if chunk.message.reasoning_content:
if content_buffer:
- yield AssistantEvent(content=content_buffer)
+ yield AssistantEvent(content=content_buffer, message_id=message_id)
content_buffer = ""
chunks_with_content = 0
@@ -333,13 +380,17 @@ class Agent:
chunks_with_reasoning += 1
if chunks_with_reasoning >= BATCH_SIZE:
- yield ReasoningEvent(content=reasoning_buffer)
+ yield ReasoningEvent(
+ content=reasoning_buffer, message_id=message_id
+ )
reasoning_buffer = ""
chunks_with_reasoning = 0
if chunk.message.content:
if reasoning_buffer:
- yield ReasoningEvent(content=reasoning_buffer)
+ yield ReasoningEvent(
+ content=reasoning_buffer, message_id=message_id
+ )
reasoning_buffer = ""
chunks_with_reasoning = 0
@@ -347,23 +398,26 @@ class Agent:
chunks_with_content += 1
if chunks_with_content >= BATCH_SIZE:
- yield AssistantEvent(content=content_buffer)
+ yield AssistantEvent(content=content_buffer, message_id=message_id)
content_buffer = ""
chunks_with_content = 0
if reasoning_buffer:
- yield ReasoningEvent(content=reasoning_buffer)
+ yield ReasoningEvent(content=reasoning_buffer, message_id=message_id)
if content_buffer:
- yield AssistantEvent(content=content_buffer)
+ yield AssistantEvent(content=content_buffer, message_id=message_id)
async def _get_assistant_event(self) -> AssistantEvent:
llm_result = await self._chat()
- return AssistantEvent(content=llm_result.message.content or "")
+ return AssistantEvent(
+ content=llm_result.message.content or "",
+ message_id=llm_result.message.message_id,
+ )
async def _handle_tool_calls(
self, resolved: ResolvedMessage
- ) -> AsyncGenerator[ToolCallEvent | ToolResultEvent]:
+ ) -> AsyncGenerator[ToolCallEvent | ToolResultEvent | ToolStreamEvent]:
for failed in resolved.failed_calls:
error_msg = f"<{TOOL_ERROR_TAG}>{failed.tool_name}: {failed.error}{TOOL_ERROR_TAG}>"
@@ -382,13 +436,11 @@ class Agent:
)
for tool_call in resolved.tool_calls:
- tool_call_id = tool_call.call_id
-
yield ToolCallEvent(
tool_name=tool_call.tool_name,
tool_class=tool_call.tool_class,
args=tool_call.validated_args,
- tool_call_id=tool_call_id,
+ tool_call_id=tool_call.call_id,
)
try:
@@ -399,19 +451,13 @@ class Agent:
tool_name=tool_call.tool_name,
tool_class=tool_call.tool_class,
error=error_msg,
- tool_call_id=tool_call_id,
- )
- self.messages.append(
- LLMMessage.model_validate(
- self.format_handler.create_tool_response_message(
- tool_call, error_msg
- )
- )
+ tool_call_id=tool_call.call_id,
)
+ self._append_tool_response(tool_call, error_msg)
continue
decision = await self._should_execute_tool(
- tool_instance, tool_call.validated_args, tool_call_id
+ tool_instance, tool_call.validated_args, tool_call.call_id
)
if decision.verdict == ToolExecutionResponse.SKIP:
@@ -427,43 +473,47 @@ class Agent:
tool_class=tool_call.tool_class,
skipped=True,
skip_reason=skip_reason,
- tool_call_id=tool_call_id,
- )
-
- self.messages.append(
- LLMMessage.model_validate(
- self.format_handler.create_tool_response_message(
- tool_call, skip_reason
- )
- )
+ tool_call_id=tool_call.call_id,
)
+ self._append_tool_response(tool_call, skip_reason)
continue
self.stats.tool_calls_agreed += 1
try:
start_time = time.perf_counter()
- result_model = await tool_instance.invoke(**tool_call.args_dict)
+ result_model = None
+
+ async for item in tool_instance.invoke(
+ ctx=InvokeContext(
+ tool_call_id=tool_call.call_id,
+ approval_callback=self.approval_callback,
+ agent_manager=self.agent_manager,
+ user_input_callback=self.user_input_callback,
+ ),
+ **tool_call.args_dict,
+ ):
+ if isinstance(item, ToolStreamEvent):
+ yield item
+ else:
+ result_model = item
+
duration = time.perf_counter() - start_time
+ if result_model is None:
+ raise ToolError("Tool did not yield a result")
+
text = "\n".join(
f"{k}: {v}" for k, v in result_model.model_dump().items()
)
-
- self.messages.append(
- LLMMessage.model_validate(
- self.format_handler.create_tool_response_message(
- tool_call, text
- )
- )
- )
+ self._append_tool_response(tool_call, text)
yield ToolResultEvent(
tool_name=tool_call.tool_name,
tool_class=tool_call.tool_class,
result=result_model,
duration=duration,
- tool_call_id=tool_call_id,
+ tool_call_id=tool_call.call_id,
)
self.stats.tool_calls_succeeded += 1
@@ -476,34 +526,9 @@ class Agent:
tool_name=tool_call.tool_name,
tool_class=tool_call.tool_class,
error=cancel,
- tool_call_id=tool_call_id,
- )
- self.messages.append(
- LLMMessage.model_validate(
- self.format_handler.create_tool_response_message(
- tool_call, cancel
- )
- )
- )
- raise
-
- except KeyboardInterrupt:
- cancel = str(
- get_user_cancellation_message(CancellationReason.TOOL_INTERRUPTED)
- )
- yield ToolResultEvent(
- tool_name=tool_call.tool_name,
- tool_class=tool_call.tool_class,
- error=cancel,
- tool_call_id=tool_call_id,
- )
- self.messages.append(
- LLMMessage.model_validate(
- self.format_handler.create_tool_response_message(
- tool_call, cancel
- )
- )
+ tool_call_id=tool_call.call_id,
)
+ self._append_tool_response(tool_call, cancel)
raise
except (ToolError, ToolPermissionError) as exc:
@@ -513,7 +538,7 @@ class Agent:
tool_name=tool_call.tool_name,
tool_class=tool_call.tool_class,
error=error_msg,
- tool_call_id=tool_call_id,
+ tool_call_id=tool_call.call_id,
)
if isinstance(exc, ToolPermissionError):
@@ -521,22 +546,21 @@ class Agent:
self.stats.tool_calls_rejected += 1
else:
self.stats.tool_calls_failed += 1
- self.messages.append(
- LLMMessage.model_validate(
- self.format_handler.create_tool_response_message(
- tool_call, error_msg
- )
- )
- )
+ self._append_tool_response(tool_call, error_msg)
continue
+ def _append_tool_response(self, tool_call: ResolvedToolCall, text: str) -> None:
+ self.messages.append(
+ LLMMessage.model_validate(
+ self.format_handler.create_tool_response_message(tool_call, text)
+ )
+ )
+
async def _chat(self, max_tokens: int | None = None) -> LLMChunk:
active_model = self.config.get_active_model()
provider = self.config.get_provider_for_model(active_model)
- available_tools = self.format_handler.get_available_tools(
- self.tool_manager, self.config
- )
+ available_tools = self.format_handler.get_available_tools(self.tool_manager)
tool_choice = self.format_handler.get_tool_choice()
try:
@@ -557,7 +581,7 @@ class Agent:
end_time = time.perf_counter()
if result.usage is None:
- raise LLMResponseError(
+ raise AgentLoopLLMResponseError(
"Usage data missing in non-streaming completion response"
)
self._update_stats(usage=result.usage, time_seconds=end_time - start_time)
@@ -579,9 +603,7 @@ class Agent:
active_model = self.config.get_active_model()
provider = self.config.get_provider_for_model(active_model)
- available_tools = self.format_handler.get_available_tools(
- self.tool_manager, self.config
- )
+ available_tools = self.format_handler.get_available_tools(self.tool_manager)
tool_choice = self.format_handler.get_tool_choice()
try:
start_time = time.perf_counter()
@@ -612,7 +634,7 @@ class Agent:
end_time = time.perf_counter()
if chunk_agg.usage is None:
- raise LLMResponseError(
+ raise AgentLoopLLMResponseError(
"Usage data missing in final chunk of streamed completion"
)
self._update_stats(usage=usage, time_seconds=end_time - start_time)
@@ -750,18 +772,26 @@ class Agent:
def _reset_session(self) -> None:
self.session_id = str(uuid4())
- self.interaction_logger.reset_session(self.session_id)
+ self.session_logger.reset_session(self.session_id)
def set_approval_callback(self, callback: ApprovalCallback) -> None:
self.approval_callback = callback
+ def set_user_input_callback(self, callback: UserInputCallback) -> None:
+ self.user_input_callback = callback
+
async def clear_history(self) -> None:
- await self.interaction_logger.save_interaction(
- self.messages, self.stats, self.config, self.tool_manager
+ await self.session_logger.save_interaction(
+ self.messages,
+ self.stats,
+ self._base_config,
+ self.tool_manager,
+ self.agent_profile,
)
self.messages = self.messages[:1]
self.stats = AgentStats()
+ self.stats.trigger_listeners()
try:
active_model = self.config.get_active_model()
@@ -779,32 +809,25 @@ class Agent:
"""Compact the conversation history."""
try:
self._clean_message_history()
- await self.interaction_logger.save_interaction(
- self.messages, self.stats, self.config, self.tool_manager
+ await self.session_logger.save_interaction(
+ self.messages,
+ self.stats,
+ self._base_config,
+ self.tool_manager,
+ self.agent_profile,
)
- last_user_message = None
- for msg in reversed(self.messages):
- if msg.role == Role.user:
- last_user_message = msg.content
- break
-
summary_request = UtilityPrompt.COMPACT.read()
self.messages.append(LLMMessage(role=Role.user, content=summary_request))
self.stats.steps += 1
summary_result = await self._chat()
if summary_result.usage is None:
- raise LLMResponseError(
+ raise AgentLoopLLMResponseError(
"Usage data missing in compaction summary response"
)
summary_content = summary_result.message.content or ""
- if last_user_message:
- summary_content += (
- f"\n\nLast request from user was: {last_user_message}"
- )
-
system_message = self.messages[0]
summary_message = LLMMessage(role=Role.user, content=summary_content)
self.messages = [system_message, summary_message]
@@ -816,17 +839,19 @@ class Agent:
actual_context_tokens = await backend.count_tokens(
model=active_model,
messages=self.messages,
- tools=self.format_handler.get_available_tools(
- self.tool_manager, self.config
- ),
+ tools=self.format_handler.get_available_tools(self.tool_manager),
extra_headers={"user-agent": get_user_agent(provider.backend)},
)
self.stats.context_tokens = actual_context_tokens
self._reset_session()
- await self.interaction_logger.save_interaction(
- self.messages, self.stats, self.config, self.tool_manager
+ await self.session_logger.save_interaction(
+ self.messages,
+ self.stats,
+ self._base_config,
+ self.tool_manager,
+ self.agent_profile,
)
self.middleware_pipeline.reset(reset_reason=ResetReason.COMPACT)
@@ -834,36 +859,40 @@ class Agent:
return summary_content or ""
except Exception:
- await self.interaction_logger.save_interaction(
- self.messages, self.stats, self.config, self.tool_manager
+ await self.session_logger.save_interaction(
+ self.messages,
+ self.stats,
+ self._base_config,
+ self.tool_manager,
+ self.agent_profile,
)
raise
- async def switch_mode(self, new_mode: AgentMode) -> None:
- if new_mode == self._mode:
+ async def switch_agent(self, agent_name: str) -> None:
+ if agent_name == self.agent_profile.name:
return
- new_config = VibeConfig.load(
- workdir=self.config.workdir, **new_mode.config_overrides
- )
-
- await self.reload_with_initial_messages(config=new_config)
- self._mode = new_mode
+ self.agent_manager.switch_profile(agent_name)
+ await self.reload_with_initial_messages()
async def reload_with_initial_messages(
self,
- config: VibeConfig | None = None,
+ base_config: VibeConfig | None = None,
max_turns: int | None = None,
max_price: float | None = None,
) -> None:
- await self.interaction_logger.save_interaction(
- self.messages, self.stats, self.config, self.tool_manager
+ await self.session_logger.save_interaction(
+ self.messages,
+ self.stats,
+ self._base_config,
+ self.tool_manager,
+ self.agent_profile,
)
- preserved_messages = self.messages[1:] if len(self.messages) > 1 else []
+ if base_config is not None:
+ self._base_config = base_config
+ self.agent_manager.invalidate_config()
- if config is not None:
- self.config = config
- self.backend = self.backend_factory()
+ self.backend = self.backend_factory()
if max_turns is not None:
self._max_turns = max_turns
@@ -874,12 +903,13 @@ class Agent:
self.skill_manager = SkillManager(lambda: self.config)
new_system_prompt = get_universal_system_prompt(
- self.tool_manager, self.config, self.skill_manager
+ self.tool_manager, self.config, self.skill_manager, self.agent_manager
)
- self.messages = [LLMMessage(role=Role.system, content=new_system_prompt)]
- if preserved_messages:
- self.messages.extend(preserved_messages)
+ self.messages = [
+ LLMMessage(role=Role.system, content=new_system_prompt),
+ *[msg for msg in self.messages if msg.role != Role.system],
+ ]
if len(self.messages) == 1:
self.stats.reset_context_state()
@@ -901,8 +931,10 @@ class Agent:
self.message_observer(msg)
self._last_observed_message_index = len(self.messages)
- self.tool_manager.reset_all()
-
- await self.interaction_logger.save_interaction(
- self.messages, self.stats, self.config, self.tool_manager
+ await self.session_logger.save_interaction(
+ self.messages,
+ self.stats,
+ self._base_config,
+ self.tool_manager,
+ self.agent_profile,
)
diff --git a/vibe/core/agents/__init__.py b/vibe/core/agents/__init__.py
new file mode 100644
index 0000000..65c9163
--- /dev/null
+++ b/vibe/core/agents/__init__.py
@@ -0,0 +1,31 @@
+from __future__ import annotations
+
+from vibe.core.agents.manager import AgentManager
+from vibe.core.agents.models import (
+ ACCEPT_EDITS,
+ AUTO_APPROVE,
+ BUILTIN_AGENTS,
+ DEFAULT,
+ EXPLORE,
+ PLAN,
+ PLAN_AGENT_TOOLS,
+ AgentProfile,
+ AgentSafety,
+ AgentType,
+ BuiltinAgentName,
+)
+
+__all__ = [
+ "ACCEPT_EDITS",
+ "AUTO_APPROVE",
+ "BUILTIN_AGENTS",
+ "DEFAULT",
+ "EXPLORE",
+ "PLAN",
+ "PLAN_AGENT_TOOLS",
+ "AgentManager",
+ "AgentProfile",
+ "AgentSafety",
+ "AgentType",
+ "BuiltinAgentName",
+]
diff --git a/vibe/core/agents/manager.py b/vibe/core/agents/manager.py
new file mode 100644
index 0000000..477ba09
--- /dev/null
+++ b/vibe/core/agents/manager.py
@@ -0,0 +1,165 @@
+from __future__ import annotations
+
+from collections.abc import Callable
+from logging import getLogger
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+from vibe.core.agents.models import (
+ BUILTIN_AGENTS,
+ AgentProfile,
+ AgentType,
+ BuiltinAgentName,
+)
+from vibe.core.paths.config_paths import resolve_local_agents_dir
+from vibe.core.paths.global_paths import GLOBAL_AGENTS_DIR
+from vibe.core.utils import name_matches
+
+if TYPE_CHECKING:
+ from vibe.core.config import VibeConfig
+
+logger = getLogger("vibe")
+
+
+class AgentManager:
+ def __init__(
+ self,
+ config_getter: Callable[[], VibeConfig],
+ initial_agent: str = BuiltinAgentName.DEFAULT,
+ ) -> None:
+ self._config_getter = config_getter
+ self._search_paths = self._compute_search_paths(self._config)
+ self._available: dict[str, AgentProfile] = self._discover_agents()
+
+ custom_count = len(self._available) - len(BUILTIN_AGENTS)
+ if custom_count > 0:
+ custom_names = [
+ name for name in self._available if name not in BUILTIN_AGENTS
+ ]
+ logger.info(
+ "Discovered custom agents %s in %s",
+ " ".join(custom_names),
+ " ".join(str(p) for p in self._search_paths),
+ )
+
+ self.active_profile = self._available.get(
+ initial_agent, self._available[BuiltinAgentName.DEFAULT]
+ )
+ self._cached_config: VibeConfig | None = None
+
+ @property
+ def _config(self) -> VibeConfig:
+ return self._config_getter()
+
+ @property
+ def available_agents(self) -> dict[str, AgentProfile]:
+ if self._config.enabled_agents:
+ return {
+ name: profile
+ for name, profile in self._available.items()
+ if name_matches(name, self._config.enabled_agents)
+ }
+ if self._config.disabled_agents:
+ return {
+ name: profile
+ for name, profile in self._available.items()
+ if not name_matches(name, self._config.disabled_agents)
+ }
+ return dict(self._available)
+
+ @property
+ def config(self) -> VibeConfig:
+ if self._cached_config is None:
+ self._cached_config = self.active_profile.apply_to_config(self._config)
+ return self._cached_config
+
+ def switch_profile(self, name: str) -> None:
+ self.active_profile = self.get_agent(name)
+ self._cached_config = None
+
+ def invalidate_config(self) -> None:
+ self._cached_config = None
+
+ @staticmethod
+ def _compute_search_paths(config: VibeConfig) -> list[Path]:
+ paths: list[Path] = []
+ for path in config.agent_paths:
+ if path.is_dir():
+ paths.append(path)
+ if (agents_dir := resolve_local_agents_dir(Path.cwd())) is not None:
+ paths.append(agents_dir)
+ if GLOBAL_AGENTS_DIR.path.is_dir():
+ paths.append(GLOBAL_AGENTS_DIR.path)
+ unique: list[Path] = []
+ for p in paths:
+ rp = p.resolve()
+ if rp not in unique:
+ unique.append(rp)
+ return unique
+
+ def _discover_agents(self) -> dict[str, AgentProfile]:
+ agents: dict[str, AgentProfile] = dict(BUILTIN_AGENTS)
+
+ for base in self._search_paths:
+ if not base.is_dir():
+ continue
+ for agent_file in base.glob("*.toml"):
+ if not agent_file.is_file():
+ continue
+ if (agent := self._try_load_agent(agent_file)) is not None:
+ if agent.name in BUILTIN_AGENTS:
+ logger.info(
+ "Custom agent '%s' overrides builtin agent", agent.name
+ )
+ elif agent.name in agents:
+ logger.debug(
+ "Skipping duplicate agent '%s' at %s",
+ agent.name,
+ agent_file,
+ )
+ continue
+ agents[agent.name] = agent
+
+ return agents
+
+ def _try_load_agent(self, agent_file: Path) -> AgentProfile | None:
+ try:
+ agent = AgentProfile.from_toml(agent_file)
+ agent.apply_to_config(self._config)
+ return agent
+ except Exception as e:
+ logger.warning("Failed to load agent at %s: %s", agent_file, e)
+ return None
+
+ def get_agent(self, name: str) -> AgentProfile:
+ if agent := self.available_agents.get(name):
+ return agent
+ raise ValueError(f"Agent '{name}' not found")
+
+ def get_subagents(self) -> list[AgentProfile]:
+ return [
+ a
+ for a in self.available_agents.values()
+ if a.agent_type == AgentType.SUBAGENT
+ ]
+
+ def get_agent_order(self) -> list[str]:
+ builtin_order: list[str] = [
+ BuiltinAgentName.DEFAULT,
+ BuiltinAgentName.PLAN,
+ BuiltinAgentName.ACCEPT_EDITS,
+ BuiltinAgentName.AUTO_APPROVE,
+ ]
+ primary_agents = [
+ name
+ for name, agent in self.available_agents.items()
+ if agent.agent_type == AgentType.AGENT
+ ]
+ order = [name for name in builtin_order if name in primary_agents]
+ custom = sorted(name for name in primary_agents if name not in builtin_order)
+ return order + custom
+
+ def next_agent(self, current: AgentProfile) -> AgentProfile:
+ order = self.get_agent_order()
+ idx = order.index(current.name) if current.name in order else -1
+ return self.available_agents[order[(idx + 1) % len(order)]]
diff --git a/vibe/core/agents/models.py b/vibe/core/agents/models.py
new file mode 100644
index 0000000..a2cd8e2
--- /dev/null
+++ b/vibe/core/agents/models.py
@@ -0,0 +1,122 @@
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from enum import StrEnum, auto
+from pathlib import Path
+import tomllib
+from typing import TYPE_CHECKING, Any
+
+if TYPE_CHECKING:
+ from vibe.core.config import VibeConfig
+
+
+def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
+ result = base.copy()
+ for key, value in override.items():
+ if key in result and isinstance(result[key], dict) and isinstance(value, dict):
+ result[key] = _deep_merge(result[key], value)
+ else:
+ result[key] = value
+ return result
+
+
+class AgentSafety(StrEnum):
+ SAFE = auto()
+ NEUTRAL = auto()
+ DESTRUCTIVE = auto()
+ YOLO = auto()
+
+
+class AgentType(StrEnum):
+ AGENT = auto()
+ SUBAGENT = auto()
+
+
+class BuiltinAgentName(StrEnum):
+ DEFAULT = "default"
+ PLAN = "plan"
+ ACCEPT_EDITS = "accept-edits"
+ AUTO_APPROVE = "auto-approve"
+ EXPLORE = "explore"
+
+
+@dataclass(frozen=True)
+class AgentProfile:
+ name: str
+ display_name: str
+ description: str
+ safety: AgentSafety
+ agent_type: AgentType = AgentType.AGENT
+ overrides: dict[str, Any] = field(default_factory=dict)
+
+ def apply_to_config(self, base: VibeConfig) -> VibeConfig:
+ from vibe.core.config import VibeConfig as VC
+
+ merged = _deep_merge(base.model_dump(), self.overrides)
+ return VC.model_validate(merged)
+
+ @classmethod
+ def from_toml(cls, path: Path) -> AgentProfile:
+ with path.open("rb") as f:
+ data = tomllib.load(f)
+ return cls(
+ name=path.stem,
+ display_name=data.pop("display_name", path.stem.replace("-", " ").title()),
+ description=data.pop("description", ""),
+ safety=AgentSafety(data.pop("safety", AgentSafety.NEUTRAL)),
+ agent_type=AgentType(data.pop("agent_type", AgentType.AGENT)),
+ overrides=data,
+ )
+
+
+PLAN_AGENT_TOOLS = ["grep", "read_file", "todo", "ask_user_question", "task"]
+
+DEFAULT = AgentProfile(
+ BuiltinAgentName.DEFAULT,
+ "Default",
+ "Requires approval for tool executions",
+ AgentSafety.NEUTRAL,
+)
+PLAN = AgentProfile(
+ BuiltinAgentName.PLAN,
+ "Plan",
+ "Read-only agent for exploration and planning",
+ AgentSafety.SAFE,
+ overrides={"auto_approve": True, "enabled_tools": PLAN_AGENT_TOOLS},
+)
+ACCEPT_EDITS = AgentProfile(
+ BuiltinAgentName.ACCEPT_EDITS,
+ "Accept Edits",
+ "Auto-approves file edits only",
+ AgentSafety.DESTRUCTIVE,
+ overrides={
+ "tools": {
+ "write_file": {"permission": "always"},
+ "search_replace": {"permission": "always"},
+ }
+ },
+)
+AUTO_APPROVE = AgentProfile(
+ BuiltinAgentName.AUTO_APPROVE,
+ "Auto Approve",
+ "Auto-approves all tool executions",
+ AgentSafety.YOLO,
+ overrides={"auto_approve": True},
+)
+
+EXPLORE = AgentProfile(
+ name=BuiltinAgentName.EXPLORE,
+ display_name="Explore",
+ description="Read-only subagent for codebase exploration",
+ safety=AgentSafety.SAFE,
+ agent_type=AgentType.SUBAGENT,
+ overrides={"enabled_tools": ["grep", "read_file"]},
+)
+
+BUILTIN_AGENTS: dict[str, AgentProfile] = {
+ BuiltinAgentName.DEFAULT: DEFAULT,
+ BuiltinAgentName.PLAN: PLAN,
+ BuiltinAgentName.ACCEPT_EDITS: ACCEPT_EDITS,
+ BuiltinAgentName.AUTO_APPROVE: AUTO_APPROVE,
+ BuiltinAgentName.EXPLORE: EXPLORE,
+}
diff --git a/vibe/core/autocompletion/completers.py b/vibe/core/autocompletion/completers.py
index 0d7ff19..387e84a 100644
--- a/vibe/core/autocompletion/completers.py
+++ b/vibe/core/autocompletion/completers.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+from collections.abc import Callable
from pathlib import Path
from typing import NamedTuple
@@ -26,28 +27,37 @@ class Completer:
class CommandCompleter(Completer):
- def __init__(self, commands: list[tuple[str, str]]) -> None:
- aliases_with_descriptions: dict[str, str] = {}
- for alias, description in commands:
- aliases_with_descriptions[alias] = description
+ def __init__(self, entries: Callable[[], list[tuple[str, str]]]) -> None:
+ self._get_entries = entries
- self._descriptions = aliases_with_descriptions
- self._aliases: list[str] = list(aliases_with_descriptions.keys())
+ def _build_lookup(self) -> tuple[list[str], dict[str, str]]:
+ descriptions: dict[str, str] = {}
+ for alias, description in self._get_entries():
+ descriptions[alias] = description
+ return list(descriptions.keys()), descriptions
def get_completions(self, text: str, cursor_pos: int) -> list[str]:
if not text.startswith("/"):
return []
+ aliases, _ = self._build_lookup()
+ word = text[1:cursor_pos].lower()
+ search_str = "/" + word
+ return [alias for alias in aliases if alias.lower().startswith(search_str)]
+
+ def get_completion_items(self, text: str, cursor_pos: int) -> list[tuple[str, str]]:
+ if not text.startswith("/"):
+ return []
+
+ aliases, descriptions = self._build_lookup()
word = text[1:cursor_pos].lower()
search_str = "/" + word
return [
- alias for alias in self._aliases if alias.lower().startswith(search_str)
+ (alias, descriptions.get(alias, ""))
+ for alias in aliases
+ if alias.lower().startswith(search_str)
]
- def get_completion_items(self, text: str, cursor_pos: int) -> list[tuple[str, str]]:
- completions = self.get_completions(text, cursor_pos)
- return [(alias, self._descriptions.get(alias, "")) for alias in completions]
-
def get_replacement_range(
self, text: str, cursor_pos: int
) -> tuple[int, int] | None:
diff --git a/vibe/core/autocompletion/path_prompt_adapter.py b/vibe/core/autocompletion/path_prompt_adapter.py
index de2f163..826f61c 100644
--- a/vibe/core/autocompletion/path_prompt_adapter.py
+++ b/vibe/core/autocompletion/path_prompt_adapter.py
@@ -102,7 +102,7 @@ def _format_content_block(block: ResourceBlock) -> str | None:
"name": block.get("name"),
"title": block.get("title"),
"description": block.get("description"),
- "mimeType": block.get("mimeType"),
+ "mime_type": block.get("mime_type"),
"size": block.get("size"),
}
parts = [
diff --git a/vibe/core/config.py b/vibe/core/config.py
index ff6a9c8..a9f2fbc 100644
--- a/vibe/core/config.py
+++ b/vibe/core/config.py
@@ -19,7 +19,7 @@ from pydantic_settings import (
)
import tomli_w
-from vibe.core.paths.config_paths import AGENT_DIR, CONFIG_DIR, CONFIG_FILE, PROMPT_DIR
+from vibe.core.paths.config_paths import CONFIG_DIR, CONFIG_FILE, PROMPT_DIR
from vibe.core.paths.global_paths import GLOBAL_ENV_FILE, SESSION_LOG_DIR
from vibe.core.prompts import SystemPrompt
from vibe.core.tools.base import BaseToolConfig
@@ -137,6 +137,14 @@ class _MCPBase(BaseModel):
prompt: str | None = Field(
default=None, description="Optional usage hint appended to tool descriptions"
)
+ startup_timeout_sec: float = Field(
+ default=10.0,
+ gt=0,
+ description="Timeout in seconds for the server to start and initialize.",
+ )
+ tool_timeout_sec: float = Field(
+ default=60.0, gt=0, description="Timeout in seconds for tool execution."
+ )
@field_validator("name", mode="after")
@classmethod
@@ -199,6 +207,10 @@ class MCPStdio(_MCPBase):
transport: Literal["stdio"]
command: str | list[str]
args: list[str] = Field(default_factory=list)
+ env: dict[str, str] = Field(
+ default_factory=dict,
+ description="Environment variables to set for the MCP server process.",
+ )
def argv(self) -> list[str]:
base = (
@@ -278,14 +290,14 @@ class VibeConfig(BaseSettings):
displayed_workdir: str = ""
auto_compact_threshold: int = 200_000
context_warnings: bool = False
- instructions: str = ""
- workdir: Path | None = Field(default=None, exclude=True)
+ auto_approve: bool = False
system_prompt_id: str = "cli"
include_commit_signature: bool = True
include_model_info: bool = True
include_project_context: bool = True
include_prompt_detail: bool = True
enable_update_checks: bool = True
+ enable_auto_update: bool = True
api_timeout: float = 720.0
providers: list[ProviderConfig] = Field(
default_factory=lambda: list(DEFAULT_PROVIDERS)
@@ -298,8 +310,10 @@ class VibeConfig(BaseSettings):
tool_paths: list[Path] = Field(
default_factory=list,
description=(
- "Additional directories to search for custom tools. "
- "Each path may be absolute or relative to the current working directory."
+ "Additional directories or files to explore for custom tools. "
+ "Paths may be absolute or relative to the current working directory. "
+ "Directories are shallow-searched for tool definition files, "
+ "while files are loaded directly if valid."
),
)
@@ -311,20 +325,39 @@ class VibeConfig(BaseSettings):
default_factory=list,
description=(
"An explicit list of tool names/patterns to enable. If set, only these"
- " tools will be active. Supports exact names, glob patterns (e.g.,"
- " 'serena_*'), and regex with 're:' prefix or regex-like patterns (e.g.,"
- " 're:^serena_.*' or 'serena.*')."
+ " tools will be active. Supports glob patterns (e.g., 'serena_*') and"
+ " regex with 're:' prefix (e.g., 're:^serena_.*')."
),
)
disabled_tools: list[str] = Field(
default_factory=list,
description=(
"A list of tool names/patterns to disable. Ignored if 'enabled_tools'"
- " is set. Supports exact names, glob patterns (e.g., 'bash*'), and"
- " regex with 're:' prefix or regex-like patterns."
+ " is set. Supports glob patterns and regex with 're:' prefix."
+ ),
+ )
+ agent_paths: list[Path] = Field(
+ default_factory=list,
+ description=(
+ "Additional directories to search for custom agent profiles. "
+ "Each path may be absolute or relative to the current working directory."
+ ),
+ )
+ enabled_agents: list[str] = Field(
+ default_factory=list,
+ description=(
+ "An explicit list of agent names/patterns to enable. If set, only these"
+ " agents will be available. Supports glob patterns (e.g., 'custom-*')"
+ " and regex with 're:' prefix."
+ ),
+ )
+ disabled_agents: list[str] = Field(
+ default_factory=list,
+ description=(
+ "A list of agent names/patterns to disable. Ignored if 'enabled_agents'"
+ " is set. Supports glob patterns and regex with 're:' prefix."
),
)
-
skill_paths: list[Path] = Field(
default_factory=list,
description=(
@@ -332,15 +365,26 @@ class VibeConfig(BaseSettings):
"Each path may be absolute or relative to the current working directory."
),
)
+ enabled_skills: list[str] = Field(
+ default_factory=list,
+ description=(
+ "An explicit list of skill names/patterns to enable. If set, only these"
+ " skills will be active. Supports glob patterns (e.g., 'search-*') and"
+ " regex with 're:' prefix."
+ ),
+ )
+ disabled_skills: list[str] = Field(
+ default_factory=list,
+ description=(
+ "A list of skill names/patterns to disable. Ignored if 'enabled_skills'"
+ " is set. Supports glob patterns and regex with 're:' prefix."
+ ),
+ )
model_config = SettingsConfigDict(
env_prefix="VIBE_", case_sensitive=False, extra="ignore"
)
- @property
- def effective_workdir(self) -> Path:
- return self.workdir if self.workdir is not None else Path.cwd()
-
@property
def system_prompt(self) -> str:
try:
@@ -439,22 +483,6 @@ class VibeConfig(BaseSettings):
return []
return [Path(p).expanduser().resolve() for p in v]
- @field_validator("workdir", mode="before")
- @classmethod
- def _expand_workdir(cls, v: Any) -> Path | None:
- if v is None or (isinstance(v, str) and not v.strip()):
- return None
-
- if isinstance(v, str):
- v = Path(v).expanduser().resolve()
- elif isinstance(v, Path):
- v = v.expanduser().resolve()
- if not v.is_dir():
- raise ValueError(
- f"Tried to set {v} as working directory, path doesn't exist"
- )
- return v
-
@field_validator("tools", mode="before")
@classmethod
def _normalize_tool_configs(cls, v: Any) -> dict[str, BaseToolConfig]:
@@ -523,29 +551,14 @@ class VibeConfig(BaseSettings):
with CONFIG_FILE.path.open("wb") as f:
tomli_w.dump(config, f)
- @classmethod
- def _get_agent_config(cls, agent: str | None) -> dict[str, Any] | None:
- if agent is None:
- return None
-
- agent_config_path = (AGENT_DIR.path / agent).with_suffix(".toml")
- try:
- return tomllib.load(agent_config_path.open("rb"))
- except FileNotFoundError:
- raise ValueError(
- f"Config '{agent}.toml' for agent not found in {AGENT_DIR.path}"
- )
-
@classmethod
def _migrate(cls) -> None:
pass
@classmethod
- def load(cls, agent: str | None = None, **overrides: Any) -> VibeConfig:
+ def load(cls, **overrides: Any) -> VibeConfig:
cls._migrate()
- agent_config = cls._get_agent_config(agent)
- init_data = {**(agent_config or {}), **overrides}
- return cls(**init_data)
+ return cls(**(overrides or {}))
@classmethod
def create_default(cls) -> dict[str, Any]:
diff --git a/vibe/core/interaction_logger.py b/vibe/core/interaction_logger.py
deleted file mode 100644
index c98c99d..0000000
--- a/vibe/core/interaction_logger.py
+++ /dev/null
@@ -1,245 +0,0 @@
-from __future__ import annotations
-
-from datetime import datetime
-import getpass
-import json
-from pathlib import Path
-import subprocess
-from typing import TYPE_CHECKING, Any
-
-import aiofiles
-
-from vibe.core.llm.format import get_active_tool_classes
-from vibe.core.types import AgentStats, LLMMessage, SessionInfo, SessionMetadata
-from vibe.core.utils import is_windows
-
-if TYPE_CHECKING:
- from vibe.core.config import SessionLoggingConfig, VibeConfig
- from vibe.core.tools.manager import ToolManager
-
-
-class InteractionLogger:
- def __init__(
- self,
- session_config: SessionLoggingConfig,
- session_id: str,
- auto_approve: bool = False,
- workdir: Path | None = None,
- ) -> None:
- if workdir is None:
- workdir = Path.cwd()
- self.session_config = session_config
- self.enabled = session_config.enabled
- self.auto_approve = auto_approve
- self.workdir = workdir
-
- if not self.enabled:
- self.save_dir: Path | None = None
- self.session_prefix: str | None = None
- self.session_id: str = "disabled"
- self.session_start_time: str = "N/A"
- self.filepath: Path | None = None
- self.session_metadata: SessionMetadata | None = None
- return
-
- self.save_dir = Path(session_config.save_dir)
- self.session_prefix = session_config.session_prefix
- self.session_id = session_id
- self.session_start_time = datetime.now().isoformat()
-
- self.save_dir.mkdir(parents=True, exist_ok=True)
- self.filepath = self._get_save_filepath()
- self.session_metadata = self._initialize_session_metadata()
-
- def _get_save_filepath(self) -> Path:
- if self.save_dir is None or self.session_prefix is None:
- raise RuntimeError("Cannot get filepath when logging is disabled")
-
- timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
- filename = f"{self.session_prefix}_{timestamp}_{self.session_id[:8]}.json"
- return self.save_dir / filename
-
- def _get_git_commit(self) -> str | None:
- try:
- result = subprocess.run(
- ["git", "rev-parse", "HEAD"],
- capture_output=True,
- cwd=self.workdir,
- stdin=subprocess.DEVNULL if is_windows() else None,
- text=True,
- timeout=5.0,
- )
- if result.returncode == 0 and result.stdout:
- return result.stdout.strip()
- except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
- pass
- return None
-
- def _get_git_branch(self) -> str | None:
- try:
- result = subprocess.run(
- ["git", "rev-parse", "--abbrev-ref", "HEAD"],
- capture_output=True,
- cwd=self.workdir,
- stdin=subprocess.DEVNULL if is_windows() else None,
- text=True,
- timeout=5.0,
- )
- if result.returncode == 0 and result.stdout:
- return result.stdout.strip()
- except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
- pass
- return None
-
- def _get_username(self) -> str:
- try:
- return getpass.getuser()
- except Exception:
- return "unknown"
-
- def _initialize_session_metadata(self) -> SessionMetadata:
- git_commit = self._get_git_commit()
- git_branch = self._get_git_branch()
- user_name = self._get_username()
-
- return SessionMetadata(
- session_id=self.session_id,
- start_time=self.session_start_time,
- end_time=None,
- git_commit=git_commit,
- git_branch=git_branch,
- auto_approve=self.auto_approve,
- username=user_name,
- environment={"working_directory": str(self.workdir)},
- )
-
- async def save_interaction(
- self,
- messages: list[LLMMessage],
- stats: AgentStats,
- config: VibeConfig,
- tool_manager: ToolManager,
- ) -> str | None:
- if not self.enabled or self.filepath is None:
- return None
-
- if self.session_metadata is None:
- return None
-
- active_tools = get_active_tool_classes(tool_manager, config)
-
- tools_available = [
- {
- "type": "function",
- "function": {
- "name": tool_class.get_name(),
- "description": tool_class.description,
- "parameters": tool_class.get_parameters(),
- },
- }
- for tool_class in active_tools
- ]
-
- interaction_data = {
- "metadata": {
- **self.session_metadata.model_dump(),
- "end_time": datetime.now().isoformat(),
- "stats": stats.model_dump(),
- "total_messages": len(messages),
- "tools_available": tools_available,
- "agent_config": config.model_dump(mode="json"),
- },
- "messages": [m.model_dump(exclude_none=True) for m in messages],
- }
-
- try:
- json_content = json.dumps(interaction_data, indent=2, ensure_ascii=False)
-
- async with aiofiles.open(self.filepath, "w", encoding="utf-8") as f:
- await f.write(json_content)
-
- return str(self.filepath)
- except Exception:
- return None
-
- def reset_session(self, session_id: str) -> None:
- if not self.enabled:
- return
-
- self.session_id = session_id
- self.session_start_time = datetime.now().isoformat()
- self.filepath = self._get_save_filepath()
- self.session_metadata = self._initialize_session_metadata()
-
- def get_session_info(
- self, messages: list[dict[str, Any]], stats: AgentStats
- ) -> SessionInfo:
- if not self.enabled or self.save_dir is None:
- return SessionInfo(
- session_id="disabled",
- start_time="N/A",
- message_count=len(messages),
- stats=stats,
- save_dir="N/A",
- )
-
- return SessionInfo(
- session_id=self.session_id,
- start_time=self.session_start_time,
- message_count=len(messages),
- stats=stats,
- save_dir=str(self.save_dir),
- )
-
- @staticmethod
- def find_latest_session(config: SessionLoggingConfig) -> Path | None:
- save_dir = Path(config.save_dir)
- if not save_dir.exists():
- return None
-
- pattern = f"{config.session_prefix}_*.json"
- session_files = list(save_dir.glob(pattern))
-
- if not session_files:
- return None
-
- return max(session_files, key=lambda p: p.stat().st_mtime)
-
- @staticmethod
- def find_session_by_id(
- session_id: str, config: SessionLoggingConfig
- ) -> Path | None:
- save_dir = Path(config.save_dir)
- if not save_dir.exists():
- return None
-
- # If it's a full UUID, extract the short form (first 8 chars)
- short_id = session_id.split("-")[0] if "-" in session_id else session_id
-
- # Try exact match first, then partial
- patterns = [
- f"{config.session_prefix}_*_{short_id}.json", # Exact short UUID
- f"{config.session_prefix}_*_{short_id}*.json", # Partial UUID
- ]
-
- for pattern in patterns:
- matches = list(save_dir.glob(pattern))
- if matches:
- return (
- max(matches, key=lambda p: p.stat().st_mtime)
- if len(matches) > 1
- else matches[0]
- )
-
- return None
-
- @staticmethod
- def load_session(filepath: Path) -> tuple[list[LLMMessage], dict[str, Any]]:
- with filepath.open("r", encoding="utf-8") as f:
- content = f.read()
-
- data = json.loads(content)
- messages = [LLMMessage.model_validate(msg) for msg in data.get("messages", [])]
- metadata = data.get("metadata", {})
-
- return messages, metadata
diff --git a/vibe/core/llm/backend/generic.py b/vibe/core/llm/backend/generic.py
index 6ecee93..2a572ec 100644
--- a/vibe/core/llm/backend/generic.py
+++ b/vibe/core/llm/backend/generic.py
@@ -134,7 +134,9 @@ class OpenAIAdapter(APIAdapter):
) -> PreparedRequest:
field_name = provider.reasoning_field_name
converted_messages = [
- self._reasoning_to_api(msg.model_dump(exclude_none=True), field_name)
+ self._reasoning_to_api(
+ msg.model_dump(exclude_none=True, exclude={"message_id"}), field_name
+ )
for msg in messages
]
@@ -150,7 +152,7 @@ class OpenAIAdapter(APIAdapter):
payload["stream_options"] = stream_options
headers = self.build_headers(api_key)
- body = json.dumps(payload).encode("utf-8")
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
return PreparedRequest(self.endpoint, headers, body)
diff --git a/vibe/core/llm/backend/mistral.py b/vibe/core/llm/backend/mistral.py
index c2c16e3..1333206 100644
--- a/vibe/core/llm/backend/mistral.py
+++ b/vibe/core/llm/backend/mistral.py
@@ -141,7 +141,7 @@ class MistralMapper:
name=tool_call.function.name,
arguments=tool_call.function.arguments
if isinstance(tool_call.function.arguments, str)
- else json.dumps(tool_call.function.arguments),
+ else json.dumps(tool_call.function.arguments, ensure_ascii=False),
),
index=tool_call.index,
)
diff --git a/vibe/core/llm/format.py b/vibe/core/llm/format.py
index e28be63..ac852d4 100644
--- a/vibe/core/llm/format.py
+++ b/vibe/core/llm/format.py
@@ -1,9 +1,6 @@
from __future__ import annotations
-from fnmatch import fnmatch
-from functools import lru_cache
import json
-import re
from typing import TYPE_CHECKING, Any
from pydantic import BaseModel, ConfigDict, Field, ValidationError
@@ -18,90 +15,9 @@ from vibe.core.types import (
)
if TYPE_CHECKING:
- from vibe.core.config import VibeConfig
from vibe.core.tools.manager import ToolManager
-def _is_regex_hint(pattern: str) -> bool:
- """Heuristically detect whether a pattern looks like a regex.
-
- - Explicit regex: starts with 're:'
- - Heuristic regex: contains common regex metachars or '.*'
- """
- if pattern.startswith("re:"):
- return True
- return bool(re.search(r"[().+|^$]", pattern) or ".*" in pattern)
-
-
-@lru_cache(maxsize=256)
-def _compile_icase(expr: str) -> re.Pattern | None:
- try:
- return re.compile(expr, re.IGNORECASE)
- except re.error:
- return None
-
-
-def _regex_match_icase(expr: str, s: str) -> bool:
- rx = _compile_icase(expr)
- return rx is not None and rx.fullmatch(s) is not None
-
-
-def _name_matches(name: str, patterns: list[str]) -> bool:
- """Check if a tool name matches any of the provided patterns.
-
- Supports three forms (case-insensitive):
- - Exact names (no wildcards/regex tokens)
- - Glob wildcards using fnmatch (e.g., 'serena_*')
- - Regex when prefixed with 're:'
- or when the pattern looks regex-y (e.g., 'serena.*')
- """
- n = name.lower()
- for raw in patterns:
- if not (p := (raw or "").strip()):
- continue
-
- match p:
- case _ if p.startswith("re:"):
- if _regex_match_icase(p.removeprefix("re:"), name):
- return True
- case _ if _is_regex_hint(p):
- if _regex_match_icase(p, name):
- return True
- case _:
- if fnmatch(n, p.lower()):
- return True
-
- return False
-
-
-def get_active_tool_classes(
- tool_manager: ToolManager, config: VibeConfig
-) -> list[type[BaseTool]]:
- """Returns a list of active tool classes based on the configuration.
-
- Args:
- tool_manager: ToolManager instance with discovered tools
- config: VibeConfig with enabled_tools/disabled_tools settings
- """
- all_tools = list(tool_manager.available_tools().values())
-
- if config.enabled_tools:
- return [
- tool_class
- for tool_class in all_tools
- if _name_matches(tool_class.get_name(), config.enabled_tools)
- ]
-
- if config.disabled_tools:
- return [
- tool_class
- for tool_class in all_tools
- if not _name_matches(tool_class.get_name(), config.disabled_tools)
- ]
-
- return all_tools
-
-
class ParsedToolCall(BaseModel):
model_config = ConfigDict(frozen=True)
tool_name: str
@@ -144,11 +60,7 @@ class APIToolFormatHandler:
def name(self) -> str:
return "api"
- def get_available_tools(
- self, tool_manager: ToolManager, config: VibeConfig
- ) -> list[AvailableTool]:
- active_tools = get_active_tool_classes(tool_manager, config)
-
+ def get_available_tools(self, tool_manager: ToolManager) -> list[AvailableTool]:
return [
AvailableTool(
function=AvailableFunction(
@@ -157,7 +69,7 @@ class APIToolFormatHandler:
parameters=tool_class.get_parameters(),
)
)
- for tool_class in active_tools
+ for tool_class in tool_manager.available_tools.values()
]
def get_tool_choice(self) -> StrToolChoice | AvailableTool:
@@ -209,15 +121,12 @@ class APIToolFormatHandler:
return ParsedMessage(tool_calls=tool_calls)
def resolve_tool_calls(
- self, parsed: ParsedMessage, tool_manager: ToolManager, config: VibeConfig
+ self, parsed: ParsedMessage, tool_manager: ToolManager
) -> ResolvedMessage:
resolved_calls = []
failed_calls = []
- active_tools = {
- tool_class.get_name(): tool_class
- for tool_class in get_active_tool_classes(tool_manager, config)
- }
+ active_tools = tool_manager.available_tools
for parsed_call in parsed.tool_calls:
tool_class = active_tools.get(parsed_call.tool_name)
diff --git a/vibe/core/llm/types.py b/vibe/core/llm/types.py
index 5cb24d5..6b059f3 100644
--- a/vibe/core/llm/types.py
+++ b/vibe/core/llm/types.py
@@ -13,7 +13,7 @@ if TYPE_CHECKING:
class BackendLike(Protocol):
"""Port protocol for dependency-injectable LLM backends.
- Any backend used by Agent should implement this async context manager
+ Any backend used by AgentLoop should implement this async context manager
interface with `complete`, `complete_streaming` and `count_tokens` methods.
"""
diff --git a/vibe/core/middleware.py b/vibe/core/middleware.py
index 10e4757..36a97e2 100644
--- a/vibe/core/middleware.py
+++ b/vibe/core/middleware.py
@@ -5,7 +5,8 @@ from dataclasses import dataclass, field
from enum import StrEnum, auto
from typing import TYPE_CHECKING, Any, Protocol
-from vibe.core.modes import AgentMode
+from vibe.core.agents import AgentProfile
+from vibe.core.agents.models import BuiltinAgentName
from vibe.core.utils import VIBE_WARNING_TAG
if TYPE_CHECKING:
@@ -143,25 +144,25 @@ class ContextWarningMiddleware:
self.has_warned = False
-PLAN_MODE_REMINDER = f"""<{VIBE_WARNING_TAG}>Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits, run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supersedes any other instructions you have received (for example, to make edits). Instead, you should:
+PLAN_AGENT_REMINDER = f"""<{VIBE_WARNING_TAG}>Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits, run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supersedes any other instructions you have received (for example, to make edits). Instead, you should:
1. Answer the user's query comprehensively
2. When you're done researching, present your plan by giving the full plan and not doing further tool calls to return input to the user. Do NOT make any file changes or run any tools that modify the system state in any way until the user has confirmed the plan.{VIBE_WARNING_TAG}>"""
-class PlanModeMiddleware:
- """Injects plan mode reminder after each assistant turn when plan mode is active."""
-
+class PlanAgentMiddleware:
def __init__(
- self, mode_getter: Callable[[], AgentMode], reminder: str = PLAN_MODE_REMINDER
+ self,
+ profile_getter: Callable[[], AgentProfile],
+ reminder: str = PLAN_AGENT_REMINDER,
) -> None:
- self._mode_getter = mode_getter
+ self._profile_getter = profile_getter
self.reminder = reminder
- def _is_plan_mode(self) -> bool:
- return self._mode_getter() == AgentMode.PLAN
+ def _is_plan_agent(self) -> bool:
+ return self._profile_getter().name == BuiltinAgentName.PLAN
async def before_turn(self, context: ConversationContext) -> MiddlewareResult:
- if not self._is_plan_mode():
+ if not self._is_plan_agent():
return MiddlewareResult()
return MiddlewareResult(
action=MiddlewareAction.INJECT_MESSAGE, message=self.reminder
diff --git a/vibe/core/modes.py b/vibe/core/modes.py
deleted file mode 100644
index cdc953c..0000000
--- a/vibe/core/modes.py
+++ /dev/null
@@ -1,108 +0,0 @@
-from __future__ import annotations
-
-from dataclasses import dataclass, field
-from enum import StrEnum, auto
-from typing import Any
-
-
-class ModeSafety(StrEnum):
- SAFE = auto()
- NEUTRAL = auto()
- DESTRUCTIVE = auto()
- YOLO = auto()
-
-
-class AgentMode(StrEnum):
- DEFAULT = auto()
- AUTO_APPROVE = auto()
- PLAN = auto()
- ACCEPT_EDITS = auto()
-
- @property
- def display_name(self) -> str:
- return MODE_CONFIGS[self].display_name
-
- @property
- def description(self) -> str:
- return MODE_CONFIGS[self].description
-
- @property
- def config_overrides(self) -> dict[str, Any]:
- return MODE_CONFIGS[self].config_overrides
-
- @property
- def auto_approve(self) -> bool:
- return MODE_CONFIGS[self].auto_approve
-
- @property
- def safety(self) -> ModeSafety:
- return MODE_CONFIGS[self].safety
-
- @classmethod
- def from_string(cls, value: str) -> AgentMode | None:
- try:
- return cls(value.lower())
- except ValueError:
- return None
-
-
-@dataclass(frozen=True)
-class ModeConfig:
- display_name: str
- description: str
- safety: ModeSafety = ModeSafety.NEUTRAL
- auto_approve: bool = False
- config_overrides: dict[str, Any] = field(default_factory=dict)
-
-
-PLAN_MODE_TOOLS = ["grep", "read_file", "todo"]
-ACCEPT_EDITS_TOOLS = ["write_file", "search_replace"]
-
-MODE_CONFIGS: dict[AgentMode, ModeConfig] = {
- AgentMode.DEFAULT: ModeConfig(
- display_name="Default",
- description="Requires approval for tool executions",
- safety=ModeSafety.NEUTRAL,
- auto_approve=False,
- ),
- AgentMode.PLAN: ModeConfig(
- display_name="Plan",
- description="Read-only mode for exploration and planning",
- safety=ModeSafety.SAFE,
- auto_approve=True,
- config_overrides={"enabled_tools": PLAN_MODE_TOOLS},
- ),
- AgentMode.ACCEPT_EDITS: ModeConfig(
- display_name="Accept Edits",
- description="Auto-approves file edits only",
- safety=ModeSafety.DESTRUCTIVE,
- auto_approve=False,
- config_overrides={
- "tools": {
- "write_file": {"permission": "always"},
- "search_replace": {"permission": "always"},
- }
- },
- ),
- AgentMode.AUTO_APPROVE: ModeConfig(
- display_name="Auto Approve",
- description="Auto-approves all tool executions",
- safety=ModeSafety.YOLO,
- auto_approve=True,
- ),
-}
-
-
-def get_mode_order() -> list[AgentMode]:
- return [
- AgentMode.DEFAULT,
- AgentMode.PLAN,
- AgentMode.ACCEPT_EDITS,
- AgentMode.AUTO_APPROVE,
- ]
-
-
-def next_mode(current: AgentMode) -> AgentMode:
- order = get_mode_order()
- idx = order.index(current)
- return order[(idx + 1) % len(order)]
diff --git a/vibe/core/output_formatters.py b/vibe/core/output_formatters.py
index 2c26fd7..1714d79 100644
--- a/vibe/core/output_formatters.py
+++ b/vibe/core/output_formatters.py
@@ -53,7 +53,7 @@ class JsonOutputFormatter(OutputFormatter):
def finalize(self) -> str | None:
messages_data = [msg.model_dump(mode="json") for msg in self._messages]
- json.dump(messages_data, self.stream, indent=2)
+ json.dump(messages_data, self.stream, indent=2, ensure_ascii=False)
self.stream.write("\n")
self.stream.flush()
return None
@@ -61,7 +61,7 @@ class JsonOutputFormatter(OutputFormatter):
class StreamingJsonOutputFormatter(OutputFormatter):
def on_message_added(self, message: LLMMessage) -> None:
- json.dump(message.model_dump(mode="json"), self.stream)
+ json.dump(message.model_dump(mode="json"), self.stream, ensure_ascii=False)
self.stream.write("\n")
self.stream.flush()
diff --git a/vibe/core/paths/config_paths.py b/vibe/core/paths/config_paths.py
index 69f5f7c..a9690ef 100644
--- a/vibe/core/paths/config_paths.py
+++ b/vibe/core/paths/config_paths.py
@@ -47,6 +47,14 @@ def resolve_local_skills_dir(dir: Path) -> Path | None:
return None
+def resolve_local_agents_dir(dir: Path) -> Path | None:
+ if not trusted_folders_manager.is_trusted(dir):
+ return None
+ if (candidate := dir / ".vibe" / "agents").is_dir():
+ return candidate
+ return None
+
+
def unlock_config_paths() -> None:
global _config_paths_locked
_config_paths_locked = False
@@ -54,7 +62,5 @@ def unlock_config_paths() -> None:
CONFIG_FILE = ConfigPath(lambda: _resolve_config_path("config.toml", "file"))
CONFIG_DIR = ConfigPath(lambda: CONFIG_FILE.path.parent)
-AGENT_DIR = ConfigPath(lambda: _resolve_config_path("agents", "dir"))
PROMPT_DIR = ConfigPath(lambda: _resolve_config_path("prompts", "dir"))
-INSTRUCTIONS_FILE = ConfigPath(lambda: _resolve_config_path("instructions.md", "file"))
HISTORY_FILE = ConfigPath(lambda: _resolve_config_path("vibehistory", "file"))
diff --git a/vibe/core/paths/global_paths.py b/vibe/core/paths/global_paths.py
index 05c56e5..7fa54da 100644
--- a/vibe/core/paths/global_paths.py
+++ b/vibe/core/paths/global_paths.py
@@ -30,6 +30,7 @@ GLOBAL_CONFIG_FILE = GlobalPath(lambda: VIBE_HOME.path / "config.toml")
GLOBAL_ENV_FILE = GlobalPath(lambda: VIBE_HOME.path / ".env")
GLOBAL_TOOLS_DIR = GlobalPath(lambda: VIBE_HOME.path / "tools")
GLOBAL_SKILLS_DIR = GlobalPath(lambda: VIBE_HOME.path / "skills")
+GLOBAL_AGENTS_DIR = GlobalPath(lambda: VIBE_HOME.path / "agents")
SESSION_LOG_DIR = GlobalPath(lambda: VIBE_HOME.path / "logs" / "session")
TRUSTED_FOLDERS_FILE = GlobalPath(lambda: VIBE_HOME.path / "trusted_folders.toml")
LOG_DIR = GlobalPath(lambda: VIBE_HOME.path / "logs")
diff --git a/vibe/core/programmatic.py b/vibe/core/programmatic.py
index 69351b3..f23552e 100644
--- a/vibe/core/programmatic.py
+++ b/vibe/core/programmatic.py
@@ -2,9 +2,9 @@ from __future__ import annotations
import asyncio
-from vibe.core.agent import Agent
+from vibe.core.agent_loop import AgentLoop
+from vibe.core.agents.models import BuiltinAgentName
from vibe.core.config import VibeConfig
-from vibe.core.modes import AgentMode
from vibe.core.output_formatters import create_formatter
from vibe.core.types import AssistantEvent, LLMMessage, OutputFormat, Role
from vibe.core.utils import ConversationLimitException, logger
@@ -17,27 +17,13 @@ def run_programmatic(
max_price: float | None = None,
output_format: OutputFormat = OutputFormat.TEXT,
previous_messages: list[LLMMessage] | None = None,
- mode: AgentMode = AgentMode.AUTO_APPROVE,
+ agent_name: str = BuiltinAgentName.AUTO_APPROVE,
) -> str | None:
- """Run in programmatic mode: execute prompt and return the assistant response.
-
- Args:
- config: Configuration for the Vibe agent
- prompt: The user prompt to process
- max_turns: Maximum number of assistant turns (LLM calls) to allow
- max_price: Maximum cost in dollars before stopping
- output_format: Format for the output
- previous_messages: Optional messages from a previous session to continue
- mode: Operational mode (defaults to AUTO_APPROVE for programmatic use)
-
- Returns:
- The final assistant response text, or None if no response
- """
formatter = create_formatter(output_format)
- agent = Agent(
+ agent_loop = AgentLoop(
config,
- mode=mode,
+ agent_name=agent_name,
message_observer=formatter.on_message_added,
max_turns=max_turns,
max_price=max_price,
@@ -50,12 +36,12 @@ def run_programmatic(
non_system_messages = [
msg for msg in previous_messages if not (msg.role == Role.system)
]
- agent.messages.extend(non_system_messages)
+ agent_loop.messages.extend(non_system_messages)
logger.info(
"Loaded %d messages from previous session", len(non_system_messages)
)
- async for event in agent.act(prompt):
+ async for event in agent_loop.act(prompt):
formatter.on_event(event)
if isinstance(event, AssistantEvent) and event.stopped_by_middleware:
raise ConversationLimitException(event.content)
diff --git a/vibe/core/prompts/cli.md b/vibe/core/prompts/cli.md
index 7fce433..38f5778 100644
--- a/vibe/core/prompts/cli.md
+++ b/vibe/core/prompts/cli.md
@@ -1,13 +1,46 @@
You are operating as and within Mistral Vibe, a CLI coding-agent built by Mistral AI and powered by default by the Devstral family of models. It wraps Mistral's Devstral models to enable natural language interaction with a local codebase. Use the available tools when helpful.
-You can:
+Act as an agentic assistant. For long tasks, break them down and execute step by step.
-- Receive user prompts, project context, and files.
-- Send responses and emit function calls (e.g., shell commands, code edits).
-- Apply patches, run commands, based on user approvals.
+## Tool Usage
-Answer the user's request using the relevant tool(s), if they are available. Check that all the required parameters for each tool call are provided or can reasonably be inferred from context. IF there are no relevant tools or there are missing values for required parameters, ask the user to supply these values; otherwise proceed with the tool calls. If the user provides a specific value for a parameter (for example provided in quotes), make sure to use that value EXACTLY. DO NOT make up values for or ask about optional parameters. Carefully analyze descriptive terms in the request as they may indicate required parameter values that should be included even if not explicitly quoted.
+- Always use tools to fulfill user requests when possible.
+- Check that all required parameters are provided or can be inferred from context. If values are missing, ask the user.
+- When the user provides a specific value (e.g., in quotes), use it EXACTLY as given.
+- Do not invent values for optional parameters.
+- Analyze descriptive terms in requests as they may indicate required parameter values.
+- If tools cannot accomplish the task, explain why and request more information.
-Always try your hardest to use the tools to answer the user's request. If you can't use the tools, explain why and ask the user for more information.
+## Code Modifications
-Act as an agentic assistant, if a user asks for a long task, break it down and do it step by step.
+- Always read a file before proposing changes. Never suggest edits to code you haven't seen.
+- Keep changes minimal and focused. Only modify what was requested.
+- Avoid over-engineering: no extra features, unnecessary abstractions, or speculative error handling.
+- NEVER add backward-compatibility hacks. No `_unused` variable renames, no re-exporting dead code, no `// removed` comments, no shims or wrappers to preserve old interfaces. If code is unused, delete it completely. If an interface changes, update all call sites. Clean rewrites are always preferred over compatibility layers.
+- Be mindful of common security pitfalls (injection, XSS, SQLI, etc.). Fix insecure code immediately if you spot it.
+- Match the existing style of the file. Avoid adding comments, defensive checks, try/catch blocks, or type casts that are inconsistent with surrounding code. Write like a human contributor to that codebase would.
+
+## Code References
+
+When mentioning specific code locations, use the format `file_path:line_number` so users can navigate directly.
+
+## Planning
+
+When outlining steps or plans, focus on concrete actions. Do not include time estimates.
+
+## Tone and Style
+
+- Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked.
+- Your output will be displayed on a command line interface. Your responses should be short and concise. You can use Github-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
+- Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session.
+- NEVER create files unless they're absolutely necessary for achieving your goal. ALWAYS prefer editing an existing file to creating a new one. This includes markdown files.
+- Never create markdown files, READMEs, or changelogs unless the user explicitly requests documentation.
+
+## Professional Objectivity
+
+- Prioritize technical accuracy and truthfulness over validating the user's beliefs.
+- Focus on facts and problem-solving, providing direct, objective technical info without any unnecessary superlatives, praise, or emotional validation.
+- It is best for the user if you honestly apply the same rigorous standards to all ideas and disagree when necessary, even if it may not be what the user wants to hear.
+- Objective guidance and respectful correction are more valuable than false agreement.
+- Whenever there is uncertainty, investigate to find the truth first rather than instinctively confirming the user's beliefs.
+- Avoid using over-the-top validation or excessive praise when responding to users such as "You're absolutely right" or similar phrases.
diff --git a/vibe/core/session/session_loader.py b/vibe/core/session/session_loader.py
new file mode 100644
index 0000000..8d2d8d6
--- /dev/null
+++ b/vibe/core/session/session_loader.py
@@ -0,0 +1,130 @@
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import TYPE_CHECKING, Any
+
+from vibe.core.session.session_logger import MESSAGES_FILENAME, METADATA_FILENAME
+from vibe.core.types import LLMMessage
+
+if TYPE_CHECKING:
+ from vibe.core.config import SessionLoggingConfig
+
+
+class SessionLoader:
+ @staticmethod
+ def _is_valid_session(session_dir: Path) -> bool:
+ """Check if a session directory contains valid metadata and messages."""
+ metadata_path = session_dir / METADATA_FILENAME
+ messages_path = session_dir / MESSAGES_FILENAME
+
+ if not metadata_path.is_file() or not messages_path.is_file():
+ return False
+
+ try:
+ with open(metadata_path) as f:
+ metadata = json.load(f)
+ if not isinstance(metadata, dict):
+ return False
+
+ with open(messages_path) as f:
+ lines = f.readlines()
+ if not lines:
+ return False
+ messages = [json.loads(line) for line in lines]
+ if not isinstance(messages, list) or not all(
+ isinstance(msg, dict) for msg in messages
+ ):
+ return False
+ except json.JSONDecodeError:
+ return False
+
+ return True
+
+ @staticmethod
+ def latest_session(session_dirs: list[Path]) -> Path | None:
+ latest_dir = None
+ latest_mtime = 0
+ for session in session_dirs:
+ if not SessionLoader._is_valid_session(session):
+ continue
+
+ messages_path = session / MESSAGES_FILENAME
+ mtime = messages_path.stat().st_mtime
+ if mtime > latest_mtime:
+ latest_mtime = mtime
+ latest_dir = session
+
+ return latest_dir
+
+ @staticmethod
+ def find_latest_session(config: SessionLoggingConfig) -> Path | None:
+ save_dir = Path(config.save_dir)
+ if not save_dir.exists():
+ return None
+
+ pattern = f"{config.session_prefix}_*"
+ session_dirs = list(save_dir.glob(pattern))
+
+ return SessionLoader.latest_session(session_dirs)
+
+ @staticmethod
+ def find_session_by_id(
+ session_id: str, config: SessionLoggingConfig
+ ) -> Path | None:
+ save_dir = Path(config.save_dir)
+ if not save_dir.exists():
+ return None
+
+ short_id = session_id[:8]
+ matches = list(save_dir.glob(f"{config.session_prefix}_*_{short_id}"))
+
+ return SessionLoader.latest_session(matches)
+
+ @staticmethod
+ def load_session(filepath: Path) -> tuple[list[LLMMessage], dict[str, Any]]:
+ # Load session messages from MESSAGES_FILENAME
+ messages_filepath = filepath / MESSAGES_FILENAME
+
+ try:
+ with messages_filepath.open("r", encoding="utf-8") as f:
+ content = f.readlines()
+ except Exception as e:
+ raise ValueError(
+ f"Error reading session messages at {filepath}: {e}"
+ ) from e
+
+ if not len(content):
+ raise ValueError(
+ f"Session messages file is empty (may have been corrupted by interruption): "
+ f"{filepath}"
+ )
+
+ try:
+ data = [json.loads(line) for line in content]
+ except json.JSONDecodeError as e:
+ raise ValueError(
+ f"Session messages contain invalid JSON (may have been corrupted): "
+ f"{filepath}\nDetails: {e}"
+ ) from e
+
+ messages = [
+ LLMMessage.model_validate(msg) for msg in data if msg["role"] != "system"
+ ]
+
+ # Load session metadata from METADATA_FILENAME
+ metadata_filepath = filepath / METADATA_FILENAME
+
+ if metadata_filepath.exists():
+ try:
+ with metadata_filepath.open("r", encoding="utf-8") as f:
+ metadata = json.load(f)
+ except json.JSONDecodeError as e:
+ raise ValueError(
+ f"Session metadata contains invalid JSON (may have been corrupted): "
+ f"{filepath}\nDetails: {e}"
+ ) from e
+ else:
+ metadata = {}
+
+ return messages, metadata
diff --git a/vibe/core/session/session_logger.py b/vibe/core/session/session_logger.py
new file mode 100644
index 0000000..1acb357
--- /dev/null
+++ b/vibe/core/session/session_logger.py
@@ -0,0 +1,314 @@
+from __future__ import annotations
+
+from datetime import datetime, timedelta
+import getpass
+import json
+import os
+from pathlib import Path
+import subprocess
+from typing import TYPE_CHECKING, Any
+
+from anyio import NamedTemporaryFile, Path as AsyncPath
+
+from vibe.core.types import AgentStats, LLMMessage, Role, SessionMetadata
+from vibe.core.utils import is_windows
+
+if TYPE_CHECKING:
+ from vibe.core.agents.models import AgentProfile
+ from vibe.core.config import SessionLoggingConfig, VibeConfig
+ from vibe.core.tools.manager import ToolManager
+
+
+METADATA_FILENAME = "meta.json"
+MESSAGES_FILENAME = "messages.jsonl"
+
+
+class SessionLogger:
+ def __init__(self, session_config: SessionLoggingConfig, session_id: str) -> None:
+ self.session_config = session_config
+ self.enabled = session_config.enabled
+
+ if not self.enabled:
+ self.save_dir: Path | None = None
+ self.session_prefix: str | None = None
+ self.session_id: str = "disabled"
+ self.session_start_time: str = "N/A"
+ self.session_dir: Path | None = None
+ self.session_metadata: SessionMetadata | None = None
+ return
+
+ self.save_dir = Path(session_config.save_dir)
+ self.session_prefix = session_config.session_prefix
+ self.session_id = session_id
+ self.session_start_time = datetime.now().isoformat()
+
+ self.save_dir.mkdir(parents=True, exist_ok=True)
+ self.session_dir = self.save_folder
+ self.session_metadata = self._initialize_session_metadata()
+
+ @property
+ def save_folder(self) -> Path:
+ if self.save_dir is None or self.session_prefix is None:
+ raise RuntimeError(
+ "Cannot get session save folder when logging is disabled"
+ )
+
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ folder_name = f"{self.session_prefix}_{timestamp}_{self.session_id[:8]}"
+ return self.save_dir / folder_name
+
+ @property
+ def metadata_filepath(self) -> Path:
+ if self.session_dir is None:
+ raise RuntimeError(
+ "Cannot get session metadata filepath when logging is disabled"
+ )
+ return self.session_dir / METADATA_FILENAME
+
+ @property
+ def messages_filepath(self) -> Path:
+ if self.session_dir is None:
+ raise RuntimeError(
+ "Cannot get session messages filepath when logging is disabled"
+ )
+ return self.session_dir / MESSAGES_FILENAME
+
+ @property
+ def git_commit(self) -> str | None:
+ try:
+ result = subprocess.run(
+ ["git", "rev-parse", "HEAD"],
+ capture_output=True,
+ stdin=subprocess.DEVNULL if is_windows() else None,
+ text=True,
+ timeout=5.0,
+ )
+ if result.returncode == 0 and result.stdout:
+ return result.stdout.strip()
+ except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
+ pass
+ return None
+
+ @property
+ def git_branch(self) -> str | None:
+ try:
+ result = subprocess.run(
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
+ capture_output=True,
+ stdin=subprocess.DEVNULL if is_windows() else None,
+ text=True,
+ timeout=5.0,
+ )
+ if result.returncode == 0 and result.stdout:
+ return result.stdout.strip()
+ except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
+ pass
+ return None
+
+ @property
+ def username(self) -> str:
+ try:
+ return getpass.getuser()
+ except Exception:
+ return "unknown"
+
+ def _initialize_session_metadata(self) -> SessionMetadata:
+ git_commit = self.git_commit
+ git_branch = self.git_branch
+ user_name = self.username
+
+ return SessionMetadata(
+ session_id=self.session_id,
+ start_time=self.session_start_time,
+ end_time=None,
+ git_commit=git_commit,
+ git_branch=git_branch,
+ username=user_name,
+ environment={"working_directory": str(Path.cwd())},
+ )
+
+ def _get_title(self, messages: list[LLMMessage]) -> str:
+ first_user_message = None
+ for message in messages:
+ if message.role == Role.user:
+ first_user_message = message
+ break
+
+ if first_user_message is None:
+ title = "Untitled session"
+ else:
+ MAX_TITLE_LENGTH = 50
+ text = str(first_user_message.content)
+ title = text[:MAX_TITLE_LENGTH]
+ if len(text) > MAX_TITLE_LENGTH:
+ title += "…"
+
+ return title
+
+ @staticmethod
+ async def persist_metadata(metadata: Any, session_dir: Path) -> None:
+ temp_metadata_filepath = None
+ metadata_filepath = session_dir / "meta.json"
+ try:
+ async with NamedTemporaryFile(
+ mode="w",
+ suffix=".json.tmp",
+ dir=str(session_dir),
+ delete=False,
+ encoding="utf-8",
+ ) as f:
+ temp_metadata_filepath = Path(str(f.name))
+ await f.write(json.dumps(metadata, indent=2, ensure_ascii=False))
+ await f.flush()
+ os.fsync(f.wrapped.fileno())
+
+ os.replace(temp_metadata_filepath, str(metadata_filepath))
+ except Exception as e:
+ raise RuntimeError(
+ f"Failed to persist session metadata to {metadata_filepath}: {e}"
+ ) from e
+ finally:
+ if (
+ temp_metadata_filepath
+ and temp_metadata_filepath.exists()
+ and temp_metadata_filepath.is_file()
+ ):
+ temp_metadata_filepath.unlink()
+
+ @staticmethod
+ async def persist_messages(messages: list[dict], session_dir: Path) -> None:
+ messages_filepath = session_dir / "messages.jsonl"
+ try:
+ if not messages_filepath.exists():
+ messages_filepath.touch()
+
+ async with await AsyncPath(messages_filepath).open("a") as f:
+ for message in messages:
+ await f.write(json.dumps(message, ensure_ascii=False) + "\n")
+ await f.flush()
+ os.fsync(f.wrapped.fileno())
+ except Exception as e:
+ raise RuntimeError(
+ f"Failed to persist session messages to {messages_filepath}: {e}"
+ ) from e
+
+ async def save_interaction(
+ self,
+ messages: list[LLMMessage],
+ stats: AgentStats,
+ base_config: VibeConfig,
+ tool_manager: ToolManager,
+ agent_profile: AgentProfile,
+ ) -> str | None:
+ if not self.enabled or self.session_dir is None:
+ return None
+
+ if self.session_metadata is None:
+ return None
+
+ # If the session directory does not exist, create it
+ try:
+ self.session_dir.mkdir(parents=True, exist_ok=True)
+ except OSError as e:
+ raise RuntimeError(
+ f"Failed to create session directory at {self.session_dir}: {type(e).__name__}: {e}"
+ ) from e
+
+ # Read old metadata and get total_messages
+ try:
+ if self.metadata_filepath.exists():
+ async with await AsyncPath(self.metadata_filepath).open() as f:
+ old_metadata = json.loads(await f.read())
+ old_total_messages = old_metadata["total_messages"]
+ else:
+ old_total_messages = 0
+ except Exception as e:
+ raise RuntimeError(
+ f"Failed to read session metadata at {self.metadata_filepath}: {e}"
+ ) from e
+
+ try:
+ # Append new messages
+ new_messages = messages[old_total_messages:]
+
+ messages_data = [
+ m.model_dump(exclude_none=True)
+ for m in new_messages
+ if m.role != Role.system
+ ]
+ await SessionLogger.persist_messages(messages_data, self.session_dir)
+
+ # If message update succeeded, write metadata
+ tools_available = [
+ {
+ "type": "function",
+ "function": {
+ "name": tool_class.get_name(),
+ "description": tool_class.description,
+ "parameters": tool_class.get_parameters(),
+ },
+ }
+ for tool_class in tool_manager.available_tools.values()
+ ]
+
+ title = self._get_title(messages)
+ if messages[0].role == Role.system:
+ system_prompt = messages[0].model_dump()
+ total_messages = len(messages[1:])
+ else:
+ system_prompt = None
+ total_messages = len(messages)
+
+ metadata_dump = {
+ **self.session_metadata.model_dump(),
+ "end_time": datetime.now().isoformat(),
+ "stats": stats.model_dump(),
+ "title": title,
+ "total_messages": total_messages,
+ "tools_available": tools_available,
+ "config": base_config.model_dump(mode="json"),
+ "agent_profile": {
+ "name": agent_profile.name,
+ "overrides": agent_profile.overrides,
+ },
+ "system_prompt": system_prompt,
+ }
+
+ await SessionLogger.persist_metadata(metadata_dump, self.session_dir)
+ except Exception as e:
+ raise RuntimeError(
+ f"Failed to save session to {self.session_dir}: {e}"
+ ) from e
+ finally:
+ self.cleanup_tmp_files()
+
+ return str(self.session_dir)
+
+ def reset_session(self, session_id: str) -> None:
+ """Clear existing session info and setup a new session"""
+ if not self.enabled:
+ return
+
+ self.session_id = session_id
+ self.session_start_time = datetime.now().isoformat()
+ self.session_dir = self.save_folder
+ self.session_metadata = self._initialize_session_metadata()
+
+ def cleanup_tmp_files(self) -> None:
+ """Delete temporary files created more than 5 minutes ago"""
+ if not self.enabled or not self.save_dir:
+ return
+
+ now = datetime.now()
+ ago = now - timedelta(minutes=5)
+
+ tmp_files = self.save_dir.glob("**/*.json.tmp") # Recursive search
+
+ for file_path in tmp_files:
+ if file_path.is_file():
+ try:
+ file_mtime = datetime.fromtimestamp(file_path.stat().st_mtime)
+ if file_mtime < ago:
+ file_path.unlink()
+ except Exception:
+ continue
diff --git a/vibe/core/session/session_migration.py b/vibe/core/session/session_migration.py
new file mode 100644
index 0000000..3b4a6bf
--- /dev/null
+++ b/vibe/core/session/session_migration.py
@@ -0,0 +1,41 @@
+from __future__ import annotations
+
+import asyncio
+import json
+from pathlib import Path
+
+from vibe.core.config import SessionLoggingConfig
+from vibe.core.session.session_logger import SessionLogger
+
+
+def migrate_sessions_entrypoint(session_config: SessionLoggingConfig) -> int:
+ return asyncio.run(migrate_sessions(session_config))
+
+
+async def migrate_sessions(session_config: SessionLoggingConfig) -> int:
+ """Helper for migrating session data from singular JSON files to the format introduced in Vibe 2.0 with per-session folders with split metadata and message files."""
+ save_dir = session_config.save_dir
+ if not save_dir or not session_config.enabled:
+ return 0
+
+ successful_migrations = 0
+ session_files = list(Path(save_dir).glob(f"{session_config.session_prefix}_*.json"))
+ for session_file in session_files:
+ try:
+ with open(session_file) as f:
+ session_data = f.read()
+ session_json = json.loads(session_data)
+ metadata = session_json["metadata"]
+ messages = session_json["messages"]
+
+ session_dir = Path(save_dir) / session_file.stem
+ session_dir.mkdir()
+
+ await SessionLogger.persist_metadata(metadata, session_dir)
+ await SessionLogger.persist_messages(messages, session_dir)
+ session_file.unlink()
+ successful_migrations += 1
+ except Exception:
+ continue
+
+ return successful_migrations
diff --git a/vibe/core/skills/manager.py b/vibe/core/skills/manager.py
index b6107a9..a72e69f 100644
--- a/vibe/core/skills/manager.py
+++ b/vibe/core/skills/manager.py
@@ -9,6 +9,7 @@ from vibe.core.paths.config_paths import resolve_local_skills_dir
from vibe.core.paths.global_paths import GLOBAL_SKILLS_DIR
from vibe.core.skills.models import SkillInfo, SkillMetadata
from vibe.core.skills.parser import SkillParseError, parse_frontmatter
+from vibe.core.utils import name_matches
if TYPE_CHECKING:
from vibe.core.config import VibeConfig
@@ -20,12 +21,12 @@ class SkillManager:
def __init__(self, config_getter: Callable[[], VibeConfig]) -> None:
self._config_getter = config_getter
self._search_paths = self._compute_search_paths(self._config)
- self.available_skills = self._discover_skills()
+ self._available: dict[str, SkillInfo] = self._discover_skills()
- if self.available_skills:
+ if self._available:
logger.info(
"Discovered %d skill(s) from %d search path(s)",
- len(self.available_skills),
+ len(self._available),
len(self._search_paths),
)
@@ -33,6 +34,22 @@ class SkillManager:
def _config(self) -> VibeConfig:
return self._config_getter()
+ @property
+ def available_skills(self) -> dict[str, SkillInfo]:
+ if self._config.enabled_skills:
+ return {
+ name: info
+ for name, info in self._available.items()
+ if name_matches(name, self._config.enabled_skills)
+ }
+ if self._config.disabled_skills:
+ return {
+ name: info
+ for name, info in self._available.items()
+ if not name_matches(name, self._config.disabled_skills)
+ }
+ return dict(self._available)
+
@staticmethod
def _compute_search_paths(config: VibeConfig) -> list[Path]:
paths: list[Path] = []
@@ -41,9 +58,7 @@ class SkillManager:
if path.is_dir():
paths.append(path)
- if (
- skills_dir := resolve_local_skills_dir(config.effective_workdir)
- ) is not None:
+ if (skills_dir := resolve_local_skills_dir(Path.cwd())) is not None:
paths.append(skills_dir)
if GLOBAL_SKILLS_DIR.path.is_dir():
diff --git a/vibe/core/skills/models.py b/vibe/core/skills/models.py
index d5372b1..aae951d 100644
--- a/vibe/core/skills/models.py
+++ b/vibe/core/skills/models.py
@@ -39,6 +39,11 @@ class SkillMetadata(BaseModel):
validation_alias="allowed-tools",
description="Space-delimited list of pre-approved tools (experimental).",
)
+ user_invocable: bool = Field(
+ default=True,
+ validation_alias="user-invocable",
+ description="Controls whether the skill appears in the slash command menu.",
+ )
@field_validator("allowed_tools", mode="before")
@classmethod
@@ -64,6 +69,7 @@ class SkillInfo(BaseModel):
compatibility: str | None = None
metadata: dict[str, str] = Field(default_factory=dict)
allowed_tools: list[str] = Field(default_factory=list)
+ user_invocable: bool = True
skill_path: Path
model_config = {"arbitrary_types_allowed": True}
@@ -81,5 +87,6 @@ class SkillInfo(BaseModel):
compatibility=meta.compatibility,
metadata=meta.metadata,
allowed_tools=meta.allowed_tools,
+ user_invocable=meta.user_invocable,
skill_path=skill_path.resolve(),
)
diff --git a/vibe/core/system_prompt.py b/vibe/core/system_prompt.py
index abea48e..985afcb 100644
--- a/vibe/core/system_prompt.py
+++ b/vibe/core/system_prompt.py
@@ -10,25 +10,17 @@ import sys
import time
from typing import TYPE_CHECKING
-from vibe.core.llm.format import get_active_tool_classes
-from vibe.core.paths.config_paths import INSTRUCTIONS_FILE
from vibe.core.prompts import UtilityPrompt
from vibe.core.trusted_folders import TRUSTABLE_FILENAMES, trusted_folders_manager
from vibe.core.utils import is_dangerous_directory, is_windows
if TYPE_CHECKING:
+ from vibe.core.agents import AgentManager
from vibe.core.config import ProjectContextConfig, VibeConfig
from vibe.core.skills.manager import SkillManager
from vibe.core.tools.manager import ToolManager
-def _load_user_instructions() -> str:
- try:
- return INSTRUCTIONS_FILE.path.read_text("utf-8", errors="ignore")
- except (FileNotFoundError, OSError):
- return ""
-
-
def _load_project_doc(workdir: Path, max_bytes: int) -> str:
if not trusted_folders_manager.is_trusted(workdir):
return ""
@@ -336,12 +328,12 @@ def _get_platform_name() -> str:
def _get_default_shell() -> str:
"""Get the default shell used by asyncio.create_subprocess_shell.
- On Unix, this is always 'sh'.
+ On Unix, uses $SHELL env var and default to sh.
On Windows, this is COMSPEC or cmd.exe.
"""
if is_windows():
return os.environ.get("COMSPEC", "cmd.exe")
- return "sh"
+ return os.environ.get("SHELL", "sh")
def _get_os_system_prompt() -> str:
@@ -379,10 +371,7 @@ def _add_commit_signature() -> str:
)
-def _get_available_skills_section(skill_manager: SkillManager | None) -> str:
- if skill_manager is None:
- return ""
-
+def _get_available_skills_section(skill_manager: SkillManager) -> str:
skills = skill_manager.available_skills
if not skills:
return ""
@@ -410,10 +399,24 @@ def _get_available_skills_section(skill_manager: SkillManager | None) -> str:
return "\n".join(lines)
+def _get_available_subagents_section(agent_manager: AgentManager) -> str:
+ agents = agent_manager.get_subagents()
+ if not agents:
+ return ""
+
+ lines = ["# Available Subagents", ""]
+ lines.append("The following subagents can be spawned via the Task tool:")
+ for agent in agents:
+ lines.append(f"- **{agent.name}**: {agent.description}")
+
+ return "\n".join(lines)
+
+
def get_universal_system_prompt(
tool_manager: ToolManager,
config: VibeConfig,
- skill_manager: SkillManager | None = None,
+ skill_manager: SkillManager,
+ agent_manager: AgentManager,
) -> str:
sections = [config.system_prompt]
@@ -426,21 +429,20 @@ def get_universal_system_prompt(
if config.include_prompt_detail:
sections.append(_get_os_system_prompt())
tool_prompts = []
- active_tools = get_active_tool_classes(tool_manager, config)
- for tool_class in active_tools:
+ for tool_class in tool_manager.available_tools.values():
if prompt := tool_class.get_tool_prompt():
tool_prompts.append(prompt)
if tool_prompts:
sections.append("\n---\n".join(tool_prompts))
- user_instructions = config.instructions.strip() or _load_user_instructions()
- if user_instructions.strip():
- sections.append(user_instructions)
-
skills_section = _get_available_skills_section(skill_manager)
if skills_section:
sections.append(skills_section)
+ subagents_section = _get_available_subagents_section(agent_manager)
+ if subagents_section:
+ sections.append(subagents_section)
+
if config.include_project_context:
is_dangerous, reason = is_dangerous_directory()
if is_dangerous:
@@ -450,13 +452,13 @@ def get_universal_system_prompt(
)
else:
context = ProjectContextProvider(
- config=config.project_context, root_path=config.effective_workdir
+ config=config.project_context, root_path=Path.cwd()
).get_full_context()
sections.append(context)
project_doc = _load_project_doc(
- config.effective_workdir, config.project_context.max_doc_bytes
+ Path.cwd(), config.project_context.max_doc_bytes
)
if project_doc.strip():
sections.append(project_doc)
diff --git a/vibe/core/tools/base.py b/vibe/core/tools/base.py
index 85bab0a..d766ae8 100644
--- a/vibe/core/tools/base.py
+++ b/vibe/core/tools/base.py
@@ -1,19 +1,47 @@
from __future__ import annotations
from abc import ABC, abstractmethod
+from collections.abc import AsyncGenerator
+from dataclasses import dataclass, field
from enum import StrEnum, auto
import functools
import inspect
from pathlib import Path
import re
import sys
-from typing import Any, ClassVar, cast, get_args, get_type_hints
+import types
+from typing import (
+ TYPE_CHECKING,
+ Any,
+ ClassVar,
+ Union,
+ cast,
+ get_args,
+ get_origin,
+ get_type_hints,
+)
-from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
+from pydantic import BaseModel, ConfigDict, Field, ValidationError
+
+from vibe.core.types import ToolStreamEvent
+
+if TYPE_CHECKING:
+ from vibe.core.agents.manager import AgentManager
+ from vibe.core.types import ApprovalCallback, UserInputCallback
ARGS_COUNT = 4
+@dataclass
+class InvokeContext:
+ """Context passed to tools during invocation."""
+
+ tool_call_id: str
+ approval_callback: ApprovalCallback | None = field(default=None)
+ agent_manager: AgentManager | None = field(default=None)
+ user_input_callback: UserInputCallback | None = field(default=None)
+
+
class ToolError(Exception):
"""Raised when the tool encounters an unrecoverable problem."""
@@ -56,7 +84,6 @@ class BaseToolConfig(BaseModel):
Attributes:
permission: The permission level required to use the tool.
- workdir: The working directory for the tool. If None, the current working directory is used.
allowlist: Patterns that automatically allow tool execution.
denylist: Patterns that automatically deny tool execution.
"""
@@ -64,25 +91,9 @@ class BaseToolConfig(BaseModel):
model_config = ConfigDict(extra="allow")
permission: ToolPermission = ToolPermission.ASK
- workdir: Path | None = Field(default=None, exclude=True)
allowlist: list[str] = Field(default_factory=list)
denylist: list[str] = Field(default_factory=list)
- @field_validator("workdir", mode="before")
- @classmethod
- def _expand_workdir(cls, v: Any) -> Path | None:
- if v is None or (isinstance(v, str) and not v.strip()):
- return None
- if isinstance(v, str):
- return Path(v).expanduser().resolve()
- if isinstance(v, Path):
- return v.expanduser().resolve()
- return None
-
- @property
- def effective_workdir(self) -> Path:
- return self.workdir if self.workdir is not None else Path.cwd()
-
class BaseToolState(BaseModel):
model_config = ConfigDict(
@@ -109,9 +120,12 @@ class BaseTool[
self.state = state
@abstractmethod
- async def run(self, args: ToolArgs) -> ToolResult:
- """Invoke the tool with the given arguments. This method must be async."""
- ...
+ async def run(
+ self, args: ToolArgs, ctx: InvokeContext | None = None
+ ) -> AsyncGenerator[ToolStreamEvent | ToolResult, None]:
+ """Invoke the tool with the given arguments."""
+ raise NotImplementedError # pragma: no cover
+ yield # type: ignore[misc]
@classmethod
@functools.cache
@@ -134,10 +148,10 @@ class BaseTool[
return None
- async def invoke(self, **raw: Any) -> ToolResult:
- """Validate arguments and run the tool.
- Pattern checking is now handled by Agent._should_execute_tool.
- """
+ async def invoke(
+ self, ctx: InvokeContext | None = None, **raw: Any
+ ) -> AsyncGenerator[ToolStreamEvent | ToolResult, None]:
+ """Validate arguments and run the tool."""
try:
args_model, _ = self._get_tool_args_results()
args = args_model.model_validate(raw)
@@ -146,7 +160,8 @@ class BaseTool[
f"Validation error in tool {self.get_name()}: {err}"
) from err
- return await self.run(args)
+ async for item in self.run(args, ctx):
+ yield item
@classmethod
def from_config(
@@ -212,28 +227,66 @@ class BaseTool[
type_hints = get_type_hints(
run_fn,
globalns=vars(sys.modules[cls.__module__]),
- localns={cls.__name__: cls},
+ localns={
+ cls.__name__: cls,
+ "InvokeContext": InvokeContext,
+ "AsyncGenerator": AsyncGenerator,
+ "ToolStreamEvent": ToolStreamEvent,
+ },
)
try:
args_model = type_hints["args"]
- result_model = type_hints["return"]
+ return_annotation = type_hints["return"]
except KeyError as e:
raise TypeError(
- f"{cls.__name__}.run must be annotated as "
- "`async def run(self, args: ToolArgs) -> ToolResult`"
+ f"{cls.__name__}.run must be annotated with args and return type"
) from e
- if not (
- issubclass(args_model, BaseModel) and issubclass(result_model, BaseModel)
- ):
+ result_model = cls._extract_result_type(return_annotation)
+
+ if not issubclass(args_model, BaseModel):
raise TypeError(
- f"{cls.__name__}.run annotations must be Pydantic models; "
- f"got {args_model!r}, {result_model!r}"
+ f"{cls.__name__}.run args annotation must be a Pydantic model; "
+ f"got {args_model!r}"
+ )
+
+ if not issubclass(result_model, BaseModel):
+ raise TypeError(
+ f"{cls.__name__}.run must yield a Pydantic model as result; "
+ f"got {result_model!r}"
)
return cast(type[ToolArgs], args_model), cast(type[ToolResult], result_model)
+ @classmethod
+ def _extract_result_type(cls, return_annotation: Any) -> type:
+ """Extract the ToolResult type from AsyncGenerator[ToolStreamEvent | ToolResult, None]."""
+ origin = get_origin(return_annotation)
+ if origin is not AsyncGenerator:
+ if isinstance(return_annotation, type):
+ return return_annotation
+ raise TypeError(f"Could not extract result type from {return_annotation!r}")
+
+ gen_args = get_args(return_annotation)
+ if not gen_args:
+ raise TypeError(f"Could not extract result type from {return_annotation!r}")
+
+ yield_type = gen_args[0]
+ yield_origin = get_origin(yield_type)
+
+ # Handle Union types (X | Y or Union[X, Y])
+ if yield_origin is Union or isinstance(yield_type, types.UnionType):
+ for arg in get_args(yield_type):
+ if arg is not ToolStreamEvent and isinstance(arg, type):
+ return arg
+
+ # Handle single type
+ if isinstance(yield_type, type):
+ return yield_type
+
+ raise TypeError(f"Could not extract result type from {return_annotation!r}")
+
@classmethod
def get_parameters(cls) -> dict[str, Any]:
"""Return a cleaned-up JSON-schema dict describing the arguments model
diff --git a/vibe/core/tools/builtins/ask_user_question.py b/vibe/core/tools/builtins/ask_user_question.py
new file mode 100644
index 0000000..3e2f37d
--- /dev/null
+++ b/vibe/core/tools/builtins/ask_user_question.py
@@ -0,0 +1,131 @@
+from __future__ import annotations
+
+from collections.abc import AsyncGenerator
+from typing import ClassVar, cast
+
+from pydantic import BaseModel, Field
+
+from vibe.core.tools.base import (
+ BaseTool,
+ BaseToolConfig,
+ BaseToolState,
+ InvokeContext,
+ ToolError,
+ ToolPermission,
+)
+from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
+from vibe.core.types import ToolCallEvent, ToolResultEvent
+
+
+class Choice(BaseModel):
+ label: str = Field(description="Short label for the choice (1-5 words)")
+ description: str = Field(
+ default="", description="Optional explanation of this choice"
+ )
+
+
+class Question(BaseModel):
+ question: str = Field(description="The question text")
+ header: str = Field(
+ default="",
+ description="Short header for the question (1-2 words, e.g. 'Auth', 'Database')",
+ max_length=12,
+ )
+ options: list[Choice] = Field(
+ description="Available options (2-4, not including 'Other'). An 'Other' option for free text is automatically added.",
+ min_length=2,
+ max_length=4,
+ )
+ multi_select: bool = Field(
+ default=False, description="If true, user can select multiple options"
+ )
+
+
+class AskUserQuestionArgs(BaseModel):
+ questions: list[Question] = Field(
+ description="Questions to ask (1-4). Displayed as tabs if multiple.",
+ min_length=1,
+ max_length=4,
+ )
+
+
+class Answer(BaseModel):
+ question: str = Field(description="The original question")
+ answer: str = Field(description="The user's answer")
+ is_other: bool = Field(
+ default=False, description="True if user typed a custom answer via 'Other'"
+ )
+
+
+class AskUserQuestionResult(BaseModel):
+ answers: list[Answer] = Field(description="List of answers")
+ cancelled: bool = Field(
+ default=False, description="True if user cancelled without answering"
+ )
+
+
+class AskUserQuestionConfig(BaseToolConfig):
+ permission: ToolPermission = ToolPermission.ALWAYS
+
+
+class AskUserQuestion(
+ BaseTool[
+ AskUserQuestionArgs, AskUserQuestionResult, AskUserQuestionConfig, BaseToolState
+ ],
+ ToolUIData[AskUserQuestionArgs, AskUserQuestionResult],
+):
+ description: ClassVar[str] = (
+ "Ask the user one or more questions and wait for their responses. "
+ "Each question has 2-4 choices plus an automatic 'Other' option for free text. "
+ "Use this to gather preferences, clarify requirements, or get decisions."
+ )
+
+ @classmethod
+ def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
+ if not isinstance(event.args, AskUserQuestionArgs):
+ return ToolCallDisplay(summary="Asking user")
+
+ args = event.args
+ count = len(args.questions)
+
+ if count == 1:
+ return ToolCallDisplay(summary=f"Asking: {args.questions[0].question}")
+
+ return ToolCallDisplay(summary=f"Asking {count} questions")
+
+ @classmethod
+ def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
+ if event.error:
+ return ToolResultDisplay(success=False, message=event.error)
+
+ if not isinstance(event.result, AskUserQuestionResult):
+ return ToolResultDisplay(success=True, message="Questions answered")
+
+ result = event.result
+
+ if result.cancelled:
+ return ToolResultDisplay(success=False, message="User cancelled")
+
+ if len(result.answers) == 1:
+ answer = result.answers[0]
+ prefix = "(Other) " if answer.is_other else ""
+ return ToolResultDisplay(success=True, message=f"{prefix}{answer.answer}")
+
+ return ToolResultDisplay(
+ success=True, message=f"{len(result.answers)} answers received"
+ )
+
+ @classmethod
+ def get_status_text(cls) -> str:
+ return "Waiting for user input"
+
+ async def run(
+ self, args: AskUserQuestionArgs, ctx: InvokeContext | None = None
+ ) -> AsyncGenerator[AskUserQuestionResult, None]:
+ if ctx is None or ctx.user_input_callback is None:
+ raise ToolError(
+ "User input not available. This tool requires an interactive UI."
+ )
+
+ result = await ctx.user_input_callback(args)
+ yield cast(AskUserQuestionResult, result)
diff --git a/vibe/core/tools/builtins/bash.py b/vibe/core/tools/builtins/bash.py
index 2aaf54a..7e0732c 100644
--- a/vibe/core/tools/builtins/bash.py
+++ b/vibe/core/tools/builtins/bash.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
+from collections.abc import AsyncGenerator
from functools import lru_cache
import os
import signal
@@ -15,9 +16,12 @@ from vibe.core.tools.base import (
BaseTool,
BaseToolConfig,
BaseToolState,
+ InvokeContext,
ToolError,
ToolPermission,
)
+from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
+from vibe.core.types import ToolCallEvent, ToolResultEvent, ToolStreamEvent
from vibe.core.utils import is_windows
@@ -61,6 +65,12 @@ def _get_subprocess_encoding() -> str:
return "utf-8"
+def _get_shell_executable() -> str | None:
+ if is_windows():
+ return None
+ return os.environ.get("SHELL")
+
+
def _get_base_env() -> dict[str, str]:
base_env = {
**os.environ,
@@ -167,7 +177,7 @@ class BashToolConfig(BaseToolConfig):
default=16_000, description="Maximum bytes to capture from stdout and stderr."
)
default_timeout: int = Field(
- default=30, description="Default timeout for commands in seconds."
+ default=300, description="Default timeout for commands in seconds."
)
allowlist: list[str] = Field(
default_factory=_get_default_allowlist,
@@ -191,14 +201,38 @@ class BashArgs(BaseModel):
class BashResult(BaseModel):
+ command: str
stdout: str
stderr: str
returncode: int
-class Bash(BaseTool[BashArgs, BashResult, BashToolConfig, BaseToolState]):
+class Bash(
+ BaseTool[BashArgs, BashResult, BashToolConfig, BaseToolState],
+ ToolUIData[BashArgs, BashResult],
+):
description: ClassVar[str] = "Run a one-off bash command and capture its output."
+ @classmethod
+ def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
+ if not isinstance(event.args, BashArgs):
+ return ToolCallDisplay(summary="bash")
+
+ return ToolCallDisplay(summary=f"bash: {event.args.command}")
+
+ @classmethod
+ def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
+ if not isinstance(event.result, BashResult):
+ return ToolResultDisplay(
+ success=False, message=event.error or event.skip_reason or "No result"
+ )
+
+ return ToolResultDisplay(success=True, message=f"Ran {event.result.command}")
+
+ @classmethod
+ def get_status_text(cls) -> str:
+ return "Running command"
+
def check_allowlist_denylist(self, args: BashArgs) -> ToolPermission | None:
if is_windows():
return None
@@ -258,9 +292,13 @@ class Bash(BaseTool[BashArgs, BashResult, BashToolConfig, BaseToolState]):
error_msg += f"\nStdout: {stdout}"
raise ToolError(error_msg.strip())
- return BashResult(stdout=stdout, stderr=stderr, returncode=returncode)
+ return BashResult(
+ command=command, stdout=stdout, stderr=stderr, returncode=returncode
+ )
- async def run(self, args: BashArgs) -> BashResult:
+ async def run(
+ self, args: BashArgs, ctx: InvokeContext | None = None
+ ) -> AsyncGenerator[ToolStreamEvent | BashResult, None]:
timeout = args.timeout or self.config.default_timeout
max_bytes = self.config.max_output_bytes
@@ -276,8 +314,8 @@ class Bash(BaseTool[BashArgs, BashResult, BashToolConfig, BaseToolState]):
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
stdin=asyncio.subprocess.DEVNULL,
- cwd=self.config.effective_workdir,
env=_get_base_env(),
+ executable=_get_shell_executable(),
**kwargs,
)
@@ -303,7 +341,7 @@ class Bash(BaseTool[BashArgs, BashResult, BashToolConfig, BaseToolState]):
returncode = proc.returncode or 0
- return self._build_result(
+ yield self._build_result(
command=args.command,
stdout=stdout,
stderr=stderr,
diff --git a/vibe/core/tools/builtins/grep.py b/vibe/core/tools/builtins/grep.py
index c99f478..b2beb39 100644
--- a/vibe/core/tools/builtins/grep.py
+++ b/vibe/core/tools/builtins/grep.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
+from collections.abc import AsyncGenerator
from enum import StrEnum, auto
from pathlib import Path
import shutil
@@ -12,10 +13,12 @@ from vibe.core.tools.base import (
BaseTool,
BaseToolConfig,
BaseToolState,
+ InvokeContext,
ToolError,
ToolPermission,
)
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
+from vibe.core.types import ToolStreamEvent
if TYPE_CHECKING:
from vibe.core.types import ToolCallEvent, ToolResultEvent
@@ -114,7 +117,9 @@ class Grep(
"Please install ripgrep: https://github.com/BurntSushi/ripgrep#installation"
)
- async def run(self, args: GrepArgs) -> GrepResult:
+ async def run(
+ self, args: GrepArgs, ctx: InvokeContext | None = None
+ ) -> AsyncGenerator[ToolStreamEvent | GrepResult, None]:
backend = self._detect_backend()
self._validate_args(args)
self.state.search_history.append(args.pattern)
@@ -123,7 +128,7 @@ class Grep(
cmd = self._build_command(args, exclude_patterns, backend)
stdout = await self._execute_search(cmd)
- return self._parse_output(
+ yield self._parse_output(
stdout, args.max_matches or self.config.default_max_matches
)
@@ -133,7 +138,7 @@ class Grep(
path_obj = Path(args.path).expanduser()
if not path_obj.is_absolute():
- path_obj = self.config.effective_workdir / path_obj
+ path_obj = Path.cwd() / path_obj
if not path_obj.exists():
raise ToolError(f"Path does not exist: {args.path}")
@@ -141,7 +146,7 @@ class Grep(
def _collect_exclude_patterns(self) -> list[str]:
patterns = list(self.config.exclude_patterns)
- codeignore_path = self.config.effective_workdir / self.config.codeignore_file
+ codeignore_path = Path.cwd() / self.config.codeignore_file
if codeignore_path.is_file():
patterns.extend(self._load_codeignore_patterns(codeignore_path))
@@ -217,10 +222,7 @@ class Grep(
async def _execute_search(self, cmd: list[str]) -> str:
try:
proc = await asyncio.create_subprocess_exec(
- *cmd,
- stdout=asyncio.subprocess.PIPE,
- stderr=asyncio.subprocess.PIPE,
- cwd=str(self.config.effective_workdir),
+ *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
try:
@@ -276,7 +278,7 @@ class Grep(
if not isinstance(event.args, GrepArgs):
return ToolCallDisplay(summary="grep")
- summary = f"grep: '{event.args.pattern}'"
+ summary = f"Grepping '{event.args.pattern}'"
if event.args.path != ".":
summary += f" in {event.args.path}"
if event.args.max_matches:
diff --git a/vibe/core/tools/builtins/prompts/ask_user_question.md b/vibe/core/tools/builtins/prompts/ask_user_question.md
new file mode 100644
index 0000000..dc7636f
--- /dev/null
+++ b/vibe/core/tools/builtins/prompts/ask_user_question.md
@@ -0,0 +1,84 @@
+Use `ask_user_question` to gather information from the user when you need clarification, want to validate assumptions, or need help making a decision. **Don't hesitate to use this tool** - it's better to ask than to guess wrong.
+
+## When to Use
+
+- **Clarifying requirements**: Ambiguous instructions, unclear scope
+- **Technical decisions**: Architecture choices, library selection, tradeoffs
+- **Preference gathering**: UI style, naming conventions, approach options
+- **Validation**: Confirming understanding before starting significant work
+- **Multiple valid paths**: When several approaches could work and you want user input
+
+## Question Structure
+
+Each question has these fields:
+
+- `question`: The full question text (be specific and clear)
+- `header`: A short label displayed as a chip (max 12 characters, e.g., "Auth", "Database", "Approach")
+- `options`: 2-4 choices (an "Other" option is automatically added for free text)
+- `multi_select`: Set to `true` if user can pick multiple options (default: `false`)
+
+### Options Structure
+
+Each option has:
+- `label`: Short display text (1-5 words)
+- `description`: Brief explanation of what this choice means or its implications
+
+## Examples
+
+**Single question with recommended option:**
+```json
+{
+ "questions": [{
+ "question": "Which authentication method should we use?",
+ "header": "Auth",
+ "options": [
+ {"label": "JWT tokens (Recommended)", "description": "Stateless, scalable, works well with APIs"},
+ {"label": "Session cookies", "description": "Traditional approach, requires session storage"},
+ {"label": "OAuth 2.0", "description": "Third-party auth, more complex setup"}
+ ],
+ "multi_select": false
+ }]
+}
+```
+
+**Multiple questions (displayed as tabs):**
+```json
+{
+ "questions": [
+ {
+ "question": "Which database should we use?",
+ "header": "Database",
+ "options": [
+ {"label": "PostgreSQL", "description": "Relational, ACID compliant"},
+ {"label": "MongoDB", "description": "Document store, flexible schema"}
+ ],
+ "multi_select": false
+ },
+ {
+ "question": "Which features should be included in v1?",
+ "header": "Features",
+ "options": [
+ {"label": "User auth", "description": "Login, signup, password reset"},
+ {"label": "Search", "description": "Full-text search across content"},
+ {"label": "Export", "description": "CSV and PDF export"}
+ ],
+ "multi_select": true
+ }
+ ]
+}
+```
+
+## Key Constraints
+
+- **Header max length**: 12 characters (keeps UI clean)
+- **Options count**: 2-4 per question (plus automatic "Other")
+- **Questions count**: 1-4 per call
+- **Label length**: Keep to 1-5 words for readability
+
+## Tips
+
+1. **Put recommended option first** and add "(Recommended)" to its label
+2. **Use descriptive headers** that categorize the question type
+3. **Keep descriptions concise** but informative about tradeoffs
+4. **Use multi_select** when choices aren't mutually exclusive (e.g., features to include)
+5. **Ask early** - it's better to clarify before starting than to redo work
diff --git a/vibe/core/tools/builtins/prompts/bash.md b/vibe/core/tools/builtins/prompts/bash.md
index 665e58d..df396aa 100644
--- a/vibe/core/tools/builtins/prompts/bash.md
+++ b/vibe/core/tools/builtins/prompts/bash.md
@@ -3,6 +3,11 @@ Use the `bash` tool to run one-off shell commands.
**Key characteristics:**
- **Stateless**: Each command runs independently in a fresh environment
+**Timeout:**
+- The `timeout` argument controls how long the command can run before being killed
+- When `timeout` is not specified (or set to `None`), the config default is used
+- If a command is timing out, do not hesitate to increase the timeout using the `timeout` argument
+
**IMPORTANT: Use dedicated tools if available instead of these bash commands:**
**File Operations - DO NOT USE:**
diff --git a/vibe/core/tools/builtins/prompts/task.md b/vibe/core/tools/builtins/prompts/task.md
new file mode 100644
index 0000000..8e36453
--- /dev/null
+++ b/vibe/core/tools/builtins/prompts/task.md
@@ -0,0 +1,24 @@
+Use `task` to delegate work to a subagent for independent execution.
+
+## When to Use This Tool
+
+- **Context management**: Delegate tasks that would consume too much main conversation context
+- **Specialized work**: Use the appropriate subagent for the type of task (exploration, research, etc.)
+- **Parallel execution**: Launch multiple subagents for independent tasks
+- **Autonomous work**: Tasks that don't require back-and-forth with the user
+
+## Best Practices
+
+1. **Write clear, detailed task descriptions** - The subagent works autonomously, so provide enough context for it to succeed independently
+
+2. **Choose the right subagent** - Match the subagent to the task type (see available subagents in system prompt)
+
+3. **Prefer direct tools for simple operations** - If you know exactly which file to read or pattern to search, use those tools directly instead of spawning a subagent
+
+4. **Trust the subagent's judgment** - Let it explore and find information without micromanaging the approach
+
+## Limitations
+
+- Subagents cannot write or modify files
+- Subagents cannot ask the user questions
+- Results are returned as text when the subagent completes
diff --git a/vibe/core/tools/builtins/read_file.py b/vibe/core/tools/builtins/read_file.py
index 078bbd7..f5c3c57 100644
--- a/vibe/core/tools/builtins/read_file.py
+++ b/vibe/core/tools/builtins/read_file.py
@@ -1,19 +1,22 @@
from __future__ import annotations
+from collections.abc import AsyncGenerator
from pathlib import Path
from typing import TYPE_CHECKING, ClassVar, NamedTuple, final
-import aiofiles
+import anyio
from pydantic import BaseModel, Field
from vibe.core.tools.base import (
BaseTool,
BaseToolConfig,
BaseToolState,
+ InvokeContext,
ToolError,
ToolPermission,
)
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
+from vibe.core.types import ToolStreamEvent
if TYPE_CHECKING:
from vibe.core.types import ToolCallEvent, ToolResultEvent
@@ -70,14 +73,16 @@ class ReadFile(
)
@final
- async def run(self, args: ReadFileArgs) -> ReadFileResult:
+ async def run(
+ self, args: ReadFileArgs, ctx: InvokeContext | None = None
+ ) -> AsyncGenerator[ToolStreamEvent | ReadFileResult, None]:
file_path = self._prepare_and_validate_path(args)
read_result = await self._read_file(args, file_path)
self._update_state_history(file_path)
- return ReadFileResult(
+ yield ReadFileResult(
path=str(file_path),
content="".join(read_result.lines),
lines_read=len(read_result.lines),
@@ -89,7 +94,7 @@ class ReadFile(
file_path = Path(args.path).expanduser()
if not file_path.is_absolute():
- file_path = self.config.effective_workdir / file_path
+ file_path = Path.cwd() / file_path
file_str = str(file_path)
for pattern in self.config.denylist:
@@ -107,7 +112,7 @@ class ReadFile(
file_path = Path(args.path).expanduser()
if not file_path.is_absolute():
- file_path = self.config.effective_workdir / file_path
+ file_path = Path.cwd() / file_path
self._validate_path(file_path)
return file_path
@@ -118,7 +123,9 @@ class ReadFile(
bytes_read = 0
was_truncated = False
- async with aiofiles.open(file_path, encoding="utf-8", errors="ignore") as f:
+ async with await anyio.Path(file_path).open(
+ encoding="utf-8", errors="ignore"
+ ) as f:
line_index = 0
async for line in f:
if line_index < args.offset:
@@ -159,7 +166,7 @@ class ReadFile(
resolved_path = file_path.resolve()
except ValueError:
raise ToolError(
- f"Security error: Cannot read path '{file_path}' outside of the project directory '{self.config.effective_workdir}'."
+ f"Security error: Cannot read path '{file_path}' outside of the project directory '{Path.cwd()}'."
)
except FileNotFoundError:
raise ToolError(f"File not found at: {file_path}")
@@ -179,7 +186,7 @@ class ReadFile(
if not isinstance(event.args, ReadFileArgs):
return ToolCallDisplay(summary="read_file")
- summary = f"read_file: {event.args.path}"
+ summary = f"Reading {event.args.path}"
if event.args.offset > 0 or event.args.limit is not None:
parts = []
if event.args.offset > 0:
diff --git a/vibe/core/tools/builtins/search_replace.py b/vibe/core/tools/builtins/search_replace.py
index 423b690..f54ed75 100644
--- a/vibe/core/tools/builtins/search_replace.py
+++ b/vibe/core/tools/builtins/search_replace.py
@@ -1,17 +1,24 @@
from __future__ import annotations
+from collections.abc import AsyncGenerator
import difflib
from pathlib import Path
import re
import shutil
from typing import ClassVar, NamedTuple, final
-import aiofiles
+import anyio
from pydantic import BaseModel, Field
-from vibe.core.tools.base import BaseTool, BaseToolConfig, BaseToolState, ToolError
+from vibe.core.tools.base import (
+ BaseTool,
+ BaseToolConfig,
+ BaseToolState,
+ InvokeContext,
+ ToolError,
+)
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
-from vibe.core.types import ToolCallEvent, ToolResultEvent
+from vibe.core.types import ToolCallEvent, ToolResultEvent, ToolStreamEvent
SEARCH_REPLACE_BLOCK_RE = re.compile(
r"<{5,} SEARCH\r?\n(.*?)\r?\n?={5,}\r?\n(.*?)\r?\n?>{5,} REPLACE", flags=re.DOTALL
@@ -106,7 +113,9 @@ class SearchReplace(
return "Editing files"
@final
- async def run(self, args: SearchReplaceArgs) -> SearchReplaceResult:
+ async def run(
+ self, args: SearchReplaceArgs, ctx: InvokeContext | None = None
+ ) -> AsyncGenerator[ToolStreamEvent | SearchReplaceResult, None]:
file_path, search_replace_blocks = self._prepare_and_validate_args(args)
original_content = await self._read_file(file_path)
@@ -146,7 +155,7 @@ class SearchReplace(
await self._write_file(file_path, modified_content)
- return SearchReplaceResult(
+ yield SearchReplaceResult(
file=str(file_path),
blocks_applied=block_result.applied,
lines_changed=lines_changed,
@@ -173,7 +182,7 @@ class SearchReplace(
if not content:
raise ToolError("Empty content provided")
- project_root = self.config.effective_workdir
+ project_root = Path.cwd()
file_path = Path(file_path_str).expanduser()
if not file_path.is_absolute():
file_path = project_root / file_path
@@ -201,7 +210,7 @@ class SearchReplace(
async def _read_file(self, file_path: Path) -> str:
try:
- async with aiofiles.open(file_path, encoding="utf-8") as f:
+ async with await anyio.Path(file_path).open(encoding="utf-8") as f:
return await f.read()
except UnicodeDecodeError as e:
raise ToolError(f"Unicode decode error reading {file_path}: {e}") from e
@@ -215,7 +224,9 @@ class SearchReplace(
async def _write_file(self, file_path: Path, content: str) -> None:
try:
- async with aiofiles.open(file_path, mode="w", encoding="utf-8") as f:
+ async with await anyio.Path(file_path).open(
+ mode="w", encoding="utf-8"
+ ) as f:
await f.write(content)
except PermissionError:
raise ToolError(f"Permission denied writing to file: {file_path}")
diff --git a/vibe/core/tools/builtins/task.py b/vibe/core/tools/builtins/task.py
new file mode 100644
index 0000000..49ffb5d
--- /dev/null
+++ b/vibe/core/tools/builtins/task.py
@@ -0,0 +1,154 @@
+from __future__ import annotations
+
+from collections.abc import AsyncGenerator
+from typing import ClassVar
+
+from pydantic import BaseModel, Field
+
+from vibe.core.agent_loop import AgentLoop
+from vibe.core.agents.models import AgentType
+from vibe.core.config import SessionLoggingConfig, VibeConfig
+from vibe.core.tools.base import (
+ BaseTool,
+ BaseToolConfig,
+ BaseToolState,
+ InvokeContext,
+ ToolError,
+ ToolPermission,
+)
+from vibe.core.tools.ui import (
+ ToolCallDisplay,
+ ToolResultDisplay,
+ ToolUIData,
+ ToolUIDataAdapter,
+)
+from vibe.core.types import (
+ AssistantEvent,
+ Role,
+ ToolCallEvent,
+ ToolResultEvent,
+ ToolStreamEvent,
+)
+
+
+class TaskArgs(BaseModel):
+ task: str = Field(description="The task to delegate to the subagent")
+ agent: str = Field(
+ default="explore",
+ description="Name of the agent profile to use (must be a subagent)",
+ )
+
+
+class TaskResult(BaseModel):
+ response: str = Field(description="The accumulated response from the subagent")
+ turns_used: int = Field(description="Number of turns the subagent used")
+ completed: bool = Field(description="Whether the task completed normally")
+
+
+class TaskToolConfig(BaseToolConfig):
+ permission: ToolPermission = ToolPermission.ASK
+
+
+class Task(
+ BaseTool[TaskArgs, TaskResult, TaskToolConfig, BaseToolState],
+ ToolUIData[TaskArgs, TaskResult],
+):
+ description: ClassVar[str] = (
+ "Delegate a task to a subagent for independent execution. "
+ "Useful for exploration, research, or parallel work that doesn't "
+ "require user interaction. The subagent runs in-memory without "
+ "saving interaction logs."
+ )
+
+ @classmethod
+ def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
+ args = event.args
+ if isinstance(args, TaskArgs):
+ return ToolCallDisplay(summary=f"Running {args.agent} agent: {args.task}")
+ return ToolCallDisplay(summary="Running subagent")
+
+ @classmethod
+ def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
+ result = event.result
+ if isinstance(result, TaskResult):
+ turn_word = "turn" if result.turns_used == 1 else "turns"
+ if not result.completed:
+ return ToolResultDisplay(
+ success=False,
+ message=f"Agent interrupted after {result.turns_used} {turn_word}",
+ )
+ return ToolResultDisplay(
+ success=True,
+ message=f"Agent completed in {result.turns_used} {turn_word}",
+ )
+ return ToolResultDisplay(success=True, message="Agent completed")
+
+ @classmethod
+ def get_status_text(cls) -> str:
+ return "Running subagent"
+
+ async def run(
+ self, args: TaskArgs, ctx: InvokeContext | None = None
+ ) -> AsyncGenerator[ToolStreamEvent | TaskResult, None]:
+ if not ctx or not ctx.agent_manager:
+ raise ToolError("Task tool requires agent_manager in context")
+
+ agent_manager = ctx.agent_manager
+
+ try:
+ agent_profile = agent_manager.get_agent(args.agent)
+ except ValueError as e:
+ raise ToolError(f"Unknown agent: {args.agent}") from e
+
+ if agent_profile.agent_type != AgentType.SUBAGENT:
+ raise ToolError(
+ f"Agent '{args.agent}' is a {agent_profile.agent_type.value} agent. "
+ f"Only subagents can be used with the task tool. "
+ f"This is a security constraint to prevent recursive spawning."
+ )
+
+ base_config = VibeConfig.load(
+ session_logging=SessionLoggingConfig(enabled=False)
+ )
+ subagent_loop = AgentLoop(config=base_config, agent_name=args.agent)
+
+ if ctx and ctx.approval_callback:
+ subagent_loop.set_approval_callback(ctx.approval_callback)
+
+ accumulated_response: list[str] = []
+ completed = True
+ try:
+ async for event in subagent_loop.act(args.task):
+ if isinstance(event, AssistantEvent) and event.content:
+ accumulated_response.append(event.content)
+ if event.stopped_by_middleware:
+ completed = False
+ elif isinstance(event, ToolResultEvent):
+ if event.skipped:
+ completed = False
+ elif event.result and event.tool_class:
+ adapter = ToolUIDataAdapter(event.tool_class)
+ display = adapter.get_result_display(event)
+ message = f"{event.tool_name}: {display.message}"
+ yield ToolStreamEvent(
+ tool_name=self.get_name(),
+ message=message,
+ tool_call_id=ctx.tool_call_id,
+ )
+
+ turns_used = sum(
+ msg.role == Role.assistant for msg in subagent_loop.messages
+ )
+
+ except Exception as e:
+ completed = False
+ accumulated_response.append(f"\n[Subagent error: {e}]")
+ turns_used = sum(
+ msg.role == Role.assistant for msg in subagent_loop.messages
+ )
+
+ yield TaskResult(
+ response="".join(accumulated_response),
+ turns_used=turns_used,
+ completed=completed,
+ )
diff --git a/vibe/core/tools/builtins/todo.py b/vibe/core/tools/builtins/todo.py
index d8482bc..ffafe2c 100644
--- a/vibe/core/tools/builtins/todo.py
+++ b/vibe/core/tools/builtins/todo.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+from collections.abc import AsyncGenerator
from enum import StrEnum, auto
from typing import ClassVar
@@ -9,11 +10,12 @@ from vibe.core.tools.base import (
BaseTool,
BaseToolConfig,
BaseToolState,
+ InvokeContext,
ToolError,
ToolPermission,
)
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
-from vibe.core.types import ToolCallEvent, ToolResultEvent
+from vibe.core.types import ToolCallEvent, ToolResultEvent, ToolStreamEvent
class TodoStatus(StrEnum):
@@ -95,12 +97,14 @@ class Todo(
def get_status_text(cls) -> str:
return "Managing todos"
- async def run(self, args: TodoArgs) -> TodoResult:
+ async def run(
+ self, args: TodoArgs, ctx: InvokeContext | None = None
+ ) -> AsyncGenerator[ToolStreamEvent | TodoResult, None]:
match args.action:
case "read":
- return self._read_todos()
+ yield self._read_todos()
case "write":
- return self._write_todos(args.todos or [])
+ yield self._write_todos(args.todos or [])
case _:
raise ToolError(
f"Invalid action '{args.action}'. Use 'read' or 'write'."
diff --git a/vibe/core/tools/builtins/write_file.py b/vibe/core/tools/builtins/write_file.py
index 7c1e0fd..7080a58 100644
--- a/vibe/core/tools/builtins/write_file.py
+++ b/vibe/core/tools/builtins/write_file.py
@@ -1,20 +1,22 @@
from __future__ import annotations
+from collections.abc import AsyncGenerator
from pathlib import Path
from typing import ClassVar, final
-import aiofiles
+import anyio
from pydantic import BaseModel, Field
from vibe.core.tools.base import (
BaseTool,
BaseToolConfig,
BaseToolState,
+ InvokeContext,
ToolError,
ToolPermission,
)
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
-from vibe.core.types import ToolCallEvent, ToolResultEvent
+from vibe.core.types import ToolCallEvent, ToolResultEvent, ToolStreamEvent
class WriteFileArgs(BaseModel):
@@ -81,7 +83,7 @@ class WriteFile(
file_path = Path(args.path).expanduser()
if not file_path.is_absolute():
- file_path = self.config.effective_workdir / file_path
+ file_path = Path.cwd() / file_path
file_str = str(file_path)
for pattern in self.config.denylist:
@@ -95,7 +97,9 @@ class WriteFile(
return None
@final
- async def run(self, args: WriteFileArgs) -> WriteFileResult:
+ async def run(
+ self, args: WriteFileArgs, ctx: InvokeContext | None = None
+ ) -> AsyncGenerator[ToolStreamEvent | WriteFileResult, None]:
file_path, file_existed, content_bytes = self._prepare_and_validate_path(args)
await self._write_file(args, file_path)
@@ -105,7 +109,7 @@ class WriteFile(
if len(self.state.recently_written_files) > BUFFER_SIZE:
self.state.recently_written_files.pop(0)
- return WriteFileResult(
+ yield WriteFileResult(
path=str(file_path),
bytes_written=content_bytes,
file_existed=file_existed,
@@ -124,11 +128,11 @@ class WriteFile(
file_path = Path(args.path).expanduser()
if not file_path.is_absolute():
- file_path = self.config.effective_workdir / file_path
+ file_path = Path.cwd() / file_path
file_path = file_path.resolve()
try:
- file_path.relative_to(self.config.effective_workdir.resolve())
+ file_path.relative_to(Path.cwd().resolve())
except ValueError:
raise ToolError(f"Cannot write outside project directory: {file_path}")
@@ -148,7 +152,9 @@ class WriteFile(
async def _write_file(self, args: WriteFileArgs, file_path: Path) -> None:
try:
- async with aiofiles.open(file_path, mode="w", encoding="utf-8") as f:
+ async with await anyio.Path(file_path).open(
+ mode="w", encoding="utf-8"
+ ) as f:
await f.write(args.content)
except Exception as e:
raise ToolError(f"Error writing {file_path}: {e}") from e
diff --git a/vibe/core/tools/manager.py b/vibe/core/tools/manager.py
index f968225..eec912d 100644
--- a/vibe/core/tools/manager.py
+++ b/vibe/core/tools/manager.py
@@ -1,6 +1,7 @@
from __future__ import annotations
from collections.abc import Callable, Iterator
+import hashlib
import importlib.util
import inspect
from logging import getLogger
@@ -19,7 +20,7 @@ from vibe.core.tools.mcp import (
list_tools_http,
list_tools_stdio,
)
-from vibe.core.utils import run_sync
+from vibe.core.utils import name_matches, run_sync
logger = getLogger("vibe")
@@ -27,6 +28,40 @@ if TYPE_CHECKING:
from vibe.core.config import MCPHttp, MCPStdio, MCPStreamableHttp, VibeConfig
+def _try_canonical_module_name(path: Path) -> str | None:
+ """Extract canonical module name for vibe package files.
+
+ Prevents Pydantic class identity mismatches when the same module
+ is imported via dynamic discovery and regular imports.
+ """
+ try:
+ parts = path.resolve().parts
+ except (OSError, ValueError):
+ return None
+
+ try:
+ vibe_idx = parts.index("vibe")
+ except ValueError:
+ return None
+
+ if vibe_idx + 1 >= len(parts):
+ return None
+
+ module_parts = [p.removesuffix(".py") for p in parts[vibe_idx:]]
+ return ".".join(module_parts)
+
+
+def _compute_module_name(path: Path) -> str:
+ """Return canonical module name for vibe files, hash-based synthetic name otherwise."""
+ if canonical := _try_canonical_module_name(path):
+ return canonical
+
+ resolved = path.resolve()
+ path_hash = hashlib.md5(str(resolved).encode()).hexdigest()[:8]
+ stem = re.sub(r"[^0-9A-Za-z_]", "_", path.stem) or "mod"
+ return f"vibe_tools_discovered_{stem}_{path_hash}"
+
+
class NoSuchToolError(Exception):
"""Exception raised when a tool is not found."""
@@ -56,15 +91,12 @@ class ToolManager:
def _compute_search_paths(config: VibeConfig) -> list[Path]:
paths: list[Path] = [DEFAULT_TOOL_DIR.path]
- for path in config.tool_paths:
- if path.is_dir():
- paths.append(path)
+ paths.extend(config.tool_paths)
- if (tools_dir := resolve_local_tools_dir(config.effective_workdir)) is not None:
+ if (tools_dir := resolve_local_tools_dir(Path.cwd())) is not None:
paths.append(tools_dir)
- if GLOBAL_TOOLS_DIR.path.is_dir():
- paths.append(GLOBAL_TOOLS_DIR.path)
+ paths.append(GLOBAL_TOOLS_DIR.path)
unique: list[Path] = []
seen: set[Path] = set()
@@ -77,38 +109,54 @@ class ToolManager:
@staticmethod
def _iter_tool_classes(search_paths: list[Path]) -> Iterator[type[BaseTool]]:
+ """Iterate over all search_paths to find tool classes.
+
+ Note: if a search path is not a directory, it is treated as a single tool file.
+ """
for base in search_paths:
- if not base.is_dir():
- continue
+ if not base.is_dir() and base.name.endswith(".py"):
+ if tools := ToolManager._load_tools_from_file(base):
+ for tool in tools:
+ yield tool
for path in base.rglob("*.py"):
- if not path.is_file():
- continue
- name = path.name
- if name.startswith("_"):
- continue
+ if tools := ToolManager._load_tools_from_file(path):
+ for tool in tools:
+ yield tool
- stem = re.sub(r"[^0-9A-Za-z_]", "_", path.stem) or "mod"
- module_name = f"vibe_tools_discovered_{stem}"
+ @staticmethod
+ def _load_tools_from_file(file_path: Path) -> list[type[BaseTool]] | None:
+ if not file_path.is_file():
+ return
+ name = file_path.name
+ if name.startswith("_"):
+ return
- spec = importlib.util.spec_from_file_location(module_name, path)
- if spec is None or spec.loader is None:
- continue
- module = importlib.util.module_from_spec(spec)
- sys.modules[module_name] = module
- try:
- spec.loader.exec_module(module)
- except Exception:
- continue
+ module_name = _compute_module_name(file_path)
- for obj in vars(module).values():
- if not inspect.isclass(obj):
- continue
- if not issubclass(obj, BaseTool) or obj is BaseTool:
- continue
- if inspect.isabstract(obj):
- continue
- yield obj
+ if module_name in sys.modules:
+ module = sys.modules[module_name]
+ else:
+ spec = importlib.util.spec_from_file_location(module_name, file_path)
+ if spec is None or spec.loader is None:
+ return
+ module = importlib.util.module_from_spec(spec)
+ sys.modules[module_name] = module
+ try:
+ spec.loader.exec_module(module)
+ except Exception:
+ return
+
+ tools = []
+ for tool_obj in vars(module).values():
+ if not inspect.isclass(tool_obj):
+ continue
+ if not issubclass(tool_obj, BaseTool) or tool_obj is BaseTool:
+ continue
+ if inspect.isabstract(tool_obj):
+ continue
+ tools.append(tool_obj)
+ return tools
@staticmethod
def discover_tool_defaults(
@@ -130,7 +178,20 @@ class ToolManager:
continue
return defaults
+ @property
def available_tools(self) -> dict[str, type[BaseTool]]:
+ if self._config.enabled_tools:
+ return {
+ name: cls
+ for name, cls in self._available.items()
+ if name_matches(name, self._config.enabled_tools)
+ }
+ if self._config.disabled_tools:
+ return {
+ name: cls
+ for name, cls in self._available.items()
+ if not name_matches(name, self._config.disabled_tools)
+ }
return dict(self._available)
def _integrate_mcp(self) -> None:
@@ -169,7 +230,9 @@ class ToolManager:
headers = srv.http_headers()
try:
- tools: list[RemoteTool] = await list_tools_http(url, headers=headers)
+ tools: list[RemoteTool] = await list_tools_http(
+ url, headers=headers, startup_timeout_sec=srv.startup_timeout_sec
+ )
except Exception as exc:
logger.warning("MCP HTTP discovery failed for %s: %s", url, exc)
return 0
@@ -183,6 +246,8 @@ class ToolManager:
alias=srv.name,
server_hint=srv.prompt,
headers=headers,
+ startup_timeout_sec=srv.startup_timeout_sec,
+ tool_timeout_sec=srv.tool_timeout_sec,
)
self._available[proxy_cls.get_name()] = proxy_cls
added += 1
@@ -202,7 +267,9 @@ class ToolManager:
return 0
try:
- tools: list[RemoteTool] = await list_tools_stdio(cmd)
+ tools: list[RemoteTool] = await list_tools_stdio(
+ cmd, env=srv.env or None, startup_timeout_sec=srv.startup_timeout_sec
+ )
except Exception as exc:
logger.warning("MCP stdio discovery failed for %r: %s", cmd, exc)
return 0
@@ -211,7 +278,13 @@ class ToolManager:
for remote in tools:
try:
proxy_cls = create_mcp_stdio_proxy_tool_class(
- command=cmd, remote=remote, alias=srv.name, server_hint=srv.prompt
+ command=cmd,
+ remote=remote,
+ alias=srv.name,
+ server_hint=srv.prompt,
+ env=srv.env or None,
+ startup_timeout_sec=srv.startup_timeout_sec,
+ tool_timeout_sec=srv.tool_timeout_sec,
)
self._available[proxy_cls.get_name()] = proxy_cls
added += 1
@@ -240,9 +313,6 @@ class ToolManager:
else:
merged_dict = {**default_config.model_dump(), **user_overrides.model_dump()}
- if self._config.workdir is not None:
- merged_dict["workdir"] = self._config.workdir
-
return config_class.model_validate(merged_dict)
def get(self, tool_name: str) -> BaseTool:
@@ -266,3 +336,6 @@ class ToolManager:
def reset_all(self) -> None:
self._instances.clear()
+
+ def invalidate_tool(self, tool_name: str) -> None:
+ self._instances.pop(tool_name, None)
diff --git a/vibe/core/tools/mcp.py b/vibe/core/tools/mcp.py
index 29b1c7d..8aa3aea 100644
--- a/vibe/core/tools/mcp.py
+++ b/vibe/core/tools/mcp.py
@@ -1,5 +1,7 @@
from __future__ import annotations
+from collections.abc import AsyncGenerator
+from datetime import timedelta
import hashlib
from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar
@@ -9,8 +11,15 @@ from mcp.client.stdio import StdioServerParameters, stdio_client
from mcp.client.streamable_http import streamablehttp_client
from pydantic import BaseModel, ConfigDict, Field, field_validator
-from vibe.core.tools.base import BaseTool, BaseToolConfig, BaseToolState, ToolError
+from vibe.core.tools.base import (
+ BaseTool,
+ BaseToolConfig,
+ BaseToolState,
+ InvokeContext,
+ ToolError,
+)
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay
+from vibe.core.types import ToolStreamEvent
if TYPE_CHECKING:
from vibe.core.types import ToolCallEvent, ToolResultEvent
@@ -57,8 +66,12 @@ class RemoteTool(BaseModel):
try:
v = dump()
except Exception:
- return {"type": "object", "properties": {}}
- return v if isinstance(v, dict) else {"type": "object", "properties": {}}
+ raise ValueError(
+ "inputSchema must be a dict or have a valid model_dump method"
+ )
+ if not isinstance(v, dict):
+ raise ValueError("inputSchema must be a dict")
+ return v
class _MCPContentBlock(BaseModel):
@@ -100,10 +113,14 @@ def _parse_call_result(server: str, tool: str, result_obj: Any) -> MCPToolResult
async def list_tools_http(
- url: str, headers: dict[str, str] | None = None
+ url: str,
+ *,
+ headers: dict[str, str] | None = None,
+ startup_timeout_sec: float | None = None,
) -> list[RemoteTool]:
+ timeout = timedelta(seconds=startup_timeout_sec) if startup_timeout_sec else None
async with streamablehttp_client(url, headers=headers) as (read, write, _):
- async with ClientSession(read, write) as session:
+ async with ClientSession(read, write, read_timeout_seconds=timeout) as session:
await session.initialize()
tools_resp = await session.list_tools()
return [RemoteTool.model_validate(t) for t in tools_resp.tools]
@@ -115,11 +132,21 @@ async def call_tool_http(
arguments: dict[str, Any],
*,
headers: dict[str, str] | None = None,
+ startup_timeout_sec: float | None = None,
+ tool_timeout_sec: float | None = None,
) -> MCPToolResult:
+ init_timeout = (
+ timedelta(seconds=startup_timeout_sec) if startup_timeout_sec else None
+ )
+ call_timeout = timedelta(seconds=tool_timeout_sec) if tool_timeout_sec else None
async with streamablehttp_client(url, headers=headers) as (read, write, _):
- async with ClientSession(read, write) as session:
+ async with ClientSession(
+ read, write, read_timeout_seconds=init_timeout
+ ) as session:
await session.initialize()
- result = await session.call_tool(tool_name, arguments)
+ result = await session.call_tool(
+ tool_name, arguments, read_timeout_seconds=call_timeout
+ )
return _parse_call_result(url, tool_name, result)
@@ -130,6 +157,8 @@ def create_mcp_http_proxy_tool_class(
alias: str | None = None,
server_hint: str | None = None,
headers: dict[str, str] | None = None,
+ startup_timeout_sec: float | None = None,
+ tool_timeout_sec: float | None = None,
) -> type[BaseTool[_OpenArgs, MCPToolResult, BaseToolConfig, BaseToolState]]:
from urllib.parse import urlparse
@@ -153,6 +182,8 @@ def create_mcp_http_proxy_tool_class(
_remote_name: ClassVar[str] = remote.name
_input_schema: ClassVar[dict[str, Any]] = remote.input_schema
_headers: ClassVar[dict[str, str]] = dict(headers or {})
+ _startup_timeout_sec: ClassVar[float | None] = startup_timeout_sec
+ _tool_timeout_sec: ClassVar[float | None] = tool_timeout_sec
@classmethod
def get_name(cls) -> str:
@@ -162,11 +193,18 @@ def create_mcp_http_proxy_tool_class(
def get_parameters(cls) -> dict[str, Any]:
return dict(cls._input_schema)
- async def run(self, args: _OpenArgs) -> MCPToolResult:
+ async def run(
+ self, args: _OpenArgs, ctx: InvokeContext | None = None
+ ) -> AsyncGenerator[ToolStreamEvent | MCPToolResult, None]:
try:
payload = args.model_dump(exclude_none=True)
- return await call_tool_http(
- self._mcp_url, self._remote_name, payload, headers=self._headers
+ yield await call_tool_http(
+ self._mcp_url,
+ self._remote_name,
+ payload,
+ headers=self._headers,
+ startup_timeout_sec=self._startup_timeout_sec,
+ tool_timeout_sec=self._tool_timeout_sec,
)
except Exception as exc:
raise ToolError(f"MCP call failed: {exc}") from exc
@@ -194,23 +232,43 @@ def create_mcp_http_proxy_tool_class(
return MCPHttpProxyTool
-async def list_tools_stdio(command: list[str]) -> list[RemoteTool]:
- params = StdioServerParameters(command=command[0], args=command[1:])
+async def list_tools_stdio(
+ command: list[str],
+ *,
+ env: dict[str, str] | None = None,
+ startup_timeout_sec: float | None = None,
+) -> list[RemoteTool]:
+ params = StdioServerParameters(command=command[0], args=command[1:], env=env)
+ timeout = timedelta(seconds=startup_timeout_sec) if startup_timeout_sec else None
async with stdio_client(params) as (read, write):
- async with ClientSession(read, write) as session:
+ async with ClientSession(read, write, read_timeout_seconds=timeout) as session:
await session.initialize()
tools_resp = await session.list_tools()
return [RemoteTool.model_validate(t) for t in tools_resp.tools]
async def call_tool_stdio(
- command: list[str], tool_name: str, arguments: dict[str, Any]
+ command: list[str],
+ tool_name: str,
+ arguments: dict[str, Any],
+ *,
+ env: dict[str, str] | None = None,
+ startup_timeout_sec: float | None = None,
+ tool_timeout_sec: float | None = None,
) -> MCPToolResult:
- params = StdioServerParameters(command=command[0], args=command[1:])
+ params = StdioServerParameters(command=command[0], args=command[1:], env=env)
+ init_timeout = (
+ timedelta(seconds=startup_timeout_sec) if startup_timeout_sec else None
+ )
+ call_timeout = timedelta(seconds=tool_timeout_sec) if tool_timeout_sec else None
async with stdio_client(params) as (read, write):
- async with ClientSession(read, write) as session:
+ async with ClientSession(
+ read, write, read_timeout_seconds=init_timeout
+ ) as session:
await session.initialize()
- result = await session.call_tool(tool_name, arguments)
+ result = await session.call_tool(
+ tool_name, arguments, read_timeout_seconds=call_timeout
+ )
return _parse_call_result("stdio:" + " ".join(command), tool_name, result)
@@ -220,6 +278,9 @@ def create_mcp_stdio_proxy_tool_class(
remote: RemoteTool,
alias: str | None = None,
server_hint: str | None = None,
+ env: dict[str, str] | None = None,
+ startup_timeout_sec: float | None = None,
+ tool_timeout_sec: float | None = None,
) -> type[BaseTool[_OpenArgs, MCPToolResult, BaseToolConfig, BaseToolState]]:
def _alias_from_command(cmd: list[str]) -> str:
prog = Path(cmd[0]).name.replace(".", "_") if cmd else "mcp"
@@ -245,6 +306,9 @@ def create_mcp_stdio_proxy_tool_class(
_stdio_command: ClassVar[list[str]] = command
_remote_name: ClassVar[str] = remote.name
_input_schema: ClassVar[dict[str, Any]] = remote.input_schema
+ _env: ClassVar[dict[str, str] | None] = env
+ _startup_timeout_sec: ClassVar[float | None] = startup_timeout_sec
+ _tool_timeout_sec: ClassVar[float | None] = tool_timeout_sec
@classmethod
def get_name(cls) -> str:
@@ -254,13 +318,20 @@ def create_mcp_stdio_proxy_tool_class(
def get_parameters(cls) -> dict[str, Any]:
return dict(cls._input_schema)
- async def run(self, args: _OpenArgs) -> MCPToolResult:
+ async def run(
+ self, args: _OpenArgs, ctx: InvokeContext | None = None
+ ) -> AsyncGenerator[ToolStreamEvent | MCPToolResult, None]:
try:
payload = args.model_dump(exclude_none=True)
result = await call_tool_stdio(
- self._stdio_command, self._remote_name, payload
+ self._stdio_command,
+ self._remote_name,
+ payload,
+ env=self._env,
+ startup_timeout_sec=self._startup_timeout_sec,
+ tool_timeout_sec=self._tool_timeout_sec,
)
- return result
+ yield result
except Exception as exc:
raise ToolError(f"MCP stdio call failed: {exc!r}") from exc
diff --git a/vibe/core/types.py b/vibe/core/types.py
index 4b372e5..cda04ab 100644
--- a/vibe/core/types.py
+++ b/vibe/core/types.py
@@ -5,7 +5,13 @@ from collections import OrderedDict
from collections.abc import Awaitable, Callable
import copy
from enum import StrEnum, auto
-from typing import Annotated, Any, Literal
+from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Literal
+from uuid import uuid4
+
+if TYPE_CHECKING:
+ from vibe.core.tools.base import BaseTool
+else:
+ BaseTool = Any
from pydantic import (
BaseModel,
@@ -16,8 +22,6 @@ from pydantic import (
model_validator,
)
-from vibe.core.tools.base import BaseTool
-
class AgentStats(BaseModel):
steps: int = 0
@@ -29,6 +33,7 @@ class AgentStats(BaseModel):
tool_calls_succeeded: int = 0
context_tokens: int = 0
+ listeners: ClassVar[dict[str, Callable[[AgentStats], None]]] = {}
last_turn_prompt_tokens: int = 0
last_turn_completion_tokens: int = 0
@@ -38,6 +43,21 @@ class AgentStats(BaseModel):
input_price_per_million: float = 0.0
output_price_per_million: float = 0.0
+ def __setattr__(self, name: str, value: Any) -> None:
+ super().__setattr__(name, value)
+ if name in self.listeners:
+ self.listeners[name](self)
+
+ def trigger_listeners(self) -> None:
+ for listener in self.listeners.values():
+ listener(self)
+
+ @classmethod
+ def add_listener(
+ cls, attr_name: str, listener: Callable[[AgentStats], None]
+ ) -> None:
+ cls.listeners[attr_name] = listener
+
@computed_field
@property
def session_total_llm_tokens(self) -> int:
@@ -104,7 +124,6 @@ class SessionMetadata(BaseModel):
git_commit: str | None
git_branch: str | None
environment: dict[str, str | None]
- auto_approve: bool = False
username: str
@@ -172,6 +191,7 @@ class LLMMessage(BaseModel):
tool_calls: list[ToolCall] | None = None
name: str | None = None
tool_call_id: str | None = None
+ message_id: str | None = None
@model_validator(mode="before")
@classmethod
@@ -179,14 +199,19 @@ class LLMMessage(BaseModel):
if isinstance(v, dict):
v.setdefault("content", "")
v.setdefault("role", "assistant")
+ if "message_id" not in v and v.get("role") != "tool":
+ v["message_id"] = str(uuid4())
return v
+ role = str(getattr(v, "role", "assistant"))
return {
- "role": str(getattr(v, "role", "assistant")),
+ "role": role,
"content": getattr(v, "content", ""),
"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),
+ "message_id": getattr(v, "message_id", None)
+ or (str(uuid4()) if role != "tool" else None),
}
def __add__(self, other: LLMMessage) -> LLMMessage:
@@ -238,6 +263,7 @@ class LLMMessage(BaseModel):
tool_calls=list(tool_calls_map.values()) or None,
name=self.name,
tool_call_id=self.tool_call_id,
+ message_id=self.message_id,
)
@@ -267,25 +293,31 @@ class LLMChunk(BaseModel):
class BaseEvent(BaseModel, ABC):
- """Abstract base class for all agent events."""
-
model_config = ConfigDict(arbitrary_types_allowed=True)
+class UserMessageEvent(BaseEvent):
+ content: str
+ message_id: str
+
+
class AssistantEvent(BaseEvent):
content: str
stopped_by_middleware: bool = False
+ message_id: str | None = None
def __add__(self, other: AssistantEvent) -> AssistantEvent:
return AssistantEvent(
content=self.content + other.content,
stopped_by_middleware=self.stopped_by_middleware
or other.stopped_by_middleware,
+ message_id=self.message_id or other.message_id,
)
class ReasoningEvent(BaseEvent):
content: str
+ message_id: str | None = None
class ToolCallEvent(BaseEvent):
@@ -306,15 +338,31 @@ class ToolResultEvent(BaseEvent):
tool_call_id: str
+class ToolStreamEvent(BaseEvent):
+ tool_name: str
+ message: str
+ tool_call_id: str
+
+
class CompactStartEvent(BaseEvent):
current_context_tokens: int
threshold: int
+ # WORKAROUND: Using tool_call to communicate compact events to the client.
+ # This should be revisited when the ACP protocol defines how compact events
+ # should be represented.
+ # [RFD](https://agentclientprotocol.com/rfds/session-usage)
+ tool_call_id: str
class CompactEndEvent(BaseEvent):
old_context_tokens: int
new_context_tokens: int
summary_length: int
+ # WORKAROUND: Using tool_call to communicate compact events to the client.
+ # This should be revisited when the ACP protocol defines how compact events
+ # should be represented.
+ # [RFD](https://agentclientprotocol.com/rfds/session-usage)
+ tool_call_id: str
class OutputFormat(StrEnum):
@@ -332,3 +380,5 @@ type SyncApprovalCallback = Callable[
]
type ApprovalCallback = AsyncApprovalCallback | SyncApprovalCallback
+
+type UserInputCallback = Callable[[BaseModel], Awaitable[BaseModel]]
diff --git a/vibe/core/utils.py b/vibe/core/utils.py
index ce01a39..97168c0 100644
--- a/vibe/core/utils.py
+++ b/vibe/core/utils.py
@@ -4,6 +4,7 @@ import asyncio
from collections.abc import AsyncGenerator, Awaitable, Callable, Coroutine
import concurrent.futures
from enum import Enum, auto
+from fnmatch import fnmatch
import functools
import logging
from pathlib import Path
@@ -273,3 +274,45 @@ def run_sync[T](coro: Coroutine[Any, Any, T]) -> T:
def is_windows() -> bool:
return sys.platform == "win32"
+
+
+@functools.lru_cache(maxsize=256)
+def _compile_icase(expr: str) -> re.Pattern[str] | None:
+ try:
+ return re.compile(expr, re.IGNORECASE)
+ except re.error:
+ return None
+
+
+def name_matches(name: str, patterns: list[str]) -> bool:
+ """Check if a name matches any of the provided patterns.
+
+ Supports two forms (case-insensitive):
+ - Glob wildcards using fnmatch (e.g., 'serena_*')
+ - Regex when prefixed with 're:' (e.g., 're:serena.*')
+ """
+ n = name.lower()
+ for raw in patterns:
+ if not (p := (raw or "").strip()):
+ continue
+
+ if p.startswith("re:"):
+ rx = _compile_icase(p.removeprefix("re:"))
+ if rx is not None and rx.fullmatch(name) is not None:
+ return True
+ elif fnmatch(n, p.lower()):
+ return True
+
+ return False
+
+
+def compact_reduction_display(old_tokens: int | None, new_tokens: int | None) -> str:
+ if old_tokens is None or new_tokens is None:
+ return "Compaction complete"
+
+ reduction = old_tokens - new_tokens
+ reduction_pct = (reduction / old_tokens * 100) if old_tokens > 0 else 0
+ return (
+ f"Compaction complete: {old_tokens:,} → "
+ f"{new_tokens:,} tokens ({-reduction_pct:+#0.2g}%)"
+ )
diff --git a/vibe/setup/onboarding/__init__.py b/vibe/setup/onboarding/__init__.py
index a7a3580..88d0ac8 100644
--- a/vibe/setup/onboarding/__init__.py
+++ b/vibe/setup/onboarding/__init__.py
@@ -50,4 +50,6 @@ def run_onboarding(app: App | None = None) -> None:
f"You may need to set it manually in {GLOBAL_ENV_FILE.path}[/]\n"
)
case "completed":
- pass
+ rprint(
+ '\nSetup complete 🎉. Run "vibe" to start using the Mistral Vibe CLI.\n'
+ )
diff --git a/vibe/setup/onboarding/screens/api_key.py b/vibe/setup/onboarding/screens/api_key.py
index a80957f..9d3a50a 100644
--- a/vibe/setup/onboarding/screens/api_key.py
+++ b/vibe/setup/onboarding/screens/api_key.py
@@ -18,7 +18,7 @@ from vibe.core.paths.global_paths import GLOBAL_ENV_FILE
from vibe.setup.onboarding.base import OnboardingScreen
PROVIDER_HELP = {
- "mistral": ("https://console.mistral.ai/codestral/vibe", "Mistral AI Studio")
+ "mistral": ("https://console.mistral.ai/codestral/cli", "Mistral AI Studio")
}
CONFIG_DOCS_URL = (
"https://github.com/mistralai/mistral-vibe?tab=readme-ov-file#configuration"
diff --git a/vibe/whats_new.md b/vibe/whats_new.md
new file mode 100644
index 0000000..6593d10
--- /dev/null
+++ b/vibe/whats_new.md
@@ -0,0 +1,14 @@
+# What's New in 2.0.0
+
+- **Subagents**: The agent can now delegate tasks to specialized sub-agents for more complex workflows.
+- **Interactive Questions**: The agent can now ask you clarifying questions as it works.
+- **Slash Commands**: You can now define your own custom slash commands through skills.
+- **Auto-Update**: Vibe will now keep itself up to date automatically. Disable with `enable_auto_update = false` in `config.toml`.
+- **MCP Servers**: Configurations now support environment variables and custom timeouts.
+- **Agents System**: Modes have been replaced by a more flexible agents system.
+
+### ⚠️ Breaking Changes
+- Custom modes must be migrated to the new agent format.
+- `workdir` setting in `config.toml` is no longer supported.
+- `instructions.md` files are no longer supported.
+- Heuristic regex matching is removed. To use a regex in `enabled_tools`, `disabled_tools`, `enabled_skills`, or `disabled_skills`, prefix it with `re:` (e.g., `re:serena.*`).