v2.10.0 (#697)
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>
7
.vscode/launch.json
vendored
|
|
@ -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",
|
||||
|
|
|
|||
34
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
139
pyproject.toml
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
78
tests/cli/test_approval_app_grace_period.py
Normal 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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
111
tests/cli/test_question_app_grace_period.py
Normal 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)
|
||||
35
tests/cli/test_typing_debounce.py
Normal 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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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]] = []
|
||||
|
|
|
|||
0
tests/core/experiments/__init__.py
Normal file
116
tests/core/experiments/test_client.py
Normal 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()
|
||||
427
tests/core/experiments/test_manager.py
Normal 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}
|
||||
68
tests/core/experiments/test_models.py
Normal 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
|
||||
168
tests/core/experiments/test_resume.py
Normal 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
|
||||
208
tests/core/experiments/test_session_helpers.py
Normal 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
|
||||
186
tests/core/experiments/test_system_prompt_variant.py
Normal 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")
|
||||
31
tests/core/experiments/test_telemetry_integration.py
Normal 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
|
||||
123
tests/core/session/test_last_session_pointer.py
Normal 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
|
|
@ -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]
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"',
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 there, who are 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'm the Vibe agent and I'm ready to 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'm the Vibe agent and I'm ready to 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)">┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐</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 |
|
|
@ -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)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="434.8" textLength="61" clip-path="url(#terminal-line-17)">Type </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)"> for more 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's a very long print 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's a very long print 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 long line (Lorem 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 ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna 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)">▋            </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 long line (Lorem 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 ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna </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)">▋           </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 </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)"> statement includes a very long line of Lorem Ipsum text to demonstrate a lengthy 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 </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)"> statement includes a very long line of Lorem Ipsum text to demonstrate a lengthy 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)">┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐</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 |
|
|
@ -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 answer is 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 answer is 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)">┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐</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 |
|
|
@ -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, I can help with 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, I can help with 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 is Vibe doing so far?  </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)">: good  </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)">: fine  </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)">: 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)">┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐</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 |
|
|
@ -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)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-9108451134-r2" x="170.8" y="532.4" textLength="146.4" clip-path="url(#terminal-9108451134-line-21)">Mistral Vibe</text><text class="terminal-9108451134-r1" x="317.2" y="532.4" textLength="122" clip-path="url(#terminal-9108451134-line-21)"> v0.0.0 · </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)"> · [Subscription] 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)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-9108451134-r1" x="170.8" y="556.8" textLength="414.8" clip-path="url(#terminal-9108451134-line-22)">1 model · 0 MCP servers · 0 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)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-9108451134-r1" x="170.8" y="581.2" textLength="61" clip-path="url(#terminal-9108451134-line-23)">Type </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)"> for more 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 MCP servers 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)">┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐</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)">></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% of 200k 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)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="532.4" textLength="146.4" clip-path="url(#terminal-line-21)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="532.4" textLength="122" clip-path="url(#terminal-line-21)"> v0.0.0 · </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)"> · [Subscription] 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)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="556.8" textLength="414.8" clip-path="url(#terminal-line-22)">1 model · 0 MCP servers · 0 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)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="581.2" textLength="61" clip-path="url(#terminal-line-23)">Type </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)"> for more 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 MCP servers or connectors 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)">┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐</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)">></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% of 200k tokens</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 13 KiB |
|
|
@ -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! I can help 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! I can help 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)">┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐</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 |
|
|
@ -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! I can help 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! I can help 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)"> speaking </text><text class="terminal-r6" x="158.6" y="727.6" textLength="219.6" clip-path="url(#terminal-line-29)">Esc/Ctrl+C to 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)">┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐</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 |
|
|
@ -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! I can help 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! I can help 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)"> summarizing </text><text class="terminal-r6" x="170.8" y="727.6" textLength="219.6" clip-path="url(#terminal-line-29)">Esc/Ctrl+C to 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)">┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐</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 |
|
|
@ -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)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="215.2" textLength="146.4" clip-path="url(#terminal-line-8)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="215.2" textLength="122" clip-path="url(#terminal-line-8)"> v0.0.0 · </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)"> · [Subscription] 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)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="239.6" textLength="414.8" clip-path="url(#terminal-line-9)">1 model · 0 MCP servers · 0 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)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="264" textLength="61" clip-path="url(#terminal-line-10)">Type </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)"> for more 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 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. Add user 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)">• JWT token 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)">• Session 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. Database 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)">• Create users 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)">• Add 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)">┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐</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)">></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% of 200k tokens</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 18 KiB |
|
|
@ -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 is my carefully considered answer. I hope this 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 is my carefully considered answer. I hope this 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)">┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐</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 |
|
|
@ -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's the first part of the 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's the first part of the 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 here's the 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 here's the 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)">┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐</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 |
|
|
@ -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 answer to your question is 42. This is the ultimate 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 answer to your question is 42. This is the ultimate 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)">┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐</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 |
|
|
@ -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 me think about this step by step... First, I need to understand the question. Then I can formulate a 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 answer to your question is 42. This is the ultimate 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 answer to your question is 42. This is the ultimate 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)">┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐</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 |
|
|
@ -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 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! How can I help 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! How can I help 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 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 |
|
|
@ -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 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! How can I help 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! How can I help 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 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 |
|
|
@ -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 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! How can I help 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! How can I help 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 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 |
|
|
@ -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 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! How can I help 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! How can I help 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 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 |
|
|
@ -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 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! How can I help 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! How can I help 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 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 |
|
|
@ -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, how are 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'm doing well, thank you! Let me read that file for 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'm doing well, thank you! Let me read that file for 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 |
|
|
@ -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, can you help 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! What do you 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! What do you 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 read my config 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 is the content of your config 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 is the content of your config 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)">┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐</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 |
|
|
@ -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 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)">  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)">    |</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)">    |  POST /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)">    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)">| Gateway |</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)">    |</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)">    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)">| Service |</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)">    |</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)">    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)">|   DB    |</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)">  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)">    |</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)">    |  POST /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)">    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)">| Gateway |</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)">    |</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)">    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)">| Service |</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)">    |</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)">    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)">|   DB    |</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)">┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐</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 |
|
|
@ -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'm ready to help 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'm ready to help 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)">┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐</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 |
|
|
@ -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:
|
||||
|
|
|
|||
43
tests/snapshots/test_ui_snapshot_plan_file_message.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
33
tests/test_agent_loop_refresh_system_prompt.py
Normal 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
|
||||
|
|
@ -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 "")
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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", {})
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
166
tests/test_permission_store.py
Normal 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
|
||||
82
tests/test_prepare_release.py
Normal 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
|
||||
|
|
@ -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?"
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,6 +141,12 @@ def load_session(
|
|||
session_to_load = None
|
||||
if args.continue_session:
|
||||
cwd = Path.cwd().resolve()
|
||||
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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
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)
|
||||
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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,6 +139,13 @@ class ChatTextArea(TextArea):
|
|||
)
|
||||
|
||||
if should_intercept:
|
||||
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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||