Co-authored-by: Clément Drouin <clement.drouin@mistral.ai>
Co-authored-by: Corentin André <corentin.andre@mistral.ai>
Co-authored-by: Guillaume LE GOFF <guillaume.lgf@gmail.com>
Co-authored-by: Kim-Adeline Miguel <51720070+kimadeline@users.noreply.github.com>
Co-authored-by: Maxime Dolores <maxime.dolores@ext.mistral.ai>
Co-authored-by: Nelson PROIA <144663685+Nelson-PROIA@users.noreply.github.com>
Co-authored-by: Peter Evers <pevers90@gmail.com>
Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai>
Co-authored-by: Quentin <quentin.torroba@mistral.ai>
Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com>
Co-authored-by: MichisGitIsKing <MichisGitIsKing@users.noreply.github.com>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Mathias Gesbert 2026-05-19 11:56:25 +02:00 committed by GitHub
parent 626f905186
commit 228f3c65a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
158 changed files with 7235 additions and 916 deletions

7
.vscode/launch.json vendored
View file

@ -54,7 +54,12 @@
"program": "vibe/cli/entrypoint.py",
"args": [],
"console": "integratedTerminal",
"justMyCode": false
"justMyCode": false,
"cwd": "${workspaceFolder}",
"python": "${workspaceFolder}/.venv/bin/python",
"env": {
"PYTHONPATH": "${workspaceFolder}"
}
},
{
"name": "Attach using PID",

View file

@ -5,6 +5,40 @@ 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.10.0] - 2026-05-19
### Added
- GrowthBook A/B testing layer with first system prompt experiment
- `--add-dir` flag to pull additional repository roots into a session
- `--no-autofill` flag for bump_version.py script
- TTY-keyed `--continue` scoping to current terminal
- Improved plan mode readability with live-editable plan display (Ctrl+G to edit)
- ACP dispatch user rating telemetry
- `enable_connectors` config flag to control connector availability
- Connectors migrated to public GA endpoints
### Changed
- Combine git subprocess calls in SessionLogger for improved performance by [@MichisGitIsKing](https://github.com/MichisGitIsKing)
- Indent assistant message content to align with other messages
- Deprecate retrying workflow status and expose retry source
- Drop consecutive user-message merging in LLM backends
### Fixed
- Share session permissions with subagents
- Bump pydantic-settings to >=2.13.0
- Avoid closing in-use backend on agent reload
- Slash commands broken when automatic IDE context is enabled
- Debounce tool approvals to avoid interrupting user typing
- Preserve newline style in search_replace
- Reset cursor position on up-arrow
- Remove hardcoded API key env var in websearch
- Fix whoami hardcoded url
- Pin dependencies in published wheel
## [2.9.6] - 2026-05-11
### Added

View file

@ -607,6 +607,14 @@ vibe --workdir /path/to/project
This is useful when you want to run Vibe from a different location than your current directory.
Use `--add-dir` (repeatable) to make additional directories available to the agent for the duration of the session:
```bash
vibe --add-dir /path/to/other-project --add-dir /path/to/library
```
Each path is implicitly trusted (no trust prompt) and contributes its `AGENTS.md` and `.vibe/` configuration (tools, skills, agents, prompts, hooks) to the session. File-tool permissions treat each `--add-dir` path the same way as your primary working directory — reads and writes inside them don't require the "outside workdir" prompt. Nested paths collapse: passing `/repo` and `/repo/sub` is equivalent to passing just `/repo`.
### Update Settings
#### Auto-Update

View file

@ -1,7 +1,7 @@
id = "mistral-vibe"
name = "Mistral Vibe"
description = "Mistral's open-source coding assistant"
version = "2.9.6"
version = "2.10.0"
schema_version = 1
authors = ["Mistral AI"]
repository = "https://github.com/mistralai/mistral-vibe"
@ -11,21 +11,21 @@ name = "Mistral Vibe"
icon = "./icons/mistral_vibe.svg"
[agent_servers.mistral-vibe.targets.darwin-aarch64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.6/vibe-acp-darwin-aarch64-2.9.6.zip"
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.10.0/vibe-acp-darwin-aarch64-2.10.0.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.darwin-x86_64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.6/vibe-acp-darwin-x86_64-2.9.6.zip"
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.10.0/vibe-acp-darwin-x86_64-2.10.0.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.linux-aarch64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.6/vibe-acp-linux-aarch64-2.9.6.zip"
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.10.0/vibe-acp-linux-aarch64-2.10.0.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.linux-x86_64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.6/vibe-acp-linux-x86_64-2.9.6.zip"
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.10.0/vibe-acp-linux-x86_64-2.10.0.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.windows-x86_64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.6/vibe-acp-windows-x86_64-2.9.6.zip"
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.10.0/vibe-acp-windows-x86_64-2.10.0.zip"
cmd = "./vibe-acp.exe"

View file

@ -1,6 +1,6 @@
[project]
name = "mistral-vibe"
version = "2.9.6"
version = "2.10.0"
description = "Minimal CLI coding agent by Mistral"
readme = "README.md"
requires-python = ">=3.12"
@ -28,42 +28,98 @@ classifiers = [
]
dependencies = [
"agent-client-protocol==0.9.0",
"anyio>=4.12.0",
"cachetools>=5.5.0",
"certifi>=2026.4.22",
"charset-normalizer>=3.4.4",
"cryptography>=44.0.0",
"gitpython>=3.1.46",
"giturlparse>=0.14.0",
"google-auth>=2.0.0",
"httpx>=0.28.1",
"jsonpatch>=1.33",
"keyring>=25.6.0",
"markdownify>=1.2.2",
"mcp>=1.14.0",
"annotated-types==0.7.0",
"anyio==4.13.0",
"attrs==26.1.0",
"beautifulsoup4==4.14.3",
"cachetools==7.0.6",
"certifi==2026.4.22",
"cffi==2.0.0",
"charset-normalizer==3.4.7",
"click==8.3.3 ; sys_platform != 'emscripten'",
"colorama==0.4.6 ; sys_platform == 'win32'",
"cryptography==47.0.0",
"eval-type-backport==0.3.1",
"gitdb==4.0.12",
"gitpython==3.1.47",
"giturlparse==0.14.0",
"google-auth==2.49.2",
"googleapis-common-protos==1.74.0",
"h11==0.16.0",
"httpcore==1.0.9",
"httpx==0.28.1",
"httpx-sse==0.4.3",
"idna==3.13",
"importlib-metadata==8.7.1",
"jaraco-classes==3.4.0",
"jaraco-context==6.1.2",
"jaraco-functools==4.4.0",
"jeepney==0.9.0 ; sys_platform == 'linux'",
"jsonpatch==1.33",
"jsonpath-python==1.1.5",
"jsonpointer==3.1.1",
"jsonschema==4.26.0",
"jsonschema-specifications==2025.9.1",
"keyring==25.7.0",
"linkify-it-py==2.1.0",
"markdown-it-py==4.0.0",
"markdownify==1.2.2",
"mcp==1.27.0",
"mdit-py-plugins==0.5.0",
"mdurl==0.1.2",
"mistralai==2.4.4",
"opentelemetry-api>=1.39.1",
"opentelemetry-exporter-otlp-proto-http>=1.39.1",
"opentelemetry-sdk>=1.39.1",
"opentelemetry-semantic-conventions>=0.60b1",
"packaging>=24.1",
"pexpect>=4.9.0",
"pydantic>=2.12.4",
"pydantic-settings>=2.12.0",
"pyperclip>=1.11.0",
"python-dotenv>=1.0.0",
"pyyaml>=6.0.0",
"requests>=2.20.0",
"rich>=14.0.0",
"sounddevice>=0.5.1",
"more-itertools==11.0.2",
"opentelemetry-api==1.39.1",
"opentelemetry-exporter-otlp-proto-common==1.39.1",
"opentelemetry-exporter-otlp-proto-http==1.39.1",
"opentelemetry-proto==1.39.1",
"opentelemetry-sdk==1.39.1",
"opentelemetry-semantic-conventions==0.60b1",
"packaging==26.2",
"pexpect==4.9.0",
"platformdirs==4.9.6",
"protobuf==6.33.6",
"ptyprocess==0.7.0",
"pyasn1==0.6.3",
"pyasn1-modules==0.4.2",
"pycparser==3.0 ; implementation_name != 'PyPy'",
"pydantic==2.13.3",
"pydantic-core==2.46.3",
"pydantic-settings==2.14.0",
"pygments==2.20.0",
"pyjwt==2.12.1",
"pyperclip==1.11.0",
"python-dateutil==2.9.0.post0",
"python-dotenv==1.2.2",
"python-multipart==0.0.27",
"pywin32==311 ; sys_platform == 'win32'",
"pywin32-ctypes==0.2.3 ; sys_platform == 'win32'",
"pyyaml==6.0.3",
"referencing==0.37.0",
"requests==2.33.1",
"rich==15.0.0",
"rpds-py==0.30.0",
"secretstorage==3.5.0 ; sys_platform == 'linux'",
"six==1.17.0",
"smmap==5.0.3",
"sounddevice==0.5.5",
"soupsieve==2.8.3",
"sse-starlette==3.4.1",
"starlette==1.0.0",
"textual==8.2.4",
"textual-speedups>=0.2.1",
"tomli-w>=1.2.0",
"tree-sitter>=0.25.2",
"tree-sitter-bash>=0.25.1",
"watchfiles>=1.1.1",
"websockets>=13.0",
"zstandard>=0.25.0",
"textual-speedups==0.2.1",
"tomli-w==1.2.0",
"tree-sitter==0.25.2",
"tree-sitter-bash==0.25.1",
"typing-extensions==4.15.0",
"typing-inspection==0.4.2",
"uc-micro-py==2.0.0",
"urllib3==2.6.3",
"uvicorn==0.46.0 ; sys_platform != 'emscripten'",
"watchfiles==1.1.1",
"websockets==16.0",
"zipp==3.23.1",
"zstandard==0.25.0",
]
[project.urls]
@ -109,12 +165,23 @@ dev = [
"pytest-xdist>=3.8.0",
"respx>=0.22.0",
"ruff>=0.14.5",
"tomlkit>=0.13.0",
"twine>=5.0.0",
"typos>=1.34.0",
"vulture>=2.14",
]
build = ["pyinstaller>=6.17.0", "truststore>=0.10.4"]
build = [
"altgraph==0.17.5",
"macholib==1.16.4 ; sys_platform == 'darwin'",
"packaging==26.2",
"pefile==2024.8.26 ; sys_platform == 'win32'",
"pyinstaller==6.20.0",
"pyinstaller-hooks-contrib==2026.4",
"pywin32-ctypes==0.2.3 ; sys_platform == 'win32'",
"setuptools==82.0.1",
"truststore==0.10.4",
]
[tool.pyright]
pythonVersion = "3.12"

View file

@ -18,3 +18,20 @@ uv run scripts/bump_version.py micro
# or
uv run scripts/bump_version.py patch
```
## Releasing
`prepare_release.py` builds the release branch from the previous public release tag, cherry-picks commits from the matching `-private` tags, and (by default) squashes them into a single release commit.
As part of release branch creation, the script **freezes the full transitive dependency graph** into both `[project].dependencies` and `[dependency-groups].build` of `pyproject.toml` using the current `uv.lock`:
```bash
uv export --no-hashes --no-dev --no-emit-project --frozen --format requirements.txt
uv export --only-group build --no-emit-project --no-hashes --frozen --format requirements.txt
```
The pinned `[project].dependencies` is what `uv build` reads in `.github/workflows/release.yml`, so the wheel published to PyPI carries `Requires-Dist:` entries pinned to exact versions (with environment markers preserved). End users installing `mistral-vibe` from PyPI get the same dependency set the team tested against.
The pinned `[dependency-groups].build` is what `uv sync --no-dev --group build` reads in `.github/workflows/build-and-upload.yml`, so the PyInstaller binaries on each release tag are built against the exact same PyInstaller / truststore versions every time.
`main` keeps `>=` ranges, so day-to-day upgrades on `main` (`uv lock --upgrade-package …`, Renovate PRs, etc.) are unaffected. Each new release re-snapshots `uv.lock` — there is no hand-maintained pin list.

View file

@ -79,7 +79,7 @@ def get_current_version() -> str:
return version_match.group(1)
def update_changelog(current_version: str, new_version: str) -> None:
def scaffold_changelog(new_version: str) -> None:
changelog_path = Path("CHANGELOG.md")
if not changelog_path.exists():
@ -104,52 +104,53 @@ def update_changelog(current_version: str, new_version: str) -> None:
updated_content = content[:insert_position] + new_entry + content[insert_position:]
changelog_path.write_text(updated_content)
# Auto-fill changelog using Vibe in headless mode
print("Filling CHANGELOG.md...")
prompt = f"""Fill the new CHANGELOG.md section for version {new_version} (the one that was just added).
Rules:
- Use only commits in origin/main that touch the `vibe` folder in this repo since version {current_version}. Inspect git history to list relevant changes.
- Follow the existing file convention: Keep a Changelog format with ### Added, ### Changed, ### Fixed, ### Removed. One bullet per line, concise. Match the tone and style of the entries already in the file.
- Do not mention commit hashes or PR numbers.
- Remove any subsection that has no bullets (leave no empty ### Added / ### Changed / etc)."""
try:
result = subprocess.run(
["vibe", "-p", prompt], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
)
if result.returncode != 0:
raise RuntimeError("Failed to auto-fill CHANGELOG.md")
except Exception:
print(
"Warning: failed to auto-fill CHANGELOG.md, please fill it manually.",
file=sys.stderr,
)
def fill_whats_new_message(new_version: str) -> None:
def scaffold_whats_new() -> 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("")
print("Filling whats_new.md...")
prompt = f"""Fill vibe/whats_new.md using only the CHANGELOG.md section for version {new_version}.
Rules:
- Include only the most important user-facing changes: visible CLI/UI behavior, new commands or key bindings, UX improvements. Exclude internal refactors, API-only changes, and dev/tooling updates.
- If there are no such changes, write nothing (empty file).
- Otherwise: first line must be "# What's new in v{new_version}" (no extra heading). Then one bullet per item, format: "- **Feature**: short summary" (e.g. - **Interactive resume**: Added a /resume command to choose which session to resume). One line per bullet, concise.
- Do not copy the full changelog; summarize only what matters to someone reading "what's new" in the app."""
def fill_release_notes(current_version: str, new_version: str, autofill: bool) -> None:
if not autofill:
print("Skipping CHANGELOG.md and whats_new.md auto-fill.")
return
print("Filling CHANGELOG.md and vibe/whats_new.md...")
prompt = f"""Fill both CHANGELOG.md and vibe/whats_new.md for version {new_version} in a single pass, reusing the same git history context for both files.
Step 1 Gather context (do this once):
- Inspect git history for commits in origin/main that touch the `vibe` folder since version {current_version}.
- Build a single mental list of relevant changes. Do not mention commit hashes or PR numbers.
Step 2 Fill CHANGELOG.md:
- Edit the section for version {new_version} that was just scaffolded at the top of the file.
- Follow the existing file convention: Keep a Changelog format with ### Added, ### Changed, ### Fixed, ### Removed. One bullet per line, concise. Match the tone and style of existing entries.
- Remove any subsection that has no bullets (leave no empty ### Added / ### Changed / etc).
Step 3 Fill vibe/whats_new.md (reuse the same context, do NOT re-inspect git):
- This file is an in-app announcement shown to users on upgrade. It is NOT a changelog. Its only purpose is to advertise a handful of notable new things. Most releases warrant 0-3 bullets. If nothing is genuinely noteworthy to an end user, leave the file empty.
- Inclusion criteria (a bullet must meet ALL):
* Visible in the CLI/UI: a new command, key binding, screen, flag, or behavior the user will actually notice.
* Net-new capability or a meaningful UX improvement (not a tweak, polish, or fix unless it unblocks a real workflow).
* Worth interrupting the user to tell them about.
- Hard exclusions (never include, even if user-facing): bug fixes, small UI polish, copy changes, refactors, performance tweaks, dependency bumps, internal/API-only changes, config plumbing, telemetry, logging, build/CI, tests, docs.
- Be ruthless. When in doubt, leave it out. Prefer an empty file over a weak bullet. Do NOT pad the list to look substantial.
- Format (only if there is at least one qualifying item):
* First line: "# What's new in v{new_version}" (no other headings).
* Then up to 3 bullets, one line each: "- **Feature**: short summary" (e.g. "- **Interactive resume**: Added a /resume command to choose which session to resume").
* Do not copy or paraphrase the full changelog."""
try:
result = subprocess.run(
["vibe", "-p", prompt], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
)
if result.returncode != 0:
raise RuntimeError("Failed to auto-fill whats_new.md")
raise RuntimeError("Failed to auto-fill release notes")
except Exception:
print(
"Warning: failed to auto-fill whats_new.md, please fill it manually.",
"Warning: failed to auto-fill CHANGELOG.md and whats_new.md, please fill them manually.",
file=sys.stderr,
)
@ -172,8 +173,14 @@ Examples:
parser.add_argument(
"bump_type", choices=BUMP_TYPES, help="Type of version bump to perform"
)
parser.add_argument(
"--no-autofill",
action="store_true",
help="Skip auto-filling CHANGELOG.md and whats_new.md via vibe -p",
)
args = parser.parse_args()
autofill = not args.no_autofill
try:
# Get current version
@ -213,9 +220,11 @@ Examples:
)
print()
update_changelog(current_version=current_version, new_version=new_version)
fill_whats_new_message(new_version=new_version)
scaffold_changelog(new_version=new_version)
scaffold_whats_new()
fill_release_notes(
current_version=current_version, new_version=new_version, autofill=autofill
)
print()
subprocess.run(["uv", "lock"], check=True)

View file

@ -1,4 +1,4 @@
#!/usr/bin/env python3
#!/usr/bin/env -S uv run python
from __future__ import annotations
@ -7,6 +7,10 @@ from pathlib import Path
import re
import subprocess
import sys
from typing import Any, cast
import tomlkit
from tomlkit.items import Array
def run_git_command(
@ -217,6 +221,79 @@ def squash_commits(
print("Successfully created release commit with co-authors")
def get_pinned_dependencies(group: str | None = None) -> list[str]:
cmd = [
"uv",
"export",
"--no-hashes",
"--no-emit-project",
"--frozen",
"--format",
"requirements.txt",
"--no-annotate",
"--no-header",
]
if group is None:
cmd.append("--no-dev")
else:
cmd += ["--only-group", group]
result = subprocess.run(cmd, check=True, capture_output=True, text=True)
pins: list[str] = []
for raw_line in result.stdout.splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
pins.append(line)
if not pins:
target = group or "[project].dependencies"
raise ValueError(f"uv export returned no dependencies for {target}")
return pins
def make_multiline_array(pins: list[str]) -> Array:
arr = tomlkit.array()
arr.extend(pins)
arr.multiline(True)
return arr
def pin_dependencies(version: str) -> None:
print("Pinning dependencies for release...")
pyproject_path = Path("pyproject.toml")
if not pyproject_path.exists():
raise FileNotFoundError("pyproject.toml not found in current directory")
project_pins = get_pinned_dependencies()
build_pins = get_pinned_dependencies(group="build")
doc = tomlkit.parse(pyproject_path.read_text())
cast(dict[str, Any], doc["project"])["dependencies"] = make_multiline_array(
project_pins
)
cast(dict[str, Any], doc["dependency-groups"])["build"] = make_multiline_array(
build_pins
)
pyproject_path.write_text(tomlkit.dumps(doc))
print(
f"Pinned {len(project_pins)} project deps and "
f"{len(build_pins)} build-group deps in pyproject.toml"
)
print("Refreshing uv.lock...")
subprocess.run(["uv", "lock"], check=True)
run_git_command("add", "pyproject.toml", "uv.lock")
run_git_command(
"commit", "--allow-empty", "-m", f"chore: pin dependencies for v{version}"
)
print(f"Committed pinned dependencies for v{version}")
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"
@ -352,7 +429,13 @@ def main() -> None:
# Step 7: Cherry-pick commits
cherry_pick_commits(previous_private_tag, current_private_tag)
# Step 8: Squash commits
# Step 8: Pin dependencies from uv.lock so the published wheel
# has frozen Requires-Dist metadata. When squashing, this commit
# is folded into the release commit; otherwise it stays as the
# final commit on the release branch.
pin_dependencies(current_version)
# Step 9: Squash commits
if squash:
squash_commits(
previous_version,
@ -361,7 +444,7 @@ def main() -> None:
current_private_tag,
)
# Step 8: Get summary information
# Step 10: Get summary information
commits_summary = get_commits_summary(previous_version, current_version)
changelog_entry = get_changelog_entry(current_version)

View file

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

View file

@ -47,12 +47,6 @@ class TestACPNewSession:
cwd=str(Path.cwd()), mcp_servers=[]
)
new_session_events = [
e for e in telemetry_events if e.get("event_name") == "vibe.new_session"
]
assert len(new_session_events) == 1
assert new_session_events[0]["properties"]["entrypoint"] == "acp"
assert session_response.session_id is not None
acp_session = next(
(
@ -63,6 +57,17 @@ class TestACPNewSession:
None,
)
assert acp_session is not None
# Telemetry now fires from the background warm-up worker once
# `wait_until_ready` joins both MCP and experiments. Awaiting it here
# forces emission before assertions.
await acp_session.agent_loop.wait_until_ready()
new_session_events = [
e for e in telemetry_events if e.get("event_name") == "vibe.new_session"
]
assert len(new_session_events) == 1
assert new_session_events[0]["properties"]["entrypoint"] == "acp"
assert (
acp_session.agent_loop.session_logger.session_id
== session_response.session_id

View file

@ -74,6 +74,31 @@ class TestTelemetryNotification:
assert props["file_extensions"] == {".py": 1}
assert props["message_id"] == "msg-abc"
@pytest.mark.asyncio
async def test_user_rating_feedback_dispatches_telemetry(
self, acp_agent_loop: VibeAcpAgentLoop, telemetry_events: list[dict[str, Any]]
) -> None:
session = await acp_agent_loop.new_session(cwd=str(Path.cwd()), mcp_servers=[])
telemetry_events.clear()
await acp_agent_loop.ext_notification(
"telemetry/send",
{
"event": "vibe.user_rating_feedback",
"session_id": session.session_id,
"properties": {"rating": 1},
},
)
rating_events = [
e
for e in telemetry_events
if e["event_name"] == "vibe.user_rating_feedback"
]
assert len(rating_events) == 1
props = rating_events[0]["properties"]
assert props["rating"] == 1
@pytest.mark.asyncio
async def test_raises_on_invalid_params(
self, acp_agent_loop: VibeAcpAgentLoop

View file

@ -139,7 +139,7 @@ class TestPrepareRequest:
{"role": "user", "content": "Hi"},
]
def test_consecutive_user_messages_are_merged(self, adapter, provider):
def test_consecutive_user_messages_are_preserved(self, adapter, provider):
payload = _prepare(
adapter,
provider,
@ -148,7 +148,10 @@ class TestPrepareRequest:
LLMMessage(role=Role.user, content="Again"),
],
)
assert payload["input"] == [{"role": "user", "content": "Hi\n\nAgain"}]
assert payload["input"] == [
{"role": "user", "content": "Hi"},
{"role": "user", "content": "Again"},
]
def test_multiple_system_messages_are_preserved(self, adapter, provider):
payload = _prepare(

View file

@ -1,7 +1,8 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
from urllib.parse import urlencode
from vibe.setup.auth import (
@ -11,6 +12,7 @@ from vibe.setup.auth import (
BrowserSignInPollResult,
BrowserSignInProcess,
)
from vibe.setup.auth.browser_sign_in import BrowserSignInService
@dataclass
@ -42,6 +44,7 @@ class StubBrowserSignInGateway(BrowserSignInGateway):
self.polled_urls: list[str] = []
self.exchange_requests: list[ExchangeRequestPayload] = []
self.closed = False
self.poll_calls = 0
self.process_number = 0
async def create_process(self, code_challenge: str) -> BrowserSignInProcess:
@ -55,10 +58,10 @@ class StubBrowserSignInGateway(BrowserSignInGateway):
async def poll(self, poll_url: str) -> BrowserSignInPollResult:
self.polled_urls.append(poll_url)
self.poll_calls += 1
if not self._poll_results:
msg = "StubBrowserSignInGateway requires scripted poll results."
raise AssertionError(msg)
result = self._poll_results.pop(0)
if isinstance(result, BrowserSignInError):
raise result
@ -109,5 +112,80 @@ def build_poll_failed_error() -> BrowserSignInError:
)
def build_poll_results_from_outcomes(
outcomes: list[str],
) -> tuple[
list[BrowserSignInProcess], list[BrowserSignInPollResult | BrowserSignInError]
]:
processes: list[BrowserSignInProcess] = []
poll_results: list[BrowserSignInPollResult | BrowserSignInError] = []
now = datetime(2026, 3, 16, tzinfo=UTC)
for process_index, outcome in enumerate(outcomes, start=1):
process_id = f"process-{process_index}"
processes.append(build_sign_in_process(now, process_id=process_id))
match outcome:
case "completed":
poll_results.extend([
BrowserSignInPollResult(status="pending"),
BrowserSignInPollResult(
status="completed", exchange_token=f"exchange-{process_id}"
),
])
case "expired":
poll_results.append(BrowserSignInPollResult(status="expired"))
case "poll_failed":
poll_results.extend([
BrowserSignInPollResult(status="pending"),
build_poll_failed_error(),
build_poll_failed_error(),
build_poll_failed_error(),
])
case _:
msg = f"Unsupported browser sign-in outcome: {outcome}"
raise AssertionError(msg)
return processes, poll_results
async def noop_sleep(_: float) -> None:
return None
def build_browser_sign_in_service_factory(
outcomes: list[str],
*,
exchange_result: str = "sk-browser-onboarding-test-key",
open_browser: Callable[[str], bool] | None = None,
sleep: Callable[[float], Awaitable[None]] = noop_sleep,
now: Callable[[], datetime] | None = None,
) -> tuple[
StubBrowserSignInGateway,
Callable[[], BrowserSignInService],
list[BrowserSignInService],
]:
processes, poll_results = build_poll_results_from_outcomes(outcomes)
gateway = StubBrowserSignInGateway(
processes=processes, poll_results=poll_results, exchange_result=exchange_result
)
created_services: list[BrowserSignInService] = []
def build_service() -> BrowserSignInService:
service = BrowserSignInService(
gateway,
open_browser=open_browser or (lambda _: True),
sleep=sleep,
now=now
or (
lambda: (
datetime(2026, 3, 16, tzinfo=UTC)
+ timedelta(seconds=gateway.poll_calls)
)
),
poll_interval=0,
)
created_services.append(service)
return service
return gateway, build_service, created_services

View file

@ -23,6 +23,7 @@ from vibe.setup.auth import (
BrowserSignInPollResult,
BrowserSignInProcess,
BrowserSignInService,
BrowserSignInStatus,
)
TEST_NOW = datetime(2026, 3, 16, tzinfo=UTC)
@ -68,7 +69,7 @@ def build_test_service(
@pytest.mark.asyncio
async def test_authenticate_returns_api_key_after_pending_poll() -> None:
opened_urls: list[str] = []
statuses: list[str] = []
statuses: list[BrowserSignInStatus] = []
gateway, service = build_test_service(
poll_results=[
BrowserSignInPollResult(status="pending"),
@ -84,10 +85,10 @@ async def test_authenticate_returns_api_key_after_pending_poll() -> None:
assert api_key == "sk-browser-key"
assert opened_urls == [TEST_SIGN_IN_URL]
assert statuses == [
"opening_browser",
"waiting_for_browser_sign_in",
"exchanging",
"completed",
BrowserSignInStatus.OPENING_BROWSER,
BrowserSignInStatus.WAITING_FOR_BROWSER_SIGN_IN,
BrowserSignInStatus.EXCHANGING,
BrowserSignInStatus.COMPLETED,
]
assert gateway.polled_urls == [TEST_POLL_URL, TEST_POLL_URL]
assert gateway.exchange_requests[0].exchange_token == "exchange-1"

View file

@ -4,6 +4,7 @@ import httpx
import pytest
import respx
from tests.conftest import build_test_vibe_config
from vibe.cli.plan_offer.adapters.http_whoami_gateway import HttpWhoAmIGateway
from vibe.cli.plan_offer.ports.whoami_gateway import (
WhoAmIGatewayError,
@ -11,6 +12,7 @@ from vibe.cli.plan_offer.ports.whoami_gateway import (
WhoAmIPlanType,
WhoAmIResponse,
)
from vibe.core.config import DEFAULT_CONSOLE_BASE_URL
@pytest.mark.asyncio
@ -170,3 +172,53 @@ async def test_return_unknown_plan_on_unsupported_plan_type(
plan_name="INDIVIDUAL",
prompt_switching_to_pro_plan=False,
)
@pytest.mark.asyncio
async def test_gateway_calls_custom_console_base_url_from_config(
respx_mock: respx.MockRouter,
) -> None:
custom_url = "https://custom-console.example.com"
config = build_test_vibe_config(console_base_url=custom_url)
route = respx_mock.get(f"{custom_url}/api/vibe/whoami").mock(
return_value=httpx.Response(
200,
json={
"plan_type": "CHAT",
"plan_name": "INDIVIDUAL",
"prompt_switching_to_pro_plan": False,
},
)
)
gateway = HttpWhoAmIGateway(base_url=config.console_base_url)
response = await gateway.whoami("api-key")
assert route.called
assert response.plan_type == "CHAT"
@pytest.mark.asyncio
async def test_gateway_uses_default_console_url_when_not_configured(
respx_mock: respx.MockRouter,
) -> None:
config = build_test_vibe_config()
assert config.console_base_url == DEFAULT_CONSOLE_BASE_URL
route = respx_mock.get(f"{DEFAULT_CONSOLE_BASE_URL}/api/vibe/whoami").mock(
return_value=httpx.Response(
200,
json={
"plan_type": "CHAT",
"plan_name": "INDIVIDUAL",
"prompt_switching_to_pro_plan": False,
},
)
)
gateway = HttpWhoAmIGateway(base_url=config.console_base_url)
await gateway.whoami("api-key")
assert route.called

View file

@ -0,0 +1,78 @@
from __future__ import annotations
from unittest.mock import patch
from pydantic import BaseModel
import pytest
from vibe.cli.textual_ui.widgets.approval_app import ApprovalApp
from vibe.core.config import VibeConfig
_TEST_GRACE_PERIOD_S = 0.5
class FakeArgs(BaseModel):
command: str = "echo hello"
@pytest.fixture
def approval_app(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
"vibe.cli.textual_ui.widgets.approval_app._INPUT_GRACE_PERIOD_S",
_TEST_GRACE_PERIOD_S,
)
config = VibeConfig()
app = ApprovalApp(tool_name="bash", tool_args=FakeArgs(), config=config)
app._mount_time = 100.0
return app
class TestGracePeriod:
def test_actions_ignored_within_grace_period(self, approval_app: ApprovalApp):
with (
patch("vibe.cli.textual_ui.widgets.approval_app.time") as mock_time,
patch.object(approval_app, "post_message") as posted,
):
mock_time.monotonic.return_value = 100.0 + _TEST_GRACE_PERIOD_S - 0.01
assert approval_app.is_within_grace_period()
approval_app.action_select()
approval_app.action_select_1()
approval_app.action_select_2()
approval_app.action_select_3()
approval_app.action_reject()
posted.assert_not_called()
def test_actions_post_messages_after_grace_period(self, approval_app: ApprovalApp):
with (
patch("vibe.cli.textual_ui.widgets.approval_app.time") as mock_time,
patch.object(approval_app, "post_message") as posted,
):
mock_time.monotonic.return_value = 100.0 + _TEST_GRACE_PERIOD_S + 0.01
assert not approval_app.is_within_grace_period()
approval_app.action_select_1()
approval_app.action_reject()
assert posted.call_count == 2
assert isinstance(
posted.call_args_list[0].args[0], ApprovalApp.ApprovalGranted
)
assert isinstance(
posted.call_args_list[1].args[0], ApprovalApp.ApprovalRejected
)
def test_arrow_keys_work_during_grace_period(self, approval_app: ApprovalApp):
with (
patch("vibe.cli.textual_ui.widgets.approval_app.time") as mock_time,
patch.object(approval_app, "_update_options"),
):
mock_time.monotonic.return_value = 100.0 + 0.01
assert approval_app.is_within_grace_period()
assert approval_app.selected_option == 0
approval_app.action_move_down()
assert approval_app.selected_option == 1
approval_app.action_move_up()
assert approval_app.selected_option == 0

View file

@ -21,6 +21,7 @@ def _make_args(**overrides: object) -> argparse.Namespace:
"agent": "default",
"setup": False,
"workdir": None,
"add_dir": [],
"trust": False,
"teleport": False,
"continue_session": False,

View file

@ -0,0 +1,111 @@
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from textual import events
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
from vibe.core.tools.builtins.ask_user_question import (
AskUserQuestionArgs,
Choice,
Question,
)
_TEST_GRACE_PERIOD_S = 0.5
@pytest.fixture
def question_app(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
"vibe.cli.textual_ui.widgets.question_app._INPUT_GRACE_PERIOD_S",
_TEST_GRACE_PERIOD_S,
)
args = AskUserQuestionArgs(
questions=[
Question(
question="Pick one",
header="Pick",
options=[Choice(label="A"), Choice(label="B")],
)
]
)
app = QuestionApp(args)
app._mount_time = 100.0
return app
class TestQuestionAppGracePeriod:
def test_select_and_cancel_ignored_within_grace_period(
self, question_app: QuestionApp
):
with (
patch("vibe.cli.textual_ui.widgets.question_app.time") as mock_time,
patch.object(question_app, "post_message") as posted,
):
mock_time.monotonic.return_value = 100.0 + _TEST_GRACE_PERIOD_S - 0.01
assert question_app.is_within_grace_period()
question_app.action_select()
question_app.action_cancel()
posted.assert_not_called()
def test_cancel_posts_message_after_grace_period(self, question_app: QuestionApp):
with (
patch("vibe.cli.textual_ui.widgets.question_app.time") as mock_time,
patch.object(question_app, "post_message") as posted,
):
mock_time.monotonic.return_value = 100.0 + _TEST_GRACE_PERIOD_S + 0.01
question_app.action_cancel()
posted.assert_called_once()
assert isinstance(posted.call_args.args[0], QuestionApp.Cancelled)
def test_navigation_works_during_grace_period(self, question_app: QuestionApp):
with patch("vibe.cli.textual_ui.widgets.question_app.time") as mock_time:
mock_time.monotonic.return_value = 100.0 + 0.01
assert question_app.is_within_grace_period()
assert question_app.selected_option == 0
question_app.action_move_down()
assert question_app.selected_option == 1
question_app.action_move_up()
assert question_app.selected_option == 0
def test_number_key_consumed_but_not_acted_within_grace_period(
self, question_app: QuestionApp
):
with (
patch("vibe.cli.textual_ui.widgets.question_app.time") as mock_time,
patch.object(question_app, "post_message") as posted,
):
mock_time.monotonic.return_value = 100.0 + _TEST_GRACE_PERIOD_S - 0.01
event = MagicMock(spec=events.Key)
event.character = "1"
handled = question_app._handle_number_key(event)
assert handled is True
event.stop.assert_called_once()
event.prevent_default.assert_called_once()
posted.assert_not_called()
def test_number_key_selects_option_after_grace_period(
self, question_app: QuestionApp
):
with (
patch("vibe.cli.textual_ui.widgets.question_app.time") as mock_time,
patch.object(question_app, "post_message") as posted,
):
mock_time.monotonic.return_value = 100.0 + _TEST_GRACE_PERIOD_S + 0.01
event = MagicMock(spec=events.Key)
event.character = "1"
handled = question_app._handle_number_key(event)
assert handled is True
assert question_app.selected_option == 0
posted.assert_called_once()
assert isinstance(posted.call_args.args[0], QuestionApp.Answered)

View file

@ -0,0 +1,35 @@
from __future__ import annotations
import pytest
from vibe.cli.textual_ui.app import _TYPING_DEBOUNCE_ENV_VAR, _resolve_typing_debounce_s
_TEST_DEFAULT_DEBOUNCE_MS = 1000
@pytest.fixture(autouse=True)
def _restore_default_debounce(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"vibe.cli.textual_ui.app._DEFAULT_TYPING_DEBOUNCE_MS", _TEST_DEFAULT_DEBOUNCE_MS
)
class TestTypingDebounceEnvVar:
@pytest.mark.parametrize(
("env_value", "expected_s"), [("500", 0.5), ("2000", 2.0), ("0", 0.0)]
)
def test_env_var_override(
self, env_value: str, expected_s: float, monkeypatch: pytest.MonkeyPatch
):
monkeypatch.setenv(_TYPING_DEBOUNCE_ENV_VAR, env_value)
assert _resolve_typing_debounce_s() == expected_s
@pytest.mark.parametrize("env_value", [None, "not-a-number", "-100"])
def test_falls_back_to_default(
self, env_value: str | None, monkeypatch: pytest.MonkeyPatch
):
if env_value is None:
monkeypatch.delenv(_TYPING_DEBOUNCE_ENV_VAR, raising=False)
else:
monkeypatch.setenv(_TYPING_DEBOUNCE_ENV_VAR, env_value)
assert _resolve_typing_debounce_s() == _TEST_DEFAULT_DEBOUNCE_MS / 1000

View file

@ -3,13 +3,14 @@ from __future__ import annotations
import pytest
from vibe.cli.textual_ui.session_exit import print_session_resume_message
from vibe.core.config import SessionLoggingConfig
from vibe.core.types import AgentStats
def test_print_session_resume_message_skips_output_without_session_id(
capsys: pytest.CaptureFixture[str],
) -> None:
print_session_resume_message(None, AgentStats())
print_session_resume_message(None, AgentStats(), SessionLoggingConfig())
assert capsys.readouterr().out == ""
@ -20,6 +21,7 @@ def test_print_session_resume_message_prints_resume_commands_and_usage(
print_session_resume_message(
"12345678-1234-1234-1234-123456789abc",
AgentStats(session_prompt_tokens=14_867, session_completion_tokens=6),
SessionLoggingConfig(),
)
assert capsys.readouterr().out == (
@ -34,7 +36,7 @@ def test_print_session_resume_message_prints_resume_commands_and_usage(
def test_print_session_resume_message_prints_zero_usage_for_resumed_run_without_llm_activity(
capsys: pytest.CaptureFixture[str],
) -> None:
print_session_resume_message("12345678", AgentStats())
print_session_resume_message("12345678", AgentStats(), SessionLoggingConfig())
assert capsys.readouterr().out == (
"\n"

View file

@ -164,6 +164,18 @@ def _disable_feedback_bar(monkeypatch: pytest.MonkeyPatch) -> None:
)
@pytest.fixture(autouse=True)
def _disable_input_grace_periods(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"vibe.cli.textual_ui.widgets.approval_app._INPUT_GRACE_PERIOD_S", 0
)
monkeypatch.setattr(
"vibe.cli.textual_ui.widgets.question_app._INPUT_GRACE_PERIOD_S", 0
)
monkeypatch.setattr("vibe.cli.textual_ui.app._DEFAULT_TYPING_DEBOUNCE_MS", 0)
monkeypatch.delenv("VIBE_TYPING_GRACE_PERIOD_MS", raising=False)
@pytest.fixture(autouse=True)
def telemetry_events(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]:
events: list[dict[str, Any]] = []

View file

View file

@ -0,0 +1,116 @@
from __future__ import annotations
import logging
import httpx
import pytest
import respx
from vibe.core.experiments._constants import build_eval_url
from vibe.core.experiments.active import ExperimentName
from vibe.core.experiments.client import RemoteEvalClient
from vibe.core.experiments.models import ExperimentAttributes
_TEST_API_HOST = "https://growthbook.test"
_TEST_CLIENT_KEY = "sdk-test"
_TEST_EVAL_URL = build_eval_url(_TEST_API_HOST, _TEST_CLIENT_KEY)
assert _TEST_EVAL_URL is not None
def _attrs() -> ExperimentAttributes:
return ExperimentAttributes(
userId="hashed",
entrypoint="cli",
agent_version="1.2.3",
client_name="vibe",
client_version="1.2.3",
os="darwin",
)
def _make_client() -> RemoteEvalClient:
return RemoteEvalClient.from_settings(_TEST_API_HOST, _TEST_CLIENT_KEY)
@pytest.mark.asyncio
@respx.mock
async def test_evaluate_happy_path() -> None:
payload = {
"features": {
ExperimentName.SYSTEM_PROMPT.value: {
"defaultValue": "cli",
"rules": [{"force": "cli_v2", "tracks": []}],
}
}
}
route = respx.post(_TEST_EVAL_URL).mock(
return_value=httpx.Response(200, json=payload)
)
client = _make_client()
response = await client.evaluate(_attrs())
await client.aclose()
assert route.called
request_body = route.calls.last.request.read()
assert b'"userId":"hashed"' in request_body
assert b'"forcedVariations":{}' in request_body
assert b'"forcedFeatures":[]' in request_body
assert response is not None
assert (
response.features[ExperimentName.SYSTEM_PROMPT.value].resolved_value()
== "cli_v2"
)
@pytest.fixture
def _silence_vibe_logger(caplog: pytest.LogCaptureFixture) -> None:
# Failure-path tests legitimately emit WARNING logs. Silence the vibe
# logger so they don't leak into ~/.vibe/logs/vibe.log (the file handler
# is bound to the real path at module import, before VIBE_HOME is
# monkeypatched in the conftest fixture).
caplog.set_level(logging.CRITICAL, logger="vibe")
@pytest.mark.asyncio
@respx.mock
async def test_evaluate_returns_none_on_5xx(_silence_vibe_logger: None) -> None:
respx.post(_TEST_EVAL_URL).mock(return_value=httpx.Response(500, text="oops"))
client = _make_client()
assert await client.evaluate(_attrs()) is None
await client.aclose()
@pytest.mark.asyncio
@respx.mock
async def test_evaluate_returns_none_on_network_error(
_silence_vibe_logger: None,
) -> None:
respx.post(_TEST_EVAL_URL).mock(side_effect=httpx.ConnectError("nope"))
client = _make_client()
assert await client.evaluate(_attrs()) is None
await client.aclose()
@pytest.mark.asyncio
@respx.mock
async def test_evaluate_returns_none_on_invalid_json(
_silence_vibe_logger: None,
) -> None:
respx.post(_TEST_EVAL_URL).mock(return_value=httpx.Response(200, text="not json"))
client = _make_client()
assert await client.evaluate(_attrs()) is None
await client.aclose()
@pytest.mark.asyncio
async def test_evaluate_skips_request_when_url_unset() -> None:
client = RemoteEvalClient.from_settings(api_host="", client_key="sdk-x")
assert await client.evaluate(_attrs()) is None
await client.aclose()
@pytest.mark.asyncio
async def test_aclose_is_idempotent() -> None:
client = _make_client()
await client.aclose()
await client.aclose()

View file

@ -0,0 +1,427 @@
from __future__ import annotations
from typing import Any
import pytest
from vibe.core.experiments.active import DEFAULT_VARIANTS, ExperimentName
from vibe.core.experiments.client import RemoteEvalClient
from vibe.core.experiments.manager import ExperimentManager, hash_api_key
from vibe.core.experiments.models import EvalResponse, ExperimentAttributes
class _StubClient(RemoteEvalClient):
def __init__(self, response: EvalResponse | None) -> None:
self._response = response
self.calls: list[ExperimentAttributes] = []
async def evaluate(self, attributes: ExperimentAttributes) -> EvalResponse | None:
self.calls.append(attributes)
return self._response
async def aclose(self) -> None:
pass
def _attrs() -> ExperimentAttributes:
return ExperimentAttributes(
userId="x", entrypoint="cli", agent_version="0", os="darwin"
)
def _response(features: dict[str, Any]) -> EvalResponse:
return EvalResponse.model_validate({"features": features})
def test_hash_api_key_is_stable_and_anonymous() -> None:
a = hash_api_key("sk-abc")
b = hash_api_key("sk-abc")
assert a == b
assert "sk-" not in a
assert len(a) == 32
def test_hash_api_key_differs_per_key() -> None:
assert hash_api_key("sk-abc") != hash_api_key("sk-def")
@pytest.mark.asyncio
async def test_get_variant_returns_default_when_uninitialized() -> None:
manager = ExperimentManager(client=_StubClient(None))
assert manager.get_variant(ExperimentName.SYSTEM_PROMPT) == "cli"
@pytest.mark.asyncio
async def test_get_variant_or_none_returns_none_when_unassigned() -> None:
manager = ExperimentManager(client=_StubClient(None))
assert manager.get_variant_or_none(ExperimentName.SYSTEM_PROMPT) is None
@pytest.mark.asyncio
async def test_get_variant_or_none_returns_override() -> None:
manager = ExperimentManager(
client=_StubClient(None), overrides={ExperimentName.SYSTEM_PROMPT.value: "lean"}
)
assert manager.get_variant_or_none(ExperimentName.SYSTEM_PROMPT) == "lean"
@pytest.mark.asyncio
async def test_get_variant_or_none_returns_resolved_value() -> None:
response = _response({
ExperimentName.SYSTEM_PROMPT.value: {
"defaultValue": "cli",
"rules": [{"force": "explore", "tracks": []}],
}
})
manager = ExperimentManager(client=_StubClient(response))
await manager.initialize(_attrs())
assert manager.get_variant_or_none(ExperimentName.SYSTEM_PROMPT) == "explore"
@pytest.mark.asyncio
async def test_get_variant_returns_resolved_value() -> None:
response = _response({
ExperimentName.SYSTEM_PROMPT.value: {
"defaultValue": "cli",
"rules": [{"force": "cli_v2", "tracks": []}],
}
})
manager = ExperimentManager(client=_StubClient(response))
await manager.initialize(_attrs())
assert manager.get_variant(ExperimentName.SYSTEM_PROMPT) == "cli_v2"
@pytest.mark.asyncio
async def test_overrides_take_precedence_over_remote() -> None:
response = _response({
ExperimentName.SYSTEM_PROMPT.value: {
"defaultValue": "cli",
"rules": [{"force": "cli_v2", "tracks": []}],
}
})
manager = ExperimentManager(
client=_StubClient(response),
overrides={ExperimentName.SYSTEM_PROMPT.value: "forced"},
)
await manager.initialize(_attrs())
assert manager.get_variant(ExperimentName.SYSTEM_PROMPT) == "forced"
@pytest.mark.asyncio
async def test_get_variant_falls_back_when_remote_returns_no_match() -> None:
response = _response({"some_other_feature": {"defaultValue": True}})
manager = ExperimentManager(client=_StubClient(response))
await manager.initialize(_attrs())
assert manager.get_variant(ExperimentName.SYSTEM_PROMPT) == "cli"
@pytest.mark.asyncio
async def test_assignments_uses_resolved_variant_value() -> None:
response = _response({
ExperimentName.SYSTEM_PROMPT.value: {
"defaultValue": "cli",
"rules": [
{
"force": "cli_v2",
"tracks": [
{
"experiment": {"key": ExperimentName.SYSTEM_PROMPT.value},
"result": {
"key": "1",
"variationId": 1,
"inExperiment": True,
},
}
],
}
],
}
})
manager = ExperimentManager(client=_StubClient(response))
await manager.initialize(_attrs())
assignments = manager.assignments()
assert assignments == {ExperimentName.SYSTEM_PROMPT.value: "cli_v2"}
@pytest.mark.asyncio
async def test_assignments_prefers_track_result_value() -> None:
# When the GrowthBook payload carries an explicit per-arm value, it wins
# over the rule's force value — it's the most precise label for the user.
response = _response({
ExperimentName.SYSTEM_PROMPT.value: {
"defaultValue": "cli",
"rules": [
{
"force": "cli_v2",
"tracks": [
{
"experiment": {"key": ExperimentName.SYSTEM_PROMPT.value},
"result": {
"key": "1",
"variationId": 1,
"value": "cli_v3",
"inExperiment": True,
},
}
],
}
],
}
})
manager = ExperimentManager(client=_StubClient(response))
await manager.initialize(_attrs())
assert manager.assignments() == {ExperimentName.SYSTEM_PROMPT.value: "cli_v3"}
@pytest.mark.asyncio
async def test_assignments_keys_on_feature_id_when_experiment_key_differs() -> None:
# GrowthBook feature ID and experiment key can diverge. Telemetry must
# key on the feature ID (matches ExperimentName) so overrides, lookups
# and downstream analysis stay consistent.
response = _response({
ExperimentName.SYSTEM_PROMPT.value: {
"defaultValue": "cli",
"rules": [
{
"force": "cli_v2",
"tracks": [
{
"experiment": {"key": "vibe-code-cli-system-prompt"},
"result": {
"key": "1",
"variationId": 1,
"inExperiment": True,
"featureId": ExperimentName.SYSTEM_PROMPT.value,
},
}
],
}
],
}
})
manager = ExperimentManager(client=_StubClient(response))
await manager.initialize(_attrs())
assignments = manager.assignments()
assert assignments == {ExperimentName.SYSTEM_PROMPT.value: "cli_v2"}
assert "vibe-code-cli-system-prompt" not in assignments
@pytest.mark.asyncio
async def test_initialize_does_nothing_on_failed_eval() -> None:
manager = ExperimentManager(client=_StubClient(None))
await manager.initialize(_attrs())
assert manager.assignments() == {}
for name in ExperimentName:
assert manager.get_variant(name) == DEFAULT_VARIANTS[name]
def test_experiment_attributes_default_custom_system_prompt_to_false() -> None:
attrs = ExperimentAttributes(
userId="x", entrypoint="cli", agent_version="0", os="darwin"
)
assert attrs.custom_system_prompt is False
assert attrs.model_dump(exclude_none=True)["custom_system_prompt"] is False
def test_experiment_attributes_serializes_custom_system_prompt() -> None:
attrs = ExperimentAttributes(
userId="x",
entrypoint="cli",
agent_version="0",
os="darwin",
custom_system_prompt=True,
)
assert attrs.model_dump(exclude_none=True)["custom_system_prompt"] is True
def test_export_state_returns_none_before_initialize() -> None:
manager = ExperimentManager(client=_StubClient(None))
assert manager.export_state() is None
@pytest.mark.asyncio
async def test_export_state_returns_response_after_initialize() -> None:
response = _response({
ExperimentName.SYSTEM_PROMPT.value: {
"defaultValue": "cli",
"rules": [{"force": "cli_v2", "tracks": []}],
}
})
manager = ExperimentManager(client=_StubClient(response))
await manager.initialize(_attrs())
assert manager.export_state() == response
def test_hydrate_does_not_call_client() -> None:
response = _response({
ExperimentName.SYSTEM_PROMPT.value: {
"defaultValue": "cli",
"rules": [{"force": "cli_v2", "tracks": []}],
}
})
stub = _StubClient(response)
manager = ExperimentManager(client=stub)
manager.hydrate(response)
assert stub.calls == []
def test_hydrate_makes_get_variant_match_initialized_manager() -> None:
response = _response({
ExperimentName.SYSTEM_PROMPT.value: {
"defaultValue": "cli",
"rules": [
{
"force": "cli_v2",
"tracks": [
{
"experiment": {"key": ExperimentName.SYSTEM_PROMPT.value},
"result": {
"key": "1",
"variationId": 1,
"inExperiment": True,
},
}
],
}
],
}
})
hydrated = ExperimentManager(client=_StubClient(None))
hydrated.hydrate(response)
assert hydrated.get_variant(ExperimentName.SYSTEM_PROMPT) == "cli_v2"
assert hydrated.assignments() == {ExperimentName.SYSTEM_PROMPT.value: "cli_v2"}
@pytest.mark.asyncio
async def test_initialize_twice_replaces_response() -> None:
# Session reset re-runs initialize_experiments, which calls manager.initialize
# again. The second response must replace the first so the new session sees
# the up-to-date variant assignment.
first = _response({
ExperimentName.SYSTEM_PROMPT.value: {
"defaultValue": "cli",
"rules": [{"force": "explore", "tracks": []}],
}
})
second = _response({
ExperimentName.SYSTEM_PROMPT.value: {
"defaultValue": "cli",
"rules": [{"force": "lean", "tracks": []}],
}
})
class _ScriptedClient(RemoteEvalClient):
def __init__(self, responses: list[EvalResponse]) -> None:
self._responses = responses
self.call_count = 0
async def evaluate(
self, attributes: ExperimentAttributes
) -> EvalResponse | None:
response = self._responses[self.call_count]
self.call_count += 1
return response
async def aclose(self) -> None:
pass
client = _ScriptedClient([first, second])
manager = ExperimentManager(client=client)
await manager.initialize(_attrs())
assert manager.get_variant(ExperimentName.SYSTEM_PROMPT) == "explore"
await manager.initialize(_attrs())
assert manager.get_variant(ExperimentName.SYSTEM_PROMPT) == "lean"
assert client.call_count == 2
@pytest.mark.asyncio
async def test_assignments_excludes_tracks_not_in_experiment() -> None:
# Holdouts and forced overrides come back with inExperiment=False.
# We must NOT report them as active experiment participants — that would
# pollute downstream A/B analysis since the assignment map IS the
# exposure record (no separate exposure event).
response = _response({
ExperimentName.SYSTEM_PROMPT.value: {
"defaultValue": "cli",
"rules": [
{
"force": "cli",
"tracks": [
{
"experiment": {"key": ExperimentName.SYSTEM_PROMPT.value},
"result": {
"key": "0",
"variationId": 0,
"inExperiment": False,
},
}
],
}
],
}
})
manager = ExperimentManager(client=_StubClient(response))
await manager.initialize(_attrs())
assert manager.assignments() == {}
@pytest.mark.asyncio
async def test_assignments_excludes_tracks_with_missing_in_experiment() -> None:
# Defensive: if the proxy returns a track without inExperiment, treat it
# as "not confirmed in the experiment" rather than silently counting it.
response = _response({
ExperimentName.SYSTEM_PROMPT.value: {
"defaultValue": "cli",
"rules": [
{
"force": "cli_v2",
"tracks": [
{
"experiment": {"key": ExperimentName.SYSTEM_PROMPT.value},
"result": {"key": "1", "variationId": 1},
}
],
}
],
}
})
manager = ExperimentManager(client=_StubClient(response))
await manager.initialize(_attrs())
assert manager.assignments() == {}
@pytest.mark.asyncio
async def test_initialize_drops_unknown_features() -> None:
# GrowthBook returns every feature defined in the org. Only the ones
# listed in ExperimentName should survive into manager state and the
# persisted snapshot.
response = _response({
ExperimentName.SYSTEM_PROMPT.value: {"defaultValue": "cli"},
"unrelated_org_flag": {"defaultValue": True},
"neko": {"defaultValue": False},
"gbdemo-checkout-layout": {"defaultValue": "dev"},
})
manager = ExperimentManager(client=_StubClient(response))
await manager.initialize(_attrs())
snapshot = manager.export_state()
assert snapshot is not None
assert set(snapshot.features.keys()) == {ExperimentName.SYSTEM_PROMPT.value}
def test_hydrate_drops_unknown_features() -> None:
# When resuming a session saved by an older vibe version, the snapshot
# may contain experiments that have since been retired. Filter them on
# hydrate so the in-memory state matches the current ExperimentName.
legacy_response = _response({
ExperimentName.SYSTEM_PROMPT.value: {"defaultValue": "cli"},
"retired_experiment": {"defaultValue": "control"},
})
manager = ExperimentManager(client=_StubClient(None))
manager.hydrate(legacy_response)
snapshot = manager.export_state()
assert snapshot is not None
assert set(snapshot.features.keys()) == {ExperimentName.SYSTEM_PROMPT.value}

View file

@ -0,0 +1,68 @@
from __future__ import annotations
from vibe.core.experiments.active import ExperimentName
from vibe.core.experiments.models import EvalResponse, FeatureDefinition, FeatureRule
class TestFeatureDefinition:
def test_resolved_value_returns_force_when_present(self) -> None:
feature = FeatureDefinition(
defaultValue="cli", rules=[FeatureRule(force="cli_v2")]
)
assert feature.resolved_value() == "cli_v2"
def test_resolved_value_falls_back_to_default(self) -> None:
feature = FeatureDefinition(defaultValue="cli")
assert feature.resolved_value() == "cli"
def test_resolved_value_picks_first_rule_with_force(self) -> None:
feature = FeatureDefinition(
defaultValue="cli",
rules=[FeatureRule(force=None), FeatureRule(force="cli_v2")],
)
assert feature.resolved_value() == "cli_v2"
class TestEvalResponse:
def test_parses_real_proxy_payload(self) -> None:
# A reduced version of the live response captured during dev probing.
sp_key = ExperimentName.SYSTEM_PROMPT.value
raw = {
"features": {
"rbac_enabled": {"defaultValue": True},
sp_key: {
"defaultValue": "cli",
"rules": [
{
"force": "cli_v2",
"tracks": [
{
"experiment": {
"key": sp_key,
"variations": [{}, "cli_v2"],
},
"result": {
"key": "1",
"variationId": 1,
"value": "cli_v2",
"inExperiment": True,
"hashAttribute": "id",
"hashValue": "abc",
},
}
],
}
],
},
},
"experiments": [],
"dateUpdated": "2026-05-11T13:29:48.394Z",
}
response = EvalResponse.model_validate(raw)
assert response.features["rbac_enabled"].defaultValue is True
sp = response.features[sp_key]
assert sp.resolved_value() == "cli_v2"
track = sp.rules[0].tracks[0]
assert track.experiment.key == sp_key
assert track.result.variationId == 1
assert track.result.inExperiment is True

View file

@ -0,0 +1,168 @@
from __future__ import annotations
import json
from pathlib import Path
import pytest
from tests.conftest import build_test_vibe_config
from vibe.core.agents import AgentManager
from vibe.core.experiments.active import ExperimentName
from vibe.core.experiments.client import RemoteEvalClient
from vibe.core.experiments.manager import ExperimentManager
from vibe.core.experiments.models import EvalResponse, ExperimentAttributes
from vibe.core.session.session_loader import SessionLoader
from vibe.core.skills.manager import SkillManager
from vibe.core.system_prompt import get_universal_system_prompt
from vibe.core.tools.manager import ToolManager
from vibe.core.types import SessionMetadata
class _StubClient(RemoteEvalClient):
def __init__(self, response: EvalResponse | None) -> None:
self._response = response
self.calls: list[ExperimentAttributes] = []
async def evaluate(self, attributes: ExperimentAttributes) -> EvalResponse | None:
self.calls.append(attributes)
return self._response
async def aclose(self) -> None:
pass
def _attrs() -> ExperimentAttributes:
return ExperimentAttributes(
userId="x", entrypoint="cli", agent_version="0", os="darwin"
)
def _response_forcing(variant: str) -> EvalResponse:
return EvalResponse.model_validate({
"features": {
ExperimentName.SYSTEM_PROMPT.value: {
"defaultValue": "cli",
"rules": [
{
"force": variant,
"tracks": [
{
"experiment": {
"key": ExperimentName.SYSTEM_PROMPT.value
},
"result": {
"key": "1",
"variationId": 1,
"inExperiment": True,
},
}
],
}
],
}
}
})
@pytest.mark.asyncio
async def test_resume_round_trip_preserves_variant() -> None:
response = _response_forcing("explore")
fresh = ExperimentManager(client=_StubClient(response))
await fresh.initialize(_attrs())
persisted_state = fresh.export_state()
assert persisted_state is not None
serialized = persisted_state.model_dump_json()
rehydrated_response = EvalResponse.model_validate_json(serialized)
stub = _StubClient(None)
resumed = ExperimentManager(client=stub)
resumed.hydrate(rehydrated_response)
assert resumed.get_variant(ExperimentName.SYSTEM_PROMPT) == "explore"
assert resumed.assignments() == fresh.assignments()
assert stub.calls == []
def test_resume_old_session_without_experiments_field_falls_back_to_defaults(
tmp_path: Path,
) -> None:
legacy_metadata = {
"session_id": "legacy",
"start_time": "2025-01-01T00:00:00+00:00",
"end_time": None,
"git_commit": None,
"git_branch": None,
"environment": {"working_directory": str(tmp_path)},
"username": "tester",
"loops": [],
}
meta_path = tmp_path / "meta.json"
meta_path.write_text(json.dumps(legacy_metadata))
metadata = SessionLoader.load_metadata(tmp_path)
assert metadata.experiments is None
def test_resume_overrides_still_win_over_hydrated_state() -> None:
response = _response_forcing("explore")
manager = ExperimentManager(
client=_StubClient(None), overrides={ExperimentName.SYSTEM_PROMPT.value: "lean"}
)
manager.hydrate(response)
assert manager.get_variant(ExperimentName.SYSTEM_PROMPT) == "lean"
def test_session_metadata_round_trips_experiments_field() -> None:
response = _response_forcing("explore")
metadata = SessionMetadata(
session_id="s",
start_time="2025-01-01T00:00:00+00:00",
end_time=None,
git_commit=None,
git_branch=None,
environment={"working_directory": "/tmp"},
username="tester",
experiments=response,
)
serialized = metadata.model_dump_json()
restored = SessionMetadata.model_validate_json(serialized)
assert restored.experiments is not None
assert restored.experiments == response
def _build_managers(config):
return (
ToolManager(lambda: config),
SkillManager(lambda: config),
AgentManager(lambda: config),
)
@pytest.mark.asyncio
async def test_graduated_experiment_with_deleted_variant_file_falls_back() -> None:
config = build_test_vibe_config(
system_prompt_id="cli",
include_project_context=False,
include_prompt_detail=False,
include_model_info=False,
include_commit_signature=False,
)
response = _response_forcing("removed_after_graduation_2025_07")
manager = ExperimentManager(client=_StubClient(None))
manager.hydrate(response)
tool_manager, skill_manager, agent_manager = _build_managers(config)
prompt = get_universal_system_prompt(
tool_manager, config, skill_manager, agent_manager, experiment_manager=manager
)
default_prompt = get_universal_system_prompt(
tool_manager, config, skill_manager, agent_manager, experiment_manager=None
)
assert prompt == default_prompt

View file

@ -0,0 +1,208 @@
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from vibe.core.experiments.client import RemoteEvalClient
from vibe.core.experiments.manager import ExperimentManager
from vibe.core.experiments.models import EvalResponse, ExperimentAttributes
from vibe.core.experiments.session import (
hydrate_experiments_from_session,
initialize_experiments,
)
class _StubClient(RemoteEvalClient):
def __init__(self, response: EvalResponse | None) -> None:
self._response = response
async def evaluate(self, attributes: ExperimentAttributes) -> EvalResponse | None:
return self._response
async def aclose(self) -> None:
pass
def _make_config(
*, enable_telemetry: bool = True, enable_experiments: bool = True
) -> Any:
config = MagicMock()
config.enable_telemetry = enable_telemetry
config.experiments.enable = enable_experiments
return config
@pytest.mark.asyncio
async def test_initialize_returns_false_when_telemetry_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
persist = AsyncMock()
session_logger = MagicMock()
session_logger.persist_experiments = persist
manager = ExperimentManager(client=_StubClient(None))
result = await initialize_experiments(
config=_make_config(enable_telemetry=False),
manager=manager,
session_logger=session_logger,
entrypoint_metadata=None,
)
assert result is False
persist.assert_not_called()
@pytest.mark.asyncio
async def test_initialize_returns_false_when_experiments_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# `experiments.enable=False` must short-circuit even when telemetry is on
# — it is the user's opt-out for being assigned to A/B tests without
# disabling all telemetry.
persist = AsyncMock()
session_logger = MagicMock()
session_logger.persist_experiments = persist
manager = ExperimentManager(client=_StubClient(None))
result = await initialize_experiments(
config=_make_config(enable_experiments=False),
manager=manager,
session_logger=session_logger,
entrypoint_metadata=None,
)
assert result is False
persist.assert_not_called()
@pytest.mark.asyncio
async def test_initialize_returns_false_when_no_mistral_provider(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
"vibe.core.experiments.session.get_mistral_provider_and_api_key",
lambda _config: None,
)
persist = AsyncMock()
session_logger = MagicMock()
session_logger.persist_experiments = persist
manager = ExperimentManager(client=_StubClient(None))
result = await initialize_experiments(
config=_make_config(),
manager=manager,
session_logger=session_logger,
entrypoint_metadata=None,
)
assert result is False
persist.assert_not_called()
@pytest.mark.asyncio
async def test_initialize_returns_false_when_remote_eval_fails(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Regression: even if telemetry is enabled and a Mistral key is set,
# a failed remote eval (returns None) leaves manager state empty —
# the helper must NOT report success, so the caller skips the
# unnecessary system prompt refresh.
monkeypatch.setattr(
"vibe.core.experiments.session.get_mistral_provider_and_api_key",
lambda _config: (MagicMock(), "fake-key"),
)
monkeypatch.setattr(
"vibe.core.experiments.session._build_attributes",
lambda *_args, **_kwargs: ExperimentAttributes(
userId="x", entrypoint="cli", agent_version="0", os="darwin"
),
)
persist = AsyncMock()
session_logger = MagicMock()
session_logger.persist_experiments = persist
manager = ExperimentManager(client=_StubClient(None))
result = await initialize_experiments(
config=_make_config(),
manager=manager,
session_logger=session_logger,
entrypoint_metadata=None,
)
assert result is False
persist.assert_not_called()
@pytest.mark.asyncio
async def test_initialize_returns_true_and_persists_when_remote_eval_succeeds(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
"vibe.core.experiments.session.get_mistral_provider_and_api_key",
lambda _config: (MagicMock(), "fake-key"),
)
monkeypatch.setattr(
"vibe.core.experiments.session._build_attributes",
lambda *_args, **_kwargs: ExperimentAttributes(
userId="x", entrypoint="cli", agent_version="0", os="darwin"
),
)
persist = AsyncMock()
session_logger = MagicMock()
session_logger.persist_experiments = persist
response = EvalResponse.model_validate({
"features": {"vibe_cli_system_prompt": {"defaultValue": "cli"}}
})
manager = ExperimentManager(client=_StubClient(response))
result = await initialize_experiments(
config=_make_config(),
manager=manager,
session_logger=session_logger,
entrypoint_metadata=None,
)
assert result is True
persist.assert_awaited_once()
@pytest.mark.asyncio
async def test_hydrate_returns_false_when_telemetry_disabled() -> None:
session_logger = MagicMock()
response = EvalResponse.model_validate({
"features": {"vibe_cli_system_prompt": {"defaultValue": "cli"}}
})
session_logger.session_metadata.experiments = response
manager = ExperimentManager(client=_StubClient(None))
result = await hydrate_experiments_from_session(
config=_make_config(enable_telemetry=False),
manager=manager,
session_logger=session_logger,
)
assert result is False
assert manager.export_state() is None
@pytest.mark.asyncio
async def test_hydrate_returns_false_when_experiments_disabled() -> None:
# Regression: without this gate, a user who flipped experiments.enable
# to False between sessions would still resume into a hydrated variant.
session_logger = MagicMock()
response = EvalResponse.model_validate({
"features": {"vibe_cli_system_prompt": {"defaultValue": "cli"}}
})
session_logger.session_metadata.experiments = response
manager = ExperimentManager(client=_StubClient(None))
result = await hydrate_experiments_from_session(
config=_make_config(enable_experiments=False),
manager=manager,
session_logger=session_logger,
)
assert result is False
assert manager.export_state() is None

View file

@ -0,0 +1,186 @@
from __future__ import annotations
import pytest
from tests.conftest import build_test_vibe_config
from vibe.core.agents import AgentManager
from vibe.core.experiments.active import ExperimentName
from vibe.core.experiments.client import RemoteEvalClient
from vibe.core.experiments.manager import ExperimentManager
from vibe.core.experiments.models import EvalResponse, ExperimentAttributes
from vibe.core.prompts import load_system_prompt
from vibe.core.skills.manager import SkillManager
from vibe.core.system_prompt import get_universal_system_prompt
from vibe.core.tools.manager import ToolManager
class _StubClient(RemoteEvalClient):
def __init__(self, response: EvalResponse | None) -> None:
self._response = response
async def evaluate(self, attributes: ExperimentAttributes) -> EvalResponse | None:
return self._response
async def aclose(self) -> None:
pass
def _build_managers(config):
return (
ToolManager(lambda: config),
SkillManager(lambda: config),
AgentManager(lambda: config),
)
@pytest.mark.asyncio
async def test_system_prompt_uses_assigned_variant() -> None:
config = build_test_vibe_config(
system_prompt_id="cli",
include_project_context=False,
include_prompt_detail=False,
include_model_info=False,
include_commit_signature=False,
)
response = EvalResponse.model_validate({
"features": {
ExperimentName.SYSTEM_PROMPT.value: {
"defaultValue": "cli",
"rules": [{"force": "tests", "tracks": []}],
}
}
})
manager = ExperimentManager(client=_StubClient(response))
await manager.initialize(
ExperimentAttributes(
userId="x", entrypoint="cli", agent_version="0", os="darwin"
)
)
tool_manager, skill_manager, agent_manager = _build_managers(config)
prompt = get_universal_system_prompt(
tool_manager, config, skill_manager, agent_manager, experiment_manager=manager
)
assert prompt.startswith("You are Vibe, a super useful programming assistant.")
@pytest.mark.asyncio
async def test_system_prompt_falls_back_to_default_when_variant_unknown() -> None:
config = build_test_vibe_config(
system_prompt_id="cli",
include_project_context=False,
include_prompt_detail=False,
include_model_info=False,
include_commit_signature=False,
)
response = EvalResponse.model_validate({
"features": {
ExperimentName.SYSTEM_PROMPT.value: {
"defaultValue": "cli",
"rules": [{"force": "nonexistent_prompt", "tracks": []}],
}
}
})
manager = ExperimentManager(client=_StubClient(response))
await manager.initialize(
ExperimentAttributes(
userId="x", entrypoint="cli", agent_version="0", os="darwin"
)
)
tool_manager, skill_manager, agent_manager = _build_managers(config)
prompt = get_universal_system_prompt(
tool_manager, config, skill_manager, agent_manager, experiment_manager=manager
)
# Falls back to the default `cli` system prompt.
assert "You are Mistral Vibe" in prompt or "You are Vibe" in prompt
def test_system_prompt_uses_default_when_no_manager() -> None:
config = build_test_vibe_config(
system_prompt_id="cli",
include_project_context=False,
include_prompt_detail=False,
include_model_info=False,
include_commit_signature=False,
)
tool_manager, skill_manager, agent_manager = _build_managers(config)
prompt = get_universal_system_prompt(
tool_manager, config, skill_manager, agent_manager
)
assert "You are Mistral Vibe" in prompt or "You are Vibe" in prompt
def test_system_prompt_honors_user_config_when_manager_uninitialized() -> None:
config = build_test_vibe_config(
system_prompt_id="lean",
include_project_context=False,
include_prompt_detail=False,
include_model_info=False,
include_commit_signature=False,
)
manager = ExperimentManager(client=_StubClient(None))
tool_manager, skill_manager, agent_manager = _build_managers(config)
prompt = get_universal_system_prompt(
tool_manager, config, skill_manager, agent_manager, experiment_manager=manager
)
assert prompt == load_system_prompt("lean")
@pytest.mark.asyncio
async def test_system_prompt_honors_user_config_when_no_remote_assignment() -> None:
config = build_test_vibe_config(
system_prompt_id="lean",
include_project_context=False,
include_prompt_detail=False,
include_model_info=False,
include_commit_signature=False,
)
response = EvalResponse.model_validate({
"features": {"some_other_feature": {"defaultValue": True}}
})
manager = ExperimentManager(client=_StubClient(response))
await manager.initialize(
ExperimentAttributes(
userId="x", entrypoint="cli", agent_version="0", os="darwin"
)
)
tool_manager, skill_manager, agent_manager = _build_managers(config)
prompt = get_universal_system_prompt(
tool_manager, config, skill_manager, agent_manager, experiment_manager=manager
)
assert prompt == load_system_prompt("lean")
@pytest.mark.asyncio
async def test_user_config_overrides_assigned_experiment_variant() -> None:
config = build_test_vibe_config(
system_prompt_id="lean",
include_project_context=False,
include_prompt_detail=False,
include_model_info=False,
include_commit_signature=False,
)
response = EvalResponse.model_validate({
"features": {
ExperimentName.SYSTEM_PROMPT.value: {
"defaultValue": "cli",
"rules": [{"force": "explore", "tracks": []}],
}
}
})
manager = ExperimentManager(client=_StubClient(response))
await manager.initialize(
ExperimentAttributes(
userId="x", entrypoint="cli", agent_version="0", os="darwin"
)
)
tool_manager, skill_manager, agent_manager = _build_managers(config)
prompt = get_universal_system_prompt(
tool_manager, config, skill_manager, agent_manager, experiment_manager=manager
)
assert prompt == load_system_prompt("lean")
assert prompt != load_system_prompt("explore")

View file

@ -0,0 +1,31 @@
from __future__ import annotations
from tests.conftest import build_test_vibe_config
from vibe.core.experiments.active import ExperimentName
from vibe.core.telemetry.send import TelemetryClient
def test_build_client_event_metadata_omits_experiments_when_empty() -> None:
config = build_test_vibe_config(enable_telemetry=True)
client = TelemetryClient(
config_getter=lambda: config, experiments_getter=lambda: {}
)
metadata = client.build_client_event_metadata()
assert "experiments" not in metadata
def test_build_client_event_metadata_includes_experiments_when_present() -> None:
config = build_test_vibe_config(enable_telemetry=True)
sp_key = ExperimentName.SYSTEM_PROMPT.value
client = TelemetryClient(
config_getter=lambda: config, experiments_getter=lambda: {sp_key: "1"}
)
metadata = client.build_client_event_metadata()
assert metadata["experiments"] == {sp_key: "1"}
def test_build_client_event_metadata_works_without_getter() -> None:
config = build_test_vibe_config(enable_telemetry=True)
client = TelemetryClient(config_getter=lambda: config)
metadata = client.build_client_event_metadata()
assert "experiments" not in metadata

View file

@ -0,0 +1,123 @@
from __future__ import annotations
from pathlib import Path
import pytest
from vibe.core.config import SessionLoggingConfig
from vibe.core.session import last_session_pointer
@pytest.fixture
def session_logging(tmp_path: Path) -> SessionLoggingConfig:
return SessionLoggingConfig(save_dir=str(tmp_path))
def _set_tty(monkeypatch: pytest.MonkeyPatch, key: str | None) -> None:
monkeypatch.setattr(last_session_pointer, "current_tty_key", lambda: key)
def test_load_returns_none_when_no_tty(
session_logging: SessionLoggingConfig, monkeypatch: pytest.MonkeyPatch
) -> None:
_set_tty(monkeypatch, None)
assert last_session_pointer.load(session_logging) is None
def test_load_returns_none_when_no_pointer_written(
session_logging: SessionLoggingConfig, monkeypatch: pytest.MonkeyPatch
) -> None:
_set_tty(monkeypatch, "ttys001")
assert last_session_pointer.load(session_logging) is None
def test_record_then_load_round_trip(
session_logging: SessionLoggingConfig, monkeypatch: pytest.MonkeyPatch
) -> None:
_set_tty(monkeypatch, "ttys001")
last_session_pointer.record(session_logging, "abc-123")
assert last_session_pointer.load(session_logging) == "abc-123"
def test_record_skips_when_no_tty(
session_logging: SessionLoggingConfig, monkeypatch: pytest.MonkeyPatch
) -> None:
_set_tty(monkeypatch, None)
last_session_pointer.record(session_logging, "abc-123")
pointer_dir = Path(session_logging.save_dir) / last_session_pointer.POINTER_DIR_NAME
assert not pointer_dir.exists()
def test_record_skips_when_logging_disabled(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
disabled = SessionLoggingConfig(save_dir=str(tmp_path), enabled=False)
_set_tty(monkeypatch, "ttys001")
last_session_pointer.record(disabled, "abc-123")
assert not (tmp_path / last_session_pointer.POINTER_DIR_NAME).exists()
def test_pointers_are_per_tty(
session_logging: SessionLoggingConfig, monkeypatch: pytest.MonkeyPatch
) -> None:
_set_tty(monkeypatch, "ttys001")
last_session_pointer.record(session_logging, "session-a")
_set_tty(monkeypatch, "ttys002")
last_session_pointer.record(session_logging, "session-b")
assert last_session_pointer.load(session_logging) == "session-b"
_set_tty(monkeypatch, "ttys001")
assert last_session_pointer.load(session_logging) == "session-a"
def test_record_ignores_empty_session_id(
session_logging: SessionLoggingConfig, monkeypatch: pytest.MonkeyPatch
) -> None:
_set_tty(monkeypatch, "ttys001")
last_session_pointer.record(session_logging, None)
last_session_pointer.record(session_logging, "")
assert last_session_pointer.load(session_logging) is None
def test_current_tty_key_returns_none_when_ttyname_is_unavailable(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delattr(last_session_pointer.os, "ttyname", raising=False)
assert last_session_pointer.current_tty_key() is None
def _patch_windows(monkeypatch: pytest.MonkeyPatch, hwnd: int) -> None:
import ctypes
from types import SimpleNamespace
fake_windll = SimpleNamespace(
kernel32=SimpleNamespace(GetConsoleWindow=lambda: hwnd)
)
monkeypatch.setattr(last_session_pointer.sys, "platform", "win32")
monkeypatch.setattr(ctypes, "windll", fake_windll, raising=False)
def test_current_tty_key_uses_console_hwnd_on_windows(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_patch_windows(monkeypatch, hwnd=12345)
assert last_session_pointer.current_tty_key() == "conhost-12345"
def test_current_tty_key_falls_back_to_wt_session(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_patch_windows(monkeypatch, hwnd=0)
monkeypatch.setenv("WT_SESSION", "abcd-1234")
assert last_session_pointer.current_tty_key() == "wt-abcd-1234"
def test_current_tty_key_falls_back_to_ppid_on_windows(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_patch_windows(monkeypatch, hwnd=0)
monkeypatch.delenv("WT_SESSION", raising=False)
monkeypatch.setattr(last_session_pointer.os, "getppid", lambda: 4242)
assert last_session_pointer.current_tty_key() == "ppid-4242"

389
tests/core/test_add_dir.py Normal file
View file

@ -0,0 +1,389 @@
from __future__ import annotations
from pathlib import Path
import pytest
from vibe.core.config.harness_files import (
HarnessFilesManager,
get_harness_files_manager,
init_harness_files_manager,
reset_harness_files_manager,
)
from vibe.core.paths import AGENTS_MD_FILENAME
from vibe.core.tools.utils import is_path_within_workdir
from vibe.core.trusted_folders import trusted_folders_manager
class TestHarnessFilesManagerAdditionalDirs:
def test_default_additional_dirs_empty(self) -> None:
mgr = HarnessFilesManager(sources=("user",))
assert mgr._additional_dirs == ()
def test_init_with_additional_dirs(self, tmp_path: Path) -> None:
d1 = tmp_path / "extra1"
d1.mkdir()
d2 = tmp_path / "extra2"
d2.mkdir()
reset_harness_files_manager()
init_harness_files_manager("user", "project", additional_dirs=[d1, d2])
mgr = get_harness_files_manager()
# init normalizes to resolved paths so the symlink/relative-vs-absolute
# spellings of the same dir compare equal on reinit.
assert mgr._additional_dirs == (d1.resolve(), d2.resolve())
def test_reinit_idempotent_when_paths_resolve_equal(self, tmp_path: Path) -> None:
target = tmp_path / "target"
target.mkdir()
link = tmp_path / "link"
link.symlink_to(target, target_is_directory=True)
reset_harness_files_manager()
init_harness_files_manager("user", "project", additional_dirs=[target])
# Second call with the symlink spelling must NOT raise — both resolve
# to the same dir.
init_harness_files_manager("user", "project", additional_dirs=[link])
def test_init_without_additional_dirs(self) -> None:
reset_harness_files_manager()
init_harness_files_manager("user", "project")
mgr = get_harness_files_manager()
assert mgr._additional_dirs == ()
def test_reinit_with_same_additional_dirs_is_idempotent(
self, tmp_path: Path
) -> None:
d = tmp_path / "extra"
d.mkdir()
reset_harness_files_manager()
init_harness_files_manager("user", "project", additional_dirs=[d])
# Same sources + same additional_dirs: no-op, no raise.
init_harness_files_manager("user", "project", additional_dirs=[d])
def test_reinit_with_different_additional_dirs_raises(self, tmp_path: Path) -> None:
d1 = tmp_path / "extra1"
d1.mkdir()
d2 = tmp_path / "extra2"
d2.mkdir()
reset_harness_files_manager()
init_harness_files_manager("user", "project", additional_dirs=[d1])
with pytest.raises(RuntimeError, match="different configuration"):
init_harness_files_manager("user", "project", additional_dirs=[d2])
class TestAdditionalDirsDiscovery:
def test_discovers_tools_from_additional_dir(self, tmp_path: Path) -> None:
extra = tmp_path / "extra_project"
tools_dir = extra / ".vibe" / "tools"
tools_dir.mkdir(parents=True)
mgr = HarnessFilesManager(sources=("user",), _additional_dirs=(extra,))
assert tools_dir in mgr.project_tools_dirs
def test_discovers_skills_from_additional_dir(self, tmp_path: Path) -> None:
extra = tmp_path / "extra_project"
skills_dir = extra / ".vibe" / "skills"
skills_dir.mkdir(parents=True)
mgr = HarnessFilesManager(sources=("user",), _additional_dirs=(extra,))
assert skills_dir in mgr.project_skills_dirs
def test_discovers_agents_from_additional_dir(self, tmp_path: Path) -> None:
extra = tmp_path / "extra_project"
agents_dir = extra / ".vibe" / "agents"
agents_dir.mkdir(parents=True)
mgr = HarnessFilesManager(sources=("user",), _additional_dirs=(extra,))
assert agents_dir in mgr.project_agents_dirs
def test_discovers_agents_skills_from_additional_dir(self, tmp_path: Path) -> None:
extra = tmp_path / "extra_project"
skills_dir = extra / ".agents" / "skills"
skills_dir.mkdir(parents=True)
mgr = HarnessFilesManager(sources=("user",), _additional_dirs=(extra,))
assert skills_dir in mgr.project_skills_dirs
def test_discovers_prompts_from_additional_dir(self, tmp_path: Path) -> None:
extra = tmp_path / "extra_project"
prompts_dir = extra / ".vibe" / "prompts"
prompts_dir.mkdir(parents=True)
mgr = HarnessFilesManager(sources=("user",), _additional_dirs=(extra,))
assert prompts_dir in mgr.project_prompts_dirs
def test_no_dirs_when_additional_dir_has_no_vibe(self, tmp_path: Path) -> None:
extra = tmp_path / "bare_project"
extra.mkdir()
mgr = HarnessFilesManager(sources=("user",), _additional_dirs=(extra,))
assert mgr.project_tools_dirs == []
assert mgr.project_skills_dirs == []
assert mgr.project_agents_dirs == []
class TestAdditionalDirsAgentsMd:
def test_load_project_docs_includes_additional_dir_agents_md(
self, tmp_path: Path
) -> None:
extra = tmp_path / "extra_project"
extra.mkdir()
agents_md = extra / AGENTS_MD_FILENAME
agents_md.write_text("Extra project instructions", encoding="utf-8")
mgr = HarnessFilesManager(sources=("user",), _additional_dirs=(extra,))
docs = mgr.load_project_docs()
assert any(d == extra and "Extra project" in content for d, content in docs)
def test_load_project_docs_skips_missing_agents_md(self, tmp_path: Path) -> None:
extra = tmp_path / "no_agents_md"
extra.mkdir()
mgr = HarnessFilesManager(sources=("user",), _additional_dirs=(extra,))
docs = mgr.load_project_docs()
assert not any(d == extra for d, _ in docs)
def test_load_project_docs_skips_empty_agents_md(self, tmp_path: Path) -> None:
extra = tmp_path / "empty_agents_md"
extra.mkdir()
(extra / AGENTS_MD_FILENAME).write_text(" \n", encoding="utf-8")
mgr = HarnessFilesManager(sources=("user",), _additional_dirs=(extra,))
docs = mgr.load_project_docs()
assert not any(d == extra for d, _ in docs)
def test_find_subdirectory_agents_md_in_additional_dir(
self, tmp_path: Path
) -> None:
extra = tmp_path / "extra_project"
sub = extra / "sub"
sub.mkdir(parents=True)
agents_md = sub / AGENTS_MD_FILENAME
agents_md.write_text("Sub instructions", encoding="utf-8")
mgr = HarnessFilesManager(sources=("user",), _additional_dirs=(extra,))
file_in_sub = sub / "main.py"
docs = mgr.find_subdirectory_agents_md(file_in_sub)
assert any("Sub instructions" in content for _, content in docs)
def test_find_subdirectory_agents_md_resolves_additional_dir(
self, tmp_path: Path
) -> None:
extra = tmp_path / "extra_project"
sub = extra / "sub"
sub.mkdir(parents=True)
link = tmp_path / "extra_link"
link.symlink_to(extra, target_is_directory=True)
agents_md = sub / AGENTS_MD_FILENAME
agents_md.write_text("Sub instructions", encoding="utf-8")
mgr = HarnessFilesManager(sources=("user",), _additional_dirs=(link,))
docs = mgr.find_subdirectory_agents_md(sub / "main.py")
assert any("Sub instructions" in content for _, content in docs)
def test_find_subdirectory_agents_md_outside_all_dirs(self, tmp_path: Path) -> None:
extra = tmp_path / "extra"
extra.mkdir()
outside = tmp_path / "outside"
outside.mkdir()
mgr = HarnessFilesManager(sources=("user",), _additional_dirs=(extra,))
docs = mgr.find_subdirectory_agents_md(outside / "file.py")
assert docs == []
class TestFilePermissionsAdditionalDirs:
def test_is_within_workdir_for_additional_dir(self, tmp_path: Path) -> None:
extra = tmp_path / "extra"
extra.mkdir()
file_in_extra = extra / "some_file.py"
reset_harness_files_manager()
init_harness_files_manager("user", "project", additional_dirs=[extra])
assert is_path_within_workdir(str(file_in_extra))
def test_is_within_workdir_resolves_additional_dir(self, tmp_path: Path) -> None:
extra = tmp_path / "extra"
extra.mkdir()
link = tmp_path / "extra_link"
link.symlink_to(extra, target_is_directory=True)
file_in_extra = extra / "some_file.py"
reset_harness_files_manager()
init_harness_files_manager("user", "project", additional_dirs=[link])
assert is_path_within_workdir(str(file_in_extra))
def test_is_within_workdir_for_cwd(self, tmp_working_directory: Path) -> None:
file_in_cwd = tmp_working_directory / "file.py"
assert is_path_within_workdir(str(file_in_cwd))
def test_is_not_within_workdir_for_outside_path(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
outside = tmp_path / "outside"
outside.mkdir()
cwd = tmp_path / "cwd"
cwd.mkdir()
monkeypatch.chdir(cwd)
reset_harness_files_manager()
init_harness_files_manager("user", "project")
assert not is_path_within_workdir(str(outside / "file.py"))
def test_is_path_within_workdir_when_manager_uninitialized(
self, tmp_working_directory: Path
) -> None:
reset_harness_files_manager()
# Without a manager, only cwd counts.
assert is_path_within_workdir(str(tmp_working_directory / "file.py"))
class TestAdditionalDirsAreImplicitlyTrusted:
def test_load_project_docs_does_not_require_trust_for_additional_dir(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Untrusted cwd: primary workdir contributes nothing.
monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: False)
extra = tmp_path / "extra_project"
extra.mkdir()
(extra / AGENTS_MD_FILENAME).write_text(
"Trusted via --add-dir", encoding="utf-8"
)
mgr = HarnessFilesManager(
sources=("user", "project"), _additional_dirs=(extra,)
)
docs = mgr.load_project_docs()
assert any("Trusted via --add-dir" in content for _, content in docs)
def test_project_tools_dirs_skip_trust_check_for_additional_dir(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: False)
extra = tmp_path / "extra_project"
tools_dir = extra / ".vibe" / "tools"
tools_dir.mkdir(parents=True)
mgr = HarnessFilesManager(
sources=("user", "project"), _additional_dirs=(extra,)
)
assert tools_dir in mgr.project_tools_dirs
class TestLoadProjectDocsDedupe:
def test_dedupes_when_cwd_under_additional_dir(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
extra = (tmp_path / "extra_project").resolve()
extra.mkdir()
sub = extra / "sub"
sub.mkdir()
(extra / AGENTS_MD_FILENAME).write_text("Root instructions", encoding="utf-8")
(sub / AGENTS_MD_FILENAME).write_text("Sub instructions", encoding="utf-8")
monkeypatch.chdir(sub)
trusted_folders_manager.trust_for_session(extra)
mgr = HarnessFilesManager(
sources=("user", "project"), _additional_dirs=(extra,)
)
docs = mgr.load_project_docs()
emitted_dirs = [d for d, _ in docs]
assert emitted_dirs.count(extra) == 1
assert sub.resolve() in emitted_dirs
class TestHookFilesAdditionalDirs:
def test_hook_files_includes_additional_dir_hooks_toml(
self, tmp_path: Path
) -> None:
extra = tmp_path / "extra"
extra.mkdir()
mgr = HarnessFilesManager(sources=("user",), _additional_dirs=(extra,))
assert extra / ".vibe" / "hooks.toml" in mgr.hook_files
def test_hook_files_deduplicates_workdir_and_additional_dir(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
workdir = tmp_path / "project"
workdir.mkdir()
monkeypatch.chdir(workdir)
trusted_folders_manager.trust_for_session(workdir)
mgr = HarnessFilesManager(
sources=("user", "project"), _additional_dirs=(workdir.resolve(),)
)
hook_file = workdir / ".vibe" / "hooks.toml"
assert mgr.hook_files.count(hook_file.resolve()) == 1
class TestProjectPromptsDirsAdditionalDirs:
def test_project_prompts_dirs_deduplicates_workdir_and_additional_dir(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
workdir = tmp_path / "project"
(workdir / ".vibe" / "prompts").mkdir(parents=True)
monkeypatch.chdir(workdir)
trusted_folders_manager.trust_for_session(workdir)
mgr = HarnessFilesManager(
sources=("user", "project"), _additional_dirs=(workdir.resolve(),)
)
assert len(mgr.project_prompts_dirs) == 1
class TestProjectRootsNestedDedup:
def test_nested_add_dirs_collapse(self, tmp_path: Path) -> None:
outer = (tmp_path / "outer").resolve()
inner = outer / "inner"
inner.mkdir(parents=True)
mgr = HarnessFilesManager(sources=("user",), _additional_dirs=(outer, inner))
assert mgr.project_roots == [outer]
def test_add_dir_containing_cwd_keeps_both(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
outer = (tmp_path / "outer").resolve()
inner = outer / "inner"
inner.mkdir(parents=True)
(outer / AGENTS_MD_FILENAME).write_text("Outer instructions", encoding="utf-8")
(inner / AGENTS_MD_FILENAME).write_text("Inner instructions", encoding="utf-8")
monkeypatch.chdir(inner)
trusted_folders_manager.trust_for_session(inner)
trusted_folders_manager.trust_for_session(outer)
mgr = HarnessFilesManager(
sources=("user", "project"), _additional_dirs=(outer,)
)
# cwd survives so its walk-up semantics still work; the add-dir keeps
# its own root-level discovery.
assert mgr.project_roots == [inner, outer]
docs = mgr.load_project_docs()
assert any("Outer" in c for _, c in docs)
assert any("Inner" in c for _, c in docs)
def test_add_dir_nested_under_trusted_workdir(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
outer = (tmp_path / "outer").resolve()
inner = outer / "inner"
inner.mkdir(parents=True)
monkeypatch.chdir(outer)
trusted_folders_manager.trust_for_session(outer)
mgr = HarnessFilesManager(
sources=("user", "project"), _additional_dirs=(inner,)
)
assert mgr.project_roots == [outer]

View file

@ -14,7 +14,7 @@ class TestTrustedWorkdir:
) -> None:
monkeypatch.chdir(tmp_path)
mgr = HarnessFilesManager(sources=("user",))
assert mgr.trusted_workdir is None
assert mgr._trusted_workdir is None
def test_returns_none_when_not_trusted(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
@ -22,7 +22,7 @@ class TestTrustedWorkdir:
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: False)
mgr = HarnessFilesManager(sources=("user", "project"))
assert mgr.trusted_workdir is None
assert mgr._trusted_workdir is None
def test_returns_cwd_when_project_in_sources_and_trusted(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
@ -30,7 +30,7 @@ class TestTrustedWorkdir:
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True)
mgr = HarnessFilesManager(sources=("user", "project"))
assert mgr.trusted_workdir == tmp_path
assert mgr._trusted_workdir == tmp_path
class TestProjectToolsDirs:

View file

@ -5,6 +5,7 @@ from pathlib import Path
from vibe.core.paths._local_config_walk import (
_MAX_DIRS,
WALK_MAX_DEPTH,
ConfigWalkResult,
walk_local_config_dirs,
)
@ -166,3 +167,29 @@ class TestWalkConfigDirs:
assert resolved / ".vibe" in result.config_dirs
assert resolved / ".agents" in result.config_dirs
assert resolved / "sub" / ".vibe" in result.config_dirs
class TestConfigWalkResultOr:
def test_or_concatenates_each_field(self) -> None:
a = ConfigWalkResult(
config_dirs=(Path("/a/.vibe"),),
tools=(Path("/a/.vibe/tools"),),
skills=(Path("/a/.vibe/skills"),),
agents=(Path("/a/.vibe/agents"),),
)
b = ConfigWalkResult(
config_dirs=(Path("/b/.vibe"),),
tools=(Path("/b/.vibe/tools"),),
skills=(Path("/b/.vibe/skills"),),
agents=(Path("/b/.vibe/agents"),),
)
merged = a | b
assert merged.config_dirs == (Path("/a/.vibe"), Path("/b/.vibe"))
assert merged.tools == (Path("/a/.vibe/tools"), Path("/b/.vibe/tools"))
assert merged.skills == (Path("/a/.vibe/skills"), Path("/b/.vibe/skills"))
assert merged.agents == (Path("/a/.vibe/agents"), Path("/b/.vibe/agents"))
def test_or_with_empty_is_identity(self) -> None:
a = ConfigWalkResult(tools=(Path("/a/.vibe/tools"),))
assert (a | ConfigWalkResult()) == a
assert (ConfigWalkResult() | a) == a

View file

@ -37,7 +37,7 @@ def _make_manager(
async def save_messages() -> None:
save_calls.append(True)
def reset_session() -> None:
async def reset_session() -> None:
reset_calls.append(True)
mgr = RewindManager(

View file

@ -102,6 +102,79 @@ class TestReadSafe:
read_safe(tmp_path / "nope.txt")
class TestReadSafeNewlines:
def test_lf(self, tmp_path: Path) -> None:
f = tmp_path / "lf.txt"
f.write_bytes(b"a\nb\nc\n")
got = read_safe(f)
assert got.text == "a\nb\nc\n"
assert got.newline == "\n"
def test_crlf(self, tmp_path: Path) -> None:
f = tmp_path / "crlf.txt"
f.write_bytes(b"a\r\nb\r\nc\r\n")
got = read_safe(f)
assert got.text == "a\nb\nc\n"
assert got.newline == "\r\n"
def test_cr(self, tmp_path: Path) -> None:
f = tmp_path / "cr.txt"
f.write_bytes(b"a\rb\rc\r")
got = read_safe(f)
assert got.text == "a\nb\nc\n"
assert got.newline == "\r"
def test_mixed_picks_most_frequent(self, tmp_path: Path) -> None:
f = tmp_path / "mixed.txt"
f.write_bytes(b"a\r\nb\r\nc\rd\n")
got = read_safe(f)
assert got.text == "a\nb\nc\nd\n"
assert got.newline == "\r\n"
@pytest.mark.parametrize(("linesep", "expected"), [("\n", "\n"), ("\r\n", "\r\n")])
def test_no_newline_defaults_to_os_linesep(
self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
linesep: str,
expected: str,
) -> None:
monkeypatch.setattr(io_utils.os, "linesep", linesep)
f = tmp_path / "single.txt"
f.write_bytes(b"hello")
got = read_safe(f)
assert got.text == "hello"
assert got.newline == expected
@pytest.mark.parametrize(("linesep", "expected"), [("\n", "\n"), ("\r\n", "\r\n")])
def test_empty_defaults_to_os_linesep(
self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
linesep: str,
expected: str,
) -> None:
monkeypatch.setattr(io_utils.os, "linesep", linesep)
f = tmp_path / "empty.txt"
f.write_bytes(b"")
got = read_safe(f)
assert got.text == ""
assert got.newline == expected
def test_decode_safe_reports_newline(self) -> None:
got = decode_safe(b"a\r\nb\r\n")
assert got.text == "a\nb\n"
assert got.newline == "\r\n"
@pytest.mark.asyncio
async def test_async_reports_newline(self, tmp_path: Path) -> None:
f = tmp_path / "crlf.txt"
f.write_bytes(b"a\r\nb\r\n")
got = await read_safe_async(f)
assert got.text == "a\nb\n"
assert got.newline == "\r\n"
class TestReadSafeResultEncoding:
def test_reports_utf8_for_plain_utf8_file(self, tmp_path: Path) -> None:
f = tmp_path / "x.txt"

View file

@ -29,6 +29,7 @@ def write_e2e_config(vibe_home: Path, api_base: str) -> None:
'active_model = "mock-model"',
"enable_update_checks = false",
"enable_auto_update = false",
"disable_welcome_banner_animation = true",
"",
"[[providers]]",
'name = "mock-provider"',

View file

@ -1,19 +1,49 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable
from pathlib import Path
from types import SimpleNamespace
from typing import cast
import pytest
from textual.pilot import Pilot
from textual.widgets import Input
from vibe.core.config import ProviderConfig
from tests.browser_sign_in.stubs import build_browser_sign_in_service_factory
from tests.conftest import build_test_vibe_config
from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig
from vibe.core.config._settings import (
DEFAULT_MISTRAL_BROWSER_AUTH_API_BASE_URL,
DEFAULT_MISTRAL_BROWSER_AUTH_BASE_URL,
)
from vibe.core.config.harness_files import (
init_harness_files_manager,
reset_harness_files_manager,
)
from vibe.core.paths import GLOBAL_ENV_FILE
from vibe.core.telemetry.build_metadata import build_entrypoint_metadata
from vibe.core.telemetry.send import TelemetryClient
from vibe.core.types import Backend
from vibe.setup.auth import (
BrowserSignInError,
BrowserSignInErrorCode,
BrowserSignInService,
BrowserSignInStatus,
)
import vibe.setup.onboarding as onboarding_module
from vibe.setup.onboarding import OnboardingApp
from vibe.setup.onboarding.screens.api_key import ApiKeyScreen, persist_api_key
from vibe.setup.onboarding.screens.auth_method import AuthMethodScreen
from vibe.setup.onboarding.screens.browser_sign_in import (
ERROR_HINT,
PENDING_HINT,
UNEXPECTED_ERROR_MESSAGE,
BrowserSignInScreen,
)
CONSOLE_URL = "https://console.mistral.ai"
API_URL = "https://api.mistral.ai"
async def _wait_for(
@ -26,40 +56,653 @@ async def _wait_for(
while not condition():
await pilot.pause(interval)
if (elapsed := elapsed + interval) >= timeout:
msg = "Timed out waiting for condition."
raise AssertionError("Timed out waiting for condition.")
def _build_onboarding_config(
*,
provider_name: str = "mistral",
model_provider: str | None = None,
backend: Backend = Backend.MISTRAL,
api_key_env_var: str = "MISTRAL_API_KEY",
browser_auth_base_url: str | None = None,
browser_auth_api_base_url: str | None = None,
enable_experimental_browser_sign_in: bool = False,
) -> VibeConfig:
provider = ProviderConfig(
name=provider_name,
api_base="https://api.mistral.ai/v1",
api_key_env_var=api_key_env_var,
browser_auth_base_url=browser_auth_base_url,
browser_auth_api_base_url=browser_auth_api_base_url,
backend=backend,
)
model = ModelConfig(
name="mistral-vibe-cli-latest",
provider=model_provider or provider_name,
alias="devstral-2",
)
return build_test_vibe_config(
providers=[provider],
models=[model],
enable_experimental_browser_sign_in=enable_experimental_browser_sign_in,
)
def _build_browser_onboarding_app(
*, browser_sign_in_service_factory: Callable[[], BrowserSignInService] | None = None
) -> OnboardingApp:
return OnboardingApp(
config=_build_onboarding_config(
browser_auth_base_url=CONSOLE_URL,
browser_auth_api_base_url=API_URL,
enable_experimental_browser_sign_in=True,
),
browser_sign_in_service_factory=browser_sign_in_service_factory,
)
def _patch_failing_browser_sign_in_service(
monkeypatch: pytest.MonkeyPatch, captured_base_urls: list[tuple[str, str]]
) -> None:
class FakeGateway:
def __init__(self, browser_base_url: str, api_base_url: str) -> None:
captured_base_urls.append((browser_base_url, api_base_url))
class FakeService:
def __init__(self, gateway: FakeGateway) -> None:
self._gateway = gateway
async def authenticate(self, *args, **kwargs) -> str:
raise BrowserSignInError(
"Browser sign-in polling failed.",
code=BrowserSignInErrorCode.POLL_FAILED,
)
async def aclose(self) -> None:
return None
monkeypatch.setattr(onboarding_module, "HttpBrowserSignInGateway", FakeGateway)
monkeypatch.setattr(onboarding_module, "BrowserSignInService", FakeService)
def _saved_env_contents() -> str:
return GLOBAL_ENV_FILE.path.read_text(encoding="utf-8")
def _build_unexpected_browser_sign_in_service_factory(
outcomes: list[str],
*,
api_key: str = "sk-browser-onboarding-test-key",
close_blocker: asyncio.Event | None = None,
close_started: asyncio.Event | None = None,
close_finished: asyncio.Event | None = None,
close_cancelled: asyncio.Event | None = None,
) -> Callable[[], BrowserSignInService]:
remaining_outcomes = list(outcomes)
class UnexpectedBrowserSignInService:
def __init__(self, outcome: str) -> None:
self._outcome = outcome
async def authenticate(
self, status_callback: Callable[[BrowserSignInStatus], None] | None = None
) -> str:
if self._outcome == "completed":
if status_callback is not None:
status_callback(BrowserSignInStatus.COMPLETED)
return api_key
if self._outcome == "runtime_error":
raise RuntimeError("boom")
msg = f"Unsupported browser sign-in outcome: {self._outcome}"
raise AssertionError(msg)
async def aclose(self) -> None:
try:
if close_started is not None:
close_started.set()
if close_blocker is not None:
await close_blocker.wait()
except asyncio.CancelledError:
if close_cancelled is not None:
close_cancelled.set()
raise
finally:
if close_finished is not None:
close_finished.set()
async def pass_welcome_screen(pilot: Pilot) -> None:
return None
def build_service() -> BrowserSignInService:
if not remaining_outcomes:
msg = (
"Unexpected browser sign-in service factory requires scripted outcomes."
)
raise AssertionError(msg)
return cast(
BrowserSignInService,
UnexpectedBrowserSignInService(remaining_outcomes.pop(0)),
)
return build_service
async def _pass_welcome_screen(pilot: Pilot) -> None:
welcome_screen = pilot.app.get_screen("welcome")
await _wait_for(
lambda: not welcome_screen.query_one("#enter-hint").has_class("hidden"), pilot
)
await pilot.press("enter")
await _wait_for(lambda: isinstance(pilot.app.screen, ApiKeyScreen), pilot)
async def _show_auth_method(pilot: Pilot) -> None:
await _pass_welcome_screen(pilot)
await _wait_for(lambda: isinstance(pilot.app.screen, AuthMethodScreen), pilot)
async def _show_browser_sign_in(pilot: Pilot) -> None:
await _show_auth_method(pilot)
await pilot.press("enter")
await _wait_for(lambda: isinstance(pilot.app.screen, BrowserSignInScreen), pilot)
@pytest.mark.asyncio
async def test_ui_gets_through_the_onboarding_successfully() -> None:
app = OnboardingApp()
async def test_ui_keeps_manual_flow_when_browser_sign_in_is_unsupported() -> None:
app = OnboardingApp(
config=_build_onboarding_config(
browser_auth_base_url="",
browser_auth_api_base_url="",
enable_experimental_browser_sign_in=True,
)
)
api_key_value = "sk-onboarding-test-key"
async with app.run_test() as pilot:
await pass_welcome_screen(pilot)
api_screen = app.screen
input_widget = api_screen.query_one("#key", Input)
await _pass_welcome_screen(pilot)
await _wait_for(lambda: isinstance(pilot.app.screen, ApiKeyScreen), pilot)
input_widget = app.screen.query_one("#key", Input)
await pilot.press(*api_key_value)
assert input_widget.value == api_key_value
await pilot.press("enter")
await _wait_for(lambda: app.return_value is not None, pilot, timeout=2.0)
assert app.return_value == "completed"
assert api_key_value in _saved_env_contents()
assert GLOBAL_ENV_FILE.path.is_file()
env_contents = GLOBAL_ENV_FILE.path.read_text(encoding="utf-8")
@pytest.mark.asyncio
async def test_ui_hides_browser_sign_in_when_experimental_flag_is_disabled() -> None:
_, browser_sign_in_service_factory, _ = build_browser_sign_in_service_factory(
outcomes=["completed"]
)
app = OnboardingApp(
config=_build_onboarding_config(
browser_auth_base_url=CONSOLE_URL, browser_auth_api_base_url=API_URL
),
browser_sign_in_service_factory=browser_sign_in_service_factory,
)
assert app.supports_browser_sign_in is False
async with app.run_test() as pilot:
await _pass_welcome_screen(pilot)
await _wait_for(lambda: isinstance(pilot.app.screen, ApiKeyScreen), pilot)
@pytest.mark.asyncio
async def test_ui_supports_browser_sign_in_when_experimental_flag_is_enabled() -> None:
_, browser_sign_in_service_factory, _ = build_browser_sign_in_service_factory(
outcomes=["completed"]
)
app = OnboardingApp(
config=_build_onboarding_config(
browser_auth_base_url=CONSOLE_URL,
browser_auth_api_base_url=API_URL,
enable_experimental_browser_sign_in=True,
),
browser_sign_in_service_factory=browser_sign_in_service_factory,
)
assert app.supports_browser_sign_in is True
async with app.run_test() as pilot:
await _show_auth_method(pilot)
@pytest.mark.asyncio
async def test_ui_offers_browser_sign_in_for_renamed_mistral_provider() -> None:
app = OnboardingApp(
config=_build_onboarding_config(
provider_name="customer-mistral",
backend=Backend.MISTRAL,
browser_auth_base_url=CONSOLE_URL,
browser_auth_api_base_url=API_URL,
enable_experimental_browser_sign_in=True,
)
)
assert app.supports_browser_sign_in is True
async with app.run_test() as pilot:
await _show_auth_method(pilot)
@pytest.mark.asyncio
async def test_ui_allows_manual_path_when_browser_sign_in_is_supported() -> None:
app = _build_browser_onboarding_app()
api_key_value = "sk-manual-onboarding-test-key"
async with app.run_test() as pilot:
await _show_auth_method(pilot)
await pilot.press("down", "enter")
await _wait_for(lambda: isinstance(pilot.app.screen, ApiKeyScreen), pilot)
input_widget = app.screen.query_one("#key", Input)
await pilot.press(*api_key_value)
await pilot.press("enter")
await _wait_for(lambda: app.return_value is not None, pilot, timeout=2.0)
assert input_widget.value == api_key_value
assert app.return_value == "completed"
assert api_key_value in _saved_env_contents()
@pytest.mark.asyncio
async def test_ui_completes_browser_sign_in_and_retries_after_failure() -> None:
gateway, browser_sign_in_service_factory, created_services = (
build_browser_sign_in_service_factory(outcomes=["expired", "completed"])
)
app = _build_browser_onboarding_app(
browser_sign_in_service_factory=browser_sign_in_service_factory
)
async with app.run_test() as pilot:
await _show_browser_sign_in(pilot)
await _wait_for(
lambda: (
"expired"
in str(app.screen.query_one("#browser-sign-in-status").render())
),
pilot,
)
await pilot.press("r")
await _wait_for(lambda: app.return_value is not None, pilot, timeout=2.0)
assert gateway.process_number == 2
assert len(created_services) == 2
assert created_services[0] is not created_services[1]
assert app.return_value == "completed"
assert "sk-browser-onboarding-test-key" in _saved_env_contents()
@pytest.mark.asyncio
async def test_ui_browser_sign_in_falls_back_to_mistral_env_var_when_missing() -> None:
_, browser_sign_in_service_factory, _ = build_browser_sign_in_service_factory(
outcomes=["completed"]
)
app = OnboardingApp(
config=_build_onboarding_config(
provider_name="custom-mistral",
api_key_env_var="",
browser_auth_base_url=CONSOLE_URL,
browser_auth_api_base_url=API_URL,
enable_experimental_browser_sign_in=True,
),
browser_sign_in_service_factory=browser_sign_in_service_factory,
)
async with app.run_test() as pilot:
await _show_browser_sign_in(pilot)
await _wait_for(lambda: app.return_value is not None, pilot, timeout=2.0)
assert app.return_value == "completed"
env_contents = _saved_env_contents()
assert "MISTRAL_API_KEY" in env_contents
assert "sk-browser-onboarding-test-key" in env_contents
@pytest.mark.asyncio
async def test_ui_shows_human_message_when_polling_fails() -> None:
_, browser_sign_in_service_factory, _ = build_browser_sign_in_service_factory(
outcomes=["poll_failed"]
)
app = _build_browser_onboarding_app(
browser_sign_in_service_factory=browser_sign_in_service_factory
)
async with app.run_test() as pilot:
await _show_browser_sign_in(pilot)
await _wait_for(
lambda: (
"We couldn't complete sign-in. Please try again."
in str(app.screen.query_one("#browser-sign-in-status").render())
),
pilot,
)
@pytest.mark.asyncio
async def test_ui_shows_retryable_error_when_browser_sign_in_fails_unexpectedly() -> (
None
):
app = _build_browser_onboarding_app(
browser_sign_in_service_factory=_build_unexpected_browser_sign_in_service_factory([
"runtime_error"
])
)
async with app.run_test() as pilot:
await _show_browser_sign_in(pilot)
await _wait_for(
lambda: (
UNEXPECTED_ERROR_MESSAGE
in str(app.screen.query_one("#browser-sign-in-status").render())
),
pilot,
)
assert isinstance(app.screen, BrowserSignInScreen)
status_widget = app.screen.query_one("#browser-sign-in-status")
assert status_widget.has_class("error")
assert not status_widget.has_class("pending")
assert ERROR_HINT in str(app.screen.query_one("#browser-sign-in-hint").render())
assert app.return_value is None
@pytest.mark.asyncio
async def test_ui_retries_after_unexpected_browser_sign_in_failure() -> None:
app = _build_browser_onboarding_app(
browser_sign_in_service_factory=_build_unexpected_browser_sign_in_service_factory([
"runtime_error",
"completed",
])
)
async with app.run_test() as pilot:
await _show_browser_sign_in(pilot)
await _wait_for(
lambda: (
UNEXPECTED_ERROR_MESSAGE
in str(app.screen.query_one("#browser-sign-in-status").render())
),
pilot,
)
await pilot.press("r")
await _wait_for(lambda: app.return_value is not None, pilot, timeout=2.0)
assert app.return_value == "completed"
assert "sk-browser-onboarding-test-key" in _saved_env_contents()
@pytest.mark.asyncio
async def test_ui_waits_for_browser_sign_in_cleanup_before_retrying() -> None:
close_started = asyncio.Event()
close_blocker = asyncio.Event()
app = _build_browser_onboarding_app(
browser_sign_in_service_factory=_build_unexpected_browser_sign_in_service_factory(
["runtime_error", "completed"],
close_blocker=close_blocker,
close_started=close_started,
)
)
async with app.run_test() as pilot:
await _show_browser_sign_in(pilot)
await _wait_for(close_started.is_set, pilot)
status_widget = app.screen.query_one("#browser-sign-in-status")
hint_widget = app.screen.query_one("#browser-sign-in-hint")
await _wait_for(
lambda: "Getting things ready..." in str(status_widget.render()), pilot
)
await _wait_for(lambda: PENDING_HINT in str(hint_widget.render()), pilot)
await pilot.press("r")
await _wait_for(
lambda: "Getting things ready..." in str(status_widget.render()), pilot
)
await _wait_for(lambda: PENDING_HINT in str(hint_widget.render()), pilot)
assert app.return_value is None
close_blocker.set()
await _wait_for(
lambda: (
UNEXPECTED_ERROR_MESSAGE
in str(app.screen.query_one("#browser-sign-in-status").render())
),
pilot,
)
await pilot.press("r")
await _wait_for(lambda: app.return_value is not None, pilot, timeout=2.0)
assert app.return_value == "completed"
assert "sk-browser-onboarding-test-key" in _saved_env_contents()
@pytest.mark.asyncio
async def test_ui_switches_to_manual_path_without_cancelling_browser_sign_in_cleanup() -> (
None
):
close_started = asyncio.Event()
close_blocker = asyncio.Event()
close_finished = asyncio.Event()
close_cancelled = asyncio.Event()
app = _build_browser_onboarding_app(
browser_sign_in_service_factory=_build_unexpected_browser_sign_in_service_factory(
["runtime_error"],
close_blocker=close_blocker,
close_started=close_started,
close_finished=close_finished,
close_cancelled=close_cancelled,
)
)
async with app.run_test() as pilot:
await _show_browser_sign_in(pilot)
await _wait_for(close_started.is_set, pilot)
await pilot.press("m")
await _wait_for(lambda: isinstance(pilot.app.screen, ApiKeyScreen), pilot)
close_blocker.set()
await _wait_for(close_finished.is_set, pilot)
assert close_cancelled.is_set() is False
@pytest.mark.asyncio
async def test_ui_switches_to_manual_path_while_browser_sign_in_is_running() -> None:
blocker = asyncio.Event()
async def wait_forever(_: float) -> None:
await blocker.wait()
gateway, browser_sign_in_service_factory, _ = build_browser_sign_in_service_factory(
outcomes=["completed"], sleep=wait_forever
)
app = _build_browser_onboarding_app(
browser_sign_in_service_factory=browser_sign_in_service_factory
)
api_key_value = "sk-manual-after-browser-cancel"
async with app.run_test() as pilot:
await _show_browser_sign_in(pilot)
await _wait_for(
lambda: (
"Waiting for you to finish signing in..."
in str(app.screen.query_one("#browser-sign-in-status").render())
),
pilot,
)
status_widget = app.screen.query_one("#browser-sign-in-status")
assert status_widget.has_class("pending")
assert not status_widget.has_class("error")
step_widgets = list(app.screen.query(".browser-sign-in-step"))
assert len(step_widgets) == 3
assert step_widgets[0].has_class("done")
assert "Open your browser" in str(step_widgets[0].render())
assert step_widgets[1].has_class("active")
assert "Sign in and return here" in str(step_widgets[1].render())
assert step_widgets[2].has_class("idle")
assert "Finish setup" in str(step_widgets[2].render())
await pilot.press("m")
await _wait_for(lambda: isinstance(pilot.app.screen, ApiKeyScreen), pilot)
await pilot.press(*api_key_value)
await pilot.press("enter")
await _wait_for(lambda: app.return_value is not None, pilot, timeout=2.0)
assert app.return_value == "completed"
assert gateway.closed is True
assert gateway.exchange_requests == []
env_contents = _saved_env_contents()
assert api_key_value in env_contents
assert "sk-browser-onboarding-test-key" not in env_contents
@pytest.mark.asyncio
async def test_ui_uses_default_mistral_browser_auth_urls_when_experiment_is_enabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured_base_urls: list[tuple[str, str]] = []
_patch_failing_browser_sign_in_service(monkeypatch, captured_base_urls)
app = OnboardingApp(
config=build_test_vibe_config(enable_experimental_browser_sign_in=True)
)
assert app.supports_browser_sign_in is True
async with app.run_test() as pilot:
await _show_browser_sign_in(pilot)
await _wait_for(lambda: bool(captured_base_urls), pilot)
assert captured_base_urls == [
(
DEFAULT_MISTRAL_BROWSER_AUTH_BASE_URL,
DEFAULT_MISTRAL_BROWSER_AUTH_API_BASE_URL,
)
]
@pytest.mark.asyncio
async def test_ui_enables_browser_sign_in_from_env_var(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("VIBE_ENABLE_EXPERIMENTAL_BROWSER_SIGN_IN", "true")
captured_base_urls: list[tuple[str, str]] = []
_patch_failing_browser_sign_in_service(monkeypatch, captured_base_urls)
app = OnboardingApp()
assert app.supports_browser_sign_in is True
async with app.run_test() as pilot:
await _show_browser_sign_in(pilot)
await _wait_for(lambda: bool(captured_base_urls), pilot)
assert captured_base_urls == [
(
DEFAULT_MISTRAL_BROWSER_AUTH_BASE_URL,
DEFAULT_MISTRAL_BROWSER_AUTH_API_BASE_URL,
)
]
@pytest.mark.asyncio
async def test_ui_keeps_browser_sign_in_disabled_from_false_env_var(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("VIBE_ENABLE_EXPERIMENTAL_BROWSER_SIGN_IN", "false")
app = OnboardingApp()
assert app.supports_browser_sign_in is False
async with app.run_test() as pilot:
await _pass_welcome_screen(pilot)
await _wait_for(lambda: isinstance(pilot.app.screen, ApiKeyScreen), pilot)
@pytest.mark.asyncio
async def test_ui_preserves_custom_browser_auth_urls_when_api_key_is_missing(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
monkeypatch.setenv("VIBE_HOME", str(tmp_path))
config_file = tmp_path / "config.toml"
config_file.write_text(
"\n".join([
'active_model = "devstral-2"',
"enable_experimental_browser_sign_in = true",
"[[providers]]",
'name = "mistral"',
'api_base = "https://api.mistral.ai/v1"',
'api_key_env_var = "MISTRAL_API_KEY"',
'browser_auth_base_url = "http://127.0.0.1:8787"',
'browser_auth_api_base_url = "http://127.0.0.1:8787"',
'backend = "mistral"',
"",
"[[models]]",
'name = "mistral-vibe-cli-latest"',
'provider = "mistral"',
'alias = "devstral-2"',
"",
]),
encoding="utf-8",
)
reset_harness_files_manager()
init_harness_files_manager("user")
captured_base_urls: list[tuple[str, str]] = []
_patch_failing_browser_sign_in_service(monkeypatch, captured_base_urls)
app = OnboardingApp()
assert app.supports_browser_sign_in is True
async with app.run_test() as pilot:
await _show_browser_sign_in(pilot)
await _wait_for(lambda: bool(captured_base_urls), pilot)
assert captured_base_urls == [("http://127.0.0.1:8787", "http://127.0.0.1:8787")]
@pytest.mark.asyncio
async def test_ui_falls_back_to_default_onboarding_context_with_invalid_active_model(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
monkeypatch.setenv("VIBE_HOME", str(tmp_path))
config_file = tmp_path / "config.toml"
config_file.write_text(
"\n".join([
'active_model = "does-not-exist"',
"enable_experimental_browser_sign_in = true",
"",
"[[providers]]",
'name = "mistral"',
'api_base = "https://api.mistral.ai/v1"',
'api_key_env_var = "MISTRAL_API_KEY"',
'browser_auth_base_url = "https://console.mistral.ai"',
'browser_auth_api_base_url = "https://api.mistral.ai"',
'backend = "mistral"',
"",
"[[models]]",
'name = "mistral-vibe-cli-latest"',
'provider = "mistral"',
'alias = "devstral-2"',
"",
]),
encoding="utf-8",
)
reset_harness_files_manager()
init_harness_files_manager("user")
app = OnboardingApp()
assert app.supports_browser_sign_in is True
async with app.run_test() as pilot:
await _show_auth_method(pilot)
def test_api_key_screen_falls_back_to_mistral_for_provider_without_env_key() -> None:

View file

@ -87,13 +87,6 @@ class TestListRemoteResumeSessions:
start_time=datetime(2026, 1, 1),
end_time=None,
)
retrying = WorkflowExecutionWithoutResultResponse(
workflow_name="vibe",
execution_id="exec-retrying",
status=WorkflowExecutionStatus.RETRYING_AFTER_ERROR,
start_time=datetime(2026, 1, 1),
end_time=None,
)
continued = WorkflowExecutionWithoutResultResponse(
workflow_name="vibe",
execution_id="exec-continued",
@ -102,9 +95,7 @@ class TestListRemoteResumeSessions:
end_time=None,
)
mock_response = WorkflowExecutionListResponse(
executions=[running, retrying, continued]
)
mock_response = WorkflowExecutionListResponse(executions=[running, continued])
config = MagicMock()
config.vibe_code_enabled = True
@ -121,10 +112,9 @@ class TestListRemoteResumeSessions:
result = await list_remote_resume_sessions(config)
assert len(result) == 3
assert len(result) == 2
session_ids = {s.session_id for s in result}
assert "exec-running" in session_ids
assert "exec-retrying" in session_ids
assert "exec-continued" in session_ids
assert all(s.source == "remote" for s in result)
@ -133,7 +123,6 @@ class TestListRemoteResumeSessions:
page_size=50,
status=[
WorkflowExecutionStatus.RUNNING,
WorkflowExecutionStatus.RETRYING_AFTER_ERROR,
WorkflowExecutionStatus.CONTINUED_AS_NEW,
],
)
@ -207,7 +196,7 @@ class TestListRemoteResumeSessions:
previous = WorkflowExecutionWithoutResultResponse(
workflow_name="vibe",
execution_id="exec-1",
status=WorkflowExecutionStatus.RETRYING_AFTER_ERROR,
status=WorkflowExecutionStatus.FAILED,
start_time=datetime(2026, 1, 1),
end_time=datetime(2026, 1, 10),
)

View file

@ -346,7 +346,10 @@ class TestSessionLoaderFindLatestSession:
assert result == valid_session
def test_find_latest_session_skips_unreadable_messages_file(
self, session_config: SessionLoggingConfig, create_test_session
self,
session_config: SessionLoggingConfig,
create_test_session,
monkeypatch: pytest.MonkeyPatch,
) -> None:
session_dir = Path(session_config.save_dir)
@ -355,14 +358,28 @@ class TestSessionLoaderFindLatestSession:
unreadable_session = create_test_session(session_dir, "unreadab-session")
unreadable_messages = unreadable_session / "messages.jsonl"
unreadable_messages.chmod(0)
# chmod doesn't restrict root, so simulate an unreadable file by
# patching Path.read_bytes (used under the hood by read_safe). This
# keeps the test working in CI environments running as root.
original_read_bytes = Path.read_bytes
def fake_read_bytes(self: Path) -> bytes:
if self == unreadable_messages:
raise PermissionError(f"Permission denied: {self}")
return original_read_bytes(self)
monkeypatch.setattr(Path, "read_bytes", fake_read_bytes)
result = SessionLoader.find_latest_session(session_config)
assert result is not None
assert result == valid_session
def test_find_latest_session_skips_unreadable_metadata_file(
self, session_config: SessionLoggingConfig, create_test_session
self,
session_config: SessionLoggingConfig,
create_test_session,
monkeypatch: pytest.MonkeyPatch,
) -> None:
session_dir = Path(session_config.save_dir)
@ -371,7 +388,15 @@ class TestSessionLoaderFindLatestSession:
unreadable_session = create_test_session(session_dir, "unreadab-session")
unreadable_metadata = unreadable_session / "meta.json"
unreadable_metadata.chmod(0)
original_read_bytes = Path.read_bytes
def fake_read_bytes(self: Path) -> bytes:
if self == unreadable_metadata:
raise PermissionError(f"Permission denied: {self}")
return original_read_bytes(self)
monkeypatch.setattr(Path, "read_bytes", fake_read_bytes)
result = SessionLoader.find_latest_session(session_config)
assert result is not None
@ -436,6 +461,41 @@ class TestSessionLoaderFindSessionById:
assert result is not None
assert result == session_2
def test_find_session_by_id_filters_by_working_directory(
self, session_config: SessionLoggingConfig, create_test_session
) -> None:
session_dir = Path(session_config.save_dir)
session_a = create_test_session(
session_dir,
"abcd1234-session",
working_directory=Path("/home/user/project-a"),
)
time.sleep(0.01)
session_b = create_test_session(
session_dir,
"abcd1234-session",
working_directory=Path("/home/user/project-b"),
)
assert (
SessionLoader.find_session_by_id(
"abcd1234",
session_config,
working_directory=Path("/home/user/project-a"),
)
== session_a
)
assert (
SessionLoader.find_session_by_id(
"abcd1234",
session_config,
working_directory=Path("/home/user/project-c"),
)
is None
)
assert SessionLoader.find_session_by_id("abcd1234", session_config) == session_b
def test_find_session_by_id_no_match(
self, session_config: SessionLoggingConfig, create_test_session
) -> None:

View file

@ -11,6 +11,7 @@ import pytest
from tests.conftest import build_test_vibe_config
from vibe.core.agents.models import AgentProfile, AgentSafety
from vibe.core.config import SessionLoggingConfig, VibeConfig
from vibe.core.experiments.models import EvalResponse
from vibe.core.loop import ScheduledLoop
from vibe.core.session.session_logger import SessionLogger
from vibe.core.tools.manager import ToolManager
@ -114,16 +115,12 @@ class TestSessionLoggerMetadata:
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"
# Mock combined git command
git_mock = MagicMock()
git_mock.returncode = 0
git_mock.stdout = "abc123\nmain\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_subprocess.return_value = git_mock
mock_getuser.return_value = "testuser"
session_id = "test-session-123"
@ -149,7 +146,7 @@ class TestSessionLoggerMetadata:
self, mock_getuser, mock_subprocess, session_config: SessionLoggingConfig
) -> None:
"""Test that session metadata handles git command errors gracefully."""
# Mock git commands to fail
# Mock combined git command to fail
mock_subprocess.side_effect = FileNotFoundError("git not found")
mock_getuser.return_value = "testuser"
@ -1002,3 +999,153 @@ class TestPersistLoops:
metadata = json.load(f)
assert len(metadata["loops"]) == 1
assert metadata["loops"][0]["id"] == "aabbccdd"
class TestPersistExperiments:
@pytest.fixture
def sample_response(self) -> EvalResponse:
return EvalResponse.model_validate({
"features": {
"vibe_code_cli_test_ab": {
"defaultValue": "cli",
"rules": [
{
"force": "cli_v2",
"tracks": [
{
"experiment": {"key": "vibe_code_cli_test_ab"},
"result": {
"key": "1",
"variationId": 1,
"inExperiment": True,
},
}
],
}
],
}
}
})
@pytest.mark.asyncio
async def test_writes_field_into_existing_metadata(
self,
session_config: SessionLoggingConfig,
mock_vibe_config: VibeConfig,
mock_tool_manager: ToolManager,
mock_agent_profile: AgentProfile,
sample_response: EvalResponse,
) -> None:
logger = SessionLogger(session_config, "exp-session")
await logger.save_interaction(
messages=[
LLMMessage(role=Role.system, content="System prompt"),
LLMMessage(role=Role.user, content="Hello"),
],
stats=AgentStats(steps=1),
base_config=mock_vibe_config,
tool_manager=mock_tool_manager,
agent_profile=mock_agent_profile,
)
await logger.persist_experiments(sample_response)
assert logger.session_dir is not None
with open(logger.session_dir / "meta.json") as f:
metadata = json.load(f)
assert "experiments" in metadata
assert (
metadata["experiments"]["features"]["vibe_code_cli_test_ab"]["defaultValue"]
== "cli"
)
@pytest.mark.asyncio
async def test_persists_none_as_null(
self,
session_config: SessionLoggingConfig,
mock_vibe_config: VibeConfig,
mock_tool_manager: ToolManager,
mock_agent_profile: AgentProfile,
) -> None:
logger = SessionLogger(session_config, "exp-none")
await logger.save_interaction(
messages=[
LLMMessage(role=Role.system, content="x"),
LLMMessage(role=Role.user, content="y"),
],
stats=AgentStats(steps=1),
base_config=mock_vibe_config,
tool_manager=mock_tool_manager,
agent_profile=mock_agent_profile,
)
await logger.persist_experiments(None)
assert logger.session_dir is not None
with open(logger.session_dir / "meta.json") as f:
metadata = json.load(f)
assert metadata.get("experiments") is None
@pytest.mark.asyncio
async def test_does_not_create_metadata_file_when_missing(
self, session_config: SessionLoggingConfig, sample_response: EvalResponse
) -> None:
# Sessions without any message must not be persisted at all —
# persist_experiments updates only in-memory state when meta.json is
# absent, and lets the eventual save_interaction write it.
logger = SessionLogger(session_config, "fresh-session")
assert logger.session_dir is not None
assert not (logger.session_dir / "meta.json").exists()
await logger.persist_experiments(sample_response)
assert not (logger.session_dir / "meta.json").exists()
assert logger.session_metadata is not None
assert logger.session_metadata.experiments == sample_response
@pytest.mark.asyncio
async def test_noop_when_logging_disabled(
self,
disabled_session_config: SessionLoggingConfig,
sample_response: EvalResponse,
) -> None:
logger = SessionLogger(disabled_session_config, "ignored")
await logger.persist_experiments(sample_response)
@pytest.mark.asyncio
async def test_first_save_interaction_includes_in_memory_experiments(
self,
session_config: SessionLoggingConfig,
mock_vibe_config: VibeConfig,
mock_tool_manager: ToolManager,
mock_agent_profile: AgentProfile,
sample_response: EvalResponse,
) -> None:
# Real flow: persist_experiments at session start (no meta.json yet,
# in-memory only). The first save_interaction must succeed AND
# include the experiments snapshot in the eventual meta.json.
logger = SessionLogger(session_config, "first-save-after-experiments")
await logger.persist_experiments(sample_response)
assert logger.session_dir is not None
assert not (logger.session_dir / "meta.json").exists()
await logger.save_interaction(
messages=[
LLMMessage(role=Role.system, content="System prompt"),
LLMMessage(role=Role.user, content="Hello"),
LLMMessage(role=Role.assistant, content="Hi"),
],
stats=AgentStats(steps=1),
base_config=mock_vibe_config,
tool_manager=mock_tool_manager,
agent_profile=mock_agent_profile,
)
with open(logger.session_dir / "meta.json") as f:
metadata = json.load(f)
assert metadata["total_messages"] == 2
assert (
metadata["experiments"]["features"]["vibe_code_cli_test_ab"]["defaultValue"]
== "cli"
)

View file

@ -187,7 +187,7 @@
</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r4" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r2" x="24.4" y="630" textLength="305" clip-path="url(#terminal-line-25)">Hello&#160;there,&#160;who&#160;are&#160;you?</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r1" x="0" y="678.8" textLength="500.2" clip-path="url(#terminal-line-27)">I&#x27;m&#160;the&#160;Vibe&#160;agent&#160;and&#160;I&#x27;m&#160;ready&#160;to&#160;help.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="24.4" y="678.8" textLength="500.2" clip-path="url(#terminal-line-27)">I&#x27;m&#160;the&#160;Vibe&#160;agent&#160;and&#160;I&#x27;m&#160;ready&#160;to&#160;help.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r5" x="0" y="752" textLength="1464" clip-path="url(#terminal-line-30)">┌────────────────────────────────────────────────────────────────────────────────────────────────────────────&#160;default&#160;─┐</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before After
Before After

View file

@ -163,7 +163,7 @@
</g>
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<rect fill="#4b4e55" x="12.2" y="611.5" width="158.6" height="24.65" shape-rendering="crispEdges"/>
<rect fill="#4b4e55" x="36.6" y="611.5" width="146.4" height="24.65" shape-rendering="crispEdges"/>
<g class="terminal-matrix">
<text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="1464" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
@ -185,14 +185,14 @@
</text><text class="terminal-r1" x="0" y="434.8" textLength="134.2" clip-path="url(#terminal-line-17)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="434.8" textLength="61" clip-path="url(#terminal-line-17)">Type&#160;</text><text class="terminal-r3" x="231.8" y="434.8" textLength="61" clip-path="url(#terminal-line-17)">/help</text><text class="terminal-r1" x="292.8" y="434.8" textLength="256.2" clip-path="url(#terminal-line-17)">&#160;for&#160;more&#160;information</text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
</text><text class="terminal-r1" x="0" y="508" textLength="451.4" clip-path="url(#terminal-line-20)">Here&#x27;s&#160;a&#160;very&#160;long&#160;print&#160;instruction:</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r1" x="24.4" y="508" textLength="451.4" clip-path="url(#terminal-line-20)">Here&#x27;s&#160;a&#160;very&#160;long&#160;print&#160;instruction:</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r1" x="0" y="556.8" textLength="36.6" clip-path="url(#terminal-line-22)">():</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r4" x="0" y="581.2" textLength="329.4" clip-path="url(#terminal-line-23)">ery&#160;long&#160;line&#160;(Lorem&#160;Ipsum)</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r5" x="0" y="605.6" textLength="1390.8" clip-path="url(#terminal-line-24)">m&#160;ipsum&#160;dolor&#160;sit&#160;amet,&#160;consectetur&#160;adipiscing&#160;elit.&#160;Sed&#160;do&#160;eiusmod&#160;tempor&#160;incididunt&#160;ut&#160;labore&#160;et&#160;dolore&#160;magna&#160;al</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r7" x="12.2" y="630" textLength="158.6" clip-path="url(#terminal-line-25)">&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text class="terminal-r8" x="170.8" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" x="24.4" y="556.8" textLength="36.6" clip-path="url(#terminal-line-22)">():</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r4" x="24.4" y="581.2" textLength="329.4" clip-path="url(#terminal-line-23)">ery&#160;long&#160;line&#160;(Lorem&#160;Ipsum)</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r5" x="24.4" y="605.6" textLength="1366.4" clip-path="url(#terminal-line-24)">m&#160;ipsum&#160;dolor&#160;sit&#160;amet,&#160;consectetur&#160;adipiscing&#160;elit.&#160;Sed&#160;do&#160;eiusmod&#160;tempor&#160;incididunt&#160;ut&#160;labore&#160;et&#160;dolore&#160;magna&#160;</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r7" x="36.6" y="630" textLength="146.4" clip-path="url(#terminal-line-25)">&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text class="terminal-r8" x="183" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r1" x="0" y="678.8" textLength="48.8" clip-path="url(#terminal-line-27)">The&#160;</text><text class="terminal-r9" x="48.8" y="678.8" textLength="61" clip-path="url(#terminal-line-27)">print</text><text class="terminal-r1" x="109.8" y="678.8" textLength="1085.8" clip-path="url(#terminal-line-27)">&#160;statement&#160;includes&#160;a&#160;very&#160;long&#160;line&#160;of&#160;Lorem&#160;Ipsum&#160;text&#160;to&#160;demonstrate&#160;a&#160;lengthy&#160;output.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="24.4" y="678.8" textLength="48.8" clip-path="url(#terminal-line-27)">The&#160;</text><text class="terminal-r9" x="73.2" y="678.8" textLength="61" clip-path="url(#terminal-line-27)">print</text><text class="terminal-r1" x="134.2" y="678.8" textLength="1085.8" clip-path="url(#terminal-line-27)">&#160;statement&#160;includes&#160;a&#160;very&#160;long&#160;line&#160;of&#160;Lorem&#160;Ipsum&#160;text&#160;to&#160;demonstrate&#160;a&#160;lengthy&#160;output.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r10" x="0" y="752" textLength="1464" clip-path="url(#terminal-line-30)">┌────────────────────────────────────────────────────────────────────────────────────────────────────────────&#160;default&#160;─┐</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

View file

@ -189,7 +189,7 @@
</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r5" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r6" x="24.4" y="630" textLength="85.4" clip-path="url(#terminal-line-25)">Thought</text><text class="terminal-r6" x="122" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r1" x="0" y="678.8" textLength="207.4" clip-path="url(#terminal-line-27)">The&#160;answer&#160;is&#160;42.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="24.4" y="678.8" textLength="207.4" clip-path="url(#terminal-line-27)">The&#160;answer&#160;is&#160;42.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r7" x="0" y="752" textLength="1464" clip-path="url(#terminal-line-30)">┌────────────────────────────────────────────────────────────────────────────────────────────────────────────&#160;default&#160;─┐</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

View file

@ -188,7 +188,7 @@
</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r4" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r2" x="24.4" y="630" textLength="61" clip-path="url(#terminal-line-25)">Hello</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r1" x="0" y="678.8" textLength="329.4" clip-path="url(#terminal-line-27)">Sure,&#160;I&#160;can&#160;help&#160;with&#160;that.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="24.4" y="678.8" textLength="329.4" clip-path="url(#terminal-line-27)">Sure,&#160;I&#160;can&#160;help&#160;with&#160;that.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r1" x="841.8" y="727.6" textLength="329.4" clip-path="url(#terminal-line-29)">How&#160;is&#160;Vibe&#160;doing&#160;so&#160;far?&#160;&#160;</text><text class="terminal-r5" x="1171.2" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">1</text><text class="terminal-r1" x="1183.4" y="727.6" textLength="97.6" clip-path="url(#terminal-line-29)">:&#160;good&#160;&#160;</text><text class="terminal-r5" x="1281" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">2</text><text class="terminal-r1" x="1293.2" y="727.6" textLength="97.6" clip-path="url(#terminal-line-29)">:&#160;fine&#160;&#160;</text><text class="terminal-r5" x="1390.8" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">3</text><text class="terminal-r1" x="1403" y="727.6" textLength="61" clip-path="url(#terminal-line-29)">:&#160;bad</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r6" x="0" y="752" textLength="1464" clip-path="url(#terminal-line-30)">┌────────────────────────────────────────────────────────────────────────────────────────────────────────────&#160;default&#160;─┐</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

View file

@ -19,183 +19,183 @@
font-weight: 700;
}
.terminal-9108451134-matrix {
.terminal-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
.terminal-9108451134-title {
.terminal-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
.terminal-9108451134-r1 { fill: #c5c8c6 }
.terminal-9108451134-r2 { fill: #ff8205;font-weight: bold }
.terminal-9108451134-r3 { fill: #68a0b3 }
.terminal-9108451134-r4 { fill: #ff8205 }
.terminal-9108451134-r5 { fill: #9a9b99 }
.terminal-r1 { fill: #c5c8c6 }
.terminal-r2 { fill: #ff8205;font-weight: bold }
.terminal-r3 { fill: #68a0b3 }
.terminal-r4 { fill: #ff8205 }
.terminal-r5 { fill: #9a9b99 }
</style>
<defs>
<clipPath id="terminal-9108451134-clip-terminal">
<clipPath id="terminal-clip-terminal">
<rect x="0" y="0" width="1463.0" height="877.4" />
</clipPath>
<clipPath id="terminal-9108451134-line-0">
<clipPath id="terminal-line-0">
<rect x="0" y="1.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-1">
<clipPath id="terminal-line-1">
<rect x="0" y="25.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-2">
<clipPath id="terminal-line-2">
<rect x="0" y="50.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-3">
<clipPath id="terminal-line-3">
<rect x="0" y="74.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-4">
<clipPath id="terminal-line-4">
<rect x="0" y="99.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-5">
<clipPath id="terminal-line-5">
<rect x="0" y="123.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-6">
<clipPath id="terminal-line-6">
<rect x="0" y="147.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-7">
<clipPath id="terminal-line-7">
<rect x="0" y="172.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-8">
<clipPath id="terminal-line-8">
<rect x="0" y="196.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-9">
<clipPath id="terminal-line-9">
<rect x="0" y="221.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-10">
<clipPath id="terminal-line-10">
<rect x="0" y="245.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-11">
<clipPath id="terminal-line-11">
<rect x="0" y="269.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-12">
<clipPath id="terminal-line-12">
<rect x="0" y="294.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-13">
<clipPath id="terminal-line-13">
<rect x="0" y="318.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-14">
<clipPath id="terminal-line-14">
<rect x="0" y="343.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-15">
<clipPath id="terminal-line-15">
<rect x="0" y="367.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-16">
<clipPath id="terminal-line-16">
<rect x="0" y="391.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-17">
<clipPath id="terminal-line-17">
<rect x="0" y="416.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-18">
<clipPath id="terminal-line-18">
<rect x="0" y="440.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-19">
<clipPath id="terminal-line-19">
<rect x="0" y="465.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-20">
<clipPath id="terminal-line-20">
<rect x="0" y="489.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-21">
<clipPath id="terminal-line-21">
<rect x="0" y="513.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-22">
<clipPath id="terminal-line-22">
<rect x="0" y="538.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-23">
<clipPath id="terminal-line-23">
<rect x="0" y="562.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-24">
<clipPath id="terminal-line-24">
<rect x="0" y="587.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-25">
<clipPath id="terminal-line-25">
<rect x="0" y="611.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-26">
<clipPath id="terminal-line-26">
<rect x="0" y="635.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-27">
<clipPath id="terminal-line-27">
<rect x="0" y="660.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-28">
<clipPath id="terminal-line-28">
<rect x="0" y="684.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-29">
<clipPath id="terminal-line-29">
<rect x="0" y="709.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-30">
<clipPath id="terminal-line-30">
<rect x="0" y="733.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-31">
<clipPath id="terminal-line-31">
<rect x="0" y="757.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-32">
<clipPath id="terminal-line-32">
<rect x="0" y="782.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-33">
<clipPath id="terminal-line-33">
<rect x="0" y="806.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-34">
<clipPath id="terminal-line-34">
<rect x="0" y="831.1" width="1464" height="24.65"/>
</clipPath>
</defs>
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1480" height="926.4" rx="8"/><text class="terminal-9108451134-title" fill="#c5c8c6" text-anchor="middle" x="740" y="27">SnapshotTestAppNoMcpServers</text>
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1480" height="926.4" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="740" y="27">SnapshotTestAppNoMcpServers</text>
<g transform="translate(26,22)">
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
<circle cx="44" cy="0" r="7" fill="#28c840"/>
</g>
<g transform="translate(9, 41)" clip-path="url(#terminal-9108451134-clip-terminal)">
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<g class="terminal-9108451134-matrix">
<text class="terminal-9108451134-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-9108451134-line-0)">
</text><text class="terminal-9108451134-r1" x="1464" y="44.4" textLength="12.2" clip-path="url(#terminal-9108451134-line-1)">
</text><text class="terminal-9108451134-r1" x="1464" y="68.8" textLength="12.2" clip-path="url(#terminal-9108451134-line-2)">
</text><text class="terminal-9108451134-r1" x="1464" y="93.2" textLength="12.2" clip-path="url(#terminal-9108451134-line-3)">
</text><text class="terminal-9108451134-r1" x="1464" y="117.6" textLength="12.2" clip-path="url(#terminal-9108451134-line-4)">
</text><text class="terminal-9108451134-r1" x="1464" y="142" textLength="12.2" clip-path="url(#terminal-9108451134-line-5)">
</text><text class="terminal-9108451134-r1" x="1464" y="166.4" textLength="12.2" clip-path="url(#terminal-9108451134-line-6)">
</text><text class="terminal-9108451134-r1" x="1464" y="190.8" textLength="12.2" clip-path="url(#terminal-9108451134-line-7)">
</text><text class="terminal-9108451134-r1" x="1464" y="215.2" textLength="12.2" clip-path="url(#terminal-9108451134-line-8)">
</text><text class="terminal-9108451134-r1" x="1464" y="239.6" textLength="12.2" clip-path="url(#terminal-9108451134-line-9)">
</text><text class="terminal-9108451134-r1" x="1464" y="264" textLength="12.2" clip-path="url(#terminal-9108451134-line-10)">
</text><text class="terminal-9108451134-r1" x="1464" y="288.4" textLength="12.2" clip-path="url(#terminal-9108451134-line-11)">
</text><text class="terminal-9108451134-r1" x="1464" y="312.8" textLength="12.2" clip-path="url(#terminal-9108451134-line-12)">
</text><text class="terminal-9108451134-r1" x="1464" y="337.2" textLength="12.2" clip-path="url(#terminal-9108451134-line-13)">
</text><text class="terminal-9108451134-r1" x="1464" y="361.6" textLength="12.2" clip-path="url(#terminal-9108451134-line-14)">
</text><text class="terminal-9108451134-r1" x="1464" y="386" textLength="12.2" clip-path="url(#terminal-9108451134-line-15)">
</text><text class="terminal-9108451134-r1" x="1464" y="410.4" textLength="12.2" clip-path="url(#terminal-9108451134-line-16)">
</text><text class="terminal-9108451134-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-9108451134-line-17)">
</text><text class="terminal-9108451134-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-9108451134-line-18)">
</text><text class="terminal-9108451134-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-9108451134-line-19)">
</text><text class="terminal-9108451134-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-9108451134-line-20)">
</text><text class="terminal-9108451134-r1" x="0" y="532.4" textLength="134.2" clip-path="url(#terminal-9108451134-line-21)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-9108451134-r2" x="170.8" y="532.4" textLength="146.4" clip-path="url(#terminal-9108451134-line-21)">Mistral&#160;Vibe</text><text class="terminal-9108451134-r1" x="317.2" y="532.4" textLength="122" clip-path="url(#terminal-9108451134-line-21)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-9108451134-r3" x="439.2" y="532.4" textLength="244" clip-path="url(#terminal-9108451134-line-21)">devstral-latest[off]</text><text class="terminal-9108451134-r1" x="683.2" y="532.4" textLength="256.2" clip-path="url(#terminal-9108451134-line-21)">&#160;·&#160;[Subscription]&#160;Pro</text><text class="terminal-9108451134-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-9108451134-line-21)">
</text><text class="terminal-9108451134-r1" x="0" y="556.8" textLength="134.2" clip-path="url(#terminal-9108451134-line-22)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-9108451134-r1" x="170.8" y="556.8" textLength="414.8" clip-path="url(#terminal-9108451134-line-22)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</text><text class="terminal-9108451134-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-9108451134-line-22)">
</text><text class="terminal-9108451134-r1" x="0" y="581.2" textLength="134.2" clip-path="url(#terminal-9108451134-line-23)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-9108451134-r1" x="170.8" y="581.2" textLength="61" clip-path="url(#terminal-9108451134-line-23)">Type&#160;</text><text class="terminal-9108451134-r3" x="231.8" y="581.2" textLength="61" clip-path="url(#terminal-9108451134-line-23)">/help</text><text class="terminal-9108451134-r1" x="292.8" y="581.2" textLength="256.2" clip-path="url(#terminal-9108451134-line-23)">&#160;for&#160;more&#160;information</text><text class="terminal-9108451134-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-9108451134-line-23)">
</text><text class="terminal-9108451134-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-9108451134-line-24)">
</text><text class="terminal-9108451134-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-9108451134-line-25)">
</text><text class="terminal-9108451134-r4" x="0" y="654.4" textLength="12.2" clip-path="url(#terminal-9108451134-line-26)"></text><text class="terminal-9108451134-r2" x="24.4" y="654.4" textLength="48.8" clip-path="url(#terminal-9108451134-line-26)">/mcp</text><text class="terminal-9108451134-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-9108451134-line-26)">
</text><text class="terminal-9108451134-r5" x="24.4" y="678.8" textLength="12.2" clip-path="url(#terminal-9108451134-line-27)"></text><text class="terminal-9108451134-r1" x="48.8" y="678.8" textLength="317.2" clip-path="url(#terminal-9108451134-line-27)">No&#160;MCP&#160;servers&#160;configured.</text><text class="terminal-9108451134-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-9108451134-line-27)">
</text><text class="terminal-9108451134-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-9108451134-line-28)">
</text><text class="terminal-9108451134-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-9108451134-line-29)">
</text><text class="terminal-9108451134-r5" x="0" y="752" textLength="1464" clip-path="url(#terminal-9108451134-line-30)">┌────────────────────────────────────────────────────────────────────────────────────────────────────────────&#160;default&#160;─┐</text><text class="terminal-9108451134-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-9108451134-line-30)">
</text><text class="terminal-9108451134-r5" x="0" y="776.4" textLength="12.2" clip-path="url(#terminal-9108451134-line-31)"></text><text class="terminal-9108451134-r2" x="24.4" y="776.4" textLength="12.2" clip-path="url(#terminal-9108451134-line-31)">&gt;</text><text class="terminal-9108451134-r5" x="1451.8" y="776.4" textLength="12.2" clip-path="url(#terminal-9108451134-line-31)"></text><text class="terminal-9108451134-r1" x="1464" y="776.4" textLength="12.2" clip-path="url(#terminal-9108451134-line-31)">
</text><text class="terminal-9108451134-r5" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-9108451134-line-32)"></text><text class="terminal-9108451134-r5" x="1451.8" y="800.8" textLength="12.2" clip-path="url(#terminal-9108451134-line-32)"></text><text class="terminal-9108451134-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-9108451134-line-32)">
</text><text class="terminal-9108451134-r5" x="0" y="825.2" textLength="12.2" clip-path="url(#terminal-9108451134-line-33)"></text><text class="terminal-9108451134-r5" x="1451.8" y="825.2" textLength="12.2" clip-path="url(#terminal-9108451134-line-33)"></text><text class="terminal-9108451134-r1" x="1464" y="825.2" textLength="12.2" clip-path="url(#terminal-9108451134-line-33)">
</text><text class="terminal-9108451134-r5" x="0" y="849.6" textLength="1464" clip-path="url(#terminal-9108451134-line-34)">└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-9108451134-r1" x="1464" y="849.6" textLength="12.2" clip-path="url(#terminal-9108451134-line-34)">
</text><text class="terminal-9108451134-r5" x="0" y="874" textLength="158.6" clip-path="url(#terminal-9108451134-line-35)">/test/workdir</text><text class="terminal-9108451134-r5" x="1256.6" y="874" textLength="207.4" clip-path="url(#terminal-9108451134-line-35)">0%&#160;of&#160;200k&#160;tokens</text>
<g class="terminal-matrix">
<text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="1464" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
</text><text class="terminal-r1" x="1464" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
</text><text class="terminal-r1" x="1464" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
</text><text class="terminal-r1" x="1464" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
</text><text class="terminal-r1" x="1464" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
</text><text class="terminal-r1" x="1464" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
</text><text class="terminal-r1" x="1464" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
</text><text class="terminal-r1" x="1464" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
</text><text class="terminal-r1" x="1464" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
</text><text class="terminal-r1" x="1464" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
</text><text class="terminal-r1" x="1464" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
</text><text class="terminal-r1" x="1464" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
</text><text class="terminal-r1" x="1464" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
</text><text class="terminal-r1" x="1464" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
</text><text class="terminal-r1" x="1464" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
</text><text class="terminal-r1" x="1464" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
</text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r1" x="0" y="532.4" textLength="134.2" clip-path="url(#terminal-line-21)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="532.4" textLength="146.4" clip-path="url(#terminal-line-21)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="532.4" textLength="122" clip-path="url(#terminal-line-21)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="532.4" textLength="244" clip-path="url(#terminal-line-21)">devstral-latest[off]</text><text class="terminal-r1" x="683.2" y="532.4" textLength="256.2" clip-path="url(#terminal-line-21)">&#160;·&#160;[Subscription]&#160;Pro</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r1" x="0" y="556.8" textLength="134.2" clip-path="url(#terminal-line-22)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="556.8" textLength="414.8" clip-path="url(#terminal-line-22)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r1" x="0" y="581.2" textLength="134.2" clip-path="url(#terminal-line-23)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="581.2" textLength="61" clip-path="url(#terminal-line-23)">Type&#160;</text><text class="terminal-r3" x="231.8" y="581.2" textLength="61" clip-path="url(#terminal-line-23)">/help</text><text class="terminal-r1" x="292.8" y="581.2" textLength="256.2" clip-path="url(#terminal-line-23)">&#160;for&#160;more&#160;information</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r4" x="0" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r2" x="24.4" y="654.4" textLength="48.8" clip-path="url(#terminal-line-26)">/mcp</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r5" x="24.4" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"></text><text class="terminal-r1" x="48.8" y="678.8" textLength="488" clip-path="url(#terminal-line-27)">No&#160;MCP&#160;servers&#160;or&#160;connectors&#160;configured.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r5" x="0" y="752" textLength="1464" clip-path="url(#terminal-line-30)">┌────────────────────────────────────────────────────────────────────────────────────────────────────────────&#160;default&#160;─┐</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">
</text><text class="terminal-r5" x="0" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r2" x="24.4" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">&gt;</text><text class="terminal-r5" x="1451.8" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r1" x="1464" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">
</text><text class="terminal-r5" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r5" x="1451.8" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
</text><text class="terminal-r5" x="0" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r5" x="1451.8" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r1" x="1464" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
</text><text class="terminal-r5" x="0" y="849.6" textLength="1464" clip-path="url(#terminal-line-34)">└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1464" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
</text><text class="terminal-r5" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r5" x="1256.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0%&#160;of&#160;200k&#160;tokens</text>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before After
Before After

View file

@ -187,7 +187,7 @@
</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r4" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r2" x="24.4" y="630" textLength="61" clip-path="url(#terminal-line-25)">Hello</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r1" x="0" y="678.8" textLength="268.4" clip-path="url(#terminal-line-27)">Hello!&#160;I&#160;can&#160;help&#160;you.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="24.4" y="678.8" textLength="268.4" clip-path="url(#terminal-line-27)">Hello!&#160;I&#160;can&#160;help&#160;you.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r5" x="0" y="752" textLength="1464" clip-path="url(#terminal-line-30)">┌────────────────────────────────────────────────────────────────────────────────────────────────────────────&#160;default&#160;─┐</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before After
Before After

View file

@ -189,7 +189,7 @@
</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r4" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r2" x="24.4" y="630" textLength="61" clip-path="url(#terminal-line-25)">Hello</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r1" x="0" y="678.8" textLength="268.4" clip-path="url(#terminal-line-27)">Hello!&#160;I&#160;can&#160;help&#160;you.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="24.4" y="678.8" textLength="268.4" clip-path="url(#terminal-line-27)">Hello!&#160;I&#160;can&#160;help&#160;you.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r5" x="0" y="727.6" textLength="36.6" clip-path="url(#terminal-line-29)">▂▅▇</text><text class="terminal-r1" x="36.6" y="727.6" textLength="122" clip-path="url(#terminal-line-29)">&#160;speaking&#160;</text><text class="terminal-r6" x="158.6" y="727.6" textLength="219.6" clip-path="url(#terminal-line-29)">Esc/Ctrl+C&#160;to&#160;stop</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r7" x="0" y="752" textLength="1464" clip-path="url(#terminal-line-30)">┌────────────────────────────────────────────────────────────────────────────────────────────────────────────&#160;default&#160;─┐</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

View file

@ -189,7 +189,7 @@
</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r4" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r2" x="24.4" y="630" textLength="61" clip-path="url(#terminal-line-25)">Hello</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r1" x="0" y="678.8" textLength="268.4" clip-path="url(#terminal-line-27)">Hello!&#160;I&#160;can&#160;help&#160;you.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="24.4" y="678.8" textLength="268.4" clip-path="url(#terminal-line-27)">Hello!&#160;I&#160;can&#160;help&#160;you.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r5" x="0" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"></text><text class="terminal-r1" x="12.2" y="727.6" textLength="158.6" clip-path="url(#terminal-line-29)">&#160;summarizing&#160;</text><text class="terminal-r6" x="170.8" y="727.6" textLength="219.6" clip-path="url(#terminal-line-29)">Esc/Ctrl+C&#160;to&#160;stop</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r7" x="0" y="752" textLength="1464" clip-path="url(#terminal-line-30)">┌────────────────────────────────────────────────────────────────────────────────────────────────────────────&#160;default&#160;─┐</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

View file

@ -0,0 +1,202 @@
<svg class="rich-terminal" viewBox="0 0 1482 928.4" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
<style>
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Regular"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
font-style: normal;
font-weight: 400;
}
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Bold"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
font-style: bold;
font-weight: 700;
}
.terminal-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
.terminal-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
.terminal-r1 { fill: #c5c8c6 }
.terminal-r2 { fill: #ff8205;font-weight: bold }
.terminal-r3 { fill: #68a0b3 }
.terminal-r4 { fill: #9a9b99 }
.terminal-r5 { fill: #608ab1;font-weight: bold }
.terminal-r6 { fill: #608ab1;text-decoration: underline; }
</style>
<defs>
<clipPath id="terminal-clip-terminal">
<rect x="0" y="0" width="1463.0" height="877.4" />
</clipPath>
<clipPath id="terminal-line-0">
<rect x="0" y="1.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-1">
<rect x="0" y="25.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-2">
<rect x="0" y="50.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-3">
<rect x="0" y="74.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-4">
<rect x="0" y="99.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-5">
<rect x="0" y="123.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-6">
<rect x="0" y="147.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-7">
<rect x="0" y="172.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-8">
<rect x="0" y="196.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-9">
<rect x="0" y="221.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-10">
<rect x="0" y="245.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-11">
<rect x="0" y="269.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-12">
<rect x="0" y="294.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-13">
<rect x="0" y="318.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-14">
<rect x="0" y="343.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-15">
<rect x="0" y="367.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-16">
<rect x="0" y="391.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-17">
<rect x="0" y="416.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-18">
<rect x="0" y="440.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-19">
<rect x="0" y="465.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-20">
<rect x="0" y="489.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-21">
<rect x="0" y="513.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-22">
<rect x="0" y="538.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-23">
<rect x="0" y="562.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-24">
<rect x="0" y="587.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-25">
<rect x="0" y="611.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-26">
<rect x="0" y="635.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-27">
<rect x="0" y="660.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-28">
<rect x="0" y="684.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-29">
<rect x="0" y="709.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-30">
<rect x="0" y="733.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-31">
<rect x="0" y="757.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-32">
<rect x="0" y="782.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-33">
<rect x="0" y="806.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-34">
<rect x="0" y="831.1" width="1464" height="24.65"/>
</clipPath>
</defs>
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1480" height="926.4" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="740" y="27">PlanFileMessageTestApp</text>
<g transform="translate(26,22)">
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
<circle cx="44" cy="0" r="7" fill="#28c840"/>
</g>
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<g class="terminal-matrix">
<text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="1464" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
</text><text class="terminal-r1" x="1464" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
</text><text class="terminal-r1" x="1464" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
</text><text class="terminal-r1" x="1464" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
</text><text class="terminal-r1" x="1464" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
</text><text class="terminal-r1" x="1464" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
</text><text class="terminal-r1" x="1464" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
</text><text class="terminal-r1" x="0" y="215.2" textLength="134.2" clip-path="url(#terminal-line-8)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="215.2" textLength="146.4" clip-path="url(#terminal-line-8)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="215.2" textLength="122" clip-path="url(#terminal-line-8)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="215.2" textLength="244" clip-path="url(#terminal-line-8)">devstral-latest[off]</text><text class="terminal-r1" x="683.2" y="215.2" textLength="256.2" clip-path="url(#terminal-line-8)">&#160;·&#160;[Subscription]&#160;Pro</text><text class="terminal-r1" x="1464" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
</text><text class="terminal-r1" x="0" y="239.6" textLength="134.2" clip-path="url(#terminal-line-9)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="239.6" textLength="414.8" clip-path="url(#terminal-line-9)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</text><text class="terminal-r1" x="1464" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
</text><text class="terminal-r1" x="0" y="264" textLength="134.2" clip-path="url(#terminal-line-10)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="264" textLength="61" clip-path="url(#terminal-line-10)">Type&#160;</text><text class="terminal-r3" x="231.8" y="264" textLength="61" clip-path="url(#terminal-line-10)">/help</text><text class="terminal-r1" x="292.8" y="264" textLength="256.2" clip-path="url(#terminal-line-10)">&#160;for&#160;more&#160;information</text><text class="terminal-r1" x="1464" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
</text><text class="terminal-r1" x="1464" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
</text><text class="terminal-r1" x="1464" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
</text><text class="terminal-r4" x="0" y="337.2" textLength="1464" clip-path="url(#terminal-line-13)">┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐</text><text class="terminal-r1" x="1464" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
</text><text class="terminal-r4" x="0" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)"></text><text class="terminal-r5" x="610" y="361.6" textLength="231.8" clip-path="url(#terminal-line-14)">Implementation&#160;Plan</text><text class="terminal-r4" x="1451.8" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)"></text><text class="terminal-r1" x="1464" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
</text><text class="terminal-r4" x="0" y="386" textLength="12.2" clip-path="url(#terminal-line-15)"></text><text class="terminal-r4" x="1451.8" y="386" textLength="12.2" clip-path="url(#terminal-line-15)"></text><text class="terminal-r1" x="1464" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
</text><text class="terminal-r4" x="0" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)"></text><text class="terminal-r4" x="1451.8" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)"></text><text class="terminal-r1" x="1464" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
</text><text class="terminal-r4" x="0" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)"></text><text class="terminal-r6" x="24.4" y="434.8" textLength="317.2" clip-path="url(#terminal-line-17)">1.&#160;Add&#160;user&#160;authentication</text><text class="terminal-r4" x="1451.8" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)"></text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r4" x="0" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)"></text><text class="terminal-r4" x="1451.8" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)"></text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text><text class="terminal-r4" x="0" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)"></text><text class="terminal-r1" x="24.4" y="483.6" textLength="268.4" clip-path="url(#terminal-line-19)">&#160;JWT&#160;token&#160;validation</text><text class="terminal-r4" x="1451.8" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)"></text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
</text><text class="terminal-r4" x="0" y="508" textLength="12.2" clip-path="url(#terminal-line-20)"></text><text class="terminal-r1" x="24.4" y="508" textLength="244" clip-path="url(#terminal-line-20)">&#160;Session&#160;management</text><text class="terminal-r4" x="1451.8" y="508" textLength="12.2" clip-path="url(#terminal-line-20)"></text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r4" x="0" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)"></text><text class="terminal-r4" x="1451.8" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)"></text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r4" x="0" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"></text><text class="terminal-r4" x="1451.8" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"></text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r4" x="0" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"></text><text class="terminal-r6" x="24.4" y="581.2" textLength="268.4" clip-path="url(#terminal-line-23)">2.&#160;Database&#160;migrations</text><text class="terminal-r4" x="1451.8" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"></text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r4" x="0" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"></text><text class="terminal-r4" x="1451.8" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"></text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r4" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r1" x="24.4" y="630" textLength="244" clip-path="url(#terminal-line-25)">&#160;Create&#160;users&#160;table</text><text class="terminal-r4" x="1451.8" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r4" x="0" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r1" x="24.4" y="654.4" textLength="158.6" clip-path="url(#terminal-line-26)">&#160;Add&#160;indexes</text><text class="terminal-r4" x="1451.8" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r4" x="0" y="678.8" textLength="1464" clip-path="url(#terminal-line-27)">└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r4" x="0" y="752" textLength="1464" clip-path="url(#terminal-line-30)">┌────────────────────────────────────────────────────────────────────────────────────────────────────────────&#160;default&#160;─┐</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">
</text><text class="terminal-r4" x="0" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r2" x="24.4" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">&gt;</text><text class="terminal-r4" x="1451.8" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r1" x="1464" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">
</text><text class="terminal-r4" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r4" x="1451.8" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
</text><text class="terminal-r4" x="0" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r4" x="1451.8" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r1" x="1464" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
</text><text class="terminal-r4" x="0" y="849.6" textLength="1464" clip-path="url(#terminal-line-34)">└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1464" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
</text><text class="terminal-r4" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r4" x="1256.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0%&#160;of&#160;200k&#160;tokens</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 18 KiB

View file

@ -189,7 +189,7 @@
</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r5" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r6" x="24.4" y="630" textLength="85.4" clip-path="url(#terminal-line-25)">Thought</text><text class="terminal-r6" x="122" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r1" x="0" y="678.8" textLength="707.6" clip-path="url(#terminal-line-27)">Here&#160;is&#160;my&#160;carefully&#160;considered&#160;answer.&#160;I&#160;hope&#160;this&#160;helps!</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="24.4" y="678.8" textLength="707.6" clip-path="url(#terminal-line-27)">Here&#160;is&#160;my&#160;carefully&#160;considered&#160;answer.&#160;I&#160;hope&#160;this&#160;helps!</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r7" x="0" y="752" textLength="1464" clip-path="url(#terminal-line-30)">┌────────────────────────────────────────────────────────────────────────────────────────────────────────────&#160;default&#160;─┐</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

View file

@ -185,11 +185,11 @@
</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r5" x="0" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)"></text><text class="terminal-r6" x="24.4" y="532.4" textLength="85.4" clip-path="url(#terminal-line-21)">Thought</text><text class="terminal-r6" x="122" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)"></text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r1" x="0" y="581.2" textLength="439.2" clip-path="url(#terminal-line-23)">Here&#x27;s&#160;the&#160;first&#160;part&#160;of&#160;the&#160;answer.</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r1" x="24.4" y="581.2" textLength="439.2" clip-path="url(#terminal-line-23)">Here&#x27;s&#160;the&#160;first&#160;part&#160;of&#160;the&#160;answer.</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r5" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r6" x="24.4" y="630" textLength="85.4" clip-path="url(#terminal-line-25)">Thought</text><text class="terminal-r6" x="122" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r1" x="0" y="678.8" textLength="317.2" clip-path="url(#terminal-line-27)">And&#160;here&#x27;s&#160;the&#160;conclusion!</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="24.4" y="678.8" textLength="317.2" clip-path="url(#terminal-line-27)">And&#160;here&#x27;s&#160;the&#160;conclusion!</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r7" x="0" y="752" textLength="1464" clip-path="url(#terminal-line-30)">┌────────────────────────────────────────────────────────────────────────────────────────────────────────────&#160;default&#160;─┐</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

View file

@ -189,7 +189,7 @@
</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r5" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r6" x="24.4" y="630" textLength="85.4" clip-path="url(#terminal-line-25)">Thought</text><text class="terminal-r6" x="122" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r1" x="0" y="678.8" textLength="768.6" clip-path="url(#terminal-line-27)">The&#160;answer&#160;to&#160;your&#160;question&#160;is&#160;42.&#160;This&#160;is&#160;the&#160;ultimate&#160;answer.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="24.4" y="678.8" textLength="768.6" clip-path="url(#terminal-line-27)">The&#160;answer&#160;to&#160;your&#160;question&#160;is&#160;42.&#160;This&#160;is&#160;the&#160;ultimate&#160;answer.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r7" x="0" y="752" textLength="1464" clip-path="url(#terminal-line-30)">┌────────────────────────────────────────────────────────────────────────────────────────────────────────────&#160;default&#160;─┐</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

View file

@ -189,7 +189,7 @@
</text><text class="terminal-r5" x="0" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"></text><text class="terminal-r6" x="24.4" y="605.6" textLength="85.4" clip-path="url(#terminal-line-24)">Thought</text><text class="terminal-r6" x="122" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"></text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r6" x="24.4" y="630" textLength="1390.8" clip-path="url(#terminal-line-25)">Let&#160;me&#160;think&#160;about&#160;this&#160;step&#160;by&#160;step...&#160;First,&#160;I&#160;need&#160;to&#160;understand&#160;the&#160;question.&#160;Then&#160;I&#160;can&#160;formulate&#160;a&#160;response.</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r1" x="0" y="678.8" textLength="768.6" clip-path="url(#terminal-line-27)">The&#160;answer&#160;to&#160;your&#160;question&#160;is&#160;42.&#160;This&#160;is&#160;the&#160;ultimate&#160;answer.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="24.4" y="678.8" textLength="768.6" clip-path="url(#terminal-line-27)">The&#160;answer&#160;to&#160;your&#160;question&#160;is&#160;42.&#160;This&#160;is&#160;the&#160;ultimate&#160;answer.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r7" x="0" y="752" textLength="1464" clip-path="url(#terminal-line-30)">┌────────────────────────────────────────────────────────────────────────────────────────────────────────────&#160;default&#160;─┐</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

View file

@ -183,7 +183,7 @@
</text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r4" x="0" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)"></text><text class="terminal-r2" x="24.4" y="459.2" textLength="158.6" clip-path="url(#terminal-line-18)">first&#160;message</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
</text><text class="terminal-r1" x="0" y="508" textLength="317.2" clip-path="url(#terminal-line-20)">Hello!&#160;How&#160;can&#160;I&#160;help&#160;you?</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r1" x="24.4" y="508" textLength="317.2" clip-path="url(#terminal-line-20)">Hello!&#160;How&#160;can&#160;I&#160;help&#160;you?</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r4" x="0" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"></text><text class="terminal-r2" x="24.4" y="556.8" textLength="170.8" clip-path="url(#terminal-line-22)">second&#160;message</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Before After
Before After

View file

@ -183,7 +183,7 @@
</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r4" x="0" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)"></text><text class="terminal-r2" x="24.4" y="532.4" textLength="158.6" clip-path="url(#terminal-line-21)">first&#160;message</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r1" x="0" y="581.2" textLength="317.2" clip-path="url(#terminal-line-23)">Hello!&#160;How&#160;can&#160;I&#160;help&#160;you?</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r1" x="24.4" y="581.2" textLength="317.2" clip-path="url(#terminal-line-23)">Hello!&#160;How&#160;can&#160;I&#160;help&#160;you?</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r4" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r2" x="24.4" y="630" textLength="170.8" clip-path="url(#terminal-line-25)">second&#160;message</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

View file

@ -182,7 +182,7 @@
</text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r4" x="0" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)"></text><text class="terminal-r2" x="24.4" y="459.2" textLength="158.6" clip-path="url(#terminal-line-18)">first&#160;message</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
</text><text class="terminal-r1" x="0" y="508" textLength="317.2" clip-path="url(#terminal-line-20)">Hello!&#160;How&#160;can&#160;I&#160;help&#160;you?</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r1" x="24.4" y="508" textLength="317.2" clip-path="url(#terminal-line-20)">Hello!&#160;How&#160;can&#160;I&#160;help&#160;you?</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r4" x="0" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"></text><text class="terminal-r2" x="24.4" y="556.8" textLength="170.8" clip-path="url(#terminal-line-22)">second&#160;message</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

View file

@ -182,7 +182,7 @@
</text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r4" x="0" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)"></text><text class="terminal-r2" x="24.4" y="459.2" textLength="158.6" clip-path="url(#terminal-line-18)">first&#160;message</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
</text><text class="terminal-r1" x="0" y="508" textLength="317.2" clip-path="url(#terminal-line-20)">Hello!&#160;How&#160;can&#160;I&#160;help&#160;you?</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r1" x="24.4" y="508" textLength="317.2" clip-path="url(#terminal-line-20)">Hello!&#160;How&#160;can&#160;I&#160;help&#160;you?</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r4" x="0" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"></text><text class="terminal-r5" x="24.4" y="556.8" textLength="170.8" clip-path="url(#terminal-line-22)">second&#160;message</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

View file

@ -182,7 +182,7 @@
</text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r4" x="0" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)"></text><text class="terminal-r2" x="24.4" y="459.2" textLength="158.6" clip-path="url(#terminal-line-18)">first&#160;message</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
</text><text class="terminal-r1" x="0" y="508" textLength="317.2" clip-path="url(#terminal-line-20)">Hello!&#160;How&#160;can&#160;I&#160;help&#160;you?</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r1" x="24.4" y="508" textLength="317.2" clip-path="url(#terminal-line-20)">Hello!&#160;How&#160;can&#160;I&#160;help&#160;you?</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r4" x="0" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"></text><text class="terminal-r2" x="24.4" y="556.8" textLength="170.8" clip-path="url(#terminal-line-22)">second&#160;message</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

View file

@ -186,7 +186,7 @@
</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r4" x="0" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"></text><text class="terminal-r2" x="24.4" y="581.2" textLength="231.8" clip-path="url(#terminal-line-23)">Hello,&#160;how&#160;are&#160;you?</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r1" x="0" y="630" textLength="695.4" clip-path="url(#terminal-line-25)">I&#x27;m&#160;doing&#160;well,&#160;thank&#160;you!&#160;Let&#160;me&#160;read&#160;that&#160;file&#160;for&#160;you.</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" x="24.4" y="630" textLength="695.4" clip-path="url(#terminal-line-25)">I&#x27;m&#160;doing&#160;well,&#160;thank&#160;you!&#160;Let&#160;me&#160;read&#160;that&#160;file&#160;for&#160;you.</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r5" x="0" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"></text><text class="terminal-r1" x="24.4" y="678.8" textLength="109.8" clip-path="url(#terminal-line-27)">read_file</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

View file

@ -183,11 +183,11 @@
</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r4" x="0" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)"></text><text class="terminal-r2" x="24.4" y="532.4" textLength="280.6" clip-path="url(#terminal-line-21)">Hello,&#160;can&#160;you&#160;help&#160;me?</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r1" x="0" y="581.2" textLength="280.6" clip-path="url(#terminal-line-23)">Sure!&#160;What&#160;do&#160;you&#160;need?</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r1" x="24.4" y="581.2" textLength="280.6" clip-path="url(#terminal-line-23)">Sure!&#160;What&#160;do&#160;you&#160;need?</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r4" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r2" x="24.4" y="630" textLength="329.4" clip-path="url(#terminal-line-25)">Please&#160;read&#160;my&#160;config&#160;file.</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r1" x="0" y="678.8" textLength="488" clip-path="url(#terminal-line-27)">Here&#160;is&#160;the&#160;content&#160;of&#160;your&#160;config&#160;file.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="24.4" y="678.8" textLength="488" clip-path="url(#terminal-line-27)">Here&#160;is&#160;the&#160;content&#160;of&#160;your&#160;config&#160;file.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r5" x="0" y="752" textLength="1464" clip-path="url(#terminal-line-30)">┌────────────────────────────────────────────────────────────────────────────────────────────────────────────&#160;default&#160;─┐</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

View file

@ -173,23 +173,23 @@
</text><text class="terminal-r1" x="1464" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
</text><text class="terminal-r4" x="0" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)"></text><text class="terminal-r2" x="24.4" y="239.6" textLength="146.4" clip-path="url(#terminal-line-9)">show&#160;diagram</text><text class="terminal-r1" x="1464" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
</text><text class="terminal-r1" x="1464" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
</text><text class="terminal-r5" x="0" y="288.4" textLength="97.6" clip-path="url(#terminal-line-11)">&#160;&#160;Client</text><text class="terminal-r1" x="1464" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
</text><text class="terminal-r5" x="0" y="312.8" textLength="61" clip-path="url(#terminal-line-12)">&#160;&#160;&#160;&#160;|</text><text class="terminal-r1" x="1464" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
</text><text class="terminal-r5" x="0" y="337.2" textLength="280.6" clip-path="url(#terminal-line-13)">&#160;&#160;&#160;&#160;|&#160;&#160;POST&#160;/api/orders</text><text class="terminal-r1" x="1464" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
</text><text class="terminal-r5" x="0" y="361.6" textLength="61" clip-path="url(#terminal-line-14)">&#160;&#160;&#160;&#160;v</text><text class="terminal-r1" x="1464" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
</text><text class="terminal-r3" x="0" y="386" textLength="134.2" clip-path="url(#terminal-line-15)">+---------+</text><text class="terminal-r1" x="1464" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
</text><text class="terminal-r5" x="0" y="410.4" textLength="134.2" clip-path="url(#terminal-line-16)">|&#160;Gateway&#160;|</text><text class="terminal-r1" x="1464" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
</text><text class="terminal-r3" x="0" y="434.8" textLength="134.2" clip-path="url(#terminal-line-17)">+---------+</text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r5" x="0" y="459.2" textLength="61" clip-path="url(#terminal-line-18)">&#160;&#160;&#160;&#160;|</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text><text class="terminal-r5" x="0" y="483.6" textLength="61" clip-path="url(#terminal-line-19)">&#160;&#160;&#160;&#160;v</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
</text><text class="terminal-r3" x="0" y="508" textLength="134.2" clip-path="url(#terminal-line-20)">+---------+</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r5" x="0" y="532.4" textLength="134.2" clip-path="url(#terminal-line-21)">|&#160;Service&#160;|</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r3" x="0" y="556.8" textLength="134.2" clip-path="url(#terminal-line-22)">+---------+</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r5" x="0" y="581.2" textLength="61" clip-path="url(#terminal-line-23)">&#160;&#160;&#160;&#160;|</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r5" x="0" y="605.6" textLength="61" clip-path="url(#terminal-line-24)">&#160;&#160;&#160;&#160;v</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r3" x="0" y="630" textLength="134.2" clip-path="url(#terminal-line-25)">+----+----+</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r5" x="0" y="654.4" textLength="134.2" clip-path="url(#terminal-line-26)">|&#160;&#160;&#160;DB&#160;&#160;&#160;&#160;|</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r3" x="0" y="678.8" textLength="134.2" clip-path="url(#terminal-line-27)">+---------+</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r5" x="24.4" y="288.4" textLength="97.6" clip-path="url(#terminal-line-11)">&#160;&#160;Client</text><text class="terminal-r1" x="1464" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
</text><text class="terminal-r5" x="24.4" y="312.8" textLength="61" clip-path="url(#terminal-line-12)">&#160;&#160;&#160;&#160;|</text><text class="terminal-r1" x="1464" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
</text><text class="terminal-r5" x="24.4" y="337.2" textLength="280.6" clip-path="url(#terminal-line-13)">&#160;&#160;&#160;&#160;|&#160;&#160;POST&#160;/api/orders</text><text class="terminal-r1" x="1464" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
</text><text class="terminal-r5" x="24.4" y="361.6" textLength="61" clip-path="url(#terminal-line-14)">&#160;&#160;&#160;&#160;v</text><text class="terminal-r1" x="1464" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
</text><text class="terminal-r3" x="24.4" y="386" textLength="134.2" clip-path="url(#terminal-line-15)">+---------+</text><text class="terminal-r1" x="1464" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
</text><text class="terminal-r5" x="24.4" y="410.4" textLength="134.2" clip-path="url(#terminal-line-16)">|&#160;Gateway&#160;|</text><text class="terminal-r1" x="1464" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
</text><text class="terminal-r3" x="24.4" y="434.8" textLength="134.2" clip-path="url(#terminal-line-17)">+---------+</text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r5" x="24.4" y="459.2" textLength="61" clip-path="url(#terminal-line-18)">&#160;&#160;&#160;&#160;|</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text><text class="terminal-r5" x="24.4" y="483.6" textLength="61" clip-path="url(#terminal-line-19)">&#160;&#160;&#160;&#160;v</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
</text><text class="terminal-r3" x="24.4" y="508" textLength="134.2" clip-path="url(#terminal-line-20)">+---------+</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r5" x="24.4" y="532.4" textLength="134.2" clip-path="url(#terminal-line-21)">|&#160;Service&#160;|</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r3" x="24.4" y="556.8" textLength="134.2" clip-path="url(#terminal-line-22)">+---------+</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r5" x="24.4" y="581.2" textLength="61" clip-path="url(#terminal-line-23)">&#160;&#160;&#160;&#160;|</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r5" x="24.4" y="605.6" textLength="61" clip-path="url(#terminal-line-24)">&#160;&#160;&#160;&#160;v</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r3" x="24.4" y="630" textLength="134.2" clip-path="url(#terminal-line-25)">+----+----+</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r5" x="24.4" y="654.4" textLength="134.2" clip-path="url(#terminal-line-26)">|&#160;&#160;&#160;DB&#160;&#160;&#160;&#160;|</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r3" x="24.4" y="678.8" textLength="134.2" clip-path="url(#terminal-line-27)">+---------+</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r7" x="0" y="752" textLength="1464" clip-path="url(#terminal-line-30)">┌────────────────────────────────────────────────────────────────────────────────────────────────────────────&#160;default&#160;─┐</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

View file

@ -187,7 +187,7 @@
</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r4" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r2" x="24.4" y="630" textLength="61" clip-path="url(#terminal-line-25)">hello</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r1" x="0" y="678.8" textLength="268.4" clip-path="url(#terminal-line-27)">I&#x27;m&#160;ready&#160;to&#160;help&#160;you.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="24.4" y="678.8" textLength="268.4" clip-path="url(#terminal-line-27)">I&#x27;m&#160;ready&#160;to&#160;help&#160;you.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r5" x="0" y="752" textLength="1464" clip-path="url(#terminal-line-30)">┌────────────────────────────────────────────────────────────────────────────────────────────────────────────&#160;default&#160;─┐</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before After
Before After

View file

@ -12,7 +12,6 @@ from tests.stubs.fake_mcp_registry import (
FakeMCPRegistryWithBrokenServer,
)
from vibe.core.config import MCPHttp, MCPStdio
from vibe.core.tools.connectors import CONNECTORS_ENV_VAR
from vibe.core.tools.mcp.tools import RemoteTool
_MCP_PATCH = "vibe.core.agent_loop.MCPRegistry"
@ -239,7 +238,6 @@ class SnapshotTestAppConnectorsMixedState(BaseSnapshotTestApp):
# ---------------------------------------------------------------------------
@patch.dict("os.environ", {CONNECTORS_ENV_VAR: "1"})
def test_snapshot_mcp_with_connectors_overview(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
@ -258,7 +256,6 @@ def test_snapshot_mcp_with_connectors_overview(snap_compare: SnapCompare) -> Non
# ---------------------------------------------------------------------------
@patch.dict("os.environ", {CONNECTORS_ENV_VAR: "1"})
def test_snapshot_connector_auth_opens_on_disconnected(
snap_compare: SnapCompare,
) -> None:
@ -280,7 +277,6 @@ def test_snapshot_connector_auth_opens_on_disconnected(
)
@patch.dict("os.environ", {CONNECTORS_ENV_VAR: "1"})
def test_snapshot_connector_auth_show_url(snap_compare: SnapCompare) -> None:
"""Selecting 'Manually show the URL' reveals the auth URL."""
@ -303,7 +299,6 @@ def test_snapshot_connector_auth_show_url(snap_compare: SnapCompare) -> None:
)
@patch.dict("os.environ", {CONNECTORS_ENV_VAR: "1"})
def test_snapshot_connector_auth_back_to_mcp(snap_compare: SnapCompare) -> None:
"""Pressing backspace in the auth app returns to the /mcp menu."""
@ -323,7 +318,6 @@ def test_snapshot_connector_auth_back_to_mcp(snap_compare: SnapCompare) -> None:
)
@patch.dict("os.environ", {CONNECTORS_ENV_VAR: "1"})
def test_snapshot_mcp_help_bar_shows_authenticate(snap_compare: SnapCompare) -> None:
"""Help bar shows 'Enter Authenticate' when a disconnected connector is highlighted."""
@ -340,7 +334,6 @@ def test_snapshot_mcp_help_bar_shows_authenticate(snap_compare: SnapCompare) ->
)
@patch.dict("os.environ", {CONNECTORS_ENV_VAR: "1"})
def test_snapshot_mcp_connectors_only(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
@ -353,7 +346,6 @@ def test_snapshot_mcp_connectors_only(snap_compare: SnapCompare) -> None:
)
@patch.dict("os.environ", {CONNECTORS_ENV_VAR: "1"})
def test_snapshot_mcp_connectors_sorted_by_status(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
@ -366,7 +358,6 @@ def test_snapshot_mcp_connectors_sorted_by_status(snap_compare: SnapCompare) ->
)
@patch.dict("os.environ", {CONNECTORS_ENV_VAR: "1"})
def test_snapshot_mcp_drill_into_connector(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
@ -387,7 +378,6 @@ def test_snapshot_mcp_drill_into_connector(snap_compare: SnapCompare) -> None:
)
@patch.dict("os.environ", {CONNECTORS_ENV_VAR: "1"})
def test_snapshot_mcp_connector_back_to_overview(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:

View file

@ -0,0 +1,43 @@
from __future__ import annotations
from pathlib import Path
from typing import cast
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.messages import PlanFileMessage
PLAN_CONTENT = """\
# Implementation Plan
## 1. Add user authentication
- JWT token validation
- Session management
## 2. Database migrations
- Create users table
- Add indexes
"""
class PlanFileMessageTestApp(BaseSnapshotTestApp):
pass
def test_snapshot_plan_file_message(snap_compare: SnapCompare, tmp_path: Path) -> None:
plan_file = tmp_path / "plan.md"
plan_file.write_text(PLAN_CONTENT)
async def run_before(pilot: Pilot) -> None:
app = cast(PlanFileMessageTestApp, pilot.app)
plan_widget = PlanFileMessage(file_path=plan_file)
await app._mount_and_scroll(plan_widget)
await pilot.pause(0.3)
assert snap_compare(
"test_ui_snapshot_plan_file_message.py:PlanFileMessageTestApp",
terminal_size=(120, 36),
run_before=run_before,
)

View file

@ -146,7 +146,7 @@ async def test_passes_parent_session_id_to_backend_after_reset(vibe_config: Vibe
[_ async for _ in agent.act("Hello")]
first_session_id = agent.session_id
agent._reset_session()
await agent._reset_session()
[_ async for _ in agent.act("Hello again")]
assert len(backend.requests_metadata) >= 2

View file

@ -0,0 +1,33 @@
from __future__ import annotations
import pytest
from tests.conftest import build_test_agent_loop, build_test_vibe_config
@pytest.mark.asyncio
async def test_refresh_system_prompt_preserves_scratchpad_section() -> None:
# Regression: refresh_system_prompt must pass scratchpad_dir, otherwise
# it silently drops the scratchpad instructions from the system prompt.
# This fires on every session start/resume via initialize_experiments
# and hydrate_experiments_from_session, so the LLM would lose awareness
# of the scratchpad on the very first turn for any user with telemetry
# enabled and a Mistral API key.
config = build_test_vibe_config(
include_project_context=False,
include_prompt_detail=True,
include_model_info=False,
include_commit_signature=False,
)
agent = build_test_agent_loop(config=config)
initial_prompt = agent.messages[0].content or ""
assert "Scratchpad Directory" in initial_prompt
assert agent.scratchpad_dir is not None
assert str(agent.scratchpad_dir) in initial_prompt
await agent.refresh_system_prompt()
refreshed_prompt = agent.messages[0].content or ""
assert "Scratchpad Directory" in refreshed_prompt
assert str(agent.scratchpad_dir) in refreshed_prompt

View file

@ -864,3 +864,34 @@ async def test_parallel_conversation_history_has_all_tool_messages() -> None:
"call_h3",
}
assert agent_loop.stats.tool_calls_succeeded == 4
@pytest.mark.asyncio
async def test_pending_injected_message_continues_loop_after_tool_result() -> None:
tool_call = make_todo_tool_call("call_inject")
backend = FakeBackend([
[mock_llm_chunk(content="Let me check.", tool_calls=[tool_call])],
[mock_llm_chunk(content="Acting on the injected guidance.")],
])
agent_loop = make_agent_loop(auto_approve=True, backend=backend)
events: list[BaseEvent] = []
async for event in agent_loop.act("Go"):
events.append(event)
if isinstance(event, ToolResultEvent):
agent_loop._pending_injected_messages.append(
LLMMessage(role=Role.user, content="updated context", injected=True)
)
assistant_events = [e for e in events if isinstance(e, AssistantEvent)]
assert len(assistant_events) == 2
injected_msgs = [
m for m in agent_loop.messages if m.role == Role.user and m.injected
]
assert any("updated context" in (m.content or "") for m in injected_msgs)
last_assistant = next(
m for m in reversed(agent_loop.messages) if m.role == Role.assistant
)
assert "Acting on the injected guidance" in (last_assistant.content or "")

View file

@ -259,7 +259,7 @@ class TestAgentApplyToConfig:
mock_manager = _MockManager(sources=("user",))
monkeypatch.setattr(
"vibe.core.config._settings.get_harness_files_manager", lambda: mock_manager
"vibe.core.prompts.get_harness_files_manager", lambda: mock_manager
)
base = VibeConfig(include_project_context=False, include_prompt_detail=False)
@ -298,7 +298,7 @@ class TestAgentApplyToConfig:
mock_manager = _MockManager(sources=("user",))
monkeypatch.setattr(
"vibe.core.config._settings.get_harness_files_manager", lambda: mock_manager
"vibe.core.prompts.get_harness_files_manager", lambda: mock_manager
)
config = VibeConfig(
@ -700,7 +700,7 @@ class TestAgentLoopInitialization:
mock_manager = _MockManager(sources=("user",))
monkeypatch.setattr(
"vibe.core.config._settings.get_harness_files_manager", lambda: mock_manager
"vibe.core.prompts.get_harness_files_manager", lambda: mock_manager
)
custom_agent = AgentProfile(

View file

@ -4,6 +4,7 @@ from pathlib import Path
import tomllib
from tests.conftest import build_test_agent_loop, build_test_vibe_config
from vibe.core.tools.base import ToolPermission
from vibe.core.tools.permissions import PermissionScope, RequiredPermission
@ -27,7 +28,10 @@ class TestApproveAlwaysPermanentNoGranularPermissions:
agent.approve_always("bash", None, save_permanently=False)
assert agent.config.tools["bash"]["permission"] == "always"
assert (
agent.tool_manager.get_tool_config("bash").permission
== ToolPermission.ALWAYS
)
persisted = _read_persisted_config(config_dir)
assert "bash" not in persisted.get("tools", {})
@ -58,8 +62,8 @@ class TestApproveAlwaysPermanentWithGranularPermissions:
agent.approve_always("bash", perms, save_permanently=True)
assert len(agent._session_rules) == 1
rule = agent._session_rules[0]
assert len(agent._permission_store._rules) == 1
rule = agent._permission_store._rules[0]
assert rule.tool_name == "bash"
assert rule.scope == PermissionScope.COMMAND_PATTERN
assert rule.session_pattern == "npm install *"
@ -70,7 +74,7 @@ class TestApproveAlwaysPermanentWithGranularPermissions:
agent.approve_always("bash", perms, save_permanently=False)
assert len(agent._session_rules) == 1
assert len(agent._permission_store._rules) == 1
persisted = _read_persisted_config(config_dir)
assert "bash" not in persisted.get("tools", {})

View file

@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
import threading
from unittest.mock import AsyncMock, MagicMock, patch
@ -12,6 +13,7 @@ from tests.mock.utils import mock_llm_chunk
from tests.stubs.fake_backend import FakeBackend
from tests.stubs.fake_connector_registry import FakeConnectorRegistry
from tests.stubs.fake_mcp_registry import FakeMCPRegistry
from vibe.core import agent_loop as agent_loop_module
from vibe.core.agent_loop import AgentLoop
from vibe.core.config import MCPStdio
from vibe.core.tools.manager import ToolManager
@ -256,3 +258,252 @@ class TestDeferredInitPublicMethods:
assert loop.is_initialized
assert loop.messages[-1].content == "context"
# ---------------------------------------------------------------------------
# start_initialize_experiments / wait_until_ready experiment gating
# ---------------------------------------------------------------------------
class TestStartInitializeExperiments:
@pytest.mark.asyncio
async def test_does_not_block_caller(self) -> None:
loop = build_test_agent_loop()
gate = asyncio.Event()
async def slow_init() -> None:
await gate.wait()
with patch.object(loop, "initialize_experiments", side_effect=slow_init):
loop.start_initialize_experiments()
task = loop._experiments_task
assert task is not None
assert not task.done()
gate.set()
await task
@pytest.mark.asyncio
async def test_is_idempotent(self) -> None:
loop = build_test_agent_loop()
init_mock = AsyncMock()
with patch.object(loop, "initialize_experiments", new=init_mock):
loop.start_initialize_experiments()
first_task = loop._experiments_task
loop.start_initialize_experiments()
second_task = loop._experiments_task
assert first_task is second_task
assert first_task is not None
await first_task
assert init_mock.await_count == 1
@pytest.mark.asyncio
async def test_sets_pending_telemetry_flags(self) -> None:
loop = build_test_agent_loop()
with patch.object(loop, "initialize_experiments", new=AsyncMock()):
loop.start_initialize_experiments()
assert loop._pending_new_session_telemetry is True
assert loop._ready_telemetry_pending is True
task = loop._experiments_task
assert task is not None
await task
@pytest.mark.asyncio
async def test_refreshes_system_prompt_when_experiments_update(self) -> None:
loop = build_test_agent_loop()
refresh_mock = AsyncMock()
with (
patch.object(
agent_loop_module,
"session_initialize_experiments",
new=AsyncMock(return_value=True),
),
patch.object(loop, "refresh_system_prompt", new=refresh_mock),
):
await loop.initialize_experiments()
refresh_mock.assert_awaited_once()
@pytest.mark.asyncio
async def test_does_not_refresh_system_prompt_when_experiments_unchanged(
self,
) -> None:
loop = build_test_agent_loop()
refresh_mock = AsyncMock()
with (
patch.object(
agent_loop_module,
"session_initialize_experiments",
new=AsyncMock(return_value=False),
),
patch.object(loop, "refresh_system_prompt", new=refresh_mock),
):
await loop.initialize_experiments()
refresh_mock.assert_not_awaited()
class TestWaitUntilReadyJoinsExperiments:
@pytest.mark.asyncio
async def test_joins_in_flight_task(self) -> None:
loop = build_test_agent_loop()
gate = asyncio.Event()
completed = False
async def slow_init() -> None:
nonlocal completed
await gate.wait()
completed = True
with patch.object(loop, "initialize_experiments", side_effect=slow_init):
loop.start_initialize_experiments()
async def release() -> None:
await asyncio.sleep(0.01)
gate.set()
asyncio.create_task(release())
await loop.wait_until_ready()
assert completed is True
task = loop._experiments_task
assert task is not None
assert task.done()
@pytest.mark.asyncio
async def test_emits_new_session_telemetry_once(self) -> None:
loop = build_test_agent_loop()
emit_new_session = MagicMock()
with (
patch.object(loop, "initialize_experiments", new=AsyncMock()),
patch.object(loop, "emit_new_session_telemetry", new=emit_new_session),
):
loop.start_initialize_experiments()
await loop.wait_until_ready()
await loop.wait_until_ready()
emit_new_session.assert_called_once()
assert loop._pending_new_session_telemetry is False
@pytest.mark.asyncio
async def test_emits_ready_telemetry_when_only_experiments_deferred(self) -> None:
loop = build_test_agent_loop()
emit_ready = MagicMock()
with (
patch.object(loop, "initialize_experiments", new=AsyncMock()),
patch.object(loop, "emit_ready_telemetry", new=emit_ready),
):
loop.start_initialize_experiments()
await loop.wait_until_ready()
await loop.wait_until_ready()
emit_ready.assert_called_once()
((duration,), _) = emit_ready.call_args
assert isinstance(duration, int)
assert duration >= 0
assert loop._ready_telemetry_pending is False
@pytest.mark.asyncio
async def test_does_not_emit_new_session_when_only_hydrating(self) -> None:
loop = build_test_agent_loop()
emit_new_session = MagicMock()
with (
patch.object(loop, "hydrate_experiments_from_session", new=AsyncMock()),
patch.object(loop, "emit_new_session_telemetry", new=emit_new_session),
):
await loop.hydrate_experiments_from_session()
await loop.wait_until_ready()
emit_new_session.assert_not_called()
assert loop._pending_new_session_telemetry is False
@pytest.mark.asyncio
async def test_no_op_when_nothing_deferred(self) -> None:
loop = build_test_agent_loop()
emit_ready = MagicMock()
emit_new_session = MagicMock()
with (
patch.object(loop, "emit_ready_telemetry", new=emit_ready),
patch.object(loop, "emit_new_session_telemetry", new=emit_new_session),
):
await loop.wait_until_ready()
emit_ready.assert_not_called()
emit_new_session.assert_not_called()
class TestACloseCancelsExperimentsTask:
@pytest.mark.asyncio
async def test_cancels_in_flight_task(self) -> None:
loop = build_test_agent_loop()
gate = asyncio.Event()
async def never_completing() -> None:
await gate.wait()
with patch.object(loop, "initialize_experiments", side_effect=never_completing):
loop.start_initialize_experiments()
task = loop._experiments_task
assert task is not None
assert not task.done()
await loop.aclose()
assert task.done()
assert task.cancelled()
@pytest.mark.asyncio
async def test_does_not_cancel_completed_task(self) -> None:
loop = build_test_agent_loop()
with patch.object(loop, "initialize_experiments", new=AsyncMock()):
loop.start_initialize_experiments()
task = loop._experiments_task
assert task is not None
await task
await loop.aclose()
assert task.done()
assert not task.cancelled()
class TestActGatesOnExperiments:
@pytest.mark.asyncio
async def test_act_awaits_experiments_before_llm_call(self) -> None:
loop = build_test_agent_loop(
backend=FakeBackend(mock_llm_chunk(content="hello"))
)
gate = asyncio.Event()
finished_init = False
async def slow_init() -> None:
nonlocal finished_init
await gate.wait()
finished_init = True
with patch.object(loop, "initialize_experiments", side_effect=slow_init):
loop.start_initialize_experiments()
async def release() -> None:
await asyncio.sleep(0.01)
gate.set()
asyncio.create_task(release())
events = [event async for event in loop.act("Hello")]
assert finished_init is True
assert any(getattr(event, "content", None) == "hello" for event in events)

View file

@ -1,47 +0,0 @@
from __future__ import annotations
from vibe.core.llm.message_utils import merge_consecutive_user_messages
from vibe.core.types import LLMMessage, Role
def test_merge_consecutive_user_messages() -> None:
messages = [
LLMMessage(role=Role.system, content="System"),
LLMMessage(role=Role.user, content="User 1"),
LLMMessage(role=Role.user, content="User 2"),
LLMMessage(role=Role.assistant, content="Assistant"),
]
result = merge_consecutive_user_messages(messages)
assert len(result) == 3
assert result[1].content == "User 1\n\nUser 2"
def test_preserves_non_consecutive_user_messages() -> None:
messages = [
LLMMessage(role=Role.user, content="User 1"),
LLMMessage(role=Role.assistant, content="Assistant"),
LLMMessage(role=Role.user, content="User 2"),
]
result = merge_consecutive_user_messages(messages)
assert len(result) == 3
def test_empty_messages() -> None:
assert merge_consecutive_user_messages([]) == []
def test_single_message() -> None:
messages = [LLMMessage(role=Role.user, content="Only one")]
result = merge_consecutive_user_messages(messages)
assert len(result) == 1
def test_three_consecutive_user_messages() -> None:
messages = [
LLMMessage(role=Role.user, content="A"),
LLMMessage(role=Role.user, content="B"),
LLMMessage(role=Role.user, content="C"),
]
result = merge_consecutive_user_messages(messages)
assert len(result) == 1
assert result[0].content == "A\n\nB\n\nC"

View file

@ -0,0 +1,166 @@
from __future__ import annotations
from pydantic import BaseModel
import pytest
from tests.conftest import build_test_agent_loop, build_test_vibe_config
from tests.mock.utils import mock_llm_chunk
from tests.stubs.fake_backend import FakeBackend
from vibe.core.tools.base import ToolPermission
from vibe.core.tools.permissions import (
ApprovedRule,
PermissionScope,
PermissionStore,
RequiredPermission,
)
from vibe.core.types import ApprovalResponse, FunctionCall, ToolCall, ToolResultEvent
class TestPermissionStore:
def test_covers_returns_false_when_empty(self):
store = PermissionStore()
rp = RequiredPermission(
scope=PermissionScope.COMMAND_PATTERN,
invocation_pattern="npm install foo",
session_pattern="npm install *",
label="npm install *",
)
assert not store.covers("bash", rp)
def test_covers_returns_true_after_matching_rule_added(self):
store = PermissionStore()
store.add_rule(
ApprovedRule(
tool_name="bash",
scope=PermissionScope.COMMAND_PATTERN,
session_pattern="npm install *",
)
)
rp = RequiredPermission(
scope=PermissionScope.COMMAND_PATTERN,
invocation_pattern="npm install foo",
session_pattern="npm install *",
label="npm install *",
)
assert store.covers("bash", rp)
def test_covers_isolates_by_tool_name(self):
store = PermissionStore()
store.add_rule(
ApprovedRule(
tool_name="bash",
scope=PermissionScope.COMMAND_PATTERN,
session_pattern="npm install *",
)
)
rp = RequiredPermission(
scope=PermissionScope.COMMAND_PATTERN,
invocation_pattern="npm install foo",
session_pattern="npm install *",
label="npm install *",
)
assert not store.covers("read_file", rp)
def test_tool_permission_round_trip(self):
store = PermissionStore()
assert store.get_tool_permission("bash") is None
store.set_tool_permission("bash", ToolPermission.ALWAYS)
assert store.get_tool_permission("bash") == ToolPermission.ALWAYS
class TestAgentLoopSharesStore:
def test_subagent_inherits_parent_session_rules(self):
store = PermissionStore()
parent = build_test_agent_loop(permission_store=store)
subagent = build_test_agent_loop(permission_store=store)
parent.approve_always(
"bash",
[
RequiredPermission(
scope=PermissionScope.COMMAND_PATTERN,
invocation_pattern="npm install foo",
session_pattern="npm install *",
label="npm install *",
)
],
)
rp = RequiredPermission(
scope=PermissionScope.COMMAND_PATTERN,
invocation_pattern="npm install bar",
session_pattern="npm install *",
label="npm install *",
)
assert subagent._permission_store.covers("bash", rp)
def test_subagent_inherits_parent_tool_permission(self):
store = PermissionStore()
parent = build_test_agent_loop(permission_store=store)
subagent = build_test_agent_loop(permission_store=store)
parent.approve_always("bash", None)
assert (
subagent._permission_store.get_tool_permission("bash")
== ToolPermission.ALWAYS
)
@pytest.mark.asyncio
async def test_subagent_applies_parent_tool_permission_before_resolution(self):
store = PermissionStore()
parent = build_test_agent_loop(permission_store=store)
parent.approve_always("bash", None)
approval_requested = False
async def approval_callback(
tool_name: str,
args: BaseModel,
tool_call_id: str,
required_permissions: list[RequiredPermission] | None,
) -> tuple[ApprovalResponse, str | None]:
nonlocal approval_requested
approval_requested = True
return ApprovalResponse.NO, None
tool_call = ToolCall(
id="call_1",
index=0,
function=FunctionCall(name="bash", arguments='{"command":"true"}'),
)
subagent = build_test_agent_loop(
config=build_test_vibe_config(enabled_tools=["bash"]),
backend=FakeBackend([
[mock_llm_chunk(content="Running it.", tool_calls=[tool_call])],
[mock_llm_chunk(content="Done.")],
]),
permission_store=store,
)
subagent.set_approval_callback(approval_callback)
events = [event async for event in subagent.act("run true")]
tool_results = [event for event in events if isinstance(event, ToolResultEvent)]
assert not approval_requested
assert len(tool_results) == 1
assert tool_results[0].skipped is False
assert "permission" not in subagent.config.tools.get("bash", {})
def test_default_store_is_per_loop_when_not_shared(self):
a = build_test_agent_loop()
b = build_test_agent_loop()
assert a._permission_store is not b._permission_store
assert a._permission_store.lock is not b._permission_store.lock
def test_subagent_shares_parent_approval_lock(self):
store = PermissionStore()
parent = build_test_agent_loop(permission_store=store)
subagent = build_test_agent_loop(permission_store=store)
assert parent._permission_store.lock is subagent._permission_store.lock

View file

@ -0,0 +1,82 @@
from __future__ import annotations
import subprocess
import tomllib
from scripts import prepare_release
def test_pin_dependencies_preserves_toml_format_and_refreshes_lock(
monkeypatch, tmp_path
):
pyproject_path = tmp_path / "pyproject.toml"
pyproject_path.write_text(
"""[project]
name = "mistral-vibe"
version = "2.9.6"
license = { text = "Apache-2.0" }
authors = [{ name = "Mistral AI" }]
dependencies = [
"httpx[http2]>=0.28.1",
]
[dependency-groups]
build = ["pyinstaller>=6.17.0"]
[tool.pytest.ini_options]
filterwarnings = [
# Keep this comment when pinning dependencies.
"ignore:example",
]
""",
encoding="utf-8",
)
(tmp_path / "uv.lock").write_text("", encoding="utf-8")
monkeypatch.chdir(tmp_path)
def fake_get_pinned_dependencies(group=None):
if group == "build":
return ["pyinstaller==6.17.0", "truststore==0.10.4"]
return ["anyio==4.12.0", 'httpx[http2]==0.28.1 ; python_version >= "3.12"']
uv_commands = []
def fake_subprocess_run(args, **kwargs):
uv_commands.append(args)
return subprocess.CompletedProcess(args=args, returncode=0, stdout="")
git_commands = []
def fake_run_git_command(*args, **kwargs):
git_commands.append(args)
return subprocess.CompletedProcess(args=args, returncode=0, stdout="")
monkeypatch.setattr(
prepare_release, "get_pinned_dependencies", fake_get_pinned_dependencies
)
monkeypatch.setattr(prepare_release.subprocess, "run", fake_subprocess_run)
monkeypatch.setattr(prepare_release, "run_git_command", fake_run_git_command)
prepare_release.pin_dependencies("2.9.6")
updated_pyproject = pyproject_path.read_text(encoding="utf-8")
updated_data = tomllib.loads(updated_pyproject)
assert 'license = { text = "Apache-2.0" }' in updated_pyproject
assert 'authors = [{ name = "Mistral AI" }]' in updated_pyproject
assert "# Keep this comment when pinning dependencies." in updated_pyproject
assert updated_data["project"]["dependencies"] == [
"anyio==4.12.0",
'httpx[http2]==0.28.1 ; python_version >= "3.12"',
]
assert updated_data["dependency-groups"]["build"] == [
"pyinstaller==6.17.0",
"truststore==0.10.4",
]
assert uv_commands == [["uv", "lock"]]
assert ("add", "pyproject.toml", "uv.lock") in git_commands
assert (
"commit",
"--allow-empty",
"-m",
"chore: pin dependencies for v2.9.6",
) in git_commands

View file

@ -61,10 +61,15 @@ async def test_ui_navigation_restores_partially_typed_draft_after_round_trip(
async with vibe_app.run_test() as pilot:
inject_history_file(vibe_app, history_file)
chat_input = vibe_app.query_one(ChatInputContainer)
textarea = chat_input.input_widget
assert textarea is not None
await pilot.press(*"he")
assert chat_input.value == "he"
await pilot.press("up")
assert chat_input.value == "he"
assert textarea.cursor_location == (0, 0)
await pilot.press("up")
assert chat_input.value == "how are you?"
await pilot.press("down")
@ -207,6 +212,7 @@ async def test_ui_intercepts_arrow_up_only_on_first_wrapped_row(
while textarea.get_cursor_up_location() != textarea.cursor_location:
textarea.action_cursor_up()
assert textarea.cursor_location == (0, 0)
await pilot.press("up")
assert chat_input.value == "how are you?"

View file

@ -1,6 +1,5 @@
from __future__ import annotations
import os
from typing import Any, cast
from unittest.mock import AsyncMock, patch
@ -8,11 +7,11 @@ import httpx
import pytest
import respx
from tests.conftest import build_test_vibe_config
from tests.stubs.fake_connector_registry import FakeConnectorRegistry
from tests.stubs.fake_mcp_registry import FakeMCPRegistry
from vibe.core.config import ConnectorConfig, VibeConfig
from vibe.core.tools.base import BaseToolConfig, ToolError
from vibe.core.tools.connectors import CONNECTORS_ENV_VAR
from vibe.core.tools.connectors.connector_registry import (
ConnectorRegistry,
RemoteTool,
@ -153,22 +152,7 @@ class TestFakeConnectorRegistry:
class TestToolManagerConnectorIntegration:
@staticmethod
def _make_config(connectors: list[ConnectorConfig] | None = None) -> VibeConfig:
"""Minimal VibeConfig-like stub for ToolManager."""
return cast(
VibeConfig,
type(
"_Cfg",
(),
{
"mcp_servers": [],
"connectors": connectors or [],
"enabled_tools": [],
"disabled_tools": [],
"tools": {},
"tool_paths": [],
},
)(),
)
return build_test_vibe_config(connectors=connectors or [])
def test_connector_tools_registered(self) -> None:
registry = FakeConnectorRegistry(
@ -198,22 +182,6 @@ class TestToolManagerConnectorIntegration:
assert connector_tools == []
# ---------------------------------------------------------------------------
# ConnectorRegistry env var gating (tested via agent_loop helper logic)
# ---------------------------------------------------------------------------
class TestConnectorRegistryEnvGating:
def test_disabled_without_env_var(self) -> None:
with patch.dict(os.environ, {}, clear=False):
os.environ.pop(CONNECTORS_ENV_VAR, None)
assert os.getenv(CONNECTORS_ENV_VAR) != "1"
def test_enabled_with_env_var(self) -> None:
with patch.dict(os.environ, {CONNECTORS_ENV_VAR: "1"}):
assert os.getenv(CONNECTORS_ENV_VAR) == "1"
# ---------------------------------------------------------------------------
# Error message helpers
# ---------------------------------------------------------------------------
@ -339,7 +307,7 @@ class TestConnectorProxyToolRun:
mock_call.assert_awaited_once()
call_args = mock_call.call_args
assert "/v1/experimental/connectors/conn-123/mcp" in call_args.args[0]
assert "/v1/connectors-gateway/conn-123/mcp" in call_args.args[0]
assert call_args.args[1] == "search"
assert call_args.kwargs["headers"]["Authorization"] == "Bearer test-key"
@ -412,21 +380,7 @@ class TestConnectorProxyToolRun:
class TestConnectorDisableFiltering:
@staticmethod
def _make_config(connectors: list[ConnectorConfig] | None = None) -> VibeConfig:
return cast(
VibeConfig,
type(
"_Cfg",
(),
{
"mcp_servers": [],
"connectors": connectors or [],
"enabled_tools": [],
"disabled_tools": [],
"tools": {},
"tool_paths": [],
},
)(),
)
return build_test_vibe_config(connectors=connectors or [])
def test_disabled_connector_excludes_all_tools(self) -> None:
registry = FakeConnectorRegistry(

View file

@ -232,7 +232,7 @@ class TestAnswerHandling:
class TestPlanFile:
@pytest.mark.asyncio
async def test_content_passed_as_preview(
async def test_keybinding_hint_shown_as_preview(
self, tool: ExitPlanMode, plan_manager: MockAgentManager, tmp_path: Path
) -> None:
plan_file = tmp_path / "plan.md"
@ -247,21 +247,36 @@ class TestPlanFile:
)
await collect_result(tool.run(ExitPlanModeArgs(), ctx))
assert isinstance(cb.received_args, AskUserQuestionArgs)
assert cb.received_args.content_preview == "# My Plan\n\n- Step 1\n- Step 2\n"
assert cb.received_args.footer_note is not None
assert "Ctrl+G" in cb.received_args.footer_note
assert str(plan_file) in cb.received_args.footer_note
@pytest.mark.asyncio
async def test_missing_file_means_none_preview(
async def test_result_does_not_include_plan_content(
self, tool: ExitPlanMode, plan_manager: MockAgentManager, tmp_path: Path
) -> None:
plan_file = tmp_path / "nonexistent.md"
plan_file = tmp_path / "plan.md"
plan_file.write_text("# My Plan\n\n- Step 1\n- Step 2\n")
cb = MockCallback(AskUserQuestionResult(answers=[], cancelled=True))
cb = MockCallback(
AskUserQuestionResult(
answers=[
Answer(
question="q",
answer="Yes, and auto approve edits",
is_other=False,
)
],
cancelled=False,
)
)
ctx = InvokeContext(
tool_call_id="t1",
agent_manager=plan_manager, # type: ignore[arg-type]
user_input_callback=cb,
plan_file_path=plan_file,
)
await collect_result(tool.run(ExitPlanModeArgs(), ctx))
assert isinstance(cb.received_args, AskUserQuestionArgs)
assert cb.received_args.content_preview is None
result = await collect_result(tool.run(ExitPlanModeArgs(), ctx))
assert result.switched is True
assert "# My Plan" not in result.message
assert "source of truth" not in result.message

View file

@ -34,8 +34,8 @@ from vibe.core.tools.permissions import (
PermissionContext,
PermissionScope,
RequiredPermission,
wildcard_match,
)
from vibe.core.tools.utils import wildcard_match
class TestBashGranularPermissions:

View file

@ -10,6 +10,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
from pydantic import ValidationError
import pytest
from tests.conftest import build_test_vibe_config
from tests.stubs.fake_mcp_registry import FakeMCPRegistry
from vibe.core.config import MCPHttp, MCPStdio, MCPStreamableHttp, VibeConfig
from vibe.core.tools.mcp import (
@ -763,21 +764,7 @@ class TestMCPDisableFiltering:
def _make_config(
mcp_servers: list[MCPHttp | MCPStdio | MCPStreamableHttp] | None = None,
) -> VibeConfig:
return cast(
VibeConfig,
type(
"_Cfg",
(),
{
"mcp_servers": mcp_servers or [],
"connectors": [],
"enabled_tools": [],
"disabled_tools": [],
"tools": {},
"tool_paths": [],
},
)(),
)
return build_test_vibe_config(mcp_servers=mcp_servers or [])
def test_disabled_server_excludes_all_tools(self):
srv = MCPHttp(

View file

@ -1,6 +1,7 @@
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
from typing import TYPE_CHECKING, cast
from unittest.mock import AsyncMock, patch
from mistralai.client import Mistral
from mistralai.client.errors import SDKError
@ -13,12 +14,47 @@ from mistralai.client.models import (
)
import pytest
from tests.conftest import build_test_vibe_config
from tests.mock.utils import collect_result
from vibe.core.config import ProviderConfig
from vibe.core.config import ProviderConfig, VibeConfig
from vibe.core.tools.base import BaseToolState, InvokeContext, ToolError
from vibe.core.tools.builtins.websearch import WebSearch, WebSearchArgs, WebSearchConfig
from vibe.core.tools.manager import ToolManager
from vibe.core.types import Backend
if TYPE_CHECKING:
from vibe.core.agents.manager import AgentManager
class InMemoryAgentManager:
def __init__(self, config: VibeConfig) -> None:
self.config = config
def _ctx_with_config(config: VibeConfig) -> InvokeContext:
return InvokeContext(
tool_call_id="t1",
agent_manager=cast("AgentManager", InMemoryAgentManager(config)),
)
def _mistral_provider(
api_key_env_var: str = "MISTRAL_API_KEY",
api_base: str = "https://on-prem.example.com/v1",
) -> ProviderConfig:
return ProviderConfig(
name="mistral",
api_base=api_base,
api_key_env_var=api_key_env_var,
backend=Backend.MISTRAL,
)
def _llamacpp_provider() -> ProviderConfig:
return ProviderConfig(
name="llamacpp", api_base="http://127.0.0.1:8080/v1", backend=Backend.GENERIC
)
def _make_response(
content: list | None = None, outputs: list | None = None
@ -109,6 +145,68 @@ async def test_run_missing_api_key(monkeypatch):
await collect_result(ws.run(WebSearchArgs(query="test")))
@pytest.mark.asyncio
async def test_run_uses_mistral_provider_api_key_env_var(monkeypatch):
monkeypatch.setenv("MISTRAL_API_KEY", "wrong-key")
monkeypatch.setenv("TEST_API_KEY", "provider-key")
config = WebSearchConfig()
ws = WebSearch(config_getter=lambda: config, state=BaseToolState())
ctx = _ctx_with_config(
build_test_vibe_config(providers=[_mistral_provider("TEST_API_KEY")])
)
response = _make_response(content=[TextChunk(text="The answer")])
with patch("vibe.core.tools.builtins.websearch.Mistral") as mistral_cls:
client = mistral_cls.return_value
client.__aenter__ = AsyncMock(return_value=client)
client.__aexit__ = AsyncMock(return_value=None)
client.beta.conversations.start_async = AsyncMock(return_value=response)
result = await collect_result(ws.run(WebSearchArgs(query="test query"), ctx))
assert result.answer == "The answer"
assert mistral_cls.call_args.kwargs["api_key"] == "provider-key"
assert mistral_cls.call_args.kwargs["server_url"] == "https://on-prem.example.com"
assert mistral_cls.call_args.kwargs["timeout_ms"] == 120000
@pytest.mark.asyncio
async def test_run_falls_back_to_default_api_key_env_var_when_provider_env_var_empty(
monkeypatch,
):
monkeypatch.setenv("MISTRAL_API_KEY", "fallback-key")
config = WebSearchConfig()
ws = WebSearch(config_getter=lambda: config, state=BaseToolState())
ctx = _ctx_with_config(build_test_vibe_config(providers=[_mistral_provider("")]))
response = _make_response(content=[TextChunk(text="The answer")])
with patch("vibe.core.tools.builtins.websearch.Mistral") as mistral_cls:
client = mistral_cls.return_value
client.__aenter__ = AsyncMock(return_value=client)
client.__aexit__ = AsyncMock(return_value=None)
client.beta.conversations.start_async = AsyncMock(return_value=response)
result = await collect_result(ws.run(WebSearchArgs(query="test query"), ctx))
assert result.answer == "The answer"
assert mistral_cls.call_args.kwargs["api_key"] == "fallback-key"
@pytest.mark.asyncio
async def test_run_reports_configured_api_key_env_var_when_missing(monkeypatch):
monkeypatch.setenv("MISTRAL_API_KEY", "fallback-key")
monkeypatch.setenv("TEST_API_KEY", "provider-key")
ctx = _ctx_with_config(
build_test_vibe_config(providers=[_mistral_provider("TEST_API_KEY")])
)
monkeypatch.delenv("TEST_API_KEY", raising=False)
config = WebSearchConfig()
ws = WebSearch(config_getter=lambda: config, state=BaseToolState())
with pytest.raises(ToolError, match="TEST_API_KEY"):
await collect_result(ws.run(WebSearchArgs(query="test"), ctx))
@pytest.mark.asyncio
async def test_run_returns_parsed_result(websearch):
response = _make_response(
@ -165,48 +263,23 @@ def test_resolve_server_url_no_agent_manager(websearch):
def test_resolve_server_url_with_mistral_provider(websearch):
config = MagicMock()
config.providers = [
ProviderConfig(
name="mistral",
api_base="https://on-prem.example.com/v1",
api_key_env_var="MISTRAL_API_KEY",
backend=Backend.MISTRAL,
)
]
agent_manager = MagicMock()
agent_manager.config = config
ctx = InvokeContext(tool_call_id="t1", agent_manager=agent_manager)
ctx = _ctx_with_config(build_test_vibe_config(providers=[_mistral_provider()]))
assert websearch._resolve_server_url(ctx) == "https://on-prem.example.com"
def test_resolve_server_url_with_default_provider(websearch):
config = MagicMock()
config.providers = [
ProviderConfig(
name="mistral",
api_base="https://api.mistral.ai/v1",
api_key_env_var="MISTRAL_API_KEY",
backend=Backend.MISTRAL,
ctx = _ctx_with_config(
build_test_vibe_config(
providers=[_mistral_provider(api_base="https://api.mistral.ai/v1")]
)
]
agent_manager = MagicMock()
agent_manager.config = config
ctx = InvokeContext(tool_call_id="t1", agent_manager=agent_manager)
)
assert websearch._resolve_server_url(ctx) == "https://api.mistral.ai"
def test_resolve_server_url_no_mistral_provider(websearch):
config = MagicMock()
config.providers = [
ProviderConfig(name="llamacpp", api_base="http://127.0.0.1:8080/v1")
]
agent_manager = MagicMock()
agent_manager.config = config
ctx = InvokeContext(tool_call_id="t1", agent_manager=agent_manager)
ctx = _ctx_with_config(
build_test_vibe_config(active_model="local", providers=[_llamacpp_provider()])
)
assert websearch._resolve_server_url(ctx) is None
@ -220,5 +293,86 @@ def test_is_available_without_key(monkeypatch):
assert WebSearch.is_available() is False
def test_is_available_uses_mistral_provider_api_key_env_var(monkeypatch):
monkeypatch.setenv("MISTRAL_API_KEY", "fallback-key")
monkeypatch.setenv("TEST_API_KEY", "provider-key")
config = build_test_vibe_config(providers=[_mistral_provider("TEST_API_KEY")])
monkeypatch.delenv("TEST_API_KEY", raising=False)
assert WebSearch.is_available(config) is False
monkeypatch.setenv("TEST_API_KEY", "provider-key")
assert WebSearch.is_available(config) is True
def test_is_available_uses_non_active_mistral_provider(monkeypatch):
monkeypatch.setenv("TEST_API_KEY", "provider-key")
config = build_test_vibe_config(
active_model="local",
providers=[_llamacpp_provider(), _mistral_provider("TEST_API_KEY")],
)
monkeypatch.delenv("TEST_API_KEY", raising=False)
assert WebSearch.is_available(config) is False
monkeypatch.setenv("TEST_API_KEY", "provider-key")
assert WebSearch.is_available(config) is True
def test_is_available_falls_back_to_default_api_key_env_var_without_mistral_provider(
monkeypatch,
):
monkeypatch.setenv("MISTRAL_API_KEY", "fallback-key")
config = build_test_vibe_config(
active_model="local", providers=[_llamacpp_provider()]
)
assert WebSearch.is_available(config) is True
monkeypatch.delenv("MISTRAL_API_KEY")
assert WebSearch.is_available(config) is False
def test_is_available_falls_back_to_default_api_key_env_var_when_provider_env_var_empty(
monkeypatch,
):
monkeypatch.setenv("MISTRAL_API_KEY", "fallback-key")
config = build_test_vibe_config(providers=[_mistral_provider("")])
assert WebSearch.is_available(config) is True
monkeypatch.delenv("MISTRAL_API_KEY")
assert WebSearch.is_available(config) is False
def test_tool_manager_websearch_availability_uses_provider_api_key_env_var(monkeypatch):
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
monkeypatch.setenv("TEST_API_KEY", "provider-key")
config = build_test_vibe_config(providers=[_mistral_provider("TEST_API_KEY")])
manager = ToolManager(lambda: config)
assert "web_search" in manager.available_tools
monkeypatch.delenv("TEST_API_KEY")
assert "web_search" not in manager.available_tools
def test_tool_manager_websearch_availability_falls_back_without_mistral_provider(
monkeypatch,
):
monkeypatch.setenv("MISTRAL_API_KEY", "fallback-key")
config = build_test_vibe_config(
active_model="local", providers=[_llamacpp_provider()]
)
manager = ToolManager(lambda: config)
assert "web_search" in manager.available_tools
monkeypatch.delenv("MISTRAL_API_KEY")
assert "web_search" not in manager.available_tools
def test_get_status_text():
assert WebSearch.get_status_text() == "Searching the web"

View file

@ -1,6 +1,6 @@
from __future__ import annotations
from vibe.core.tools.utils import wildcard_match
from vibe.core.tools.permissions import wildcard_match
class TestWildcardMatch:

213
uv.lock generated
View file

@ -3,7 +3,7 @@ revision = 3
requires-python = ">=3.12"
[options]
exclude-newer = "2026-05-04T08:14:44.283731Z"
exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values.
exclude-newer-span = "P7D"
[options.exclude-newer-package]
@ -820,51 +820,114 @@ wheels = [
[[package]]
name = "mistral-vibe"
version = "2.9.6"
version = "2.10.0"
source = { editable = "." }
dependencies = [
{ name = "agent-client-protocol" },
{ name = "annotated-types" },
{ name = "anyio" },
{ name = "attrs" },
{ name = "beautifulsoup4" },
{ name = "cachetools" },
{ name = "certifi" },
{ name = "cffi" },
{ name = "charset-normalizer" },
{ name = "click", marker = "sys_platform != 'emscripten'" },
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "cryptography" },
{ name = "eval-type-backport" },
{ name = "gitdb" },
{ name = "gitpython" },
{ name = "giturlparse" },
{ name = "google-auth" },
{ name = "googleapis-common-protos" },
{ name = "h11" },
{ name = "httpcore" },
{ name = "httpx" },
{ name = "httpx-sse" },
{ name = "idna" },
{ name = "importlib-metadata" },
{ name = "jaraco-classes" },
{ name = "jaraco-context" },
{ name = "jaraco-functools" },
{ name = "jeepney", marker = "sys_platform == 'linux'" },
{ name = "jsonpatch" },
{ name = "jsonpath-python" },
{ name = "jsonpointer" },
{ name = "jsonschema" },
{ name = "jsonschema-specifications" },
{ name = "keyring" },
{ name = "linkify-it-py" },
{ name = "markdown-it-py" },
{ name = "markdownify" },
{ name = "mcp" },
{ name = "mdit-py-plugins" },
{ name = "mdurl" },
{ name = "mistralai" },
{ name = "more-itertools" },
{ name = "opentelemetry-api" },
{ name = "opentelemetry-exporter-otlp-proto-common" },
{ name = "opentelemetry-exporter-otlp-proto-http" },
{ name = "opentelemetry-proto" },
{ name = "opentelemetry-sdk" },
{ name = "opentelemetry-semantic-conventions" },
{ name = "packaging" },
{ name = "pexpect" },
{ name = "platformdirs" },
{ name = "protobuf" },
{ name = "ptyprocess" },
{ name = "pyasn1" },
{ name = "pyasn1-modules" },
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
{ name = "pydantic" },
{ name = "pydantic-core" },
{ name = "pydantic-settings" },
{ name = "pygments" },
{ name = "pyjwt" },
{ name = "pyperclip" },
{ name = "python-dateutil" },
{ name = "python-dotenv" },
{ name = "python-multipart" },
{ name = "pywin32", marker = "sys_platform == 'win32'" },
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
{ name = "pyyaml" },
{ name = "referencing" },
{ name = "requests" },
{ name = "rich" },
{ name = "rpds-py" },
{ name = "secretstorage", marker = "sys_platform == 'linux'" },
{ name = "six" },
{ name = "smmap" },
{ name = "sounddevice" },
{ name = "soupsieve" },
{ name = "sse-starlette" },
{ name = "starlette" },
{ name = "textual" },
{ name = "textual-speedups" },
{ name = "tomli-w" },
{ name = "tree-sitter" },
{ name = "tree-sitter-bash" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
{ name = "uc-micro-py" },
{ name = "urllib3" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
{ name = "watchfiles" },
{ name = "websockets" },
{ name = "zipp" },
{ name = "zstandard" },
]
[package.dev-dependencies]
build = [
{ name = "altgraph" },
{ name = "macholib", marker = "sys_platform == 'darwin'" },
{ name = "packaging" },
{ name = "pefile", marker = "sys_platform == 'win32'" },
{ name = "pyinstaller" },
{ name = "pyinstaller-hooks-contrib" },
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
{ name = "setuptools" },
{ name = "truststore" },
]
dev = [
@ -879,6 +942,7 @@ dev = [
{ name = "pytest-xdist" },
{ name = "respx" },
{ name = "ruff" },
{ name = "tomlkit" },
{ name = "twine" },
{ name = "typos" },
{ name = "vulture" },
@ -887,48 +951,111 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "agent-client-protocol", specifier = "==0.9.0" },
{ name = "anyio", specifier = ">=4.12.0" },
{ name = "cachetools", specifier = ">=5.5.0" },
{ name = "certifi", specifier = ">=2026.4.22" },
{ name = "charset-normalizer", specifier = ">=3.4.4" },
{ name = "cryptography", specifier = ">=44.0.0" },
{ name = "gitpython", specifier = ">=3.1.46" },
{ name = "giturlparse", specifier = ">=0.14.0" },
{ name = "google-auth", specifier = ">=2.0.0" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "jsonpatch", specifier = ">=1.33" },
{ name = "keyring", specifier = ">=25.6.0" },
{ name = "markdownify", specifier = ">=1.2.2" },
{ name = "mcp", specifier = ">=1.14.0" },
{ name = "annotated-types", specifier = "==0.7.0" },
{ name = "anyio", specifier = "==4.13.0" },
{ name = "attrs", specifier = "==26.1.0" },
{ name = "beautifulsoup4", specifier = "==4.14.3" },
{ name = "cachetools", specifier = "==7.0.6" },
{ name = "certifi", specifier = "==2026.4.22" },
{ name = "cffi", specifier = "==2.0.0" },
{ name = "charset-normalizer", specifier = "==3.4.7" },
{ name = "click", marker = "sys_platform != 'emscripten'", specifier = "==8.3.3" },
{ name = "colorama", marker = "sys_platform == 'win32'", specifier = "==0.4.6" },
{ name = "cryptography", specifier = "==47.0.0" },
{ name = "eval-type-backport", specifier = "==0.3.1" },
{ name = "gitdb", specifier = "==4.0.12" },
{ name = "gitpython", specifier = "==3.1.47" },
{ name = "giturlparse", specifier = "==0.14.0" },
{ name = "google-auth", specifier = "==2.49.2" },
{ name = "googleapis-common-protos", specifier = "==1.74.0" },
{ name = "h11", specifier = "==0.16.0" },
{ name = "httpcore", specifier = "==1.0.9" },
{ name = "httpx", specifier = "==0.28.1" },
{ name = "httpx-sse", specifier = "==0.4.3" },
{ name = "idna", specifier = "==3.13" },
{ name = "importlib-metadata", specifier = "==8.7.1" },
{ name = "jaraco-classes", specifier = "==3.4.0" },
{ name = "jaraco-context", specifier = "==6.1.2" },
{ name = "jaraco-functools", specifier = "==4.4.0" },
{ name = "jeepney", marker = "sys_platform == 'linux'", specifier = "==0.9.0" },
{ name = "jsonpatch", specifier = "==1.33" },
{ name = "jsonpath-python", specifier = "==1.1.5" },
{ name = "jsonpointer", specifier = "==3.1.1" },
{ name = "jsonschema", specifier = "==4.26.0" },
{ name = "jsonschema-specifications", specifier = "==2025.9.1" },
{ name = "keyring", specifier = "==25.7.0" },
{ name = "linkify-it-py", specifier = "==2.1.0" },
{ name = "markdown-it-py", specifier = "==4.0.0" },
{ name = "markdownify", specifier = "==1.2.2" },
{ name = "mcp", specifier = "==1.27.0" },
{ name = "mdit-py-plugins", specifier = "==0.5.0" },
{ name = "mdurl", specifier = "==0.1.2" },
{ name = "mistralai", specifier = "==2.4.4" },
{ name = "opentelemetry-api", specifier = ">=1.39.1" },
{ name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.39.1" },
{ name = "opentelemetry-sdk", specifier = ">=1.39.1" },
{ name = "opentelemetry-semantic-conventions", specifier = ">=0.60b1" },
{ name = "packaging", specifier = ">=24.1" },
{ name = "pexpect", specifier = ">=4.9.0" },
{ name = "pydantic", specifier = ">=2.12.4" },
{ name = "pydantic-settings", specifier = ">=2.12.0" },
{ name = "pyperclip", specifier = ">=1.11.0" },
{ name = "python-dotenv", specifier = ">=1.0.0" },
{ name = "pyyaml", specifier = ">=6.0.0" },
{ name = "requests", specifier = ">=2.20.0" },
{ name = "rich", specifier = ">=14.0.0" },
{ name = "sounddevice", specifier = ">=0.5.1" },
{ name = "more-itertools", specifier = "==11.0.2" },
{ name = "opentelemetry-api", specifier = "==1.39.1" },
{ name = "opentelemetry-exporter-otlp-proto-common", specifier = "==1.39.1" },
{ name = "opentelemetry-exporter-otlp-proto-http", specifier = "==1.39.1" },
{ name = "opentelemetry-proto", specifier = "==1.39.1" },
{ name = "opentelemetry-sdk", specifier = "==1.39.1" },
{ name = "opentelemetry-semantic-conventions", specifier = "==0.60b1" },
{ name = "packaging", specifier = "==26.2" },
{ name = "pexpect", specifier = "==4.9.0" },
{ name = "platformdirs", specifier = "==4.9.6" },
{ name = "protobuf", specifier = "==6.33.6" },
{ name = "ptyprocess", specifier = "==0.7.0" },
{ name = "pyasn1", specifier = "==0.6.3" },
{ name = "pyasn1-modules", specifier = "==0.4.2" },
{ name = "pycparser", marker = "implementation_name != 'PyPy'", specifier = "==3.0" },
{ name = "pydantic", specifier = "==2.13.3" },
{ name = "pydantic-core", specifier = "==2.46.3" },
{ name = "pydantic-settings", specifier = "==2.14.0" },
{ name = "pygments", specifier = "==2.20.0" },
{ name = "pyjwt", specifier = "==2.12.1" },
{ name = "pyperclip", specifier = "==1.11.0" },
{ name = "python-dateutil", specifier = "==2.9.0.post0" },
{ name = "python-dotenv", specifier = "==1.2.2" },
{ name = "python-multipart", specifier = "==0.0.27" },
{ name = "pywin32", marker = "sys_platform == 'win32'", specifier = "==311" },
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'", specifier = "==0.2.3" },
{ name = "pyyaml", specifier = "==6.0.3" },
{ name = "referencing", specifier = "==0.37.0" },
{ name = "requests", specifier = "==2.33.1" },
{ name = "rich", specifier = "==15.0.0" },
{ name = "rpds-py", specifier = "==0.30.0" },
{ name = "secretstorage", marker = "sys_platform == 'linux'", specifier = "==3.5.0" },
{ name = "six", specifier = "==1.17.0" },
{ name = "smmap", specifier = "==5.0.3" },
{ name = "sounddevice", specifier = "==0.5.5" },
{ name = "soupsieve", specifier = "==2.8.3" },
{ name = "sse-starlette", specifier = "==3.4.1" },
{ name = "starlette", specifier = "==1.0.0" },
{ name = "textual", specifier = "==8.2.4" },
{ name = "textual-speedups", specifier = ">=0.2.1" },
{ name = "tomli-w", specifier = ">=1.2.0" },
{ name = "tree-sitter", specifier = ">=0.25.2" },
{ name = "tree-sitter-bash", specifier = ">=0.25.1" },
{ name = "watchfiles", specifier = ">=1.1.1" },
{ name = "websockets", specifier = ">=13.0" },
{ name = "zstandard", specifier = ">=0.25.0" },
{ name = "textual-speedups", specifier = "==0.2.1" },
{ name = "tomli-w", specifier = "==1.2.0" },
{ name = "tree-sitter", specifier = "==0.25.2" },
{ name = "tree-sitter-bash", specifier = "==0.25.1" },
{ name = "typing-extensions", specifier = "==4.15.0" },
{ name = "typing-inspection", specifier = "==0.4.2" },
{ name = "uc-micro-py", specifier = "==2.0.0" },
{ name = "urllib3", specifier = "==2.6.3" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'", specifier = "==0.46.0" },
{ name = "watchfiles", specifier = "==1.1.1" },
{ name = "websockets", specifier = "==16.0" },
{ name = "zipp", specifier = "==3.23.1" },
{ name = "zstandard", specifier = "==0.25.0" },
]
[package.metadata.requires-dev]
build = [
{ name = "pyinstaller", specifier = ">=6.17.0" },
{ name = "truststore", specifier = ">=0.10.4" },
{ name = "altgraph", specifier = "==0.17.5" },
{ name = "macholib", marker = "sys_platform == 'darwin'", specifier = "==1.16.4" },
{ name = "packaging", specifier = "==26.2" },
{ name = "pefile", marker = "sys_platform == 'win32'", specifier = "==2024.8.26" },
{ name = "pyinstaller", specifier = "==6.20.0" },
{ name = "pyinstaller-hooks-contrib", specifier = "==2026.4" },
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'", specifier = "==0.2.3" },
{ name = "setuptools", specifier = "==82.0.1" },
{ name = "truststore", specifier = "==0.10.4" },
]
dev = [
{ name = "debugpy", specifier = ">=1.8.19" },
@ -942,6 +1069,7 @@ dev = [
{ name = "pytest-xdist", specifier = ">=3.8.0" },
{ name = "respx", specifier = ">=0.22.0" },
{ name = "ruff", specifier = ">=0.14.5" },
{ name = "tomlkit", specifier = ">=0.13.0" },
{ name = "twine", specifier = ">=5.0.0" },
{ name = "typos", specifier = ">=1.34.0" },
{ name = "vulture", specifier = ">=2.14" },
@ -2019,6 +2147,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" },
]
[[package]]
name = "tomlkit"
version = "0.14.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167, upload-time = "2026-01-13T01:14:53.304Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" },
]
[[package]]
name = "tree-sitter"
version = "0.25.2"

View file

@ -3,4 +3,4 @@ from __future__ import annotations
from pathlib import Path
VIBE_ROOT = Path(__file__).parent
__version__ = "2.9.6"
__version__ = "2.10.0"

View file

@ -195,8 +195,17 @@ def _dispatch_at_mention_inserted(
)
def _dispatch_user_rating_feedback(
client: TelemetryClient, properties: dict[str, Any]
) -> None:
client.send_user_rating_feedback(
rating=properties.get("rating", 0), model=properties.get("model", "")
)
_EVENT_DISPATCHERS: dict[str, Callable[[TelemetryClient, dict[str, Any]], None]] = {
"vibe.at_mention_inserted": _dispatch_at_mention_inserted
"vibe.at_mention_inserted": _dispatch_at_mention_inserted,
"vibe.user_rating_feedback": _dispatch_user_rating_feedback,
}
@ -396,7 +405,7 @@ class VibeAcpAgentLoop(AcpAgent):
except Exception as e:
raise ConfigurationError(str(e)) from e
agent_loop.emit_new_session_telemetry()
agent_loop.start_initialize_experiments()
modes_state, _, models_state, _ = self._build_session_state(session)
@ -660,6 +669,7 @@ class VibeAcpAgentLoop(AcpAgent):
agent_loop.session_logger.resume_existing_session(
loaded_session_id, session_dir
)
await agent_loop.hydrate_experiments_from_session()
non_system_messages = [
msg for msg in loaded_messages if msg.role != Role.system
@ -862,8 +872,17 @@ class VibeAcpAgentLoop(AcpAgent):
)
def _build_text_prompt(self, acp_prompt: list[ContentBlock]) -> str:
def _is_automatic_resource(block: ContentBlock) -> bool:
return block.type == "resource" and bool(
block.field_meta and block.field_meta.get("automatic")
)
ordered = [b for b in acp_prompt if not _is_automatic_resource(b)] + [
b for b in acp_prompt if _is_automatic_resource(b)
]
text_prompt = ""
for block in acp_prompt:
for block in ordered:
separator = "\n\n" if text_prompt else ""
match block.type:
# NOTE: ACP supports annotations, but we don't use them here yet.
@ -1206,7 +1225,11 @@ class VibeAcpAgentLoop(AcpAgent):
)
return
dispatcher(session.agent_loop.telemetry_client, notification.properties)
properties = {
"model": session.agent_loop.config.active_model,
**notification.properties,
}
dispatcher(session.agent_loop.telemetry_client, properties)
@override
def on_connect(self, conn: Client) -> None:

View file

@ -30,6 +30,7 @@ from vibe.core.utils.io import ReadSafeResult
class AcpSearchReplaceState(BaseToolState, AcpToolState):
file_backup_content: str | None = None
file_backup_encoding: str = "utf-8"
file_backup_newline: str = "\n"
class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
@ -56,7 +57,8 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
self.state.file_backup_content = response.content
self.state.file_backup_encoding = "utf-8"
return ReadSafeResult(response.content, "utf-8")
self.state.file_backup_newline = "\n"
return ReadSafeResult(response.content, "utf-8", "\n")
async def _backup_file(self, file_path: Path) -> None:
if self.state.file_backup_content is None:
@ -66,9 +68,12 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
file_path.with_suffix(file_path.suffix + ".bak"),
self.state.file_backup_content,
self.state.file_backup_encoding,
self.state.file_backup_newline,
)
async def _write_file(self, file_path: Path, content: str, encoding: str) -> None:
async def _write_file(
self, file_path: Path, content: str, encoding: str, newline: str
) -> None:
client, session_id, _ = self._load_state()
try:

View file

@ -23,6 +23,7 @@ from vibe.core.hooks.config import load_hooks_from_fs
from vibe.core.logger import logger
from vibe.core.paths import HISTORY_FILE
from vibe.core.programmatic import run_programmatic
from vibe.core.session import last_session_pointer
from vibe.core.session.session_loader import SessionLoader
from vibe.core.telemetry.build_metadata import build_entrypoint_metadata
from vibe.core.telemetry.types import EntrypointMetadata
@ -140,9 +141,15 @@ def load_session(
session_to_load = None
if args.continue_session:
cwd = Path.cwd().resolve()
session_to_load = SessionLoader.find_latest_session(
config.session_logging, working_directory=cwd
)
pointer_session_id = last_session_pointer.load(config.session_logging)
if pointer_session_id:
session_to_load = SessionLoader.find_session_by_id(
pointer_session_id, config.session_logging, working_directory=cwd
)
if not session_to_load:
session_to_load = SessionLoader.find_latest_session(
config.session_logging, working_directory=cwd
)
if not session_to_load:
rprint(
f"[red]No previous sessions found in "

View file

@ -96,6 +96,15 @@ def parse_arguments() -> argparse.Namespace:
metavar="DIR",
help="Change to this directory before running",
)
parser.add_argument(
"--add-dir",
action="append",
metavar="DIR",
default=[],
help="Additional working directory for file access and context. "
"Implicitly trusted for the session (same semantics as --trust). "
"Can be specified multiple times.",
)
parser.add_argument(
"--trust",
action="store_true",
@ -180,10 +189,22 @@ def main() -> None:
if args.trust:
trusted_folders_manager.trust_for_session(cwd)
additional_dirs: list[Path] = []
for d in args.add_dir:
resolved = Path(d).expanduser().resolve()
if not resolved.is_dir():
rprint(
f"[red]Error: --add-dir path does not exist "
f"or is not a directory: {d}[/]"
)
sys.exit(1)
additional_dirs.append(resolved)
trusted_folders_manager.trust_for_session(resolved)
is_interactive = args.prompt is None
if is_interactive:
check_and_resolve_trusted_folder(cwd)
init_harness_files_manager("user", "project")
init_harness_files_manager("user", "project", additional_dirs=additional_dirs)
from vibe.cli.cli import run_cli

View file

@ -12,12 +12,11 @@ from vibe.cli.plan_offer.ports.whoami_gateway import (
)
from vibe.core.utils.http import build_ssl_context
BASE_URL = "https://console.mistral.ai"
WHOAMI_PATH = "/api/vibe/whoami"
class HttpWhoAmIGateway:
def __init__(self, base_url: str = BASE_URL) -> None:
def __init__(self, base_url: str) -> None:
self._base_url = base_url.rstrip("/")
async def whoami(self, api_key: str) -> WhoAmIResponse:

View file

@ -11,15 +11,15 @@ from vibe.cli.plan_offer.ports.whoami_gateway import (
WhoAmIPlanType,
WhoAmIResponse,
)
from vibe.core.config import DEFAULT_MISTRAL_API_ENV_KEY, ProviderConfig
from vibe.core.config import (
DEFAULT_CONSOLE_BASE_URL,
DEFAULT_MISTRAL_API_ENV_KEY,
ProviderConfig,
)
from vibe.core.types import Backend
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 MistralCodePlanName(StrEnum):
FREE = "F"
@ -96,16 +96,21 @@ def resolve_api_key_for_plan(provider: ProviderConfig) -> str | None:
return getenv(api_env_key)
def plan_offer_cta(payload: PlanInfo | None) -> str | None:
def plan_offer_cta(
payload: PlanInfo | None, console_base_url: str = DEFAULT_CONSOLE_BASE_URL
) -> str | None:
if not payload:
return
console_cli_url = f"{console_base_url.rstrip('/')}/codestral/cli"
switch_to_pro_key_url = console_cli_url
upgrade_url = console_cli_url
if payload.prompt_switching_to_pro_plan:
return f"### Switch to your [Le Chat Pro API key]({SWITCH_TO_PRO_KEY_URL})"
return f"### Switch to your [Le Chat Pro API key]({switch_to_pro_key_url})"
if (
payload.plan_type in {WhoAmIPlanType.API, WhoAmIPlanType.UNAUTHORIZED}
or payload.is_free_mistral_code_plan()
):
return f"### Unlock more with Vibe - [Upgrade to Le Chat Pro]({UPGRADE_URL})"
return f"### Unlock more with Vibe - [Upgrade to Le Chat Pro]({upgrade_url})"
def plan_title(payload: PlanInfo | None) -> str | None: # noqa: PLR0911

View file

@ -160,7 +160,7 @@ from vibe.core.tools.builtins.ask_user_question import (
Choice,
Question,
)
from vibe.core.tools.connectors import ConnectorRegistry, connectors_enabled
from vibe.core.tools.connectors import ConnectorRegistry
from vibe.core.tools.mcp_settings import persist_mcp_toggle
from vibe.core.tools.permissions import RequiredPermission
from vibe.core.transcribe import make_transcribe_client
@ -257,6 +257,19 @@ PRUNE_LOW_MARK = 1000
PRUNE_HIGH_MARK = 1500
DOUBLE_ESC_DELAY = 0.2
_DEFAULT_TYPING_DEBOUNCE_MS = 1000
_TYPING_DEBOUNCE_ENV_VAR = "VIBE_TYPING_GRACE_PERIOD_MS"
def _resolve_typing_debounce_s() -> float:
try:
ms = int(os.environ[_TYPING_DEBOUNCE_ENV_VAR])
if ms < 0:
raise ValueError
except (KeyError, ValueError):
ms = _DEFAULT_TYPING_DEBOUNCE_MS
return ms / 1000
async def prune_oldest_children(
messages_area: Widget, low_mark: int, high_mark: int
@ -320,6 +333,9 @@ class VibeApp(App): # noqa: PLR0904
Binding(
"shift+down", "scroll_chat_down", "Scroll Down", show=False, priority=True
),
Binding(
"ctrl+g", "open_plan_in_editor", "Edit Plan", show=False, priority=False
),
Binding("ctrl+backslash", "toggle_debug_console", "Debug Console", show=False),
Binding("alt+up", "rewind_prev", "Rewind Previous", show=False, priority=True),
Binding("ctrl+p", "rewind_prev", "Rewind Previous", show=False, priority=True),
@ -425,7 +441,7 @@ class VibeApp(App): # noqa: PLR0904
@property
def _connectors_enabled(self) -> bool:
return connectors_enabled() and self.agent_loop.connector_registry is not None
return self.agent_loop.connector_registry is not None
def _get_command_availability_context(self) -> CommandAvailabilityContext:
return CommandAvailabilityContext(
@ -519,8 +535,10 @@ class VibeApp(App): # noqa: PLR0904
self._loop_runner.start()
await self._check_and_show_whats_new()
self._schedule_update_notification()
if not self._is_resuming_session:
self.agent_loop.emit_new_session_telemetry()
if self._is_resuming_session:
await self.agent_loop.hydrate_experiments_from_session()
else:
self.agent_loop.start_initialize_experiments()
self.call_after_refresh(self._refresh_banner)
self._show_hook_config_issues_once()
@ -1250,6 +1268,29 @@ class VibeApp(App): # noqa: PLR0904
def _is_tool_enabled_in_main_agent(self, tool: str) -> bool:
return tool in self.agent_loop.tool_manager.available_tools
async def _wait_for_typing_pause(self) -> None:
try:
text_area = self.query_one(ChatTextArea)
except Exception:
return
debounce_s = _resolve_typing_debounce_s()
if text_area.time_since_last_keystroke() >= debounce_s:
return
if self._loading_widget:
self._loading_widget.show_debounce_hint()
try:
while True:
elapsed = text_area.time_since_last_keystroke()
if elapsed >= debounce_s:
return
await asyncio.sleep(debounce_s - elapsed)
finally:
if self._loading_widget:
self._loading_widget.hide_debounce_hint()
async def _approval_callback(
self,
tool: str,
@ -1264,6 +1305,7 @@ class VibeApp(App): # noqa: PLR0904
return (ApprovalResponse.YES, None)
async with self._user_interaction_lock:
await self._wait_for_typing_pause()
self._pending_approval = asyncio.Future()
self._terminal_notifier.notify(NotificationContext.ACTION_REQUIRED)
try:
@ -1279,6 +1321,7 @@ class VibeApp(App): # noqa: PLR0904
question_args = cast(AskUserQuestionArgs, args)
async with self._user_interaction_lock:
await self._wait_for_typing_pause()
self._pending_question = asyncio.Future()
self._terminal_notifier.notify(NotificationContext.ACTION_REQUIRED)
try:
@ -1620,12 +1663,9 @@ class VibeApp(App): # noqa: PLR0904
connector_registry is not None and connector_registry.connector_count > 0
)
if not mcp_servers and not has_connectors:
msg = (
"No MCP servers or connectors configured."
if self._connectors_enabled
else "No MCP servers configured."
await self._mount_and_scroll(
UserCommandMessage("No MCP servers or connectors configured.")
)
await self._mount_and_scroll(UserCommandMessage(msg))
return
if self._current_bottom_app == BottomApp.MCP:
@ -1854,9 +1894,6 @@ class VibeApp(App): # noqa: PLR0904
if self._chat_input_container:
self._chat_input_container.set_custom_border(None)
current_system_messages = [
msg for msg in self.agent_loop.messages if msg.role == Role.system
]
non_system_messages = [
msg for msg in loaded_messages if msg.role != Role.system
]
@ -1866,6 +1903,10 @@ class VibeApp(App): # noqa: PLR0904
self.agent_loop.session_logger.resume_existing_session(
session.session_id, session_path
)
await self.agent_loop.hydrate_experiments_from_session()
current_system_messages = [
msg for msg in self.agent_loop.messages if msg.role == Role.system
]
self.agent_loop.messages.reset(current_system_messages + non_system_messages)
self._refresh_profile_widgets()
@ -2321,19 +2362,25 @@ class VibeApp(App): # noqa: PLR0904
def _handle_approval_app_escape(self) -> None:
try:
approval_app = self.query_one(ApprovalApp)
approval_app.action_reject()
if not approval_app.is_within_grace_period():
approval_app.action_reject()
self.agent_loop.telemetry_client.send_user_cancelled_action(
"reject_approval"
)
except Exception:
pass
self.agent_loop.telemetry_client.send_user_cancelled_action("reject_approval")
self._last_escape_time = None
def _handle_question_app_escape(self) -> None:
try:
question_app = self.query_one(QuestionApp)
question_app.action_cancel()
if not question_app.is_within_grace_period():
question_app.action_cancel()
self.agent_loop.telemetry_client.send_user_cancelled_action(
"cancel_question"
)
except Exception:
pass
self.agent_loop.telemetry_client.send_user_cancelled_action("cancel_question")
self._last_escape_time = None
def _handle_model_picker_app_escape(self) -> None:
@ -2869,7 +2916,9 @@ class VibeApp(App): # noqa: PLR0904
content = load_whats_new_content()
if content is not None:
whats_new_message = WhatsNewMessage(content)
plan_offer = plan_offer_cta(self._plan_info)
plan_offer = plan_offer_cta(
self._plan_info, console_base_url=self.config.console_base_url
)
if plan_offer is not None:
whats_new_message = WhatsNewMessage(f"{content}\n\n{plan_offer}")
if self._history_widget_indices:
@ -3024,6 +3073,13 @@ class VibeApp(App): # noqa: PLR0904
if self._chat_input_container and self._chat_input_container.input_widget:
self._chat_input_container.input_widget.set_app_focus(True)
def action_open_plan_in_editor(self) -> None:
if self.event_handler is None:
return
if plan_file_message := self.event_handler.plan_file_message:
plan_file_message.open_in_editor()
def action_suspend_with_message(self) -> None:
if WINDOWS or self._driver is None or not self._driver.can_suspend:
return
@ -3053,7 +3109,7 @@ def run_textual_ui(
update_notifier = PyPIUpdateGateway(project_name="mistral-vibe")
update_cache_repository = FileSystemUpdateCacheRepository()
plan_offer_gateway = HttpWhoAmIGateway()
plan_offer_gateway = HttpWhoAmIGateway(base_url=agent_loop.config.console_base_url)
with stderr_guard():
app = VibeApp(
@ -3065,4 +3121,6 @@ def run_textual_ui(
)
session_id = app.run()
print_session_resume_message(session_id, agent_loop.stats)
print_session_resume_message(
session_id, agent_loop.stats, agent_loop.config.session_logging
)

View file

@ -248,6 +248,7 @@ Markdown {
margin-top: 1;
width: 100%;
height: auto;
padding-left: 2;
Markdown {
width: 100%;
@ -325,6 +326,35 @@ Markdown {
width: 100%;
}
.plan-file-message {
margin-top: 1;
margin-bottom: 0;
height: auto;
width: 100%;
.plan-file-wrapper {
height: auto;
width: 100%;
border: solid ansi_bright_black;
padding: 0 1;
}
Markdown {
width: 100%;
height: auto;
padding: 0;
margin: 0;
& > MarkdownBlock:first-child {
margin: 0 0 1 0;
}
& > MarkdownBlock:last-child {
margin: 1 0 0 0;
}
}
}
.interrupt-container,
.error-container,
.warning-container,
@ -669,7 +699,12 @@ StatusMessage {
width: auto;
height: auto;
color: ansi_bright_black;
}
.loading-debounce {
width: auto;
height: auto;
margin-left: 1;
}
.history-load-more-message {
@ -877,10 +912,6 @@ StatusMessage {
height: auto;
}
#question-content.question-content-docked {
dock: bottom;
}
.question-tabs {
height: auto;
color: ansi_blue;
@ -935,34 +966,19 @@ StatusMessage {
margin-top: 1;
}
.question-footer-note {
height: auto;
color: ansi_bright_black;
text-style: italic;
margin-top: 1;
}
.question-help {
height: auto;
color: ansi_bright_black;
margin-top: 1;
}
.question-content-preview {
width: 100%;
height: auto;
max-height: 50vh;
border: none;
border-left: wide ansi_bright_black;
padding: 0 0 0 1;
margin-bottom: 1;
}
.question-content-preview-text {
width: 100%;
height: auto;
color: ansi_default;
padding: 0;
margin: 0;
& > MarkdownBlock:last-child {
margin-bottom: 0;
}
}
ExpandingBorder {
width: auto;
height: 100%;

View file

@ -16,15 +16,19 @@ class ExternalEditor:
def get_editor() -> str:
return os.environ.get("VISUAL") or os.environ.get("EDITOR") or "nano"
@classmethod
def edit_file(cls, file_path: Path, *, check: bool = False) -> None:
editor = cls.get_editor()
parts = shlex.split(editor)
subprocess.run([*parts, str(file_path)], check=check)
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)
self.edit_file(Path(filepath), check=True)
content = read_safe(Path(filepath)).text.rstrip()
return content if content != initial_content else None

View file

@ -1,6 +1,7 @@
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
from typing import TYPE_CHECKING
from vibe.cli.textual_ui.widgets.compact import CompactMessage
@ -9,6 +10,7 @@ from vibe.cli.textual_ui.widgets.messages import (
AssistantMessage,
HookRunContainer,
HookSystemMessageLine,
PlanFileMessage,
ReasoningMessage,
UserMessage,
)
@ -28,6 +30,8 @@ from vibe.core.types import (
BaseEvent,
CompactEndEvent,
CompactStartEvent,
PlanReviewEndedEvent,
PlanReviewRequestedEvent,
ReasoningEvent,
ToolCallEvent,
ToolResultEvent,
@ -57,6 +61,7 @@ class EventHandler:
self.current_compact: CompactMessage | None = None
self.current_streaming_message: AssistantMessage | None = None
self.current_streaming_reasoning: ReasoningMessage | None = None
self.plan_file_message: PlanFileMessage | None = None
self._hook_run_container: HookRunContainer | None = None
async def _handle_hook_event(
@ -85,7 +90,7 @@ class EventHandler:
if loading_widget:
loading_widget.set_status(DEFAULT_LOADING_STATUS)
async def handle_event(
async def handle_event( # noqa: PLR0912
self, event: BaseEvent, loading_widget: LoadingWidget | None = None
) -> ToolCallMessage | None:
match event:
@ -117,6 +122,10 @@ class EventHandler:
await self.mount_callback(UserMessage(event.content))
case HookEvent():
await self._handle_hook_event(event, loading_widget)
case PlanReviewRequestedEvent():
await self._handle_start_plan_review(file_path=event.file_path)
case PlanReviewEndedEvent():
self._handle_stop_plan_review()
case WaitingForInputEvent():
await self.finalize_streaming()
case _:
@ -245,3 +254,16 @@ class EventHandler:
if self.current_compact:
self.current_compact.stop_spinning(success=False)
self.current_compact = None
async def _handle_start_plan_review(self, file_path: Path) -> None:
file_path.touch()
msg = PlanFileMessage(file_path=file_path)
self.plan_file_message = msg
await self.mount_callback(msg)
def _handle_stop_plan_review(self) -> None:
if self.plan_file_message is None:
return
self.plan_file_message.stop_watching()
self.plan_file_message = None

View file

@ -2,6 +2,8 @@ from __future__ import annotations
from rich import print as rprint
from vibe.core.config import SessionLoggingConfig
from vibe.core.session import last_session_pointer
from vibe.core.types import AgentStats
@ -14,10 +16,14 @@ def format_session_usage(stats: AgentStats) -> str:
)
def print_session_resume_message(session_id: str | None, stats: AgentStats) -> None:
def print_session_resume_message(
session_id: str | None, stats: AgentStats, session_logging: SessionLoggingConfig
) -> None:
if not session_id:
return
last_session_pointer.record(session_logging, session_id)
print()
print(format_session_usage(stats))
print()

View file

@ -1,5 +1,6 @@
from __future__ import annotations
import time
from typing import ClassVar
from pydantic import BaseModel
@ -15,6 +16,8 @@ from vibe.cli.textual_ui.widgets.tool_widgets import get_approval_widget
from vibe.core.config import VibeConfig
from vibe.core.tools.permissions import RequiredPermission
_INPUT_GRACE_PERIOD_S = 0.5
class ApprovalApp(Container):
can_focus = True
@ -88,6 +91,7 @@ class ApprovalApp(Container):
self.tool_info_container: Vertical | None = None
self.option_widgets: list[Static] = []
self.help_widget: Static | None = None
self._mount_time: float = 0.0
def compose(self) -> ComposeResult:
with Vertical(id="approval-options"):
@ -119,10 +123,14 @@ class ApprovalApp(Container):
return f"Permission for the {self.tool_name} tool"
async def on_mount(self) -> None:
self._mount_time = time.monotonic()
await self._update_tool_info()
self._update_options()
self.focus()
def is_within_grace_period(self) -> bool:
return (time.monotonic() - self._mount_time) < _INPUT_GRACE_PERIOD_S
async def _update_tool_info(self) -> None:
if not self.tool_info_container:
return
@ -175,28 +183,29 @@ class ApprovalApp(Container):
self.selected_option = (self.selected_option + 1) % self.NUM_OPTIONS
self._update_options()
def _select_if_unguarded(self, option: int) -> None:
if self.is_within_grace_period():
return
self.selected_option = option
self._handle_selection(option)
def action_select(self) -> None:
self._handle_selection(self.selected_option)
self._select_if_unguarded(self.selected_option)
def action_select_1(self) -> None:
self.selected_option = 0
self._handle_selection(0)
self._select_if_unguarded(0)
def action_select_2(self) -> None:
self.selected_option = 1
self._handle_selection(1)
self._select_if_unguarded(1)
def action_select_3(self) -> None:
self.selected_option = 2
self._handle_selection(2)
self._select_if_unguarded(2)
def action_select_4(self) -> None:
self.selected_option = 3
self._handle_selection(3)
self._select_if_unguarded(3)
def action_reject(self) -> None:
self.selected_option = 3
self._handle_selection(3)
self._select_if_unguarded(3)
def _handle_selection(self, option: int) -> None:
match option:

View file

@ -1,5 +1,6 @@
from __future__ import annotations
import time
from typing import Any, ClassVar, Literal
from textual import events
@ -75,6 +76,7 @@ class ChatTextArea(TextArea):
self._completion_manager: MultiCompletionManager | None = None
self._app_has_focus: bool = True
self._voice_manager = voice_manager
self._last_keystroke_time: float = 0.0
def on_blur(self, event: events.Blur) -> None:
if self._app_has_focus:
@ -137,8 +139,15 @@ class ChatTextArea(TextArea):
)
if should_intercept:
self._navigating_history = True
self.post_message(self.HistoryPrevious())
if (
self.text
and self.cursor_location != (0, 0)
and not history_loaded_and_cursor_unmoved
):
self.move_cursor((0, 0))
else:
self._navigating_history = True
self.post_message(self.HistoryPrevious())
return True
return False
@ -198,7 +207,12 @@ class ChatTextArea(TextArea):
return False
def time_since_last_keystroke(self) -> float:
return time.monotonic() - self._last_keystroke_time
async def _on_key(self, event: events.Key) -> None: # noqa: PLR0911
self._last_keystroke_time = time.monotonic()
if await self._handle_voice_key(event):
return

View file

@ -16,6 +16,7 @@ from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.cli.textual_ui.widgets.spinner import SpinnerMixin, SpinnerType
DEFAULT_LOADING_STATUS = "Generating"
_DEBOUNCE_HINT_TEXT = "[dim italic]typing detected, waiting…[/]"
def _format_elapsed(seconds: int) -> str:
@ -84,6 +85,7 @@ class LoadingWidget(SpinnerMixin, Static):
self._status_widget: Static | None = None
self.hint_widget: Static | None = None
self._show_hint = show_hint
self.debounce_widget: Static | None = None
self.start_time: float | None = None
self._last_elapsed: int = -1
self._paused_total: float = 0.0
@ -112,6 +114,15 @@ class LoadingWidget(SpinnerMixin, Static):
def _apply_easter_egg(self, status: str) -> str:
return self._get_easter_egg() or status
def show_debounce_hint(self) -> None:
if self.debounce_widget:
self.debounce_widget.update(_DEBOUNCE_HINT_TEXT)
self.debounce_widget.display = True
def hide_debounce_hint(self) -> None:
if self.debounce_widget:
self.debounce_widget.display = False
def pause_timer(self) -> None:
if self._pause_start is None:
self._pause_start = time()
@ -142,6 +153,10 @@ class LoadingWidget(SpinnerMixin, Static):
)
yield self.hint_widget
self.debounce_widget = Static("", classes="loading-debounce")
self.debounce_widget.display = False
yield self.debounce_widget
def on_mount(self) -> None:
self.start_time = time()
self._update_animation()

View file

@ -16,7 +16,7 @@ from textual.worker import Worker
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.core.config import ConnectorConfig
from vibe.core.tools.connectors import ConnectorRegistry, connectors_enabled
from vibe.core.tools.connectors import ConnectorRegistry
from vibe.core.tools.mcp.tools import MCPTool
from vibe.core.tools.mcp_settings import updated_tool_list
@ -44,7 +44,7 @@ def collect_mcp_tool_index(
registered = tool_manager.registered_tools
available = tool_manager.available_tools
configured_servers = {server.name for server in mcp_servers}
connector_set = set(connector_names) if connectors_enabled() else set()
connector_set = set(connector_names)
server_tools: dict[str, list[tuple[str, type[MCPTool]]]] = {}
connector_tools: dict[str, list[tuple[str, type[MCPTool]]]] = {}
@ -410,7 +410,7 @@ class MCPApp(Container):
def _show_list_view(self, option_list: OptionList, index: MCPToolIndex) -> None:
self._viewing_server = None
self._viewing_kind = None
has_connectors = connectors_enabled() and bool(self._connector_names)
has_connectors = bool(self._connector_names)
title = "MCP Servers & Connectors" if has_connectors else "MCP Servers"
self.query_one("#mcp-title", NoMarkupStatic).update(title)
self._set_help_text(_LIST_VIEW_HELP_TOOLS)
@ -424,12 +424,9 @@ class MCPApp(Container):
option_list.add_option(Option(Text("", no_wrap=True), disabled=True))
self._list_connectors(option_list=option_list, index=index)
if not has_servers and not has_connectors:
empty_msg = (
"No MCP servers or connectors configured"
if connectors_enabled()
else "No MCP servers configured"
option_list.add_option(
Option("No MCP servers or connectors configured", disabled=True)
)
option_list.add_option(Option(empty_msg, disabled=True))
if has_servers or has_connectors:
# Skip disabled header options (e.g. section labels).

View file

@ -1,16 +1,25 @@
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast
from vibe.core.hooks.models import HookMessageSeverity
from vibe.core.logger import logger
from vibe.core.utils.io import read_safe_async
if TYPE_CHECKING:
from vibe.cli.textual_ui.app import ChatScroll
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical
from textual.css.query import NoMatches
from textual.reactive import reactive
from textual.widget import Widget
from textual.widgets import Static
from textual.widgets._markdown import MarkdownStream
from watchfiles import awatch
from vibe.cli.textual_ui.ansi_markdown import AnsiMarkdown as Markdown
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
@ -443,3 +452,60 @@ class WarningMessage(Static):
if self._show_border:
yield ExpandingBorder(classes="warning-border")
yield NoMarkupStatic(self._message, classes="warning-content")
class PlanFileMessage(Widget):
content: reactive[str] = reactive("")
def __init__(self, file_path: Path) -> None:
super().__init__()
self.add_class("plan-file-message")
self._file_path = file_path
self._watch_task: asyncio.Task | None = None
def compose(self) -> ComposeResult:
with Vertical(classes="plan-file-wrapper"):
yield Markdown(self.content, classes="plan-file-content")
def watch_content(self, new_content: str) -> None:
try:
self.query_one(Markdown).update(new_content)
except NoMatches:
pass
async def on_mount(self) -> None:
self.content = (await read_safe_async(self._file_path)).text
self._watch_task = asyncio.create_task(self._watch_file())
async def _watch_file(self) -> None:
try:
async for _ in awatch(self._file_path):
self.content = (await read_safe_async(self._file_path)).text
except (asyncio.CancelledError, FileNotFoundError):
pass
def open_in_editor(self) -> None:
from vibe.cli.textual_ui.external_editor import ExternalEditor
try:
self._file_path.parent.mkdir(parents=True, exist_ok=True)
with self.app.suspend():
ExternalEditor.edit_file(self._file_path)
except OSError:
logger.warning(
"Failed to open plan file in editor: %s", self._file_path, exc_info=True
)
self.app.notify(
f"Could not open plan in editor: {self._file_path}",
severity="error",
timeout=6,
)
def stop_watching(self) -> None:
if self._watch_task is None:
return
if not self._watch_task.done():
self._watch_task.cancel()
self._watch_task = None

View file

@ -1,20 +1,22 @@
from __future__ import annotations
import itertools
import time
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, VerticalScroll
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.ansi_markdown import AnsiMarkdown
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.cli.textual_ui.widgets.vscode_compat import VscodeCompatInput
_INPUT_GRACE_PERIOD_S = 0.5
if TYPE_CHECKING:
from vibe.core.tools.builtins.ask_user_question import (
AskUserQuestionArgs,
@ -66,6 +68,7 @@ class QuestionApp(Container):
self.submit_widget: NoMarkupStatic | None = None
self.help_widget: NoMarkupStatic | None = None
self.tabs_widget: NoMarkupStatic | None = None
self._mount_time: float = 0.0
@property
def _current_question(self) -> Question:
@ -110,16 +113,7 @@ class QuestionApp(Container):
)
def compose(self) -> ComposeResult:
if self.args.content_preview:
with VerticalScroll(classes="question-content-preview"):
yield AnsiMarkdown(
self.args.content_preview, classes="question-content-preview-text"
)
question_content_classes = (
"question-content-docked" if self.args.content_preview else ""
)
with Vertical(id="question-content", classes=question_content_classes):
with Vertical(id="question-content"):
if len(self.questions) > 1:
self.tabs_widget = NoMarkupStatic("", classes="question-tabs")
yield self.tabs_widget
@ -147,13 +141,22 @@ class QuestionApp(Container):
self.submit_widget = NoMarkupStatic("", classes="question-submit")
yield self.submit_widget
if self.args.footer_note:
yield NoMarkupStatic(
self.args.footer_note, classes="question-footer-note"
)
self.help_widget = NoMarkupStatic("", classes="question-help")
yield self.help_widget
async def on_mount(self) -> None:
self._mount_time = time.monotonic()
self._update_display()
self.focus()
def is_within_grace_period(self) -> bool:
return (time.monotonic() - self._mount_time) < _INPUT_GRACE_PERIOD_S
def _watch_current_question_idx(self) -> None:
self._update_display()
@ -361,6 +364,8 @@ class QuestionApp(Container):
self._switch_question(new_idx)
def action_select(self) -> None:
if self.is_within_grace_period():
return
if self._current_question.multi_select:
self._handle_multi_select_action()
else:
@ -415,6 +420,8 @@ class QuestionApp(Container):
self._switch_question(new_idx)
def action_cancel(self) -> None:
if self.is_within_grace_period():
return
self.post_message(self.Cancelled())
def on_input_submitted(self, _event: Input.Submitted) -> None:
@ -463,8 +470,11 @@ class QuestionApp(Container):
event.stop()
event.prevent_default()
self.selected_option = option_idx
if self.is_within_grace_period():
return True
self.selected_option = option_idx
if not (self._has_other and option_idx == self._other_option_idx):
self.action_select()
return True

View file

@ -23,7 +23,7 @@ from vibe.core.types import (
)
def _empty_session_metadata() -> dict[str, str]:
def _empty_session_metadata() -> dict[str, Any]:
return {}
@ -34,13 +34,13 @@ class TurnSummaryTracker(TurnSummaryPort):
model: ModelConfig,
on_summary: Callable[[TurnSummaryResult], None] | None = None,
max_tokens: int = 512,
session_metadata_getter: Callable[[], dict[str, str]] | None = None,
session_metadata_getter: Callable[[], dict[str, Any]] | None = None,
) -> None:
self._backend = backend
self._model = model
self._on_summary = on_summary
self._max_tokens = max_tokens
self._session_metadata_getter: Callable[[], dict[str, str]] = (
self._session_metadata_getter: Callable[[], dict[str, Any]] = (
_empty_session_metadata
if session_metadata_getter is None
else session_metadata_getter

Some files were not shown because too many files have changed in this diff Show more