2.0.0
Co-Authored-By: Quentin Torroba <quentin.torroba@mistral.ai> Co-Authored-By: Michel Thomazo <michel.thomazo@mistral.ai> Co-Authored-By: Clément Drouin <clement.drouin@mistral.ai> Co-Authored-By: Vincent Guilloux <vincent.guilloux@mistral.ai> Co-Authored-By: Clément Siriex <clement.sirieix@mistral.ai> Co-Authored-By: Kim-Adeline Miguel <kimadeline.miguel@mistral.ai> Co-Authored-By: Thaddee Tyl <thaddee.tyl@gmail.com> Co-Authored-By: David Brochart <david.brochart@gmail.com> Co-Authored-By: Joseph Guhlin <joseph.guhlin@gmail.com> Co-Authored-By: Thomas Kenbeek <thomaskenbeek@gmail.com> Co-Authored-By: Remenby31 <baptiste.cruvellier31@gmail.com>
3
.github/workflows/ci.yml
vendored
@@ -15,6 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
@@ -54,6 +55,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
@@ -89,6 +91,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
|
||||
25
.vscode/launch.json
vendored
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.3.5",
|
||||
"version": "2.0.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "ACP Server",
|
||||
@@ -15,7 +15,10 @@
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"module": "pytest",
|
||||
"args": ["-v", "-s"],
|
||||
"args": [
|
||||
"-v",
|
||||
"-s"
|
||||
],
|
||||
"console": "integratedTerminal",
|
||||
"justMyCode": false,
|
||||
"cwd": "${workspaceFolder}",
|
||||
@@ -28,7 +31,13 @@
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"module": "pytest",
|
||||
"args": ["-k", "${input:test_identifier}", "-vvv", "-s", "--no-header"],
|
||||
"args": [
|
||||
"-k",
|
||||
"${input:test_identifier}",
|
||||
"-vvv",
|
||||
"-s",
|
||||
"--no-header"
|
||||
],
|
||||
"console": "integratedTerminal",
|
||||
"justMyCode": false,
|
||||
"cwd": "${workspaceFolder}",
|
||||
@@ -53,6 +62,16 @@
|
||||
"request": "attach",
|
||||
"processId": "${command:pickProcess}",
|
||||
"justMyCode": true
|
||||
},
|
||||
{
|
||||
"name": "Attach to Zed Process (Port)",
|
||||
"type": "debugpy",
|
||||
"request": "attach",
|
||||
"connect": {
|
||||
"host": "localhost",
|
||||
"port": 5678
|
||||
},
|
||||
"justMyCode": true
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
|
||||
46
CHANGELOG.md
@@ -5,6 +5,52 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [2.0.0] - 2026-01-27
|
||||
|
||||
### Added
|
||||
|
||||
- Subagent support
|
||||
- AskUserQuestion tool for interactive user input
|
||||
- User-defined slash commands through skills
|
||||
- What's new message display on version update
|
||||
- Auto-update feature
|
||||
- Environment variables and timeout support for MCP servers
|
||||
- Editor shortcut support
|
||||
- Shift+enter support for VS Code Insiders
|
||||
- Message ID property for messages
|
||||
- Client notification of compaction events
|
||||
- debugpy support for macOS debugging
|
||||
|
||||
### Changed
|
||||
|
||||
- Mode system refactored to Agents
|
||||
- Standardized managers
|
||||
- Improved system prompt
|
||||
- Updated session storage to separate metadata from messages
|
||||
- Use shell environment to determine shell in bash tool
|
||||
- Expanded user input handling
|
||||
- Bumped agent-client-protocol to 0.7.1
|
||||
- Refactored UI to require AgentLoop at VibeApp construction
|
||||
- Updated README with new MCP server config
|
||||
- Improved readability of the AskUserQuerstion tool output
|
||||
|
||||
### Fixed
|
||||
|
||||
- Use ensure_ascii=False for all JSON dumps
|
||||
- Delete long-living temporary session files
|
||||
- Ignore system prompt when saving/loading session messages
|
||||
- Bash tool timeout handling
|
||||
- Clipboard: no markup parsing of selected texts
|
||||
- Canonical imports
|
||||
- Remove last user message from compaction
|
||||
- Pause tool timer while awaiting user action
|
||||
|
||||
### Removed
|
||||
|
||||
- instructions.md support
|
||||
- workdir setting in config file
|
||||
|
||||
|
||||
## [1.3.5] - 2026-01-12
|
||||
|
||||
### Fixed
|
||||
|
||||
328
README.md
@@ -53,6 +53,39 @@ uv tool install mistral-vibe
|
||||
pip install mistral-vibe
|
||||
```
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Features](#features)
|
||||
- [Built-in Agents](#built-in-agents)
|
||||
- [Subagents and Task Delegation](#subagents-and-task-delegation)
|
||||
- [Interactive User Questions](#interactive-user-questions)
|
||||
- [Terminal Requirements](#terminal-requirements)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Usage](#usage)
|
||||
- [Interactive Mode](#interactive-mode)
|
||||
- [Trust Folder System](#trust-folder-system)
|
||||
- [Programmatic Mode](#programmatic-mode)
|
||||
- [Slash Commands](#slash-commands)
|
||||
- [Built-in Slash Commands](#built-in-slash-commands)
|
||||
- [Custom Slash Commands via Skills](#custom-slash-commands-via-skills)
|
||||
- [Skills System](#skills-system)
|
||||
- [Creating Skills](#creating-skills)
|
||||
- [Skill Discovery](#skill-discovery)
|
||||
- [Managing Skills](#managing-skills)
|
||||
- [Configuration](#configuration)
|
||||
- [Configuration File Location](#configuration-file-location)
|
||||
- [API Key Configuration](#api-key-configuration)
|
||||
- [Custom System Prompts](#custom-system-prompts)
|
||||
- [Custom Agent Configurations](#custom-agent-configurations)
|
||||
- [Tool Management](#tool-management)
|
||||
- [MCP Server Configuration](#mcp-server-configuration)
|
||||
- [Session Management](#session-management)
|
||||
- [Update Settings](#update-settings)
|
||||
- [Custom Vibe Home Directory](#custom-vibe-home-directory)
|
||||
- [Editors/IDEs](#editorsides)
|
||||
- [Resources](#resources)
|
||||
- [License](#license)
|
||||
|
||||
## Features
|
||||
|
||||
- **Interactive Chat**: A conversational AI agent that understands your requests and breaks down complex tasks.
|
||||
@@ -61,6 +94,8 @@ pip install mistral-vibe
|
||||
- Execute shell commands in a stateful terminal (`bash`).
|
||||
- Recursively search code with `grep` (with `ripgrep` support).
|
||||
- Manage a `todo` list to track the agent's work.
|
||||
- Ask interactive questions to gather user input (`ask_user_question`).
|
||||
- Delegate tasks to subagents for parallel work (`task`).
|
||||
- **Project-Aware Context**: Vibe automatically scans your project's file structure and Git status to provide relevant context to the agent, improving its understanding of your codebase.
|
||||
- **Advanced CLI Experience**: Built with modern libraries for a smooth and efficient workflow.
|
||||
- Autocompletion for slash commands (`/`) and file paths (`@`).
|
||||
@@ -68,6 +103,70 @@ pip install mistral-vibe
|
||||
- Beautiful Themes.
|
||||
- **Highly Configurable**: Customize models, providers, tool permissions, and UI preferences through a simple `config.toml` file.
|
||||
- **Safety First**: Features tool execution approval.
|
||||
- **Multiple Built-in Agents**: Choose from different agent profiles tailored for specific workflows.
|
||||
|
||||
### Built-in Agents
|
||||
|
||||
Vibe comes with several built-in agent profiles, each designed for different use cases:
|
||||
|
||||
- **`default`**: Standard agent that requires approval for tool executions. Best for general use.
|
||||
- **`plan`**: Read-only agent for exploration and planning. Auto-approves safe tools like `grep` and `read_file`.
|
||||
- **`accept-edits`**: Auto-approves file edits only (`write_file`, `search_replace`). Useful for code refactoring.
|
||||
- **`auto-approve`**: Auto-approves all tool executions. Use with caution.
|
||||
|
||||
Use the `--agent` flag to select a different agent:
|
||||
|
||||
```bash
|
||||
vibe --agent plan
|
||||
```
|
||||
|
||||
### Subagents and Task Delegation
|
||||
|
||||
Vibe supports subagents for delegating tasks. Subagents run independently and can perform specialized work without user interaction, preventing the context from being overloaded.
|
||||
|
||||
The `task` tool allows the agent to delegate work to subagents:
|
||||
|
||||
```
|
||||
> Can you explore the codebase structure while I work on something else?
|
||||
|
||||
🤖 I'll use the task tool to delegate this to the explore subagent.
|
||||
|
||||
> task(task="Analyze the project structure and architecture", agent="explore")
|
||||
```
|
||||
|
||||
Create custom subagents by adding `agent_type = "subagent"` to your agent configuration. Vibe comes with a built-in subagent called `explore`, a read-only subagent for codebase exploration used internally for delegation.
|
||||
|
||||
### Interactive User Questions
|
||||
|
||||
The `ask_user_question` tool allows the agent to ask you clarifying questions during its work. This enables more interactive and collaborative workflows.
|
||||
|
||||
```
|
||||
> Can you help me refactor this function?
|
||||
|
||||
🤖 I need to understand your requirements better before proceeding.
|
||||
|
||||
> ask_user_question(questions=[{
|
||||
"question": "What's the main goal of this refactoring?",
|
||||
"options": [
|
||||
{"label": "Performance", "description": "Make it run faster"},
|
||||
{"label": "Readability", "description": "Make it easier to understand"},
|
||||
{"label": "Maintainability", "description": "Make it easier to modify"}
|
||||
]
|
||||
}])
|
||||
```
|
||||
|
||||
The agent can ask multiple questions at once, displayed as tabs. Each question supports 2-4 options plus an automatic "Other" option for free text responses.
|
||||
|
||||
## Terminal Requirements
|
||||
|
||||
Vibe's interactive interface requires a modern terminal emulator. Recommended terminal emulators include:
|
||||
|
||||
- **WezTerm** (cross-platform)
|
||||
- **Alacritty** (cross-platform)
|
||||
- **Ghostty** (Linux and macOS)
|
||||
- **Kitty** (Linux and macOS)
|
||||
|
||||
Most modern terminals should work, but older or minimal terminal emulators may have display issues.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -89,6 +188,8 @@ pip install mistral-vibe
|
||||
- Prompt you to enter your API key if it's not already configured
|
||||
- Save your API key to `~/.vibe/.env` for future use
|
||||
|
||||
Alternatively, you can configure your API key separately using `vibe --setup`.
|
||||
|
||||
4. Start interacting with the agent!
|
||||
|
||||
```
|
||||
@@ -112,8 +213,12 @@ Simply run `vibe` to enter the interactive chat loop.
|
||||
- **Multi-line Input**: Press `Ctrl+J` or `Shift+Enter` for select terminals to insert a newline.
|
||||
- **File Paths**: Reference files in your prompt using the `@` symbol for smart autocompletion (e.g., `> Read the file @src/agent.py`).
|
||||
- **Shell Commands**: Prefix any command with `!` to execute it directly in your shell, bypassing the agent (e.g., `> !ls -l`).
|
||||
- **External Editor**: Press `Ctrl+G` to edit your current input in an external editor.
|
||||
- **Tool Output Toggle**: Press `Ctrl+O` to toggle the tool output view.
|
||||
- **Todo View Toggle**: Press `Ctrl+T` to toggle the todo list view.
|
||||
- **Auto-Approve Toggle**: Press `Shift+Tab` to toggle auto-approve mode on/off.
|
||||
|
||||
You can start Vibe with a prompt with the following command:
|
||||
You can start Vibe with a prompt using the following command:
|
||||
|
||||
```bash
|
||||
vibe "Refactor the main function in cli/main.py to be more modular."
|
||||
@@ -121,6 +226,14 @@ vibe "Refactor the main function in cli/main.py to be more modular."
|
||||
|
||||
**Note**: The `--auto-approve` flag automatically approves all tool executions without prompting. In interactive mode, you can also toggle auto-approve on/off using `Shift+Tab`.
|
||||
|
||||
### Trust Folder System
|
||||
|
||||
Vibe includes a trust folder system to ensure you only run the agent in directories you trust. When you first run Vibe in a new directory which contains a `.vibe` subfolder, it may ask you to confirm whether you trust the folder.
|
||||
|
||||
Trusted folders are remembered for future sessions. You can manage trusted folders through its configuration file `~/.vibe/trusted_folders.toml`.
|
||||
|
||||
This safety feature helps prevent accidental execution in sensitive directories.
|
||||
|
||||
### Programmatic Mode
|
||||
|
||||
You can run Vibe non-interactively by piping input or using the `--prompt` flag. This is useful for scripting.
|
||||
@@ -129,18 +242,126 @@ You can run Vibe non-interactively by piping input or using the `--prompt` flag.
|
||||
vibe --prompt "Refactor the main function in cli/main.py to be more modular."
|
||||
```
|
||||
|
||||
by default it will use `auto-approve` mode.
|
||||
By default, it uses `auto-approve` mode.
|
||||
|
||||
### Slash Commands
|
||||
#### Programmatic Mode Options
|
||||
|
||||
When using `--prompt`, you can specify additional options:
|
||||
|
||||
- **`--max-turns N`**: Limit the maximum number of assistant turns. The session will stop after N turns.
|
||||
- **`--max-price DOLLARS`**: Set a maximum cost limit in dollars. The session will be interrupted if the cost exceeds this limit.
|
||||
- **`--enabled-tools TOOL`**: Enable specific tools. In programmatic mode, this disables all other tools. Can be specified multiple times. Supports exact names, glob patterns (e.g., `bash*`), or regex with `re:` prefix (e.g., `re:^serena_.*$`).
|
||||
- **`--output FORMAT`**: Set the output format. Options:
|
||||
- `text` (default): Human-readable text output
|
||||
- `json`: All messages as JSON at the end
|
||||
- `streaming`: Newline-delimited JSON per message
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
vibe --prompt "Analyze the codebase" --max-turns 5 --max-price 1.0 --output json
|
||||
```
|
||||
|
||||
## Slash Commands
|
||||
|
||||
Use slash commands for meta-actions and configuration changes during a session.
|
||||
|
||||
### Built-in Slash Commands
|
||||
|
||||
Vibe provides several built-in slash commands. Use slash commands by typing them in the input box:
|
||||
|
||||
```
|
||||
> /help
|
||||
```
|
||||
|
||||
### Custom Slash Commands via Skills
|
||||
|
||||
You can define your own slash commands through the skills system. Skills are reusable components that extend Vibe's functionality.
|
||||
|
||||
To create a custom slash command:
|
||||
|
||||
1. Create a skill directory with a `SKILL.md` file
|
||||
2. Set `user-invocable = true` in the skill metadata
|
||||
3. Define the command logic in your skill
|
||||
|
||||
Example skill metadata:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: my-skill
|
||||
description: My custom skill with slash commands
|
||||
user-invocable: true
|
||||
---
|
||||
```
|
||||
|
||||
Custom slash commands appear in the autocompletion menu alongside built-in commands.
|
||||
|
||||
## Skills System
|
||||
|
||||
Vibe's skills system allows you to extend functionality through reusable components. Skills can add new tools, slash commands, and specialized behaviors.
|
||||
|
||||
Vibe follows the [Agent Skills specification](https://agentskills.io/specification) for skill format and structure.
|
||||
|
||||
### Creating Skills
|
||||
|
||||
Skills are defined in directories with a `SKILL.md` file containing metadata in YAML frontmatter. For example, `~/.vibe/skills/code-review/SKILL.md`:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: code-review
|
||||
description: Perform automated code reviews
|
||||
license: MIT
|
||||
compatibility: Python 3.12+
|
||||
user-invocable: true
|
||||
allowed-tools:
|
||||
- read_file
|
||||
- grep
|
||||
- ask_user_question
|
||||
---
|
||||
|
||||
# Code Review Skill
|
||||
|
||||
This skill helps analyze code quality and suggest improvements.
|
||||
```
|
||||
|
||||
### Skill Discovery
|
||||
|
||||
Vibe discovers skills from multiple locations:
|
||||
|
||||
1. **Global skills directory**: `~/.vibe/skills/`
|
||||
2. **Local project skills**: `.vibe/skills/` in your project
|
||||
3. **Custom paths**: Configured in `config.toml`
|
||||
|
||||
```toml
|
||||
skill_paths = ["/path/to/custom/skills"]
|
||||
```
|
||||
|
||||
### Managing Skills
|
||||
|
||||
Enable or disable skills using patterns in your configuration:
|
||||
|
||||
```toml
|
||||
# Enable specific skills
|
||||
enabled_skills = ["code-review", "test-*"]
|
||||
|
||||
# Disable specific skills
|
||||
disabled_skills = ["experimental-*"]
|
||||
```
|
||||
|
||||
Skills support the same pattern matching as tools (exact names, glob patterns, and regex).
|
||||
|
||||
## Configuration
|
||||
|
||||
### Configuration File Location
|
||||
|
||||
Vibe is configured via a `config.toml` file. It looks for this file first in `./.vibe/config.toml` and then falls back to `~/.vibe/config.toml`.
|
||||
|
||||
### API Key Configuration
|
||||
|
||||
To use Vibe, you'll need a Mistral API key. You can obtain one by signing up at [https://console.mistral.ai](https://console.mistral.ai).
|
||||
|
||||
You can configure your API key using `vibe --setup`, or through one of the methods below.
|
||||
|
||||
Vibe supports multiple ways to configure your API keys:
|
||||
|
||||
1. **Interactive Setup (Recommended for first-time users)**: When you run Vibe for the first time or if your API key is missing, Vibe will prompt you to enter it. The key will be securely saved to `~/.vibe/.env` for future sessions.
|
||||
@@ -204,7 +425,32 @@ permission = "always"
|
||||
permission = "always"
|
||||
```
|
||||
|
||||
Note: this implies that you have setup a redteam prompt names `~/.vibe/prompts/redteam.md`
|
||||
Note: This implies that you have set up a redteam prompt named `~/.vibe/prompts/redteam.md`.
|
||||
|
||||
### Tool Management
|
||||
|
||||
#### Enable/Disable Tools with Patterns
|
||||
|
||||
You can control which tools are active using `enabled_tools` and `disabled_tools`.
|
||||
These fields support exact names, glob patterns, and regular expressions.
|
||||
|
||||
Examples:
|
||||
|
||||
```toml
|
||||
# Only enable tools that start with "serena_" (glob)
|
||||
enabled_tools = ["serena_*"]
|
||||
|
||||
# Regex (prefix with re:) — matches full tool name (case-insensitive)
|
||||
enabled_tools = ["re:^serena_.*$"]
|
||||
|
||||
# Disable a group with glob; everything else stays enabled
|
||||
disabled_tools = ["mcp_*", "grep"]
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- MCP tool names use underscores, e.g., `serena_list` not `serena.list`.
|
||||
- Regex patterns are matched against the full tool name using fullmatch.
|
||||
|
||||
### MCP Server Configuration
|
||||
|
||||
@@ -232,6 +478,7 @@ name = "fetch_server"
|
||||
transport = "stdio"
|
||||
command = "uvx"
|
||||
args = ["mcp-server-fetch"]
|
||||
env = { "DEBUG" = "1", "LOG_LEVEL" = "info" }
|
||||
```
|
||||
|
||||
Supported transports:
|
||||
@@ -249,6 +496,9 @@ Key fields:
|
||||
- `api_key_env`: Environment variable containing the API key
|
||||
- `command`: Command to run for stdio transport
|
||||
- `args`: Additional arguments for stdio transport
|
||||
- `startup_timeout_sec`: Timeout in seconds for the server to start and initialize (default 10s)
|
||||
- `tool_timeout_sec`: Timeout in seconds for tool execution (default 60s)
|
||||
- `env`: Environment variables to set for the MCP server of transport type stdio
|
||||
|
||||
MCP tools are named using the pattern `{server_name}_{tool_name}` and can be configured with permissions like built-in tools:
|
||||
|
||||
@@ -261,31 +511,63 @@ permission = "always"
|
||||
permission = "ask"
|
||||
```
|
||||
|
||||
### Enable/disable tools with patterns
|
||||
MCP server configurations support additional features:
|
||||
|
||||
You can control which tools are active using `enabled_tools` and `disabled_tools`.
|
||||
These fields support exact names, glob patterns, and regular expressions.
|
||||
- **Environment variables**: Set environment variables for MCP servers
|
||||
- **Custom timeouts**: Configure startup and tool execution timeouts
|
||||
|
||||
Examples:
|
||||
Example with environment variables and timeouts:
|
||||
|
||||
```toml
|
||||
# Only enable tools that start with "serena_" (glob)
|
||||
enabled_tools = ["serena_*"]
|
||||
|
||||
# Regex (prefix with re:) — matches full tool name (case-insensitive)
|
||||
enabled_tools = ["re:^serena_.*$"]
|
||||
|
||||
# Heuristic regex support (patterns like `serena.*` are treated as regex)
|
||||
enabled_tools = ["serena.*"]
|
||||
|
||||
# Disable a group with glob; everything else stays enabled
|
||||
disabled_tools = ["mcp_*", "grep"]
|
||||
[[mcp_servers]]
|
||||
name = "my_server"
|
||||
transport = "http"
|
||||
url = "http://localhost:8000"
|
||||
env = { "DEBUG" = "1", "LOG_LEVEL" = "info" }
|
||||
startup_timeout_sec = 15
|
||||
tool_timeout_sec = 120
|
||||
```
|
||||
|
||||
Notes:
|
||||
### Session Management
|
||||
|
||||
- MCP tool names use underscores, e.g., `serena_list` not `serena.list`.
|
||||
- Regex patterns are matched against the full tool name using fullmatch.
|
||||
#### Session Continuation and Resumption
|
||||
|
||||
Vibe supports continuing from previous sessions:
|
||||
|
||||
- **`--continue`** or **`-c`**: Continue from the most recent saved session
|
||||
- **`--resume SESSION_ID`**: Resume a specific session by ID (supports partial matching)
|
||||
|
||||
```bash
|
||||
# Continue from last session
|
||||
vibe --continue
|
||||
|
||||
# Resume specific session
|
||||
vibe --resume abc123
|
||||
```
|
||||
|
||||
Session logging must be enabled in your configuration for these features to work.
|
||||
|
||||
#### Working Directory Control
|
||||
|
||||
Use the `--workdir` option to specify a working directory:
|
||||
|
||||
```bash
|
||||
vibe --workdir /path/to/project
|
||||
```
|
||||
|
||||
This is useful when you want to run Vibe from a different location than your current directory.
|
||||
|
||||
### Update Settings
|
||||
|
||||
#### Auto-Update
|
||||
|
||||
Vibe includes an automatic update feature that keeps your installation current. This is enabled by default.
|
||||
|
||||
To disable auto-updates, add this to your `config.toml`:
|
||||
|
||||
```toml
|
||||
enable_auto_update = false
|
||||
```
|
||||
|
||||
### Custom Vibe Home Directory
|
||||
|
||||
@@ -302,7 +584,7 @@ This affects where Vibe looks for:
|
||||
- `agents/` - Custom agent configurations
|
||||
- `prompts/` - Custom system prompts
|
||||
- `tools/` - Custom tools
|
||||
- `logs/` - Session logsRetryTo run code, enable code execution and file creation in Settings > Capabilities.
|
||||
- `logs/` - Session logs
|
||||
|
||||
## Editors/IDEs
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "mistral-vibe"
|
||||
name = "Mistral Vibe"
|
||||
description = "Mistral's open-source coding assistant"
|
||||
version = "1.3.5"
|
||||
version = "2.0.0"
|
||||
schema_version = 1
|
||||
authors = ["Mistral AI"]
|
||||
repository = "https://github.com/mistralai/mistral-vibe"
|
||||
@@ -11,25 +11,25 @@ name = "Mistral Vibe"
|
||||
icon = "./icons/mistral_vibe.svg"
|
||||
|
||||
[agent_servers.mistral-vibe.targets.darwin-aarch64]
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.5/vibe-acp-darwin-aarch64-1.3.5.zip"
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.0.0/vibe-acp-darwin-aarch64-2.0.0.zip"
|
||||
cmd = "./vibe-acp"
|
||||
|
||||
[agent_servers.mistral-vibe.targets.darwin-x86_64]
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.5/vibe-acp-darwin-x86_64-1.3.5.zip"
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.0.0/vibe-acp-darwin-x86_64-2.0.0.zip"
|
||||
cmd = "./vibe-acp"
|
||||
|
||||
[agent_servers.mistral-vibe.targets.linux-aarch64]
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.5/vibe-acp-linux-aarch64-1.3.5.zip"
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.0.0/vibe-acp-linux-aarch64-2.0.0.zip"
|
||||
cmd = "./vibe-acp"
|
||||
|
||||
[agent_servers.mistral-vibe.targets.linux-x86_64]
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.5/vibe-acp-linux-x86_64-1.3.5.zip"
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.0.0/vibe-acp-linux-x86_64-2.0.0.zip"
|
||||
cmd = "./vibe-acp"
|
||||
|
||||
[agent_servers.mistral-vibe.targets.windows-aarch64]
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.5/vibe-acp-windows-aarch64-1.3.5.zip"
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.0.0/vibe-acp-windows-aarch64-2.0.0.zip"
|
||||
cmd = "./vibe-acp.exe"
|
||||
|
||||
[agent_servers.mistral-vibe.targets.windows-x86_64]
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.5/vibe-acp-windows-x86_64-1.3.5.zip"
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.0.0/vibe-acp-windows-x86_64-2.0.0.zip"
|
||||
cmd = "./vibe-acp.exe"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "mistral-vibe"
|
||||
version = "1.3.5"
|
||||
version = "2.0.0"
|
||||
description = "Minimal CLI coding agent by Mistral"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
@@ -27,8 +27,8 @@ classifiers = [
|
||||
"Topic :: Utilities",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-client-protocol==0.6.3",
|
||||
"aiofiles>=24.1.0",
|
||||
"agent-client-protocol==0.7.1",
|
||||
"anyio>=4.12.0",
|
||||
"httpx>=0.28.1",
|
||||
"mcp>=1.14.0",
|
||||
"mistralai==1.9.11",
|
||||
@@ -90,6 +90,7 @@ dev = [
|
||||
"typos>=1.34.0",
|
||||
"vulture>=2.14",
|
||||
"pytest-xdist>=3.8.0",
|
||||
"debugpy>=1.8.19",
|
||||
]
|
||||
|
||||
build = ["pyinstaller>=6.17.0"]
|
||||
|
||||
@@ -10,6 +10,8 @@ This script increments the version in pyproject.toml based on the specified bump
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from datetime import date
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
@@ -50,7 +52,6 @@ def update_hard_values_files(filepath: str, patterns: list[tuple[str, str]]) ->
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"{filepath} not found in current directory")
|
||||
|
||||
# Replace patterns
|
||||
for pattern, replacement in patterns:
|
||||
content = path.read_text()
|
||||
updated_content = re.sub(pattern, replacement, content, flags=re.MULTILINE)
|
||||
@@ -71,7 +72,6 @@ def get_current_version() -> str:
|
||||
|
||||
content = pyproject_path.read_text()
|
||||
|
||||
# Find version line
|
||||
version_match = re.search(r'^version = "([^"]+)"$', content, re.MULTILINE)
|
||||
if not version_match:
|
||||
raise ValueError("Version not found in pyproject.toml")
|
||||
@@ -79,7 +79,60 @@ def get_current_version() -> str:
|
||||
return version_match.group(1)
|
||||
|
||||
|
||||
def update_changelog(new_version: str) -> None:
|
||||
changelog_path = Path("CHANGELOG.md")
|
||||
|
||||
if not changelog_path.exists():
|
||||
raise FileNotFoundError("CHANGELOG.md not found in current directory")
|
||||
|
||||
content = changelog_path.read_text()
|
||||
today = date.today().isoformat()
|
||||
|
||||
first_entry_match = re.search(r"^## \[[\d.]+\]", content, re.MULTILINE)
|
||||
if not first_entry_match:
|
||||
raise ValueError("Could not find version entry in CHANGELOG.md")
|
||||
|
||||
insert_position = first_entry_match.start()
|
||||
|
||||
new_entry = f"## [{new_version}] - {today}\n\n"
|
||||
new_entry += "### Added\n\n"
|
||||
new_entry += "### Changed\n\n"
|
||||
new_entry += "### Fixed\n\n"
|
||||
new_entry += "### Removed\n\n"
|
||||
new_entry += "\n"
|
||||
|
||||
updated_content = content[:insert_position] + new_entry + content[insert_position:]
|
||||
changelog_path.write_text(updated_content)
|
||||
|
||||
print(f"Added changelog entry for version {new_version}")
|
||||
|
||||
|
||||
def print_warning(new_version: str) -> None:
|
||||
warning = f"""
|
||||
{"=" * 80}
|
||||
⚠️ WARNING: CHANGELOG UPDATE REQUIRED ⚠️
|
||||
{"=" * 80}
|
||||
|
||||
Don't forget to fill in the changelog entry for version {new_version} in CHANGELOG.md! 📝
|
||||
|
||||
Also, remember to fill in vibe/whats_new.md if needed (you can leave it blank). 📝
|
||||
|
||||
{"=" * 80}
|
||||
"""
|
||||
print(warning, file=sys.stderr)
|
||||
|
||||
|
||||
def clean_up_whats_new_message() -> None:
|
||||
whats_new_path = Path("vibe/whats_new.md")
|
||||
if not whats_new_path.exists():
|
||||
raise FileNotFoundError("whats_new.md not found in current directory")
|
||||
|
||||
whats_new_path.write_text("")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
os.chdir(Path(__file__).parent.parent)
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Bump semver version in pyproject.toml",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
@@ -140,9 +193,15 @@ Examples:
|
||||
[(f'version="{current_version}"', f'version="{new_version}"')],
|
||||
)
|
||||
|
||||
# Update CHANGELOG.md
|
||||
update_changelog(new_version)
|
||||
|
||||
clean_up_whats_new_message()
|
||||
|
||||
subprocess.run(["uv", "lock"], check=True)
|
||||
|
||||
print(f"\nSuccessfully bumped version from {current_version} to {new_version}")
|
||||
print_warning(new_version)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
|
||||
284
scripts/prepare_release.py
Executable file
@@ -0,0 +1,284 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def run_git_command(
|
||||
*args: str, check: bool = True, capture_output: bool = False
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
"""Run a git command and return the result."""
|
||||
result = subprocess.run(
|
||||
["git"] + list(args), check=check, capture_output=capture_output, text=True
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def ensure_public_remote() -> None:
|
||||
result = run_git_command("remote", "-v", capture_output=True, check=False)
|
||||
remotes = result.stdout
|
||||
|
||||
public_remote_url = "git@github.com:mistralai/mistral-vibe.git"
|
||||
if public_remote_url in remotes:
|
||||
print("Public remote already exists with correct URL")
|
||||
return
|
||||
|
||||
print(f"Creating public remote: {public_remote_url}")
|
||||
run_git_command("remote", "add", "public", public_remote_url)
|
||||
print("Public remote created successfully")
|
||||
|
||||
|
||||
def switch_to_tag(version: str) -> None:
|
||||
tag = f"v{version}"
|
||||
print(f"Switching to tag {tag}...")
|
||||
|
||||
result = run_git_command(
|
||||
"rev-parse", "--verify", tag, capture_output=True, check=False
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise ValueError(f"Tag {tag} does not exist")
|
||||
|
||||
run_git_command("switch", "--detach", tag)
|
||||
print(f"Successfully switched to tag {tag}")
|
||||
|
||||
|
||||
def get_version_from_pyproject() -> str:
|
||||
pyproject_path = Path("pyproject.toml")
|
||||
if not pyproject_path.exists():
|
||||
raise FileNotFoundError("pyproject.toml not found in current directory")
|
||||
|
||||
content = pyproject_path.read_text()
|
||||
version_match = re.search(r'^version = "([^"]+)"$', content, re.MULTILINE)
|
||||
if not version_match:
|
||||
raise ValueError("Version not found in pyproject.toml")
|
||||
|
||||
return version_match.group(1)
|
||||
|
||||
|
||||
def get_latest_version() -> str:
|
||||
result = run_git_command("ls-remote", "--tags", "public", capture_output=True)
|
||||
remote_tags_output = (
|
||||
result.stdout.strip().split("\n") if result.stdout.strip() else []
|
||||
)
|
||||
|
||||
if not remote_tags_output:
|
||||
raise ValueError("No version tags found on public remote")
|
||||
|
||||
versions: list[tuple[int, int, int, str]] = []
|
||||
MIN_PARTS_IN_LS_REMOTE_LINE = 2 # hash and ref
|
||||
for line in remote_tags_output:
|
||||
parts = line.split()
|
||||
if len(parts) < MIN_PARTS_IN_LS_REMOTE_LINE:
|
||||
continue
|
||||
|
||||
_hash, tag_ref = parts[0], parts[1]
|
||||
if not tag_ref.startswith("refs/tags/"):
|
||||
continue
|
||||
|
||||
tag = tag_ref.replace("refs/tags/", "")
|
||||
match = re.match(r"^v(\d+\.\d+\.\d+)$", tag)
|
||||
if not match:
|
||||
continue
|
||||
|
||||
tag_version = match.group(1)
|
||||
try:
|
||||
major, minor, patch = parse_version(tag_version)
|
||||
versions.append((major, minor, patch, tag_version))
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if not versions:
|
||||
raise ValueError(
|
||||
"No valid version tags found on public remote (format: vX.Y.Z)"
|
||||
)
|
||||
|
||||
versions.sort()
|
||||
|
||||
return max(versions)[3]
|
||||
|
||||
|
||||
def parse_version(version_str: str) -> tuple[int, int, int]:
|
||||
match = re.match(r"^(\d+)\.(\d+)\.(\d+)$", version_str.strip())
|
||||
if not match:
|
||||
raise ValueError(f"Invalid version format: {version_str}")
|
||||
|
||||
return int(match.group(1)), int(match.group(2)), int(match.group(3))
|
||||
|
||||
|
||||
def create_release_branch(version: str) -> None:
|
||||
branch_name = f"release/v{version}"
|
||||
print(f"Creating release branch: {branch_name}")
|
||||
|
||||
result = run_git_command(
|
||||
"branch", "--list", branch_name, capture_output=True, check=False
|
||||
)
|
||||
if result.stdout.strip():
|
||||
print(f"Warning: Branch {branch_name} already exists", file=sys.stderr)
|
||||
response = input(f"Delete and recreate {branch_name}? (y/N): ")
|
||||
if response.lower() == "y":
|
||||
run_git_command("branch", "-D", branch_name)
|
||||
else:
|
||||
print("Aborting", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
run_git_command("switch", "-c", branch_name)
|
||||
print(f"Created and switched to branch {branch_name}")
|
||||
|
||||
|
||||
def cherry_pick_commits(previous_version: str, current_version: str) -> None:
|
||||
previous_tag = f"v{previous_version}-private"
|
||||
current_tag = f"v{current_version}-private"
|
||||
|
||||
result = run_git_command(
|
||||
"rev-parse", "--verify", previous_tag, capture_output=True, check=False
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise ValueError(f"Tag {previous_tag} does not exist")
|
||||
|
||||
result = run_git_command(
|
||||
"rev-parse", "--verify", current_tag, capture_output=True, check=False
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise ValueError(f"Tag {current_tag} does not exist")
|
||||
|
||||
print(f"Cherry-picking commits from {previous_tag}..{current_tag}...")
|
||||
run_git_command("cherry-pick", f"{previous_tag}..{current_tag}")
|
||||
print("Successfully cherry-picked all commits")
|
||||
|
||||
|
||||
def get_commits_summary(previous_version: str, current_version: str) -> str:
|
||||
previous_tag = f"v{previous_version}-private"
|
||||
current_tag = f"v{current_version}-private"
|
||||
|
||||
result = run_git_command(
|
||||
"log", f"{previous_tag}..{current_tag}", "--oneline", capture_output=True
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def get_changelog_entry(version: str) -> str:
|
||||
changelog_path = Path("CHANGELOG.md")
|
||||
if not changelog_path.exists():
|
||||
return "CHANGELOG.md not found"
|
||||
|
||||
content = changelog_path.read_text()
|
||||
|
||||
pattern = rf"^## \[{re.escape(version)}\] - .+?(?=^## \[|\Z)"
|
||||
match = re.search(pattern, content, re.MULTILINE | re.DOTALL)
|
||||
|
||||
if not match:
|
||||
return f"No changelog entry found for version {version}"
|
||||
|
||||
return match.group(0).strip()
|
||||
|
||||
|
||||
def print_summary(
|
||||
current_version: str,
|
||||
previous_version: str,
|
||||
commits_summary: str,
|
||||
changelog_entry: str,
|
||||
) -> None:
|
||||
print("\n" + "=" * 80)
|
||||
print("RELEASE PREPARATION SUMMARY")
|
||||
print("=" * 80)
|
||||
print(f"\nVersion: {current_version}")
|
||||
print(f"Previous version: {previous_version}")
|
||||
print(f"Release branch: release/v{current_version}")
|
||||
|
||||
print("\n" + "-" * 80)
|
||||
print("COMMITS IN THIS RELEASE")
|
||||
print("-" * 80)
|
||||
if commits_summary:
|
||||
print(commits_summary)
|
||||
else:
|
||||
print("No commits found")
|
||||
|
||||
print("\n" + "-" * 80)
|
||||
print("CHANGELOG ENTRY")
|
||||
print("-" * 80)
|
||||
print(changelog_entry)
|
||||
|
||||
print("\n" + "-" * 80)
|
||||
print("NEXT STEPS")
|
||||
print("-" * 80)
|
||||
print(
|
||||
f"To review/edit commits before publishing, use interactive rebase:\n"
|
||||
f" git rebase -i v{previous_version}"
|
||||
)
|
||||
|
||||
print("\n" + "-" * 80)
|
||||
print("REMINDERS")
|
||||
print("-" * 80)
|
||||
print("Before publishing the release:")
|
||||
print(" ✓ Credit any public contributors in the release notes")
|
||||
print(" ✓ Close related issues once the release is published")
|
||||
print(
|
||||
" ✓ Review and update the changelog if needed "
|
||||
"(should be made in the private main branch)"
|
||||
)
|
||||
print("\n" + "=" * 80)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Prepare a release branch by cherry-picking from private tags",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
|
||||
parser.add_argument("version", help="Version to prepare release for (e.g., 1.1.3)")
|
||||
|
||||
args = parser.parse_args()
|
||||
current_version = args.version
|
||||
|
||||
try:
|
||||
# Step 1: Ensure public remote exists
|
||||
ensure_public_remote()
|
||||
|
||||
# Step 2: Fetch all remotes
|
||||
print("Fetching all remotes...")
|
||||
run_git_command("fetch", "--all")
|
||||
print("Successfully fetched all remotes")
|
||||
|
||||
# Step 3: Find latest version
|
||||
previous_version = get_latest_version()
|
||||
print(f"Previous version: {previous_version}")
|
||||
|
||||
# Step 4: Verify version matches pyproject.toml
|
||||
pyproject_version = get_version_from_pyproject()
|
||||
if current_version != pyproject_version:
|
||||
raise ValueError(
|
||||
f"Version mismatch: provided version '{current_version}' does not match "
|
||||
f"pyproject.toml version '{pyproject_version}'"
|
||||
)
|
||||
print(f"Version verified: {current_version}")
|
||||
|
||||
# Step 5: Switch to previous version tag
|
||||
switch_to_tag(previous_version)
|
||||
|
||||
# Step 6: Create release branch
|
||||
create_release_branch(current_version)
|
||||
|
||||
# Step 7: Cherry-pick commits
|
||||
cherry_pick_commits(previous_version, current_version)
|
||||
|
||||
# Step 8: Get summary information
|
||||
commits_summary = get_commits_summary(previous_version, current_version)
|
||||
changelog_entry = get_changelog_entry(current_version)
|
||||
|
||||
# Step 9: Print summary
|
||||
print_summary(
|
||||
current_version, previous_version, commits_summary, changelog_entry
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
42
tests/acp/conftest.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.stubs.fake_backend import FakeBackend
|
||||
from tests.stubs.fake_client import FakeClient
|
||||
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
|
||||
from vibe.core.agent_loop import AgentLoop
|
||||
from vibe.core.types import LLMChunk, LLMMessage, LLMUsage, Role
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def backend() -> FakeBackend:
|
||||
backend = FakeBackend(
|
||||
LLMChunk(
|
||||
message=LLMMessage(role=Role.assistant, content="Hi"),
|
||||
usage=LLMUsage(prompt_tokens=1, completion_tokens=1),
|
||||
)
|
||||
)
|
||||
return backend
|
||||
|
||||
|
||||
def _create_acp_agent() -> VibeAcpAgentLoop:
|
||||
vibe_acp_agent = VibeAcpAgentLoop()
|
||||
client = FakeClient()
|
||||
|
||||
vibe_acp_agent.on_connect(client)
|
||||
client.on_connect(vibe_acp_agent)
|
||||
|
||||
return vibe_acp_agent # pyright: ignore[reportReturnType]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def acp_agent_loop(backend: FakeBackend) -> VibeAcpAgentLoop:
|
||||
class PatchedAgent(AgentLoop):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs, backend=backend)
|
||||
|
||||
patch("vibe.acp.acp_agent_loop.AgentLoop", side_effect=PatchedAgent).start()
|
||||
return _create_acp_agent()
|
||||
@@ -189,7 +189,7 @@ class WriteTextFileJsonRpcResponse(JsonRpcResponse):
|
||||
result: None = None
|
||||
|
||||
|
||||
async def get_acp_agent_process(
|
||||
async def get_acp_agent_loop_process(
|
||||
mock_env: dict[str, str], vibe_home: Path
|
||||
) -> AsyncGenerator[asyncio.subprocess.Process]:
|
||||
current_env = os.environ.copy()
|
||||
@@ -358,43 +358,43 @@ def parse_conversation(message_texts: list[str]) -> list[JsonRpcMessage]:
|
||||
return parsed_messages
|
||||
|
||||
|
||||
async def initialize_session(acp_agent_process: asyncio.subprocess.Process) -> str:
|
||||
async def initialize_session(acp_agent_loop_process: asyncio.subprocess.Process) -> str:
|
||||
await send_json_rpc(
|
||||
acp_agent_process,
|
||||
InitializeJsonRpcRequest(id=1, params=InitializeRequest(protocolVersion=1)),
|
||||
acp_agent_loop_process,
|
||||
InitializeJsonRpcRequest(id=1, params=InitializeRequest(protocol_version=1)),
|
||||
)
|
||||
initialize_response = await read_response_for_id(
|
||||
acp_agent_process, expected_id=1, timeout=5.0
|
||||
acp_agent_loop_process, expected_id=1, timeout=5.0
|
||||
)
|
||||
assert initialize_response is not None
|
||||
|
||||
await send_json_rpc(
|
||||
acp_agent_process,
|
||||
acp_agent_loop_process,
|
||||
NewSessionJsonRpcRequest(
|
||||
id=2, params=NewSessionRequest(cwd=str(PLAYGROUND_DIR), mcpServers=[])
|
||||
id=2, params=NewSessionRequest(cwd=str(PLAYGROUND_DIR), mcp_servers=[])
|
||||
),
|
||||
)
|
||||
session_response = await read_response_for_id(acp_agent_process, expected_id=2)
|
||||
session_response = await read_response_for_id(acp_agent_loop_process, expected_id=2)
|
||||
assert session_response is not None
|
||||
session_response_json = json.loads(session_response)
|
||||
session_response_obj = NewSessionJsonRpcResponse.model_validate(
|
||||
session_response_json
|
||||
)
|
||||
assert session_response_obj.result is not None, "No result in response"
|
||||
return session_response_obj.result.sessionId
|
||||
return session_response_obj.result.session_id
|
||||
|
||||
|
||||
class TestSessionManagement:
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_sessions_unique_ids(self, vibe_home_dir: Path) -> None:
|
||||
mock_env = get_mocking_env(mock_chunks=[mock_llm_chunk() for _ in range(3)])
|
||||
async for process in get_acp_agent_process(
|
||||
async for process in get_acp_agent_loop_process(
|
||||
mock_env=mock_env, vibe_home=vibe_home_dir
|
||||
):
|
||||
await send_json_rpc(
|
||||
process,
|
||||
InitializeJsonRpcRequest(
|
||||
id=1, params=InitializeRequest(protocolVersion=1)
|
||||
id=1, params=InitializeRequest(protocol_version=1)
|
||||
),
|
||||
)
|
||||
await read_response_for_id(process, expected_id=1, timeout=5.0)
|
||||
@@ -406,7 +406,7 @@ class TestSessionManagement:
|
||||
NewSessionJsonRpcRequest(
|
||||
id=i + 2,
|
||||
params=NewSessionRequest(
|
||||
cwd=str(PLAYGROUND_DIR), mcpServers=[]
|
||||
cwd=str(PLAYGROUND_DIR), mcp_servers=[]
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -418,16 +418,18 @@ class TestSessionManagement:
|
||||
response = NewSessionJsonRpcResponse.model_validate(response_json)
|
||||
assert response.error is None, f"JSON-RPC error: {response.error}"
|
||||
assert response.result is not None, "No result in response"
|
||||
session_ids.append(response.result.sessionId)
|
||||
session_ids.append(response.result.session_id)
|
||||
|
||||
assert len(set(session_ids)) == 3
|
||||
|
||||
|
||||
class TestSessionUpdates:
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_message_chunk_structure(self, vibe_home_dir: Path) -> None:
|
||||
async def test_agent_loop_message_chunk_structure(
|
||||
self, vibe_home_dir: Path
|
||||
) -> None:
|
||||
mock_env = get_mocking_env([mock_llm_chunk(content="Hi")])
|
||||
async for process in get_acp_agent_process(
|
||||
async for process in get_acp_agent_loop_process(
|
||||
mock_env=mock_env, vibe_home=vibe_home_dir
|
||||
):
|
||||
# Check stderr for error details if process failed
|
||||
@@ -444,11 +446,19 @@ class TestSessionUpdates:
|
||||
PromptJsonRpcRequest(
|
||||
id=3,
|
||||
params=PromptRequest(
|
||||
sessionId=session_id,
|
||||
session_id=session_id,
|
||||
prompt=[TextContentBlock(type="text", text="Just say hi")],
|
||||
),
|
||||
),
|
||||
)
|
||||
user_response_text = await read_response(process)
|
||||
assert user_response_text is not None
|
||||
user_response = UpdateJsonRpcNotification.model_validate(
|
||||
json.loads(user_response_text)
|
||||
)
|
||||
assert user_response.params is not None
|
||||
assert user_response.params.update.session_update == "user_message_chunk"
|
||||
|
||||
text_response = await read_response(process)
|
||||
assert text_response is not None
|
||||
response = UpdateJsonRpcNotification.model_validate(
|
||||
@@ -456,8 +466,9 @@ class TestSessionUpdates:
|
||||
)
|
||||
|
||||
assert response.params is not None
|
||||
assert response.params.update.sessionUpdate == "agent_message_chunk"
|
||||
assert response.params.update.session_update == "agent_message_chunk"
|
||||
assert response.params.update.content is not None
|
||||
assert isinstance(response.params.update.content, TextContentBlock)
|
||||
assert response.params.update.content.type == "text"
|
||||
assert response.params.update.content.text is not None
|
||||
assert response.params.update.content.text == "Hi"
|
||||
@@ -478,7 +489,7 @@ class TestSessionUpdates:
|
||||
),
|
||||
mock_llm_chunk(content="The files containing the pattern 'auth' are ..."),
|
||||
])
|
||||
async for process in get_acp_agent_process(
|
||||
async for process in get_acp_agent_loop_process(
|
||||
mock_env=mock_env, vibe_home=vibe_home_dir
|
||||
):
|
||||
session_id = await initialize_session(process)
|
||||
@@ -488,7 +499,7 @@ class TestSessionUpdates:
|
||||
PromptJsonRpcRequest(
|
||||
id=3,
|
||||
params=PromptRequest(
|
||||
sessionId=session_id,
|
||||
session_id=session_id,
|
||||
prompt=[
|
||||
TextContentBlock(
|
||||
type="text",
|
||||
@@ -511,7 +522,7 @@ class TestSessionUpdates:
|
||||
for r in responses
|
||||
if isinstance(r, UpdateJsonRpcNotification)
|
||||
and r.params is not None
|
||||
and r.params.update.sessionUpdate == "tool_call"
|
||||
and r.params.update.session_update == "tool_call"
|
||||
),
|
||||
None,
|
||||
)
|
||||
@@ -519,11 +530,11 @@ class TestSessionUpdates:
|
||||
assert tool_call.params is not None
|
||||
assert tool_call.params.update is not None
|
||||
|
||||
assert tool_call.params.update.sessionUpdate == "tool_call"
|
||||
assert tool_call.params.update.session_update == "tool_call"
|
||||
assert tool_call.params.update.kind == "search"
|
||||
assert tool_call.params.update.title == "grep: 'auth'"
|
||||
assert tool_call.params.update.title == "Grepping 'auth'"
|
||||
assert (
|
||||
tool_call.params.update.rawInput
|
||||
tool_call.params.update.raw_input
|
||||
== '{"pattern":"auth","path":".","max_matches":null,"use_default_ignore":true}'
|
||||
)
|
||||
|
||||
@@ -537,7 +548,7 @@ async def start_session_with_request_permission(
|
||||
PromptJsonRpcRequest(
|
||||
id=3,
|
||||
params=PromptRequest(
|
||||
sessionId=session_id,
|
||||
session_id=session_id,
|
||||
prompt=[TextContentBlock(type="text", text=prompt)],
|
||||
),
|
||||
),
|
||||
@@ -575,7 +586,7 @@ class TestToolCallStructure:
|
||||
)
|
||||
]
|
||||
mock_env = get_mocking_env(custom_results)
|
||||
async for process in get_acp_agent_process(
|
||||
async for process in get_acp_agent_loop_process(
|
||||
mock_env=mock_env, vibe_home=vibe_home_grep_ask
|
||||
):
|
||||
session_id = await initialize_session(process)
|
||||
@@ -584,7 +595,7 @@ class TestToolCallStructure:
|
||||
PromptJsonRpcRequest(
|
||||
id=3,
|
||||
params=PromptRequest(
|
||||
sessionId=session_id,
|
||||
session_id=session_id,
|
||||
prompt=[
|
||||
TextContentBlock(
|
||||
type="text",
|
||||
@@ -611,8 +622,8 @@ class TestToolCallStructure:
|
||||
|
||||
first_request = permission_requests[0]
|
||||
assert first_request.params is not None
|
||||
assert first_request.params.toolCall is not None
|
||||
assert first_request.params.toolCall.toolCallId is not None
|
||||
assert first_request.params.tool_call is not None
|
||||
assert first_request.params.tool_call.tool_call_id is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_call_update_approved_structure(
|
||||
@@ -635,7 +646,7 @@ class TestToolCallStructure:
|
||||
mock_llm_chunk(content="The file test.txt has been created"),
|
||||
]
|
||||
mock_env = get_mocking_env(custom_results)
|
||||
async for process in get_acp_agent_process(
|
||||
async for process in get_acp_agent_loop_process(
|
||||
mock_env=mock_env, vibe_home=vibe_home_grep_ask
|
||||
):
|
||||
permission_request = await start_session_with_request_permission(
|
||||
@@ -649,7 +660,7 @@ class TestToolCallStructure:
|
||||
id=permission_request.id,
|
||||
result=RequestPermissionResponse(
|
||||
outcome=AllowedOutcome(
|
||||
outcome="selected", optionId=selected_option_id
|
||||
outcome="selected", option_id=selected_option_id
|
||||
)
|
||||
),
|
||||
),
|
||||
@@ -665,9 +676,9 @@ class TestToolCallStructure:
|
||||
and r.method == "session/update"
|
||||
and r.params is not None
|
||||
and r.params.update is not None
|
||||
and r.params.update.sessionUpdate == "tool_call_update"
|
||||
and r.params.update.toolCallId
|
||||
== (permission_request.params.toolCall.toolCallId)
|
||||
and r.params.update.session_update == "tool_call_update"
|
||||
and r.params.update.tool_call_id
|
||||
== (permission_request.params.tool_call.tool_call_id)
|
||||
and r.params.update.status == "completed"
|
||||
),
|
||||
None,
|
||||
@@ -697,7 +708,7 @@ class TestToolCallStructure:
|
||||
),
|
||||
]
|
||||
mock_env = get_mocking_env(custom_results)
|
||||
async for process in get_acp_agent_process(
|
||||
async for process in get_acp_agent_loop_process(
|
||||
mock_env=mock_env, vibe_home=vibe_home_grep_ask
|
||||
):
|
||||
permission_request = await start_session_with_request_permission(
|
||||
@@ -712,7 +723,7 @@ class TestToolCallStructure:
|
||||
id=permission_request.id,
|
||||
result=RequestPermissionResponse(
|
||||
outcome=AllowedOutcome(
|
||||
outcome="selected", optionId=selected_option_id
|
||||
outcome="selected", option_id=selected_option_id
|
||||
)
|
||||
),
|
||||
),
|
||||
@@ -727,9 +738,9 @@ class TestToolCallStructure:
|
||||
if isinstance(r, UpdateJsonRpcNotification)
|
||||
and r.method == "session/update"
|
||||
and r.params is not None
|
||||
and r.params.update.sessionUpdate == "tool_call_update"
|
||||
and r.params.update.toolCallId
|
||||
== (permission_request.params.toolCall.toolCallId)
|
||||
and r.params.update.session_update == "tool_call_update"
|
||||
and r.params.update.tool_call_id
|
||||
== (permission_request.params.tool_call.tool_call_id)
|
||||
and r.params.update.status == "failed"
|
||||
),
|
||||
None,
|
||||
@@ -758,7 +769,7 @@ class TestToolCallStructure:
|
||||
mock_llm_chunk(content="The command sleep 3 has been run"),
|
||||
]
|
||||
mock_env = get_mocking_env(custom_results)
|
||||
async for process in get_acp_agent_process(
|
||||
async for process in get_acp_agent_loop_process(
|
||||
mock_env=mock_env, vibe_home=vibe_home_grep_ask
|
||||
):
|
||||
session_id = await initialize_session(process)
|
||||
@@ -767,7 +778,7 @@ class TestToolCallStructure:
|
||||
PromptJsonRpcRequest(
|
||||
id=3,
|
||||
params=PromptRequest(
|
||||
sessionId=session_id,
|
||||
session_id=session_id,
|
||||
prompt=[
|
||||
TextContentBlock(
|
||||
type="text",
|
||||
@@ -786,7 +797,7 @@ class TestToolCallStructure:
|
||||
for r in responses
|
||||
if isinstance(r, UpdateJsonRpcNotification)
|
||||
and r.params is not None
|
||||
and r.params.update.sessionUpdate == "tool_call_update"
|
||||
and r.params.update.session_update == "tool_call_update"
|
||||
and r.params.update.status == "in_progress"
|
||||
]
|
||||
|
||||
@@ -817,7 +828,7 @@ class TestToolCallStructure:
|
||||
),
|
||||
]
|
||||
mock_env = get_mocking_env(custom_results)
|
||||
async for process in get_acp_agent_process(
|
||||
async for process in get_acp_agent_loop_process(
|
||||
mock_env=mock_env, vibe_home=vibe_home_grep_ask
|
||||
):
|
||||
permission_request = await start_session_with_request_permission(
|
||||
@@ -832,7 +843,7 @@ class TestToolCallStructure:
|
||||
id=permission_request.id,
|
||||
result=RequestPermissionResponse(
|
||||
outcome=AllowedOutcome(
|
||||
outcome="selected", optionId=selected_option_id
|
||||
outcome="selected", option_id=selected_option_id
|
||||
)
|
||||
),
|
||||
),
|
||||
@@ -847,10 +858,10 @@ class TestToolCallStructure:
|
||||
for r in responses
|
||||
if isinstance(r, UpdateJsonRpcNotification)
|
||||
and r.params is not None
|
||||
and r.params.update.sessionUpdate == "tool_call_update"
|
||||
and r.params.update.session_update == "tool_call_update"
|
||||
and r.params.update.status == "failed"
|
||||
and r.params.update.rawOutput is not None
|
||||
and r.params.update.toolCallId is not None
|
||||
and r.params.update.raw_output is not None
|
||||
and r.params.update.tool_call_id is not None
|
||||
),
|
||||
None,
|
||||
)
|
||||
@@ -888,7 +899,7 @@ class TestCancellationStructure:
|
||||
),
|
||||
]
|
||||
mock_env = get_mocking_env(custom_results)
|
||||
async for process in get_acp_agent_process(
|
||||
async for process in get_acp_agent_loop_process(
|
||||
mock_env=mock_env, vibe_home=vibe_home_dir
|
||||
):
|
||||
permission_request = await start_session_with_request_permission(
|
||||
@@ -920,9 +931,9 @@ class TestCancellationStructure:
|
||||
if isinstance(r, UpdateJsonRpcNotification)
|
||||
and r.method == "session/update"
|
||||
and r.params is not None
|
||||
and r.params.update.sessionUpdate == "tool_call_update"
|
||||
and r.params.update.toolCallId
|
||||
== (permission_request.params.toolCall.toolCallId)
|
||||
and r.params.update.session_update == "tool_call_update"
|
||||
and r.params.update.tool_call_id
|
||||
== (permission_request.params.tool_call.tool_call_id)
|
||||
and r.params.update.status == "failed"
|
||||
),
|
||||
None,
|
||||
@@ -935,7 +946,7 @@ class TestCancellationStructure:
|
||||
for r in responses
|
||||
if isinstance(r, PromptJsonRpcResponse)
|
||||
and r.result is not None
|
||||
and r.result.stopReason == "cancelled"
|
||||
and r.result.stop_reason == "cancelled"
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
@@ -2,9 +2,10 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from acp.schema import TerminalOutputResponse, WaitForTerminalExitResponse
|
||||
from acp.schema import EnvVariable, TerminalOutputResponse, WaitForTerminalExitResponse
|
||||
import pytest
|
||||
|
||||
from tests.mock.utils import collect_result
|
||||
from vibe.acp.tools.builtins.bash import AcpBashState, Bash
|
||||
from vibe.core.tools.base import ToolError
|
||||
from vibe.core.tools.builtins.bash import BashArgs, BashResult, BashToolConfig
|
||||
@@ -26,7 +27,7 @@ class MockTerminalHandle:
|
||||
|
||||
async def wait_for_exit(self) -> WaitForTerminalExitResponse:
|
||||
await asyncio.sleep(self._wait_delay)
|
||||
return WaitForTerminalExitResponse(exitCode=self._exit_code)
|
||||
return WaitForTerminalExitResponse(exit_code=self._exit_code)
|
||||
|
||||
async def current_output(self) -> TerminalOutputResponse:
|
||||
return TerminalOutputResponse(output=self._output, truncated=False)
|
||||
@@ -38,36 +39,72 @@ class MockTerminalHandle:
|
||||
pass
|
||||
|
||||
|
||||
class MockConnection:
|
||||
class MockClient:
|
||||
def __init__(self, terminal_handle: MockTerminalHandle | None = None) -> None:
|
||||
self._terminal_handle = terminal_handle or MockTerminalHandle()
|
||||
self._create_terminal_called = False
|
||||
self._session_update_called = False
|
||||
self._create_terminal_error: Exception | None = None
|
||||
self._last_create_request = None
|
||||
self._last_create_params: dict[
|
||||
str, str | list[str] | list[EnvVariable] | int | None
|
||||
] = {}
|
||||
|
||||
async def createTerminal(self, request) -> MockTerminalHandle:
|
||||
async def create_terminal(
|
||||
self,
|
||||
command: str,
|
||||
session_id: str,
|
||||
args: list[str] | None = None,
|
||||
cwd: str | None = None,
|
||||
env: list | None = None,
|
||||
output_byte_limit: int | None = None,
|
||||
**kwargs,
|
||||
) -> MockTerminalHandle:
|
||||
self._create_terminal_called = True
|
||||
self._last_create_request = request
|
||||
self._last_create_params = {
|
||||
"command": command,
|
||||
"session_id": session_id,
|
||||
"args": args,
|
||||
"cwd": cwd,
|
||||
"env": env,
|
||||
"output_byte_limit": output_byte_limit,
|
||||
}
|
||||
if self._create_terminal_error:
|
||||
raise self._create_terminal_error
|
||||
return self._terminal_handle
|
||||
|
||||
async def sessionUpdate(self, notification) -> None:
|
||||
async def terminal_output(
|
||||
self, session_id: str, terminal_id: str, **kwargs
|
||||
) -> TerminalOutputResponse:
|
||||
return await self._terminal_handle.current_output()
|
||||
|
||||
async def wait_for_terminal_exit(
|
||||
self, session_id: str, terminal_id: str, **kwargs
|
||||
) -> WaitForTerminalExitResponse:
|
||||
return await self._terminal_handle.wait_for_exit()
|
||||
|
||||
async def release_terminal(
|
||||
self, session_id: str, terminal_id: str, **kwargs
|
||||
) -> None:
|
||||
await self._terminal_handle.release()
|
||||
|
||||
async def kill_terminal(self, session_id: str, terminal_id: str, **kwargs) -> None:
|
||||
await self._terminal_handle.kill()
|
||||
|
||||
async def session_update(self, session_id: str, update, **kwargs) -> None:
|
||||
self._session_update_called = True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_connection() -> MockConnection:
|
||||
return MockConnection()
|
||||
def mock_client() -> MockClient:
|
||||
return MockClient()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def acp_bash_tool(mock_connection: MockConnection) -> Bash:
|
||||
def acp_bash_tool(mock_client: MockClient) -> Bash:
|
||||
config = BashToolConfig()
|
||||
# Use model_construct to bypass Pydantic validation for testing
|
||||
state = AcpBashState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
client=mock_client,
|
||||
session_id="test_session_123",
|
||||
tool_call_id="test_tool_call_456",
|
||||
)
|
||||
@@ -128,72 +165,69 @@ class TestAcpBashBasic:
|
||||
class TestAcpBashExecution:
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_success(
|
||||
self, acp_bash_tool: Bash, mock_connection: MockConnection
|
||||
self, acp_bash_tool: Bash, mock_client: MockClient
|
||||
) -> None:
|
||||
from pathlib import Path
|
||||
|
||||
args = BashArgs(command="echo hello")
|
||||
result = await acp_bash_tool.run(args)
|
||||
result = await collect_result(acp_bash_tool.run(args))
|
||||
|
||||
assert isinstance(result, BashResult)
|
||||
assert result.stdout == "test output"
|
||||
assert result.stderr == ""
|
||||
assert result.returncode == 0
|
||||
assert mock_connection._create_terminal_called
|
||||
assert mock_client._create_terminal_called
|
||||
|
||||
# Verify CreateTerminalRequest was created correctly
|
||||
request = mock_connection._last_create_request
|
||||
assert request is not None
|
||||
assert request.sessionId == "test_session_123"
|
||||
assert request.command == "echo"
|
||||
assert request.args == ["hello"]
|
||||
assert request.cwd == str(Path.cwd()) # effective_workdir defaults to cwd
|
||||
# Verify create_terminal was called correctly
|
||||
params = mock_client._last_create_params
|
||||
assert params["session_id"] == "test_session_123"
|
||||
assert params["command"] == "echo"
|
||||
assert params["args"] == ["hello"]
|
||||
assert params["cwd"] == str(Path.cwd()) # effective_workdir defaults to cwd
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_creates_terminal_with_env_vars(
|
||||
self, mock_connection: MockConnection
|
||||
self, mock_client: MockClient
|
||||
) -> None:
|
||||
tool = Bash(
|
||||
config=BashToolConfig(),
|
||||
state=AcpBashState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id="test_session",
|
||||
tool_call_id="test_call",
|
||||
client=mock_client, session_id="test_session", tool_call_id="test_call"
|
||||
),
|
||||
)
|
||||
|
||||
args = BashArgs(command="NODE_ENV=test npm run build")
|
||||
await tool.run(args)
|
||||
await collect_result(tool.run(args))
|
||||
|
||||
request = mock_connection._last_create_request
|
||||
assert request is not None
|
||||
assert len(request.env) == 1
|
||||
assert request.env[0].name == "NODE_ENV"
|
||||
assert request.env[0].value == "test"
|
||||
assert request.command == "npm"
|
||||
assert request.args == ["run", "build"]
|
||||
params = mock_client._last_create_params
|
||||
env = params["env"]
|
||||
assert env is not None
|
||||
assert (
|
||||
isinstance(env, list) and len(env) > 0 and isinstance(env[0], EnvVariable)
|
||||
)
|
||||
assert len(env) == 1
|
||||
assert env[0].name == "NODE_ENV"
|
||||
assert env[0].value == "test"
|
||||
assert params["command"] == "npm"
|
||||
assert params["args"] == ["run", "build"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_with_nonzero_exit_code(
|
||||
self, mock_connection: MockConnection
|
||||
) -> None:
|
||||
async def test_run_with_nonzero_exit_code(self, mock_client: MockClient) -> None:
|
||||
custom_handle = MockTerminalHandle(
|
||||
terminal_id="custom_terminal", exit_code=1, output="error: command failed"
|
||||
)
|
||||
mock_connection._terminal_handle = custom_handle
|
||||
mock_client._terminal_handle = custom_handle
|
||||
|
||||
tool = Bash(
|
||||
config=BashToolConfig(),
|
||||
state=AcpBashState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id="test_session",
|
||||
tool_call_id="test_call",
|
||||
client=mock_client, session_id="test_session", tool_call_id="test_call"
|
||||
),
|
||||
)
|
||||
|
||||
args = BashArgs(command="test_command")
|
||||
with pytest.raises(ToolError) as exc_info:
|
||||
await tool.run(args)
|
||||
await collect_result(tool.run(args))
|
||||
|
||||
assert (
|
||||
str(exc_info.value)
|
||||
@@ -201,23 +235,19 @@ class TestAcpBashExecution:
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_create_terminal_failure(
|
||||
self, mock_connection: MockConnection
|
||||
) -> None:
|
||||
mock_connection._create_terminal_error = RuntimeError("Connection failed")
|
||||
async def test_run_create_terminal_failure(self, mock_client: MockClient) -> None:
|
||||
mock_client._create_terminal_error = RuntimeError("Connection failed")
|
||||
|
||||
tool = Bash(
|
||||
config=BashToolConfig(),
|
||||
state=AcpBashState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id="test_session",
|
||||
tool_call_id="test_call",
|
||||
client=mock_client, session_id="test_session", tool_call_id="test_call"
|
||||
),
|
||||
)
|
||||
|
||||
args = BashArgs(command="test")
|
||||
with pytest.raises(ToolError) as exc_info:
|
||||
await tool.run(args)
|
||||
await collect_result(tool.run(args))
|
||||
|
||||
assert (
|
||||
str(exc_info.value)
|
||||
@@ -225,38 +255,36 @@ class TestAcpBashExecution:
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_without_connection(self) -> None:
|
||||
async def test_run_without_client(self) -> None:
|
||||
tool = Bash(
|
||||
config=BashToolConfig(),
|
||||
state=AcpBashState.model_construct(
|
||||
connection=None, session_id="test_session", tool_call_id="test_call"
|
||||
client=None, session_id="test_session", tool_call_id="test_call"
|
||||
),
|
||||
)
|
||||
|
||||
args = BashArgs(command="test")
|
||||
with pytest.raises(ToolError) as exc_info:
|
||||
await tool.run(args)
|
||||
await collect_result(tool.run(args))
|
||||
|
||||
assert (
|
||||
str(exc_info.value)
|
||||
== "Connection not available in tool state. This tool can only be used within an ACP session."
|
||||
== "Client not available in tool state. This tool can only be used within an ACP session."
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_without_session_id(self) -> None:
|
||||
mock_connection = MockConnection()
|
||||
mock_client = MockClient()
|
||||
tool = Bash(
|
||||
config=BashToolConfig(),
|
||||
state=AcpBashState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id=None,
|
||||
tool_call_id="test_call",
|
||||
client=mock_client, session_id=None, tool_call_id="test_call"
|
||||
),
|
||||
)
|
||||
|
||||
args = BashArgs(command="test")
|
||||
with pytest.raises(ToolError) as exc_info:
|
||||
await tool.run(args)
|
||||
await collect_result(tool.run(args))
|
||||
|
||||
assert (
|
||||
str(exc_info.value)
|
||||
@@ -264,25 +292,21 @@ class TestAcpBashExecution:
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_with_none_exit_code(
|
||||
self, mock_connection: MockConnection
|
||||
) -> None:
|
||||
async def test_run_with_none_exit_code(self, mock_client: MockClient) -> None:
|
||||
custom_handle = MockTerminalHandle(
|
||||
terminal_id="none_exit_terminal", exit_code=None, output="output"
|
||||
)
|
||||
mock_connection._terminal_handle = custom_handle
|
||||
mock_client._terminal_handle = custom_handle
|
||||
|
||||
tool = Bash(
|
||||
config=BashToolConfig(),
|
||||
state=AcpBashState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id="test_session",
|
||||
tool_call_id="test_call",
|
||||
client=mock_client, session_id="test_session", tool_call_id="test_call"
|
||||
),
|
||||
)
|
||||
|
||||
args = BashArgs(command="test_command")
|
||||
result = await tool.run(args)
|
||||
result = await collect_result(tool.run(args))
|
||||
|
||||
assert result.returncode == 0
|
||||
assert result.stdout == "output"
|
||||
@@ -291,41 +315,39 @@ class TestAcpBashExecution:
|
||||
class TestAcpBashTimeout:
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_with_timeout_raises_error_and_kills(
|
||||
self, mock_connection: MockConnection
|
||||
self, mock_client: MockClient
|
||||
) -> None:
|
||||
custom_handle = MockTerminalHandle(
|
||||
terminal_id="timeout_terminal",
|
||||
output="partial output",
|
||||
wait_delay=20, # Longer than the 1 second timeout
|
||||
)
|
||||
mock_connection._terminal_handle = custom_handle
|
||||
mock_client._terminal_handle = custom_handle
|
||||
|
||||
# Use a config with different default timeout to verify args timeout overrides it
|
||||
tool = Bash(
|
||||
config=BashToolConfig(default_timeout=30),
|
||||
state=AcpBashState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id="test_session",
|
||||
tool_call_id="test_call",
|
||||
client=mock_client, session_id="test_session", tool_call_id="test_call"
|
||||
),
|
||||
)
|
||||
|
||||
args = BashArgs(command="slow_command", timeout=1)
|
||||
with pytest.raises(ToolError) as exc_info:
|
||||
await tool.run(args)
|
||||
await collect_result(tool.run(args))
|
||||
|
||||
assert str(exc_info.value) == "Command timed out after 1s: 'slow_command'"
|
||||
assert custom_handle._killed
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_timeout_handles_kill_failure(
|
||||
self, mock_connection: MockConnection
|
||||
self, mock_client: MockClient
|
||||
) -> None:
|
||||
custom_handle = MockTerminalHandle(
|
||||
terminal_id="kill_failure_terminal",
|
||||
wait_delay=20, # Longer than the 1 second timeout
|
||||
)
|
||||
mock_connection._terminal_handle = custom_handle
|
||||
mock_client._terminal_handle = custom_handle
|
||||
|
||||
async def failing_kill() -> None:
|
||||
raise RuntimeError("Kill failed")
|
||||
@@ -335,78 +357,70 @@ class TestAcpBashTimeout:
|
||||
tool = Bash(
|
||||
config=BashToolConfig(),
|
||||
state=AcpBashState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id="test_session",
|
||||
tool_call_id="test_call",
|
||||
client=mock_client, session_id="test_session", tool_call_id="test_call"
|
||||
),
|
||||
)
|
||||
|
||||
args = BashArgs(command="slow_command", timeout=1)
|
||||
# Should still raise timeout error even if kill fails
|
||||
with pytest.raises(ToolError) as exc_info:
|
||||
await tool.run(args)
|
||||
await collect_result(tool.run(args))
|
||||
|
||||
assert str(exc_info.value) == "Command timed out after 1s: 'slow_command'"
|
||||
|
||||
|
||||
class TestAcpBashEmbedding:
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_with_embedding(self, mock_connection: MockConnection) -> None:
|
||||
async def test_run_with_embedding(self, mock_client: MockClient) -> None:
|
||||
tool = Bash(
|
||||
config=BashToolConfig(),
|
||||
state=AcpBashState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id="test_session",
|
||||
tool_call_id="test_call",
|
||||
client=mock_client, session_id="test_session", tool_call_id="test_call"
|
||||
),
|
||||
)
|
||||
|
||||
args = BashArgs(command="test")
|
||||
await tool.run(args)
|
||||
await collect_result(tool.run(args))
|
||||
|
||||
assert mock_connection._session_update_called
|
||||
assert mock_client._session_update_called
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_embedding_without_tool_call_id(
|
||||
self, mock_connection: MockConnection
|
||||
self, mock_client: MockClient
|
||||
) -> None:
|
||||
tool = Bash(
|
||||
config=BashToolConfig(),
|
||||
state=AcpBashState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id="test_session",
|
||||
tool_call_id=None,
|
||||
client=mock_client, session_id="test_session", tool_call_id=None
|
||||
),
|
||||
)
|
||||
|
||||
args = BashArgs(command="test")
|
||||
await tool.run(args)
|
||||
await collect_result(tool.run(args))
|
||||
|
||||
# Embedding should be skipped when tool_call_id is None
|
||||
assert not mock_connection._session_update_called
|
||||
assert not mock_client._session_update_called
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_embedding_handles_exception(
|
||||
self, mock_connection: MockConnection
|
||||
self, mock_client: MockClient
|
||||
) -> None:
|
||||
# Make sessionUpdate raise an exception
|
||||
async def failing_session_update(notification) -> None:
|
||||
# Make session_update raise an exception
|
||||
async def failing_session_update(session_id: str, update, **kwargs) -> None:
|
||||
raise RuntimeError("Session update failed")
|
||||
|
||||
mock_connection.sessionUpdate = failing_session_update
|
||||
mock_client.session_update = failing_session_update
|
||||
|
||||
tool = Bash(
|
||||
config=BashToolConfig(),
|
||||
state=AcpBashState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id="test_session",
|
||||
tool_call_id="test_call",
|
||||
client=mock_client, session_id="test_session", tool_call_id="test_call"
|
||||
),
|
||||
)
|
||||
|
||||
args = BashArgs(command="test")
|
||||
# Should not raise, embedding failure is silently ignored
|
||||
result = await tool.run(args)
|
||||
result = await collect_result(tool.run(args))
|
||||
|
||||
assert result is not None
|
||||
assert result.stdout == "test output"
|
||||
@@ -415,25 +429,23 @@ class TestAcpBashEmbedding:
|
||||
class TestAcpBashConfig:
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_uses_config_default_timeout(
|
||||
self, mock_connection: MockConnection
|
||||
self, mock_client: MockClient
|
||||
) -> None:
|
||||
custom_handle = MockTerminalHandle(
|
||||
terminal_id="config_timeout_terminal",
|
||||
wait_delay=0.01, # Shorter than config timeout
|
||||
)
|
||||
mock_connection._terminal_handle = custom_handle
|
||||
mock_client._terminal_handle = custom_handle
|
||||
|
||||
tool = Bash(
|
||||
config=BashToolConfig(default_timeout=30),
|
||||
state=AcpBashState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id="test_session",
|
||||
tool_call_id="test_call",
|
||||
client=mock_client, session_id="test_session", tool_call_id="test_call"
|
||||
),
|
||||
)
|
||||
|
||||
args = BashArgs(command="fast", timeout=None)
|
||||
result = await tool.run(args)
|
||||
result = await collect_result(tool.run(args))
|
||||
|
||||
# Should succeed with config timeout
|
||||
assert result.returncode == 0
|
||||
@@ -442,10 +454,10 @@ class TestAcpBashConfig:
|
||||
class TestAcpBashCleanup:
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_releases_terminal_on_success(
|
||||
self, mock_connection: MockConnection
|
||||
self, mock_client: MockClient
|
||||
) -> None:
|
||||
custom_handle = MockTerminalHandle(terminal_id="cleanup_terminal")
|
||||
mock_connection._terminal_handle = custom_handle
|
||||
mock_client._terminal_handle = custom_handle
|
||||
|
||||
release_called = False
|
||||
|
||||
@@ -458,20 +470,18 @@ class TestAcpBashCleanup:
|
||||
tool = Bash(
|
||||
config=BashToolConfig(),
|
||||
state=AcpBashState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id="test_session",
|
||||
tool_call_id="test_call",
|
||||
client=mock_client, session_id="test_session", tool_call_id="test_call"
|
||||
),
|
||||
)
|
||||
|
||||
args = BashArgs(command="test")
|
||||
await tool.run(args)
|
||||
await collect_result(tool.run(args))
|
||||
|
||||
assert release_called
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_releases_terminal_on_timeout(
|
||||
self, mock_connection: MockConnection
|
||||
self, mock_client: MockClient
|
||||
) -> None:
|
||||
# The handle will wait 2 seconds, but timeout is 1 second,
|
||||
# so asyncio.wait_for() will raise TimeoutError
|
||||
@@ -479,7 +489,7 @@ class TestAcpBashCleanup:
|
||||
terminal_id="timeout_cleanup_terminal",
|
||||
wait_delay=2.0, # Longer than the 1 second timeout
|
||||
)
|
||||
mock_connection._terminal_handle = custom_handle
|
||||
mock_client._terminal_handle = custom_handle
|
||||
|
||||
release_called = False
|
||||
|
||||
@@ -492,45 +502,39 @@ class TestAcpBashCleanup:
|
||||
tool = Bash(
|
||||
config=BashToolConfig(),
|
||||
state=AcpBashState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id="test_session",
|
||||
tool_call_id="test_call",
|
||||
client=mock_client, session_id="test_session", tool_call_id="test_call"
|
||||
),
|
||||
)
|
||||
|
||||
args = BashArgs(command="slow", timeout=1)
|
||||
# Timeout raises an error, but terminal should still be released
|
||||
try:
|
||||
await tool.run(args)
|
||||
await collect_result(tool.run(args))
|
||||
except ToolError:
|
||||
pass
|
||||
|
||||
assert release_called
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_handles_release_failure(
|
||||
self, mock_connection: MockConnection
|
||||
) -> None:
|
||||
async def test_run_handles_release_failure(self, mock_client: MockClient) -> None:
|
||||
custom_handle = MockTerminalHandle(terminal_id="release_failure_terminal")
|
||||
|
||||
async def failing_release() -> None:
|
||||
raise RuntimeError("Release failed")
|
||||
|
||||
custom_handle.release = failing_release
|
||||
mock_connection._terminal_handle = custom_handle
|
||||
mock_client._terminal_handle = custom_handle
|
||||
|
||||
tool = Bash(
|
||||
config=BashToolConfig(),
|
||||
state=AcpBashState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id="test_session",
|
||||
tool_call_id="test_call",
|
||||
client=mock_client, session_id="test_session", tool_call_id="test_call"
|
||||
),
|
||||
)
|
||||
|
||||
args = BashArgs(command="test")
|
||||
# Should not raise, release failure is silently ignored
|
||||
result = await tool.run(args)
|
||||
result = await collect_result(tool.run(args))
|
||||
|
||||
assert result is not None
|
||||
assert result.stdout == "test output"
|
||||
|
||||
83
tests/acp/test_compact_session_updates.py
Normal file
@@ -0,0 +1,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
from unittest.mock import patch
|
||||
|
||||
from acp.schema import TextContentBlock, ToolCallProgress, ToolCallStart
|
||||
import pytest
|
||||
|
||||
from tests.stubs.fake_backend import FakeBackend
|
||||
from tests.stubs.fake_client import FakeClient
|
||||
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
|
||||
from vibe.core.agent_loop import AgentLoop
|
||||
from vibe.core.config import SessionLoggingConfig, VibeConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def acp_agent_loop(backend: FakeBackend) -> VibeAcpAgentLoop:
|
||||
class PatchedAgent(AgentLoop):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
# Force our config with auto_compact_threshold=1
|
||||
kwargs["config"] = VibeConfig(
|
||||
session_logging=SessionLoggingConfig(enabled=False),
|
||||
auto_compact_threshold=1,
|
||||
)
|
||||
super().__init__(*args, **kwargs, backend=backend)
|
||||
|
||||
patch("vibe.acp.acp_agent_loop.AgentLoop", side_effect=PatchedAgent).start()
|
||||
vibe_acp_agent = VibeAcpAgentLoop()
|
||||
client = FakeClient()
|
||||
vibe_acp_agent.on_connect(client)
|
||||
client.on_connect(vibe_acp_agent)
|
||||
return vibe_acp_agent
|
||||
|
||||
|
||||
class TestCompactEventHandling:
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_handles_compact_events(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
) -> None:
|
||||
"""Verify prompt() sends tool_call session updates for compact events."""
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
session = acp_agent_loop.sessions[session_response.session_id]
|
||||
session.agent_loop.stats.context_tokens = 2
|
||||
|
||||
await acp_agent_loop.prompt(
|
||||
prompt=[TextContentBlock(type="text", text="Hello")],
|
||||
session_id=session_response.session_id,
|
||||
)
|
||||
|
||||
mock_client = cast(FakeClient, acp_agent_loop.client)
|
||||
updates = [n.update for n in mock_client._session_updates]
|
||||
|
||||
compact_start = next(
|
||||
(
|
||||
u
|
||||
for u in updates
|
||||
if isinstance(u, ToolCallStart)
|
||||
and u.title.startswith("Compacting conversation history")
|
||||
),
|
||||
None,
|
||||
)
|
||||
assert compact_start is not None
|
||||
assert compact_start.session_update == "tool_call"
|
||||
assert compact_start.kind == "other"
|
||||
assert compact_start.status == "in_progress"
|
||||
|
||||
compact_end = next(
|
||||
(
|
||||
u
|
||||
for u in updates
|
||||
if isinstance(u, ToolCallProgress)
|
||||
and u.tool_call_id == compact_start.tool_call_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
assert compact_end is not None
|
||||
assert compact_end.session_update == "tool_call_update"
|
||||
assert compact_end.status == "completed"
|
||||
|
||||
assert compact_start.tool_call_id == compact_end.tool_call_id
|
||||
@@ -1,9 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from acp import AgentSideConnection, NewSessionRequest, PromptRequest
|
||||
from acp import PromptRequest
|
||||
from acp.schema import (
|
||||
EmbeddedResourceContentBlock,
|
||||
ResourceContentBlock,
|
||||
@@ -13,58 +12,28 @@ from acp.schema import (
|
||||
import pytest
|
||||
|
||||
from tests.stubs.fake_backend import FakeBackend
|
||||
from tests.stubs.fake_connection import FakeAgentSideConnection
|
||||
from vibe.acp.acp_agent import VibeAcpAgent
|
||||
from vibe.core.agent import Agent
|
||||
from vibe.core.types import LLMChunk, LLMMessage, LLMUsage, Role
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def backend() -> FakeBackend:
|
||||
backend = FakeBackend(
|
||||
LLMChunk(
|
||||
message=LLMMessage(role=Role.assistant, content="Hi"),
|
||||
usage=LLMUsage(prompt_tokens=1, completion_tokens=1),
|
||||
)
|
||||
)
|
||||
return backend
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def acp_agent(backend: FakeBackend) -> VibeAcpAgent:
|
||||
class PatchedAgent(Agent):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs, backend=backend)
|
||||
|
||||
patch("vibe.acp.acp_agent.VibeAgent", side_effect=PatchedAgent).start()
|
||||
|
||||
vibe_acp_agent: VibeAcpAgent | None = None
|
||||
|
||||
def _create_agent(connection: AgentSideConnection) -> VibeAcpAgent:
|
||||
nonlocal vibe_acp_agent
|
||||
vibe_acp_agent = VibeAcpAgent(connection)
|
||||
return vibe_acp_agent
|
||||
|
||||
FakeAgentSideConnection(_create_agent)
|
||||
return vibe_acp_agent # pyright: ignore[reportReturnType]
|
||||
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
|
||||
from vibe.core.types import Role
|
||||
|
||||
|
||||
class TestACPContent:
|
||||
@pytest.mark.asyncio
|
||||
async def test_text_content(
|
||||
self, acp_agent: VibeAcpAgent, backend: FakeBackend
|
||||
self, acp_agent_loop: VibeAcpAgentLoop, backend: FakeBackend
|
||||
) -> None:
|
||||
session_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
prompt_request = PromptRequest(
|
||||
prompt=[TextContentBlock(type="text", text="Say hi")],
|
||||
sessionId=session_response.sessionId,
|
||||
session_id=session_response.session_id,
|
||||
)
|
||||
|
||||
response = await acp_agent.prompt(params=prompt_request)
|
||||
response = await acp_agent_loop.prompt(
|
||||
prompt=prompt_request.prompt, session_id=session_response.session_id
|
||||
)
|
||||
|
||||
assert response.stopReason == "end_turn"
|
||||
assert response.stop_reason == "end_turn"
|
||||
user_message = next(
|
||||
(msg for msg in backend._requests_messages[0] if msg.role == Role.user),
|
||||
None,
|
||||
@@ -74,12 +43,13 @@ class TestACPContent:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resource_content(
|
||||
self, acp_agent: VibeAcpAgent, backend: FakeBackend
|
||||
self, acp_agent_loop: VibeAcpAgentLoop, backend: FakeBackend
|
||||
) -> None:
|
||||
session_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
prompt_request = PromptRequest(
|
||||
|
||||
response = await acp_agent_loop.prompt(
|
||||
prompt=[
|
||||
TextContentBlock(type="text", text="What does this file do?"),
|
||||
EmbeddedResourceContentBlock(
|
||||
@@ -87,16 +57,14 @@ class TestACPContent:
|
||||
resource=TextResourceContents(
|
||||
uri="file:///home/my_file.py",
|
||||
text="def hello():\n print('Hello, world!')",
|
||||
mimeType="text/x-python",
|
||||
mime_type="text/x-python",
|
||||
),
|
||||
),
|
||||
],
|
||||
sessionId=session_response.sessionId,
|
||||
session_id=session_response.session_id,
|
||||
)
|
||||
|
||||
response = await acp_agent.prompt(params=prompt_request)
|
||||
|
||||
assert response.stopReason == "end_turn"
|
||||
assert response.stop_reason == "end_turn"
|
||||
user_message = next(
|
||||
(msg for msg in backend._requests_messages[0] if msg.role == Role.user),
|
||||
None,
|
||||
@@ -111,12 +79,13 @@ class TestACPContent:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resource_link_content(
|
||||
self, acp_agent: VibeAcpAgent, backend: FakeBackend
|
||||
self, acp_agent_loop: VibeAcpAgentLoop, backend: FakeBackend
|
||||
) -> None:
|
||||
session_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
prompt_request = PromptRequest(
|
||||
|
||||
response = await acp_agent_loop.prompt(
|
||||
prompt=[
|
||||
TextContentBlock(type="text", text="Analyze this resource"),
|
||||
ResourceContentBlock(
|
||||
@@ -125,16 +94,14 @@ class TestACPContent:
|
||||
name="document.pdf",
|
||||
title="Important Document",
|
||||
description="A PDF document containing project specifications",
|
||||
mimeType="application/pdf",
|
||||
mime_type="application/pdf",
|
||||
size=1024,
|
||||
),
|
||||
],
|
||||
sessionId=session_response.sessionId,
|
||||
session_id=session_response.session_id,
|
||||
)
|
||||
|
||||
response = await acp_agent.prompt(params=prompt_request)
|
||||
|
||||
assert response.stopReason == "end_turn"
|
||||
assert response.stop_reason == "end_turn"
|
||||
user_message = next(
|
||||
(msg for msg in backend._requests_messages[0] if msg.role == Role.user),
|
||||
None,
|
||||
@@ -146,19 +113,20 @@ class TestACPContent:
|
||||
+ "\nname: document.pdf"
|
||||
+ "\ntitle: Important Document"
|
||||
+ "\ndescription: A PDF document containing project specifications"
|
||||
+ "\nmimeType: application/pdf"
|
||||
+ "\nmime_type: application/pdf"
|
||||
+ "\nsize: 1024"
|
||||
)
|
||||
assert user_message.content == expected_content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resource_link_minimal(
|
||||
self, acp_agent: VibeAcpAgent, backend: FakeBackend
|
||||
self, acp_agent_loop: VibeAcpAgentLoop, backend: FakeBackend
|
||||
) -> None:
|
||||
session_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
prompt_request = PromptRequest(
|
||||
|
||||
response = await acp_agent_loop.prompt(
|
||||
prompt=[
|
||||
ResourceContentBlock(
|
||||
type="resource_link",
|
||||
@@ -166,12 +134,10 @@ class TestACPContent:
|
||||
name="minimal.txt",
|
||||
)
|
||||
],
|
||||
sessionId=session_response.sessionId,
|
||||
session_id=session_response.session_id,
|
||||
)
|
||||
|
||||
response = await acp_agent.prompt(params=prompt_request)
|
||||
|
||||
assert response.stopReason == "end_turn"
|
||||
assert response.stop_reason == "end_turn"
|
||||
user_message = next(
|
||||
(msg for msg in backend._requests_messages[0] if msg.role == Role.user),
|
||||
None,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from acp import PROTOCOL_VERSION, AgentSideConnection, InitializeRequest
|
||||
from acp import PROTOCOL_VERSION
|
||||
from acp.schema import (
|
||||
AgentCapabilities,
|
||||
ClientCapabilities,
|
||||
@@ -9,66 +9,51 @@ from acp.schema import (
|
||||
)
|
||||
import pytest
|
||||
|
||||
from tests.stubs.fake_connection import FakeAgentSideConnection
|
||||
from vibe.acp.acp_agent import VibeAcpAgent
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def acp_agent() -> VibeAcpAgent:
|
||||
vibe_acp_agent: VibeAcpAgent | None = None
|
||||
|
||||
def _create_agent(connection: AgentSideConnection) -> VibeAcpAgent:
|
||||
nonlocal vibe_acp_agent
|
||||
vibe_acp_agent = VibeAcpAgent(connection)
|
||||
return vibe_acp_agent
|
||||
|
||||
FakeAgentSideConnection(_create_agent)
|
||||
return vibe_acp_agent # pyright: ignore[reportReturnType]
|
||||
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
|
||||
|
||||
|
||||
class TestACPInitialize:
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize(self, acp_agent: VibeAcpAgent) -> None:
|
||||
"""Test regular initialize without terminal-auth capabilities."""
|
||||
request = InitializeRequest(protocolVersion=PROTOCOL_VERSION)
|
||||
response = await acp_agent.initialize(request)
|
||||
async def test_initialize(self, acp_agent_loop: VibeAcpAgentLoop) -> None:
|
||||
response = await acp_agent_loop.initialize(protocol_version=PROTOCOL_VERSION)
|
||||
|
||||
assert response.protocolVersion == PROTOCOL_VERSION
|
||||
assert response.agentCapabilities == AgentCapabilities(
|
||||
loadSession=False,
|
||||
promptCapabilities=PromptCapabilities(
|
||||
audio=False, embeddedContext=True, image=False
|
||||
assert response.protocol_version == PROTOCOL_VERSION
|
||||
assert response.agent_capabilities == AgentCapabilities(
|
||||
load_session=False,
|
||||
prompt_capabilities=PromptCapabilities(
|
||||
audio=False, embedded_context=True, image=False
|
||||
),
|
||||
)
|
||||
assert response.agentInfo == Implementation(
|
||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="1.3.5"
|
||||
assert response.agent_info == Implementation(
|
||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.0.0"
|
||||
)
|
||||
|
||||
assert response.authMethods == []
|
||||
assert response.auth_methods == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_with_terminal_auth(self, acp_agent: VibeAcpAgent) -> None:
|
||||
async def test_initialize_with_terminal_auth(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
) -> None:
|
||||
"""Test initialize with terminal-auth capabilities to check it was included."""
|
||||
client_capabilities = ClientCapabilities(field_meta={"terminal-auth": True})
|
||||
request = InitializeRequest(
|
||||
protocolVersion=PROTOCOL_VERSION, clientCapabilities=client_capabilities
|
||||
response = await acp_agent_loop.initialize(
|
||||
protocol_version=PROTOCOL_VERSION, client_capabilities=client_capabilities
|
||||
)
|
||||
response = await acp_agent.initialize(request)
|
||||
|
||||
assert response.protocolVersion == PROTOCOL_VERSION
|
||||
assert response.agentCapabilities == AgentCapabilities(
|
||||
loadSession=False,
|
||||
promptCapabilities=PromptCapabilities(
|
||||
audio=False, embeddedContext=True, image=False
|
||||
assert response.protocol_version == PROTOCOL_VERSION
|
||||
assert response.agent_capabilities == AgentCapabilities(
|
||||
load_session=False,
|
||||
prompt_capabilities=PromptCapabilities(
|
||||
audio=False, embedded_context=True, image=False
|
||||
),
|
||||
)
|
||||
assert response.agentInfo == Implementation(
|
||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="1.3.5"
|
||||
assert response.agent_info == Implementation(
|
||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.0.0"
|
||||
)
|
||||
|
||||
assert response.authMethods is not None
|
||||
assert len(response.authMethods) == 1
|
||||
auth_method = response.authMethods[0]
|
||||
assert response.auth_methods is not None
|
||||
assert len(response.auth_methods) == 1
|
||||
auth_method = response.auth_methods[0]
|
||||
assert auth_method.id == "vibe-setup"
|
||||
assert auth_method.name == "Register your API Key"
|
||||
assert auth_method.description == "Register your API Key inside Mistral Vibe"
|
||||
|
||||
@@ -2,101 +2,52 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
from acp import (
|
||||
PROTOCOL_VERSION,
|
||||
InitializeRequest,
|
||||
NewSessionRequest,
|
||||
PromptRequest,
|
||||
RequestError,
|
||||
)
|
||||
from acp import PROTOCOL_VERSION, RequestError
|
||||
from acp.schema import TextContentBlock
|
||||
import pytest
|
||||
from pytest import raises
|
||||
|
||||
from tests.mock.utils import mock_llm_chunk
|
||||
from tests.stubs.fake_backend import FakeBackend
|
||||
from tests.stubs.fake_connection import FakeAgentSideConnection
|
||||
from vibe.acp.acp_agent import VibeAcpAgent
|
||||
from vibe.core.agent import Agent
|
||||
from vibe.core.config import ModelConfig, VibeConfig
|
||||
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
|
||||
from vibe.core.types import Role
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def backend() -> FakeBackend:
|
||||
backend = FakeBackend()
|
||||
return backend
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def acp_agent(backend: FakeBackend) -> VibeAcpAgent:
|
||||
config = VibeConfig(
|
||||
active_model="devstral-latest",
|
||||
models=[
|
||||
ModelConfig(
|
||||
name="devstral-latest", provider="mistral", alias="devstral-latest"
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
class PatchedAgent(Agent):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.backend = backend
|
||||
self.config = config
|
||||
|
||||
patch("vibe.acp.acp_agent.VibeAgent", side_effect=PatchedAgent).start()
|
||||
|
||||
vibe_acp_agent: VibeAcpAgent | None = None
|
||||
|
||||
def _create_agent(connection: Any) -> VibeAcpAgent:
|
||||
nonlocal vibe_acp_agent
|
||||
vibe_acp_agent = VibeAcpAgent(connection)
|
||||
return vibe_acp_agent
|
||||
|
||||
FakeAgentSideConnection(_create_agent)
|
||||
return vibe_acp_agent # pyright: ignore[reportReturnType]
|
||||
|
||||
|
||||
class TestMultiSessionCore:
|
||||
@pytest.mark.asyncio
|
||||
async def test_different_sessions_use_different_agents(
|
||||
self, acp_agent: VibeAcpAgent
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
) -> None:
|
||||
await acp_agent.initialize(InitializeRequest(protocolVersion=PROTOCOL_VERSION))
|
||||
session1_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
await acp_agent_loop.initialize(protocol_version=PROTOCOL_VERSION)
|
||||
session1_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
session1 = acp_agent.sessions[session1_response.sessionId]
|
||||
session2_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
session1 = acp_agent_loop.sessions[session1_response.session_id]
|
||||
session2_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
session2 = acp_agent.sessions[session2_response.sessionId]
|
||||
session2 = acp_agent_loop.sessions[session2_response.session_id]
|
||||
|
||||
assert session1.id != session2.id
|
||||
# Each agent should be independent
|
||||
assert session1.agent is not session2.agent
|
||||
assert id(session1.agent) != id(session2.agent)
|
||||
# Each agent loop should be independent
|
||||
assert session1.agent_loop is not session2.agent_loop
|
||||
assert id(session1.agent_loop) != id(session2.agent_loop)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_on_nonexistent_session(self, acp_agent: VibeAcpAgent) -> None:
|
||||
await acp_agent.initialize(InitializeRequest(protocolVersion=PROTOCOL_VERSION))
|
||||
await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
)
|
||||
async def test_error_on_nonexistent_session(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
) -> None:
|
||||
await acp_agent_loop.initialize(protocol_version=PROTOCOL_VERSION)
|
||||
await acp_agent_loop.new_session(cwd=str(Path.cwd()), mcp_servers=[])
|
||||
|
||||
fake_session_id = "fake-session-id-" + str(uuid4())
|
||||
|
||||
with raises(RequestError) as exc_info:
|
||||
await acp_agent.prompt(
|
||||
PromptRequest(
|
||||
sessionId=fake_session_id,
|
||||
prompt=[TextContentBlock(type="text", text="Hello, world!")],
|
||||
)
|
||||
await acp_agent_loop.prompt(
|
||||
session_id=fake_session_id,
|
||||
prompt=[TextContentBlock(type="text", text="Hello, world!")],
|
||||
)
|
||||
|
||||
assert isinstance(exc_info.value, RequestError)
|
||||
@@ -104,17 +55,17 @@ class TestMultiSessionCore:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_simultaneous_message_processing(
|
||||
self, acp_agent: VibeAcpAgent, backend: FakeBackend
|
||||
self, acp_agent_loop: VibeAcpAgentLoop, backend: FakeBackend
|
||||
) -> None:
|
||||
await acp_agent.initialize(InitializeRequest(protocolVersion=PROTOCOL_VERSION))
|
||||
session1_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
await acp_agent_loop.initialize(protocol_version=PROTOCOL_VERSION)
|
||||
session1_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
session1 = acp_agent.sessions[session1_response.sessionId]
|
||||
session2_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
session1 = acp_agent_loop.sessions[session1_response.session_id]
|
||||
session2_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
session2 = acp_agent.sessions[session2_response.sessionId]
|
||||
session2 = acp_agent_loop.sessions[session2_response.session_id]
|
||||
|
||||
backend._streams = [
|
||||
[mock_llm_chunk(content="Response 1")],
|
||||
@@ -122,40 +73,38 @@ class TestMultiSessionCore:
|
||||
]
|
||||
|
||||
async def run_session1():
|
||||
await acp_agent.prompt(
|
||||
PromptRequest(
|
||||
sessionId=session1.id,
|
||||
prompt=[TextContentBlock(type="text", text="Prompt for session 1")],
|
||||
)
|
||||
await acp_agent_loop.prompt(
|
||||
session_id=session1.id,
|
||||
prompt=[TextContentBlock(type="text", text="Prompt for session 1")],
|
||||
)
|
||||
|
||||
async def run_session2():
|
||||
await acp_agent.prompt(
|
||||
PromptRequest(
|
||||
sessionId=session2.id,
|
||||
prompt=[TextContentBlock(type="text", text="Prompt for session 2")],
|
||||
)
|
||||
await acp_agent_loop.prompt(
|
||||
session_id=session2.id,
|
||||
prompt=[TextContentBlock(type="text", text="Prompt for session 2")],
|
||||
)
|
||||
|
||||
await asyncio.gather(run_session1(), run_session2())
|
||||
|
||||
user_message1 = next(
|
||||
(msg for msg in session1.agent.messages if msg.role == Role.user), None
|
||||
(msg for msg in session1.agent_loop.messages if msg.role == Role.user), None
|
||||
)
|
||||
assert user_message1 is not None
|
||||
assert user_message1.content == "Prompt for session 1"
|
||||
assistant_message1 = next(
|
||||
(msg for msg in session1.agent.messages if msg.role == Role.assistant), None
|
||||
(msg for msg in session1.agent_loop.messages if msg.role == Role.assistant),
|
||||
None,
|
||||
)
|
||||
assert assistant_message1 is not None
|
||||
assert assistant_message1.content == "Response 1"
|
||||
user_message2 = next(
|
||||
(msg for msg in session2.agent.messages if msg.role == Role.user), None
|
||||
(msg for msg in session2.agent_loop.messages if msg.role == Role.user), None
|
||||
)
|
||||
assert user_message2 is not None
|
||||
assert user_message2.content == "Prompt for session 2"
|
||||
assistant_message2 = next(
|
||||
(msg for msg in session2.agent.messages if msg.role == Role.assistant), None
|
||||
(msg for msg in session2.agent_loop.messages if msg.role == Role.assistant),
|
||||
None,
|
||||
)
|
||||
assert assistant_message2 is not None
|
||||
assert assistant_message2.content == "Response 2"
|
||||
|
||||
@@ -3,31 +3,17 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from acp import AgentSideConnection, NewSessionRequest, SetSessionModelRequest
|
||||
import pytest
|
||||
|
||||
from tests.stubs.fake_backend import FakeBackend
|
||||
from tests.stubs.fake_connection import FakeAgentSideConnection
|
||||
from vibe.acp.acp_agent import VibeAcpAgent
|
||||
from vibe.core.agent import Agent
|
||||
from tests.acp.conftest import _create_acp_agent
|
||||
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
|
||||
from vibe.core.agent_loop import AgentLoop
|
||||
from vibe.core.agents.models import BuiltinAgentName
|
||||
from vibe.core.config import ModelConfig, VibeConfig
|
||||
from vibe.core.modes import AgentMode
|
||||
from vibe.core.types import LLMChunk, LLMMessage, LLMUsage, Role
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def backend() -> FakeBackend:
|
||||
backend = FakeBackend(
|
||||
LLMChunk(
|
||||
message=LLMMessage(role=Role.assistant, content="Hi"),
|
||||
usage=LLMUsage(prompt_tokens=1, completion_tokens=1),
|
||||
)
|
||||
)
|
||||
return backend
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def acp_agent(backend: FakeBackend) -> VibeAcpAgent:
|
||||
def acp_agent_loop(backend) -> VibeAcpAgentLoop:
|
||||
config = VibeConfig(
|
||||
active_model="devstral-latest",
|
||||
models=[
|
||||
@@ -40,101 +26,90 @@ def acp_agent(backend: FakeBackend) -> VibeAcpAgent:
|
||||
],
|
||||
)
|
||||
|
||||
class PatchedAgent(Agent):
|
||||
class PatchedAgentLoop(AgentLoop):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **{**kwargs, "backend": backend})
|
||||
self.config = config
|
||||
self._base_config = config
|
||||
self.agent_manager.invalidate_config()
|
||||
|
||||
patch("vibe.acp.acp_agent.VibeAgent", side_effect=PatchedAgent).start()
|
||||
patch("vibe.acp.acp_agent_loop.AgentLoop", side_effect=PatchedAgentLoop).start()
|
||||
|
||||
vibe_acp_agent: VibeAcpAgent | None = None
|
||||
|
||||
def _create_agent(connection: AgentSideConnection) -> VibeAcpAgent:
|
||||
nonlocal vibe_acp_agent
|
||||
vibe_acp_agent = VibeAcpAgent(connection)
|
||||
return vibe_acp_agent
|
||||
|
||||
FakeAgentSideConnection(_create_agent)
|
||||
return vibe_acp_agent # pyright: ignore[reportReturnType]
|
||||
return _create_acp_agent()
|
||||
|
||||
|
||||
class TestACPNewSession:
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_session_response_structure(
|
||||
self, acp_agent: VibeAcpAgent
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
) -> None:
|
||||
session_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
|
||||
assert session_response.sessionId is not None
|
||||
assert session_response.session_id is not None
|
||||
acp_session = next(
|
||||
(
|
||||
s
|
||||
for s in acp_agent.sessions.values()
|
||||
if s.id == session_response.sessionId
|
||||
for s in acp_agent_loop.sessions.values()
|
||||
if s.id == session_response.session_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
assert acp_session is not None
|
||||
assert (
|
||||
acp_session.agent.interaction_logger.session_id
|
||||
== session_response.sessionId
|
||||
acp_session.agent_loop.session_logger.session_id
|
||||
== session_response.session_id
|
||||
)
|
||||
|
||||
assert session_response.sessionId == acp_session.agent.session_id
|
||||
assert session_response.session_id == acp_session.agent_loop.session_id
|
||||
|
||||
assert session_response.models is not None
|
||||
assert session_response.models.currentModelId is not None
|
||||
assert session_response.models.availableModels is not None
|
||||
assert len(session_response.models.availableModels) == 2
|
||||
assert session_response.models.current_model_id is not None
|
||||
assert session_response.models.available_models is not None
|
||||
assert len(session_response.models.available_models) == 2
|
||||
|
||||
assert session_response.models.currentModelId == "devstral-latest"
|
||||
assert session_response.models.availableModels[0].modelId == "devstral-latest"
|
||||
assert session_response.models.availableModels[0].name == "devstral-latest"
|
||||
assert session_response.models.availableModels[1].modelId == "devstral-small"
|
||||
assert session_response.models.availableModels[1].name == "devstral-small"
|
||||
assert session_response.models.current_model_id == "devstral-latest"
|
||||
assert session_response.models.available_models[0].model_id == "devstral-latest"
|
||||
assert session_response.models.available_models[0].name == "devstral-latest"
|
||||
assert session_response.models.available_models[1].model_id == "devstral-small"
|
||||
assert session_response.models.available_models[1].name == "devstral-small"
|
||||
|
||||
assert session_response.modes is not None
|
||||
assert session_response.modes.currentModeId is not None
|
||||
assert session_response.modes.availableModes is not None
|
||||
assert len(session_response.modes.availableModes) == 4
|
||||
assert session_response.modes.current_mode_id is not None
|
||||
assert session_response.modes.available_modes is not None
|
||||
assert len(session_response.modes.available_modes) == 4
|
||||
|
||||
assert session_response.modes.currentModeId == AgentMode.DEFAULT.value
|
||||
assert session_response.modes.availableModes[0].id == AgentMode.DEFAULT.value
|
||||
assert session_response.modes.availableModes[0].name == "Default"
|
||||
assert (
|
||||
session_response.modes.availableModes[1].id == AgentMode.AUTO_APPROVE.value
|
||||
)
|
||||
assert session_response.modes.availableModes[1].name == "Auto Approve"
|
||||
assert session_response.modes.availableModes[2].id == AgentMode.PLAN.value
|
||||
assert session_response.modes.availableModes[2].name == "Plan"
|
||||
assert (
|
||||
session_response.modes.availableModes[3].id == AgentMode.ACCEPT_EDITS.value
|
||||
)
|
||||
assert session_response.modes.availableModes[3].name == "Accept Edits"
|
||||
assert session_response.modes.current_mode_id == BuiltinAgentName.DEFAULT
|
||||
# Check that all primary agents are available (order may vary)
|
||||
mode_ids = {m.id for m in session_response.modes.available_modes}
|
||||
assert mode_ids == {
|
||||
BuiltinAgentName.DEFAULT,
|
||||
BuiltinAgentName.AUTO_APPROVE,
|
||||
BuiltinAgentName.PLAN,
|
||||
BuiltinAgentName.ACCEPT_EDITS,
|
||||
}
|
||||
|
||||
@pytest.mark.skip(reason="TODO: Fix this test")
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_session_preserves_model_after_set_model(
|
||||
self, acp_agent: VibeAcpAgent
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
) -> None:
|
||||
session_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
session_id = session_response.sessionId
|
||||
session_id = session_response.session_id
|
||||
|
||||
assert session_response.models is not None
|
||||
assert session_response.models.currentModelId == "devstral-latest"
|
||||
assert session_response.models.current_model_id == "devstral-latest"
|
||||
|
||||
response = await acp_agent.setSessionModel(
|
||||
SetSessionModelRequest(sessionId=session_id, modelId="devstral-small")
|
||||
response = await acp_agent_loop.set_session_model(
|
||||
session_id=session_id, model_id="devstral-small"
|
||||
)
|
||||
assert response is not None
|
||||
|
||||
session_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
|
||||
assert session_response.models is not None
|
||||
assert session_response.models.currentModelId == "devstral-small"
|
||||
assert session_response.models.current_model_id == "devstral-small"
|
||||
|
||||
@@ -2,9 +2,10 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from acp import ReadTextFileRequest, ReadTextFileResponse
|
||||
from acp import ReadTextFileResponse
|
||||
import pytest
|
||||
|
||||
from tests.mock.utils import collect_result
|
||||
from vibe.acp.tools.builtins.read_file import AcpReadFileState, ReadFile
|
||||
from vibe.core.tools.base import ToolError
|
||||
from vibe.core.tools.builtins.read_file import (
|
||||
@@ -14,7 +15,7 @@ from vibe.core.tools.builtins.read_file import (
|
||||
)
|
||||
|
||||
|
||||
class MockConnection:
|
||||
class MockClient:
|
||||
def __init__(
|
||||
self,
|
||||
file_content: str = "line 1\nline 2\nline 3",
|
||||
@@ -24,41 +25,54 @@ class MockConnection:
|
||||
self._read_error = read_error
|
||||
self._read_text_file_called = False
|
||||
self._session_update_called = False
|
||||
self._last_read_request: ReadTextFileRequest | None = None
|
||||
self._last_read_params: dict[str, str | int | None] = {}
|
||||
|
||||
async def readTextFile(self, request: ReadTextFileRequest) -> ReadTextFileResponse:
|
||||
async def read_text_file(
|
||||
self,
|
||||
path: str,
|
||||
session_id: str,
|
||||
limit: int | None = None,
|
||||
line: int | None = None,
|
||||
**kwargs,
|
||||
) -> ReadTextFileResponse:
|
||||
self._read_text_file_called = True
|
||||
self._last_read_request = request
|
||||
self._last_read_params = {
|
||||
"path": path,
|
||||
"session_id": session_id,
|
||||
"limit": limit,
|
||||
"line": line,
|
||||
}
|
||||
|
||||
if self._read_error:
|
||||
raise self._read_error
|
||||
|
||||
content = self._file_content
|
||||
if request.line is not None or request.limit is not None:
|
||||
if line is not None or limit is not None:
|
||||
lines = content.splitlines(keepends=True)
|
||||
start_line = (request.line or 1) - 1 # Convert to 0-indexed
|
||||
end_line = (
|
||||
start_line + request.limit if request.limit is not None else len(lines)
|
||||
)
|
||||
start_line = (line or 1) - 1 # Convert to 0-indexed
|
||||
end_line = start_line + limit if limit is not None else len(lines)
|
||||
lines = lines[start_line:end_line]
|
||||
content = "".join(lines)
|
||||
|
||||
return ReadTextFileResponse(content=content)
|
||||
|
||||
async def sessionUpdate(self, notification) -> None:
|
||||
async def session_update(self, session_id: str, update, **kwargs) -> None:
|
||||
self._session_update_called = True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_connection() -> MockConnection:
|
||||
return MockConnection()
|
||||
def mock_client() -> MockClient:
|
||||
return MockClient()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def acp_read_file_tool(mock_connection: MockConnection, tmp_path: Path) -> ReadFile:
|
||||
config = ReadFileToolConfig(workdir=tmp_path)
|
||||
def acp_read_file_tool(
|
||||
mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> ReadFile:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
config = ReadFileToolConfig()
|
||||
state = AcpReadFileState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
client=mock_client, # type: ignore[arg-type]
|
||||
session_id="test_session_123",
|
||||
tool_call_id="test_tool_call_456",
|
||||
)
|
||||
@@ -73,166 +87,159 @@ class TestAcpReadFileBasic:
|
||||
class TestAcpReadFileExecution:
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_success(
|
||||
self,
|
||||
acp_read_file_tool: ReadFile,
|
||||
mock_connection: MockConnection,
|
||||
tmp_path: Path,
|
||||
self, acp_read_file_tool: ReadFile, mock_client: MockClient, tmp_path: Path
|
||||
) -> None:
|
||||
test_file = tmp_path / "test_file.txt"
|
||||
test_file.touch()
|
||||
args = ReadFileArgs(path=str(test_file))
|
||||
result = await acp_read_file_tool.run(args)
|
||||
result = await collect_result(acp_read_file_tool.run(args))
|
||||
|
||||
assert isinstance(result, ReadFileResult)
|
||||
assert result.path == str(test_file)
|
||||
assert result.content == "line 1\nline 2\nline 3"
|
||||
assert result.lines_read == 3
|
||||
assert mock_connection._read_text_file_called
|
||||
assert mock_connection._session_update_called
|
||||
assert mock_client._read_text_file_called
|
||||
assert mock_client._session_update_called
|
||||
|
||||
# Verify ReadTextFileRequest was created correctly
|
||||
request = mock_connection._last_read_request
|
||||
assert request is not None
|
||||
assert request.sessionId == "test_session_123"
|
||||
assert request.path == str(test_file)
|
||||
assert request.line is None # offset=0 means no line specified
|
||||
assert request.limit is None
|
||||
# Verify read_text_file was called correctly
|
||||
params = mock_client._last_read_params
|
||||
assert params["session_id"] == "test_session_123"
|
||||
assert params["path"] == str(test_file)
|
||||
assert params["line"] is None # offset=0 means no line specified
|
||||
assert params["limit"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_with_offset(
|
||||
self, mock_connection: MockConnection, tmp_path: Path
|
||||
self, mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
test_file = tmp_path / "test_file.txt"
|
||||
test_file.touch()
|
||||
tool = ReadFile(
|
||||
config=ReadFileToolConfig(workdir=tmp_path),
|
||||
config=ReadFileToolConfig(),
|
||||
state=AcpReadFileState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id="test_session",
|
||||
tool_call_id="test_call",
|
||||
client=mock_client, session_id="test_session", tool_call_id="test_call"
|
||||
),
|
||||
)
|
||||
|
||||
args = ReadFileArgs(path=str(test_file), offset=1)
|
||||
result = await tool.run(args)
|
||||
result = await collect_result(tool.run(args))
|
||||
|
||||
assert result.lines_read == 2
|
||||
assert result.content == "line 2\nline 3"
|
||||
|
||||
request = mock_connection._last_read_request
|
||||
assert request is not None
|
||||
assert request.line == 2 # offset=1 means line 2 (1-indexed)
|
||||
params = mock_client._last_read_params
|
||||
assert params["line"] == 2 # offset=1 means line 2 (1-indexed)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_with_limit(
|
||||
self, mock_connection: MockConnection, tmp_path: Path
|
||||
self, mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
test_file = tmp_path / "test_file.txt"
|
||||
test_file.touch()
|
||||
tool = ReadFile(
|
||||
config=ReadFileToolConfig(workdir=tmp_path),
|
||||
config=ReadFileToolConfig(),
|
||||
state=AcpReadFileState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id="test_session",
|
||||
tool_call_id="test_call",
|
||||
client=mock_client, session_id="test_session", tool_call_id="test_call"
|
||||
),
|
||||
)
|
||||
|
||||
args = ReadFileArgs(path=str(test_file), limit=2)
|
||||
result = await tool.run(args)
|
||||
result = await collect_result(tool.run(args))
|
||||
|
||||
assert result.lines_read == 2
|
||||
assert result.content == "line 1\nline 2\n"
|
||||
|
||||
request = mock_connection._last_read_request
|
||||
assert request is not None
|
||||
assert request.limit == 2
|
||||
params = mock_client._last_read_params
|
||||
assert params["limit"] == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_with_offset_and_limit(
|
||||
self, mock_connection: MockConnection, tmp_path: Path
|
||||
self, mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
test_file = tmp_path / "test_file.txt"
|
||||
test_file.touch()
|
||||
tool = ReadFile(
|
||||
config=ReadFileToolConfig(workdir=tmp_path),
|
||||
config=ReadFileToolConfig(),
|
||||
state=AcpReadFileState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id="test_session",
|
||||
tool_call_id="test_call",
|
||||
client=mock_client, session_id="test_session", tool_call_id="test_call"
|
||||
),
|
||||
)
|
||||
|
||||
args = ReadFileArgs(path=str(test_file), offset=1, limit=1)
|
||||
result = await tool.run(args)
|
||||
result = await collect_result(tool.run(args))
|
||||
|
||||
assert result.lines_read == 1
|
||||
assert result.content == "line 2\n"
|
||||
|
||||
request = mock_connection._last_read_request
|
||||
assert request is not None
|
||||
assert request.line == 2
|
||||
assert request.limit == 1
|
||||
params = mock_client._last_read_params
|
||||
assert params["line"] == 2
|
||||
assert params["limit"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_read_error(
|
||||
self, mock_connection: MockConnection, tmp_path: Path
|
||||
self, mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
mock_connection._read_error = RuntimeError("File not found")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
mock_client._read_error = RuntimeError("File not found")
|
||||
test_file = tmp_path / "test.txt"
|
||||
test_file.touch()
|
||||
tool = ReadFile(
|
||||
config=ReadFileToolConfig(workdir=tmp_path),
|
||||
config=ReadFileToolConfig(),
|
||||
state=AcpReadFileState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id="test_session",
|
||||
tool_call_id="test_call",
|
||||
client=mock_client, session_id="test_session", tool_call_id="test_call"
|
||||
),
|
||||
)
|
||||
|
||||
args = ReadFileArgs(path=str(test_file))
|
||||
with pytest.raises(ToolError) as exc_info:
|
||||
await tool.run(args)
|
||||
await collect_result(tool.run(args))
|
||||
|
||||
assert str(exc_info.value) == f"Error reading {test_file}: File not found"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_without_connection(self, tmp_path: Path) -> None:
|
||||
async def test_run_without_connection(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
test_file = tmp_path / "test.txt"
|
||||
test_file.touch()
|
||||
tool = ReadFile(
|
||||
config=ReadFileToolConfig(workdir=tmp_path),
|
||||
config=ReadFileToolConfig(),
|
||||
state=AcpReadFileState.model_construct(
|
||||
connection=None, session_id="test_session", tool_call_id="test_call"
|
||||
client=None, session_id="test_session", tool_call_id="test_call"
|
||||
),
|
||||
)
|
||||
|
||||
args = ReadFileArgs(path=str(test_file))
|
||||
with pytest.raises(ToolError) as exc_info:
|
||||
await tool.run(args)
|
||||
await collect_result(tool.run(args))
|
||||
|
||||
assert (
|
||||
str(exc_info.value)
|
||||
== "Connection not available in tool state. This tool can only be used within an ACP session."
|
||||
== "Client not available in tool state. This tool can only be used within an ACP session."
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_without_session_id(self, tmp_path: Path) -> None:
|
||||
async def test_run_without_session_id(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
test_file = tmp_path / "test.txt"
|
||||
test_file.touch()
|
||||
mock_connection = MockConnection()
|
||||
mock_client = MockClient()
|
||||
tool = ReadFile(
|
||||
config=ReadFileToolConfig(workdir=tmp_path),
|
||||
config=ReadFileToolConfig(),
|
||||
state=AcpReadFileState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id=None,
|
||||
tool_call_id="test_call",
|
||||
client=mock_client, session_id=None, tool_call_id="test_call"
|
||||
),
|
||||
)
|
||||
|
||||
args = ReadFileArgs(path=str(test_file))
|
||||
with pytest.raises(ToolError) as exc_info:
|
||||
await tool.run(args)
|
||||
await collect_result(tool.run(args))
|
||||
|
||||
assert (
|
||||
str(exc_info.value)
|
||||
|
||||
@@ -2,9 +2,10 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from acp import ReadTextFileRequest, ReadTextFileResponse, WriteTextFileRequest
|
||||
from acp import ReadTextFileResponse
|
||||
import pytest
|
||||
|
||||
from tests.mock.utils import collect_result
|
||||
from vibe.acp.tools.builtins.search_replace import AcpSearchReplaceState, SearchReplace
|
||||
from vibe.core.tools.base import ToolError
|
||||
from vibe.core.tools.builtins.search_replace import (
|
||||
@@ -15,7 +16,7 @@ from vibe.core.tools.builtins.search_replace import (
|
||||
from vibe.core.types import ToolCallEvent, ToolResultEvent
|
||||
|
||||
|
||||
class MockConnection:
|
||||
class MockClient:
|
||||
def __init__(
|
||||
self,
|
||||
file_content: str = "original line 1\noriginal line 2\noriginal line 3",
|
||||
@@ -28,43 +29,59 @@ class MockConnection:
|
||||
self._read_text_file_called = False
|
||||
self._write_text_file_called = False
|
||||
self._session_update_called = False
|
||||
self._last_read_request: ReadTextFileRequest | None = None
|
||||
self._last_write_request: WriteTextFileRequest | None = None
|
||||
self._write_calls: list[WriteTextFileRequest] = []
|
||||
self._last_read_params: dict[str, str | int | None] = {}
|
||||
self._last_write_params: dict[str, str] = {}
|
||||
self._write_calls: list[dict[str, str]] = []
|
||||
|
||||
async def readTextFile(self, request: ReadTextFileRequest) -> ReadTextFileResponse:
|
||||
async def read_text_file(
|
||||
self,
|
||||
path: str,
|
||||
session_id: str,
|
||||
limit: int | None = None,
|
||||
line: int | None = None,
|
||||
**kwargs,
|
||||
) -> ReadTextFileResponse:
|
||||
self._read_text_file_called = True
|
||||
self._last_read_request = request
|
||||
self._last_read_params = {
|
||||
"path": path,
|
||||
"session_id": session_id,
|
||||
"limit": limit,
|
||||
"line": line,
|
||||
}
|
||||
|
||||
if self._read_error:
|
||||
raise self._read_error
|
||||
|
||||
return ReadTextFileResponse(content=self._file_content)
|
||||
|
||||
async def writeTextFile(self, request: WriteTextFileRequest) -> None:
|
||||
async def write_text_file(
|
||||
self, content: str, path: str, session_id: str, **kwargs
|
||||
) -> None:
|
||||
self._write_text_file_called = True
|
||||
self._last_write_request = request
|
||||
self._write_calls.append(request)
|
||||
params = {"content": content, "path": path, "session_id": session_id}
|
||||
self._last_write_params = params
|
||||
self._write_calls.append(params)
|
||||
|
||||
if self._write_error:
|
||||
raise self._write_error
|
||||
|
||||
async def sessionUpdate(self, notification) -> None:
|
||||
async def session_update(self, session_id: str, update, **kwargs) -> None:
|
||||
self._session_update_called = True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_connection() -> MockConnection:
|
||||
return MockConnection()
|
||||
def mock_client() -> MockClient:
|
||||
return MockClient()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def acp_search_replace_tool(
|
||||
mock_connection: MockConnection, tmp_path: Path
|
||||
mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> SearchReplace:
|
||||
config = SearchReplaceConfig(workdir=tmp_path)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
config = SearchReplaceConfig()
|
||||
state = AcpSearchReplaceState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
client=mock_client,
|
||||
session_id="test_session_123",
|
||||
tool_call_id="test_tool_call_456",
|
||||
)
|
||||
@@ -81,7 +98,7 @@ class TestAcpSearchReplaceExecution:
|
||||
async def test_run_success(
|
||||
self,
|
||||
acp_search_replace_tool: SearchReplace,
|
||||
mock_connection: MockConnection,
|
||||
mock_client: MockClient,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
test_file = tmp_path / "test_file.txt"
|
||||
@@ -92,41 +109,39 @@ class TestAcpSearchReplaceExecution:
|
||||
args = SearchReplaceArgs(
|
||||
file_path=str(test_file), content=search_replace_content
|
||||
)
|
||||
result = await acp_search_replace_tool.run(args)
|
||||
result = await collect_result(acp_search_replace_tool.run(args))
|
||||
|
||||
assert isinstance(result, SearchReplaceResult)
|
||||
assert result.file == str(test_file)
|
||||
assert result.blocks_applied == 1
|
||||
assert mock_connection._read_text_file_called
|
||||
assert mock_connection._write_text_file_called
|
||||
assert mock_connection._session_update_called
|
||||
assert mock_client._read_text_file_called
|
||||
assert mock_client._write_text_file_called
|
||||
assert mock_client._session_update_called
|
||||
|
||||
# Verify ReadTextFileRequest was created correctly
|
||||
read_request = mock_connection._last_read_request
|
||||
assert read_request is not None
|
||||
assert read_request.sessionId == "test_session_123"
|
||||
assert read_request.path == str(test_file)
|
||||
# Verify read_text_file was called correctly
|
||||
read_params = mock_client._last_read_params
|
||||
assert read_params["session_id"] == "test_session_123"
|
||||
assert read_params["path"] == str(test_file)
|
||||
|
||||
# Verify WriteTextFileRequest was created correctly
|
||||
write_request = mock_connection._last_write_request
|
||||
assert write_request is not None
|
||||
assert write_request.sessionId == "test_session_123"
|
||||
assert write_request.path == str(test_file)
|
||||
# Verify write_text_file was called correctly
|
||||
write_params = mock_client._last_write_params
|
||||
assert write_params["session_id"] == "test_session_123"
|
||||
assert write_params["path"] == str(test_file)
|
||||
assert (
|
||||
write_request.content == "original line 1\nmodified line 2\noriginal line 3"
|
||||
write_params["content"]
|
||||
== "original line 1\nmodified line 2\noriginal line 3"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_with_backup(
|
||||
self, mock_connection: MockConnection, tmp_path: Path
|
||||
self, mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
config = SearchReplaceConfig(create_backup=True, workdir=tmp_path)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
config = SearchReplaceConfig(create_backup=True)
|
||||
tool = SearchReplace(
|
||||
config=config,
|
||||
state=AcpSearchReplaceState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id="test_session",
|
||||
tool_call_id="test_call",
|
||||
client=mock_client, session_id="test_session", tool_call_id="test_call"
|
||||
),
|
||||
)
|
||||
|
||||
@@ -138,26 +153,25 @@ class TestAcpSearchReplaceExecution:
|
||||
args = SearchReplaceArgs(
|
||||
file_path=str(test_file), content=search_replace_content
|
||||
)
|
||||
result = await tool.run(args)
|
||||
result = await collect_result(tool.run(args))
|
||||
|
||||
assert result.blocks_applied == 1
|
||||
# Should have written the main file and the backup
|
||||
assert len(mock_connection._write_calls) >= 1
|
||||
assert len(mock_client._write_calls) >= 1
|
||||
# Check if backup was written (it should be written to .bak file)
|
||||
assert sum(w.path.endswith(".bak") for w in mock_connection._write_calls) == 1
|
||||
assert sum(w["path"].endswith(".bak") for w in mock_client._write_calls) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_read_error(
|
||||
self, mock_connection: MockConnection, tmp_path: Path
|
||||
self, mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
mock_connection._read_error = RuntimeError("File not found")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
mock_client._read_error = RuntimeError("File not found")
|
||||
|
||||
tool = SearchReplace(
|
||||
config=SearchReplaceConfig(workdir=tmp_path),
|
||||
config=SearchReplaceConfig(),
|
||||
state=AcpSearchReplaceState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id="test_session",
|
||||
tool_call_id="test_call",
|
||||
client=mock_client, session_id="test_session", tool_call_id="test_call"
|
||||
),
|
||||
)
|
||||
|
||||
@@ -168,7 +182,7 @@ class TestAcpSearchReplaceExecution:
|
||||
file_path=str(test_file), content=search_replace_content
|
||||
)
|
||||
with pytest.raises(ToolError) as exc_info:
|
||||
await tool.run(args)
|
||||
await collect_result(tool.run(args))
|
||||
|
||||
assert (
|
||||
str(exc_info.value)
|
||||
@@ -177,19 +191,18 @@ class TestAcpSearchReplaceExecution:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_write_error(
|
||||
self, mock_connection: MockConnection, tmp_path: Path
|
||||
self, mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
mock_connection._write_error = RuntimeError("Permission denied")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
mock_client._write_error = RuntimeError("Permission denied")
|
||||
test_file = tmp_path / "test.txt"
|
||||
test_file.touch()
|
||||
mock_connection._file_content = "old" # Update mock to return correct content
|
||||
mock_client._file_content = "old" # Update mock to return correct content
|
||||
|
||||
tool = SearchReplace(
|
||||
config=SearchReplaceConfig(workdir=tmp_path),
|
||||
config=SearchReplaceConfig(),
|
||||
state=AcpSearchReplaceState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id="test_session",
|
||||
tool_call_id="test_call",
|
||||
client=mock_client, session_id="test_session", tool_call_id="test_call"
|
||||
),
|
||||
)
|
||||
|
||||
@@ -198,21 +211,21 @@ class TestAcpSearchReplaceExecution:
|
||||
file_path=str(test_file), content=search_replace_content
|
||||
)
|
||||
with pytest.raises(ToolError) as exc_info:
|
||||
await tool.run(args)
|
||||
await collect_result(tool.run(args))
|
||||
|
||||
assert str(exc_info.value) == f"Error writing {test_file}: Permission denied"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"connection,session_id,expected_error",
|
||||
"client,session_id,expected_error",
|
||||
[
|
||||
(
|
||||
None,
|
||||
"test_session",
|
||||
"Connection not available in tool state. This tool can only be used within an ACP session.",
|
||||
"Client not available in tool state. This tool can only be used within an ACP session.",
|
||||
),
|
||||
(
|
||||
MockConnection(),
|
||||
MockClient(),
|
||||
None,
|
||||
"Session ID not available in tool state. This tool can only be used within an ACP session.",
|
||||
),
|
||||
@@ -221,18 +234,18 @@ class TestAcpSearchReplaceExecution:
|
||||
async def test_run_without_required_state(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
connection: MockConnection | None,
|
||||
client: MockClient | None,
|
||||
session_id: str | None,
|
||||
expected_error: str,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
test_file = tmp_path / "test.txt"
|
||||
test_file.touch()
|
||||
tool = SearchReplace(
|
||||
config=SearchReplaceConfig(workdir=tmp_path),
|
||||
config=SearchReplaceConfig(),
|
||||
state=AcpSearchReplaceState.model_construct(
|
||||
connection=connection, # type: ignore[arg-type]
|
||||
session_id=session_id,
|
||||
tool_call_id="test_call",
|
||||
client=client, session_id=session_id, tool_call_id="test_call"
|
||||
),
|
||||
)
|
||||
|
||||
@@ -241,7 +254,7 @@ class TestAcpSearchReplaceExecution:
|
||||
file_path=str(test_file), content=search_replace_content
|
||||
)
|
||||
with pytest.raises(ToolError) as exc_info:
|
||||
await tool.run(args)
|
||||
await collect_result(tool.run(args))
|
||||
|
||||
assert str(exc_info.value) == expected_error
|
||||
|
||||
@@ -262,16 +275,17 @@ class TestAcpSearchReplaceSessionUpdates:
|
||||
|
||||
update = SearchReplace.tool_call_session_update(event)
|
||||
assert update is not None
|
||||
assert update.sessionUpdate == "tool_call"
|
||||
assert update.toolCallId == "test_call_123"
|
||||
assert update.session_update == "tool_call"
|
||||
assert update.tool_call_id == "test_call_123"
|
||||
assert update.kind == "edit"
|
||||
assert update.title is not None
|
||||
assert update.content is not None
|
||||
assert isinstance(update.content, list)
|
||||
assert len(update.content) == 1
|
||||
assert update.content[0].type == "diff"
|
||||
assert update.content[0].path == "/tmp/test.txt"
|
||||
assert update.content[0].oldText == "old text"
|
||||
assert update.content[0].newText == "new text"
|
||||
assert update.content[0].old_text == "old text"
|
||||
assert update.content[0].new_text == "new text"
|
||||
assert update.locations is not None
|
||||
assert len(update.locations) == 1
|
||||
assert update.locations[0].path == "/tmp/test.txt"
|
||||
@@ -311,15 +325,16 @@ class TestAcpSearchReplaceSessionUpdates:
|
||||
|
||||
update = SearchReplace.tool_result_session_update(event)
|
||||
assert update is not None
|
||||
assert update.sessionUpdate == "tool_call_update"
|
||||
assert update.toolCallId == "test_call_123"
|
||||
assert update.session_update == "tool_call_update"
|
||||
assert update.tool_call_id == "test_call_123"
|
||||
assert update.status == "completed"
|
||||
assert update.content is not None
|
||||
assert isinstance(update.content, list)
|
||||
assert len(update.content) == 1
|
||||
assert update.content[0].type == "diff"
|
||||
assert update.content[0].path == "/tmp/test.txt"
|
||||
assert update.content[0].oldText == "old text"
|
||||
assert update.content[0].newText == "new text"
|
||||
assert update.content[0].old_text == "old text"
|
||||
assert update.content[0].new_text == "new text"
|
||||
assert update.locations is not None
|
||||
assert len(update.locations) == 1
|
||||
assert update.locations[0].path == "/tmp/test.txt"
|
||||
|
||||
@@ -1,207 +1,178 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from acp import AgentSideConnection, NewSessionRequest, SetSessionModeRequest
|
||||
import pytest
|
||||
|
||||
from tests.stubs.fake_backend import FakeBackend
|
||||
from tests.stubs.fake_connection import FakeAgentSideConnection
|
||||
from vibe.acp.acp_agent import VibeAcpAgent
|
||||
from vibe.core.agent import Agent
|
||||
from vibe.core.modes import AgentMode
|
||||
from vibe.core.types import LLMChunk, LLMMessage, LLMUsage, Role
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def backend() -> FakeBackend:
|
||||
backend = FakeBackend(
|
||||
LLMChunk(
|
||||
message=LLMMessage(role=Role.assistant, content="Hi"),
|
||||
usage=LLMUsage(prompt_tokens=1, completion_tokens=1),
|
||||
)
|
||||
)
|
||||
return backend
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def acp_agent(backend: FakeBackend) -> VibeAcpAgent:
|
||||
class PatchedAgent(Agent):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs, backend=backend)
|
||||
|
||||
patch("vibe.acp.acp_agent.VibeAgent", side_effect=PatchedAgent).start()
|
||||
|
||||
vibe_acp_agent: VibeAcpAgent | None = None
|
||||
|
||||
def _create_agent(connection: AgentSideConnection) -> VibeAcpAgent:
|
||||
nonlocal vibe_acp_agent
|
||||
vibe_acp_agent = VibeAcpAgent(connection)
|
||||
return vibe_acp_agent
|
||||
|
||||
FakeAgentSideConnection(_create_agent)
|
||||
return vibe_acp_agent # pyright: ignore[reportReturnType]
|
||||
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
|
||||
from vibe.core.agents.models import BuiltinAgentName
|
||||
|
||||
|
||||
class TestACPSetMode:
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_mode_to_default(self, acp_agent: VibeAcpAgent) -> None:
|
||||
session_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
async def test_set_mode_to_default(self, acp_agent_loop: VibeAcpAgentLoop) -> None:
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
session_id = session_response.sessionId
|
||||
session_id = session_response.session_id
|
||||
acp_session = next(
|
||||
(s for s in acp_agent.sessions.values() if s.id == session_id), None
|
||||
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
|
||||
)
|
||||
assert acp_session is not None
|
||||
|
||||
await acp_session.agent.switch_mode(AgentMode.AUTO_APPROVE)
|
||||
await acp_session.agent_loop.switch_agent(BuiltinAgentName.AUTO_APPROVE)
|
||||
|
||||
response = await acp_agent.setSessionMode(
|
||||
SetSessionModeRequest(sessionId=session_id, modeId=AgentMode.DEFAULT.value)
|
||||
response = await acp_agent_loop.set_session_mode(
|
||||
session_id=session_id, mode_id=BuiltinAgentName.DEFAULT
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert acp_session.agent.mode == AgentMode.DEFAULT
|
||||
assert acp_session.agent.auto_approve is False
|
||||
assert acp_session.agent_loop.agent_profile.name == BuiltinAgentName.DEFAULT
|
||||
assert acp_session.agent_loop.auto_approve is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_mode_to_auto_approve(self, acp_agent: VibeAcpAgent) -> None:
|
||||
session_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
async def test_set_mode_to_auto_approve(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
) -> None:
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
session_id = session_response.sessionId
|
||||
session_id = session_response.session_id
|
||||
acp_session = next(
|
||||
(s for s in acp_agent.sessions.values() if s.id == session_id), None
|
||||
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
|
||||
)
|
||||
assert acp_session is not None
|
||||
|
||||
assert acp_session.agent.mode == AgentMode.DEFAULT
|
||||
assert acp_session.agent.auto_approve is False
|
||||
assert acp_session.agent_loop.agent_profile.name == BuiltinAgentName.DEFAULT
|
||||
assert acp_session.agent_loop.auto_approve is False
|
||||
|
||||
response = await acp_agent.setSessionMode(
|
||||
SetSessionModeRequest(
|
||||
sessionId=session_id, modeId=AgentMode.AUTO_APPROVE.value
|
||||
)
|
||||
response = await acp_agent_loop.set_session_mode(
|
||||
session_id=session_id, mode_id=BuiltinAgentName.AUTO_APPROVE
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert acp_session.agent.mode == AgentMode.AUTO_APPROVE
|
||||
assert acp_session.agent.auto_approve is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_mode_to_plan(self, acp_agent: VibeAcpAgent) -> None:
|
||||
session_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
)
|
||||
session_id = session_response.sessionId
|
||||
acp_session = next(
|
||||
(s for s in acp_agent.sessions.values() if s.id == session_id), None
|
||||
)
|
||||
assert acp_session is not None
|
||||
|
||||
assert acp_session.agent.mode == AgentMode.DEFAULT
|
||||
|
||||
response = await acp_agent.setSessionMode(
|
||||
SetSessionModeRequest(sessionId=session_id, modeId=AgentMode.PLAN.value)
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert acp_session.agent.mode == AgentMode.PLAN
|
||||
assert (
|
||||
acp_session.agent.auto_approve is True
|
||||
acp_session.agent_loop.agent_profile.name == BuiltinAgentName.AUTO_APPROVE
|
||||
)
|
||||
assert acp_session.agent_loop.auto_approve is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_mode_to_plan(self, acp_agent_loop: VibeAcpAgentLoop) -> None:
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
session_id = session_response.session_id
|
||||
acp_session = next(
|
||||
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
|
||||
)
|
||||
assert acp_session is not None
|
||||
|
||||
assert acp_session.agent_loop.agent_profile.name == BuiltinAgentName.DEFAULT
|
||||
|
||||
response = await acp_agent_loop.set_session_mode(
|
||||
session_id=session_id, mode_id=BuiltinAgentName.PLAN
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert acp_session.agent_loop.agent_profile.name == BuiltinAgentName.PLAN
|
||||
assert (
|
||||
acp_session.agent_loop.auto_approve is True
|
||||
) # Plan mode auto-approves read-only tools
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_mode_to_accept_edits(self, acp_agent: VibeAcpAgent) -> None:
|
||||
session_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
async def test_set_mode_to_accept_edits(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
) -> None:
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
session_id = session_response.sessionId
|
||||
session_id = session_response.session_id
|
||||
acp_session = next(
|
||||
(s for s in acp_agent.sessions.values() if s.id == session_id), None
|
||||
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
|
||||
)
|
||||
assert acp_session is not None
|
||||
|
||||
assert acp_session.agent.mode == AgentMode.DEFAULT
|
||||
assert acp_session.agent_loop.agent_profile.name == BuiltinAgentName.DEFAULT
|
||||
|
||||
response = await acp_agent.setSessionMode(
|
||||
SetSessionModeRequest(
|
||||
sessionId=session_id, modeId=AgentMode.ACCEPT_EDITS.value
|
||||
)
|
||||
response = await acp_agent_loop.set_session_mode(
|
||||
session_id=session_id, mode_id=BuiltinAgentName.ACCEPT_EDITS
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert acp_session.agent.mode == AgentMode.ACCEPT_EDITS
|
||||
assert (
|
||||
acp_session.agent.auto_approve is False
|
||||
acp_session.agent_loop.agent_profile.name == BuiltinAgentName.ACCEPT_EDITS
|
||||
)
|
||||
assert (
|
||||
acp_session.agent_loop.auto_approve is False
|
||||
) # Accept Edits mode doesn't auto-approve all
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_mode_invalid_mode_returns_none(
|
||||
self, acp_agent: VibeAcpAgent
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
) -> None:
|
||||
session_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
session_id = session_response.sessionId
|
||||
session_id = session_response.session_id
|
||||
acp_session = next(
|
||||
(s for s in acp_agent.sessions.values() if s.id == session_id), None
|
||||
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
|
||||
)
|
||||
assert acp_session is not None
|
||||
|
||||
initial_mode = acp_session.agent.mode
|
||||
initial_auto_approve = acp_session.agent.auto_approve
|
||||
initial_agent = acp_session.agent_loop.agent_profile.name
|
||||
initial_auto_approve = acp_session.agent_loop.auto_approve
|
||||
|
||||
response = await acp_agent.setSessionMode(
|
||||
SetSessionModeRequest(sessionId=session_id, modeId="invalid-mode")
|
||||
response = await acp_agent_loop.set_session_mode(
|
||||
session_id=session_id, mode_id="invalid-mode"
|
||||
)
|
||||
|
||||
assert response is None
|
||||
assert acp_session.agent.mode == initial_mode
|
||||
assert acp_session.agent.auto_approve == initial_auto_approve
|
||||
assert acp_session.agent_loop.agent_profile.name == initial_agent
|
||||
assert acp_session.agent_loop.auto_approve == initial_auto_approve
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_mode_to_same_mode(self, acp_agent: VibeAcpAgent) -> None:
|
||||
session_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
async def test_set_mode_to_same_mode(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
) -> None:
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
session_id = session_response.sessionId
|
||||
session_id = session_response.session_id
|
||||
acp_session = next(
|
||||
(s for s in acp_agent.sessions.values() if s.id == session_id), None
|
||||
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
|
||||
)
|
||||
assert acp_session is not None
|
||||
|
||||
initial_mode = AgentMode.DEFAULT
|
||||
assert acp_session.agent.mode == initial_mode
|
||||
assert acp_session.agent_loop.agent_profile.name == BuiltinAgentName.DEFAULT
|
||||
|
||||
response = await acp_agent.setSessionMode(
|
||||
SetSessionModeRequest(sessionId=session_id, modeId=initial_mode.value)
|
||||
response = await acp_agent_loop.set_session_mode(
|
||||
session_id=session_id, mode_id=BuiltinAgentName.DEFAULT
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert acp_session.agent.mode == initial_mode
|
||||
assert acp_session.agent.auto_approve is False
|
||||
assert acp_session.agent_loop.agent_profile.name == BuiltinAgentName.DEFAULT
|
||||
assert acp_session.agent_loop.auto_approve is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_mode_with_empty_string(self, acp_agent: VibeAcpAgent) -> None:
|
||||
session_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
async def test_set_mode_with_empty_string(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
) -> None:
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
session_id = session_response.sessionId
|
||||
session_id = session_response.session_id
|
||||
acp_session = next(
|
||||
(s for s in acp_agent.sessions.values() if s.id == session_id), None
|
||||
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
|
||||
)
|
||||
assert acp_session is not None
|
||||
|
||||
initial_mode = acp_session.agent.mode
|
||||
initial_auto_approve = acp_session.agent.auto_approve
|
||||
initial_agent = acp_session.agent_loop.agent_profile.name
|
||||
initial_auto_approve = acp_session.agent_loop.auto_approve
|
||||
|
||||
response = await acp_agent.setSessionMode(
|
||||
SetSessionModeRequest(sessionId=session_id, modeId="")
|
||||
response = await acp_agent_loop.set_session_mode(
|
||||
session_id=session_id, mode_id=""
|
||||
)
|
||||
|
||||
assert response is None
|
||||
assert acp_session.agent.mode == initial_mode
|
||||
assert acp_session.agent.auto_approve == initial_auto_approve
|
||||
assert acp_session.agent_loop.agent_profile.name == initial_agent
|
||||
assert acp_session.agent_loop.auto_approve == initial_auto_approve
|
||||
|
||||
@@ -3,30 +3,17 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from acp import AgentSideConnection, NewSessionRequest, SetSessionModelRequest
|
||||
import pytest
|
||||
|
||||
from tests.stubs.fake_backend import FakeBackend
|
||||
from tests.stubs.fake_connection import FakeAgentSideConnection
|
||||
from vibe.acp.acp_agent import VibeAcpAgent
|
||||
from vibe.core.agent import Agent
|
||||
from tests.acp.conftest import _create_acp_agent
|
||||
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
|
||||
from vibe.core.agent_loop import AgentLoop
|
||||
from vibe.core.config import ModelConfig, VibeConfig
|
||||
from vibe.core.types import LLMChunk, LLMMessage, LLMUsage, Role
|
||||
from vibe.core.types import LLMMessage, Role
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def backend() -> FakeBackend:
|
||||
backend = FakeBackend(
|
||||
LLMChunk(
|
||||
message=LLMMessage(role=Role.assistant, content="Hi"),
|
||||
usage=LLMUsage(prompt_tokens=1, completion_tokens=1),
|
||||
)
|
||||
)
|
||||
return backend
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def acp_agent(backend: FakeBackend) -> VibeAcpAgent:
|
||||
def acp_agent_loop(backend) -> VibeAcpAgentLoop:
|
||||
config = VibeConfig(
|
||||
active_model="devstral-latest",
|
||||
models=[
|
||||
@@ -49,10 +36,11 @@ def acp_agent(backend: FakeBackend) -> VibeAcpAgent:
|
||||
|
||||
VibeConfig.dump_config(config.model_dump())
|
||||
|
||||
class PatchedAgent(Agent):
|
||||
class PatchedAgentLoop(AgentLoop):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **{**kwargs, "backend": backend})
|
||||
self.config = config
|
||||
self._base_config = config
|
||||
self.agent_manager.invalidate_config()
|
||||
try:
|
||||
active_model = config.get_active_model()
|
||||
self.stats.input_price_per_million = active_model.input_price
|
||||
@@ -60,90 +48,86 @@ def acp_agent(backend: FakeBackend) -> VibeAcpAgent:
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
patch("vibe.acp.acp_agent.VibeAgent", side_effect=PatchedAgent).start()
|
||||
patch("vibe.acp.acp_agent_loop.AgentLoop", side_effect=PatchedAgentLoop).start()
|
||||
|
||||
vibe_acp_agent: VibeAcpAgent | None = None
|
||||
|
||||
def _create_agent(connection: AgentSideConnection) -> VibeAcpAgent:
|
||||
nonlocal vibe_acp_agent
|
||||
vibe_acp_agent = VibeAcpAgent(connection)
|
||||
return vibe_acp_agent
|
||||
|
||||
FakeAgentSideConnection(_create_agent)
|
||||
return vibe_acp_agent # pyright: ignore[reportReturnType]
|
||||
return _create_acp_agent()
|
||||
|
||||
|
||||
class TestACPSetModel:
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_model_success(self, acp_agent: VibeAcpAgent) -> None:
|
||||
session_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
async def test_set_model_success(self, acp_agent_loop: VibeAcpAgentLoop) -> None:
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
session_id = session_response.sessionId
|
||||
session_id = session_response.session_id
|
||||
acp_session = next(
|
||||
(s for s in acp_agent.sessions.values() if s.id == session_id), None
|
||||
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
|
||||
)
|
||||
assert acp_session is not None
|
||||
assert acp_session.agent.config.active_model == "devstral-latest"
|
||||
assert acp_session.agent_loop.config.active_model == "devstral-latest"
|
||||
|
||||
response = await acp_agent.setSessionModel(
|
||||
SetSessionModelRequest(sessionId=session_id, modelId="devstral-small")
|
||||
response = await acp_agent_loop.set_session_model(
|
||||
session_id=session_id, model_id="devstral-small"
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert acp_session.agent.config.active_model == "devstral-small"
|
||||
assert acp_session.agent_loop.config.active_model == "devstral-small"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_model_invalid_model_returns_none(
|
||||
self, acp_agent: VibeAcpAgent
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
) -> None:
|
||||
session_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
session_id = session_response.sessionId
|
||||
session_id = session_response.session_id
|
||||
acp_session = next(
|
||||
(s for s in acp_agent.sessions.values() if s.id == session_id), None
|
||||
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
|
||||
)
|
||||
assert acp_session is not None
|
||||
initial_model = acp_session.agent.config.active_model
|
||||
initial_model = acp_session.agent_loop.config.active_model
|
||||
|
||||
response = await acp_agent.setSessionModel(
|
||||
SetSessionModelRequest(sessionId=session_id, modelId="non-existent-model")
|
||||
response = await acp_agent_loop.set_session_model(
|
||||
session_id=session_id, model_id="non-existent-model"
|
||||
)
|
||||
|
||||
assert response is None
|
||||
assert acp_session.agent.config.active_model == initial_model
|
||||
assert acp_session.agent_loop.config.active_model == initial_model
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_model_to_same_model(self, acp_agent: VibeAcpAgent) -> None:
|
||||
session_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
async def test_set_model_to_same_model(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
) -> None:
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
session_id = session_response.sessionId
|
||||
session_id = session_response.session_id
|
||||
acp_session = next(
|
||||
(s for s in acp_agent.sessions.values() if s.id == session_id), None
|
||||
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
|
||||
)
|
||||
initial_model = "devstral-latest"
|
||||
assert acp_session is not None
|
||||
assert acp_session.agent.config.active_model == initial_model
|
||||
assert acp_session.agent_loop.config.active_model == initial_model
|
||||
|
||||
response = await acp_agent.setSessionModel(
|
||||
SetSessionModelRequest(sessionId=session_id, modelId=initial_model)
|
||||
response = await acp_agent_loop.set_session_model(
|
||||
session_id=session_id, model_id=initial_model
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert acp_session.agent.config.active_model == initial_model
|
||||
assert acp_session.agent_loop.config.active_model == initial_model
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_model_saves_to_config(self, acp_agent: VibeAcpAgent) -> None:
|
||||
session_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
async def test_set_model_saves_to_config(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
) -> None:
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
session_id = session_response.sessionId
|
||||
session_id = session_response.session_id
|
||||
|
||||
with patch("vibe.acp.acp_agent.VibeConfig.save_updates") as mock_save:
|
||||
response = await acp_agent.setSessionModel(
|
||||
SetSessionModelRequest(sessionId=session_id, modelId="devstral-small")
|
||||
with patch("vibe.acp.acp_agent_loop.VibeConfig.save_updates") as mock_save:
|
||||
response = await acp_agent_loop.set_session_model(
|
||||
session_id=session_id, model_id="devstral-small"
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
@@ -151,157 +135,171 @@ class TestACPSetModel:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_model_does_not_save_on_invalid_model(
|
||||
self, acp_agent: VibeAcpAgent
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
) -> None:
|
||||
session_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
session_id = session_response.sessionId
|
||||
session_id = session_response.session_id
|
||||
|
||||
with patch("vibe.acp.acp_agent.VibeConfig.save_updates") as mock_save:
|
||||
response = await acp_agent.setSessionModel(
|
||||
SetSessionModelRequest(
|
||||
sessionId=session_id, modelId="non-existent-model"
|
||||
)
|
||||
with patch("vibe.acp.acp_agent_loop.VibeConfig.save_updates") as mock_save:
|
||||
response = await acp_agent_loop.set_session_model(
|
||||
session_id=session_id, model_id="non-existent-model"
|
||||
)
|
||||
|
||||
assert response is None
|
||||
mock_save.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_model_with_empty_string(self, acp_agent: VibeAcpAgent) -> None:
|
||||
session_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
async def test_set_model_with_empty_string(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
) -> None:
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
session_id = session_response.sessionId
|
||||
session_id = session_response.session_id
|
||||
acp_session = next(
|
||||
(s for s in acp_agent.sessions.values() if s.id == session_id), None
|
||||
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
|
||||
)
|
||||
assert acp_session is not None
|
||||
|
||||
initial_model = acp_session.agent.config.active_model
|
||||
initial_model = acp_session.agent_loop.config.active_model
|
||||
|
||||
response = await acp_agent.setSessionModel(
|
||||
SetSessionModelRequest(sessionId=session_id, modelId="")
|
||||
response = await acp_agent_loop.set_session_model(
|
||||
session_id=session_id, model_id=""
|
||||
)
|
||||
|
||||
assert response is None
|
||||
assert acp_session.agent.config.active_model == initial_model
|
||||
assert acp_session.agent_loop.config.active_model == initial_model
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_model_updates_active_model(
|
||||
self, acp_agent: VibeAcpAgent
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
) -> None:
|
||||
session_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
session_id = session_response.sessionId
|
||||
session_id = session_response.session_id
|
||||
acp_session = next(
|
||||
(s for s in acp_agent.sessions.values() if s.id == session_id), None
|
||||
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
|
||||
)
|
||||
assert acp_session is not None
|
||||
assert acp_session.agent.config.get_active_model().alias == "devstral-latest"
|
||||
|
||||
await acp_agent.setSessionModel(
|
||||
SetSessionModelRequest(sessionId=session_id, modelId="devstral-small")
|
||||
assert (
|
||||
acp_session.agent_loop.config.get_active_model().alias == "devstral-latest"
|
||||
)
|
||||
|
||||
assert acp_session.agent.config.get_active_model().alias == "devstral-small"
|
||||
await acp_agent_loop.set_session_model(
|
||||
session_id=session_id, model_id="devstral-small"
|
||||
)
|
||||
|
||||
assert (
|
||||
acp_session.agent_loop.config.get_active_model().alias == "devstral-small"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_model_calls_reload_with_initial_messages(
|
||||
self, acp_agent: VibeAcpAgent
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
) -> None:
|
||||
session_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
session_id = session_response.sessionId
|
||||
session_id = session_response.session_id
|
||||
acp_session = next(
|
||||
(s for s in acp_agent.sessions.values() if s.id == session_id), None
|
||||
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
|
||||
)
|
||||
assert acp_session is not None
|
||||
|
||||
with patch.object(
|
||||
acp_session.agent, "reload_with_initial_messages"
|
||||
acp_session.agent_loop, "reload_with_initial_messages"
|
||||
) as mock_reload:
|
||||
response = await acp_agent.setSessionModel(
|
||||
SetSessionModelRequest(sessionId=session_id, modelId="devstral-small")
|
||||
response = await acp_agent_loop.set_session_model(
|
||||
session_id=session_id, model_id="devstral-small"
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
mock_reload.assert_called_once()
|
||||
call_args = mock_reload.call_args
|
||||
assert call_args.kwargs["config"] is not None
|
||||
assert call_args.kwargs["config"].active_model == "devstral-small"
|
||||
assert call_args.kwargs["base_config"] is not None
|
||||
assert call_args.kwargs["base_config"].active_model == "devstral-small"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_model_preserves_conversation_history(
|
||||
self, acp_agent: VibeAcpAgent
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
) -> None:
|
||||
session_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
session_id = session_response.sessionId
|
||||
session_id = session_response.session_id
|
||||
acp_session = next(
|
||||
(s for s in acp_agent.sessions.values() if s.id == session_id), None
|
||||
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
|
||||
)
|
||||
assert acp_session is not None
|
||||
|
||||
user_msg = LLMMessage(role=Role.user, content="Hello")
|
||||
assistant_msg = LLMMessage(role=Role.assistant, content="Hi there!")
|
||||
acp_session.agent.messages.append(user_msg)
|
||||
acp_session.agent.messages.append(assistant_msg)
|
||||
acp_session.agent_loop.messages.append(user_msg)
|
||||
acp_session.agent_loop.messages.append(assistant_msg)
|
||||
|
||||
assert len(acp_session.agent.messages) == 3
|
||||
assert len(acp_session.agent_loop.messages) == 3
|
||||
|
||||
response = await acp_agent.setSessionModel(
|
||||
SetSessionModelRequest(sessionId=session_id, modelId="devstral-small")
|
||||
response = await acp_agent_loop.set_session_model(
|
||||
session_id=session_id, model_id="devstral-small"
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert len(acp_session.agent.messages) == 3
|
||||
assert acp_session.agent.messages[0].role == Role.system
|
||||
assert acp_session.agent.messages[1].content == "Hello"
|
||||
assert acp_session.agent.messages[2].content == "Hi there!"
|
||||
assert len(acp_session.agent_loop.messages) == 3
|
||||
assert acp_session.agent_loop.messages[0].role == Role.system
|
||||
assert acp_session.agent_loop.messages[1].content == "Hello"
|
||||
assert acp_session.agent_loop.messages[2].content == "Hi there!"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_model_resets_stats_with_new_model_pricing(
|
||||
self, acp_agent: VibeAcpAgent
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
) -> None:
|
||||
session_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
session_id = session_response.sessionId
|
||||
session_id = session_response.session_id
|
||||
acp_session = next(
|
||||
(s for s in acp_agent.sessions.values() if s.id == session_id), None
|
||||
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
|
||||
)
|
||||
assert acp_session is not None
|
||||
|
||||
initial_model = acp_session.agent.config.get_active_model()
|
||||
initial_model = acp_session.agent_loop.config.get_active_model()
|
||||
initial_input_price = initial_model.input_price
|
||||
initial_output_price = initial_model.output_price
|
||||
|
||||
initial_stats_input = acp_session.agent.stats.input_price_per_million
|
||||
initial_stats_output = acp_session.agent.stats.output_price_per_million
|
||||
initial_stats_input = acp_session.agent_loop.stats.input_price_per_million
|
||||
initial_stats_output = acp_session.agent_loop.stats.output_price_per_million
|
||||
|
||||
assert acp_session.agent.stats.input_price_per_million == initial_input_price
|
||||
assert acp_session.agent.stats.output_price_per_million == initial_output_price
|
||||
assert (
|
||||
acp_session.agent_loop.stats.input_price_per_million == initial_input_price
|
||||
)
|
||||
assert (
|
||||
acp_session.agent_loop.stats.output_price_per_million
|
||||
== initial_output_price
|
||||
)
|
||||
|
||||
response = await acp_agent.setSessionModel(
|
||||
SetSessionModelRequest(sessionId=session_id, modelId="devstral-small")
|
||||
response = await acp_agent_loop.set_session_model(
|
||||
session_id=session_id, model_id="devstral-small"
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
|
||||
new_model = acp_session.agent.config.get_active_model()
|
||||
new_model = acp_session.agent_loop.config.get_active_model()
|
||||
new_input_price = new_model.input_price
|
||||
new_output_price = new_model.output_price
|
||||
|
||||
assert new_input_price != initial_input_price
|
||||
assert new_output_price != initial_output_price
|
||||
|
||||
assert acp_session.agent.stats.input_price_per_million == new_input_price
|
||||
assert acp_session.agent.stats.output_price_per_million == new_output_price
|
||||
assert acp_session.agent_loop.stats.input_price_per_million == new_input_price
|
||||
assert acp_session.agent_loop.stats.output_price_per_million == new_output_price
|
||||
|
||||
assert acp_session.agent.stats.input_price_per_million != initial_stats_input
|
||||
assert acp_session.agent.stats.output_price_per_million != initial_stats_output
|
||||
assert (
|
||||
acp_session.agent_loop.stats.input_price_per_million != initial_stats_input
|
||||
)
|
||||
assert (
|
||||
acp_session.agent_loop.stats.output_price_per_million
|
||||
!= initial_stats_output
|
||||
)
|
||||
|
||||
@@ -2,9 +2,9 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from acp import WriteTextFileRequest
|
||||
import pytest
|
||||
|
||||
from tests.mock.utils import collect_result
|
||||
from vibe.acp.tools.builtins.write_file import AcpWriteFileState, WriteFile
|
||||
from vibe.core.tools.base import ToolError
|
||||
from vibe.core.tools.builtins.write_file import (
|
||||
@@ -15,7 +15,7 @@ from vibe.core.tools.builtins.write_file import (
|
||||
from vibe.core.types import ToolCallEvent, ToolResultEvent
|
||||
|
||||
|
||||
class MockConnection:
|
||||
class MockClient:
|
||||
def __init__(
|
||||
self, write_error: Exception | None = None, file_exists: bool = False
|
||||
) -> None:
|
||||
@@ -23,29 +23,38 @@ class MockConnection:
|
||||
self._file_exists = file_exists
|
||||
self._write_text_file_called = False
|
||||
self._session_update_called = False
|
||||
self._last_write_request: WriteTextFileRequest | None = None
|
||||
self._last_write_params: dict[str, str] = {}
|
||||
|
||||
async def writeTextFile(self, request: WriteTextFileRequest) -> None:
|
||||
async def write_text_file(
|
||||
self, content: str, path: str, session_id: str, **kwargs
|
||||
) -> None:
|
||||
self._write_text_file_called = True
|
||||
self._last_write_request = request
|
||||
self._last_write_params = {
|
||||
"content": content,
|
||||
"path": path,
|
||||
"session_id": session_id,
|
||||
}
|
||||
|
||||
if self._write_error:
|
||||
raise self._write_error
|
||||
|
||||
async def sessionUpdate(self, notification) -> None:
|
||||
async def session_update(self, session_id: str, update, **kwargs) -> None:
|
||||
self._session_update_called = True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_connection() -> MockConnection:
|
||||
return MockConnection()
|
||||
def mock_client() -> MockClient:
|
||||
return MockClient()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def acp_write_file_tool(mock_connection: MockConnection, tmp_path: Path) -> WriteFile:
|
||||
config = WriteFileConfig(workdir=tmp_path)
|
||||
def acp_write_file_tool(
|
||||
mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> WriteFile:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
config = WriteFileConfig()
|
||||
state = AcpWriteFileState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
client=mock_client,
|
||||
session_id="test_session_123",
|
||||
tool_call_id="test_tool_call_456",
|
||||
)
|
||||
@@ -60,40 +69,35 @@ class TestAcpWriteFileBasic:
|
||||
class TestAcpWriteFileExecution:
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_success_new_file(
|
||||
self,
|
||||
acp_write_file_tool: WriteFile,
|
||||
mock_connection: MockConnection,
|
||||
tmp_path: Path,
|
||||
self, acp_write_file_tool: WriteFile, mock_client: MockClient, tmp_path: Path
|
||||
) -> None:
|
||||
test_file = tmp_path / "test_file.txt"
|
||||
args = WriteFileArgs(path=str(test_file), content="Hello, world!")
|
||||
result = await acp_write_file_tool.run(args)
|
||||
result = await collect_result(acp_write_file_tool.run(args))
|
||||
|
||||
assert isinstance(result, WriteFileResult)
|
||||
assert result.path == str(test_file)
|
||||
assert result.content == "Hello, world!"
|
||||
assert result.bytes_written == len(b"Hello, world!")
|
||||
assert result.file_existed is False
|
||||
assert mock_connection._write_text_file_called
|
||||
assert mock_connection._session_update_called
|
||||
assert mock_client._write_text_file_called
|
||||
assert mock_client._session_update_called
|
||||
|
||||
# Verify WriteTextFileRequest was created correctly
|
||||
request = mock_connection._last_write_request
|
||||
assert request is not None
|
||||
assert request.sessionId == "test_session_123"
|
||||
assert request.path == str(test_file)
|
||||
assert request.content == "Hello, world!"
|
||||
# Verify write_text_file was called correctly
|
||||
params = mock_client._last_write_params
|
||||
assert params["session_id"] == "test_session_123"
|
||||
assert params["path"] == str(test_file)
|
||||
assert params["content"] == "Hello, world!"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_success_overwrite(
|
||||
self, mock_connection: MockConnection, tmp_path: Path
|
||||
self, mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
tool = WriteFile(
|
||||
config=WriteFileConfig(workdir=tmp_path),
|
||||
config=WriteFileConfig(),
|
||||
state=AcpWriteFileState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id="test_session",
|
||||
tool_call_id="test_call",
|
||||
client=mock_client, session_id="test_session", tool_call_id="test_call"
|
||||
),
|
||||
)
|
||||
|
||||
@@ -102,78 +106,80 @@ class TestAcpWriteFileExecution:
|
||||
# Simulate existing file by checking in the core tool logic
|
||||
# The ACP tool doesn't check existence, it's handled by the core tool
|
||||
args = WriteFileArgs(path=str(test_file), content="New content", overwrite=True)
|
||||
result = await tool.run(args)
|
||||
result = await collect_result(tool.run(args))
|
||||
|
||||
assert isinstance(result, WriteFileResult)
|
||||
assert result.path == str(test_file)
|
||||
assert result.content == "New content"
|
||||
assert result.bytes_written == len(b"New content")
|
||||
assert result.file_existed is True
|
||||
assert mock_connection._write_text_file_called
|
||||
assert mock_connection._session_update_called
|
||||
assert mock_client._write_text_file_called
|
||||
assert mock_client._session_update_called
|
||||
|
||||
# Verify WriteTextFileRequest was created correctly
|
||||
request = mock_connection._last_write_request
|
||||
assert request is not None
|
||||
assert request.sessionId == "test_session"
|
||||
assert request.path == str(test_file)
|
||||
assert request.content == "New content"
|
||||
# Verify write_text_file was called correctly
|
||||
params = mock_client._last_write_params
|
||||
assert params["session_id"] == "test_session"
|
||||
assert params["path"] == str(test_file)
|
||||
assert params["content"] == "New content"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_write_error(
|
||||
self, mock_connection: MockConnection, tmp_path: Path
|
||||
self, mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
mock_connection._write_error = RuntimeError("Permission denied")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
mock_client._write_error = RuntimeError("Permission denied")
|
||||
|
||||
tool = WriteFile(
|
||||
config=WriteFileConfig(workdir=tmp_path),
|
||||
config=WriteFileConfig(),
|
||||
state=AcpWriteFileState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id="test_session",
|
||||
tool_call_id="test_call",
|
||||
client=mock_client, session_id="test_session", tool_call_id="test_call"
|
||||
),
|
||||
)
|
||||
|
||||
test_file = tmp_path / "test.txt"
|
||||
args = WriteFileArgs(path=str(test_file), content="test")
|
||||
with pytest.raises(ToolError) as exc_info:
|
||||
await tool.run(args)
|
||||
await collect_result(tool.run(args))
|
||||
|
||||
assert str(exc_info.value) == f"Error writing {test_file}: Permission denied"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_without_connection(self, tmp_path: Path) -> None:
|
||||
async def test_run_without_connection(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
tool = WriteFile(
|
||||
config=WriteFileConfig(workdir=tmp_path),
|
||||
config=WriteFileConfig(),
|
||||
state=AcpWriteFileState.model_construct(
|
||||
connection=None, session_id="test_session", tool_call_id="test_call"
|
||||
client=None, session_id="test_session", tool_call_id="test_call"
|
||||
),
|
||||
)
|
||||
|
||||
args = WriteFileArgs(path=str(tmp_path / "test.txt"), content="test")
|
||||
with pytest.raises(ToolError) as exc_info:
|
||||
await tool.run(args)
|
||||
await collect_result(tool.run(args))
|
||||
|
||||
assert (
|
||||
str(exc_info.value)
|
||||
== "Connection not available in tool state. This tool can only be used within an ACP session."
|
||||
== "Client not available in tool state. This tool can only be used within an ACP session."
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_without_session_id(self, tmp_path: Path) -> None:
|
||||
mock_connection = MockConnection()
|
||||
async def test_run_without_session_id(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
mock_client = MockClient()
|
||||
tool = WriteFile(
|
||||
config=WriteFileConfig(workdir=tmp_path),
|
||||
config=WriteFileConfig(),
|
||||
state=AcpWriteFileState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id=None,
|
||||
tool_call_id="test_call",
|
||||
client=mock_client, session_id=None, tool_call_id="test_call"
|
||||
),
|
||||
)
|
||||
|
||||
args = WriteFileArgs(path=str(tmp_path / "test.txt"), content="test")
|
||||
with pytest.raises(ToolError) as exc_info:
|
||||
await tool.run(args)
|
||||
await collect_result(tool.run(args))
|
||||
|
||||
assert (
|
||||
str(exc_info.value)
|
||||
@@ -192,16 +198,17 @@ class TestAcpWriteFileSessionUpdates:
|
||||
|
||||
update = WriteFile.tool_call_session_update(event)
|
||||
assert update is not None
|
||||
assert update.sessionUpdate == "tool_call"
|
||||
assert update.toolCallId == "test_call_123"
|
||||
assert update.session_update == "tool_call"
|
||||
assert update.tool_call_id == "test_call_123"
|
||||
assert update.kind == "edit"
|
||||
assert update.title is not None
|
||||
assert update.content is not None
|
||||
assert isinstance(update.content, list)
|
||||
assert len(update.content) == 1
|
||||
assert update.content[0].type == "diff"
|
||||
assert update.content[0].path == "/tmp/test.txt"
|
||||
assert update.content[0].oldText is None
|
||||
assert update.content[0].newText == "Hello"
|
||||
assert update.content[0].old_text is None
|
||||
assert update.content[0].new_text == "Hello"
|
||||
assert update.locations is not None
|
||||
assert len(update.locations) == 1
|
||||
assert update.locations[0].path == "/tmp/test.txt"
|
||||
@@ -241,15 +248,16 @@ class TestAcpWriteFileSessionUpdates:
|
||||
|
||||
update = WriteFile.tool_result_session_update(event)
|
||||
assert update is not None
|
||||
assert update.sessionUpdate == "tool_call_update"
|
||||
assert update.toolCallId == "test_call_123"
|
||||
assert update.session_update == "tool_call_update"
|
||||
assert update.tool_call_id == "test_call_123"
|
||||
assert update.status == "completed"
|
||||
assert update.content is not None
|
||||
assert isinstance(update.content, list)
|
||||
assert len(update.content) == 1
|
||||
assert update.content[0].type == "diff"
|
||||
assert update.content[0].path == "/tmp/test.txt"
|
||||
assert update.content[0].oldText is None
|
||||
assert update.content[0].newText == "Hello"
|
||||
assert update.content[0].old_text is None
|
||||
assert update.content[0].new_text == "Hello"
|
||||
assert update.locations is not None
|
||||
assert len(update.locations) == 1
|
||||
assert update.locations[0].path == "/tmp/test.txt"
|
||||
|
||||
@@ -61,7 +61,7 @@ def make_controller(
|
||||
("/exit", "Exit application"),
|
||||
("/vim", "Toggle vim keybindings"),
|
||||
]
|
||||
completer = CommandCompleter(commands)
|
||||
completer = CommandCompleter(lambda: commands)
|
||||
view = StubView()
|
||||
controller = SlashCommandController(completer, view)
|
||||
|
||||
@@ -162,3 +162,74 @@ def test_on_key_enter_submits_selected_completion() -> None:
|
||||
assert result is CompletionResult.SUBMIT
|
||||
assert view.replacements == [Replacement(0, 2, "/compact")]
|
||||
assert view.reset_count == 1
|
||||
|
||||
|
||||
def test_callable_entries_updates_completions_dynamically() -> None:
|
||||
"""Test that CommandCompleter with a callable updates entries when the callable returns different values.
|
||||
|
||||
This simulates config reload where available skills change.
|
||||
"""
|
||||
available_skills: list[tuple[str, str]] = []
|
||||
|
||||
def get_entries() -> list[tuple[str, str]]:
|
||||
base_commands = [("/help", "Display help"), ("/config", "Show configuration")]
|
||||
return base_commands + available_skills
|
||||
|
||||
completer = CommandCompleter(get_entries)
|
||||
view = StubView()
|
||||
controller = SlashCommandController(completer, view)
|
||||
|
||||
# Initially, only base commands are available
|
||||
controller.on_text_changed("/", cursor_index=1)
|
||||
suggestions, _ = view.suggestion_events[-1]
|
||||
assert [s.alias for s in suggestions] == ["/help", "/config"]
|
||||
|
||||
# Simulate config reload: add a skill
|
||||
available_skills.append(("/summarize", "Summarize the conversation"))
|
||||
|
||||
# Now completions should include the new skill
|
||||
controller.on_text_changed("/", cursor_index=1)
|
||||
suggestions, _ = view.suggestion_events[-1]
|
||||
assert [s.alias for s in suggestions] == ["/help", "/config", "/summarize"]
|
||||
|
||||
# And searching for "/s" should find the new skill
|
||||
controller.on_text_changed("/s", cursor_index=2)
|
||||
suggestions, _ = view.suggestion_events[-1]
|
||||
assert [s.alias for s in suggestions] == ["/summarize"]
|
||||
assert suggestions[0].description == "Summarize the conversation"
|
||||
|
||||
|
||||
def test_callable_entries_reflects_enabled_disabled_skills() -> None:
|
||||
"""Test that skill enable/disable changes are reflected in completions.
|
||||
|
||||
This simulates the scenario where a user changes enabled_skills in config
|
||||
and runs /reload.
|
||||
"""
|
||||
enabled_skills: set[str] = {"commit", "review"}
|
||||
|
||||
all_skills = [
|
||||
("/commit", "Create a git commit"),
|
||||
("/review", "Review code changes"),
|
||||
("/deploy", "Deploy to production"),
|
||||
]
|
||||
|
||||
def get_entries() -> list[tuple[str, str]]:
|
||||
return [(name, desc) for name, desc in all_skills if name[1:] in enabled_skills]
|
||||
|
||||
completer = CommandCompleter(get_entries)
|
||||
view = StubView()
|
||||
controller = SlashCommandController(completer, view)
|
||||
|
||||
# Initially only commit and review are enabled
|
||||
controller.on_text_changed("/", cursor_index=1)
|
||||
suggestions, _ = view.suggestion_events[-1]
|
||||
assert [s.alias for s in suggestions] == ["/commit", "/review"]
|
||||
|
||||
# Simulate config reload: enable deploy, disable commit
|
||||
enabled_skills.discard("commit")
|
||||
enabled_skills.add("deploy")
|
||||
|
||||
# Now completions should reflect the change
|
||||
controller.on_text_changed("/", cursor_index=1)
|
||||
suggestions, _ = view.suggestion_events[-1]
|
||||
assert [s.alias for s in suggestions] == ["/review", "/deploy"]
|
||||
|
||||
@@ -10,6 +10,7 @@ from textual.widgets import Markdown
|
||||
from vibe.cli.textual_ui.app import VibeApp
|
||||
from vibe.cli.textual_ui.widgets.chat_input.completion_popup import CompletionPopup
|
||||
from vibe.cli.textual_ui.widgets.chat_input.container import ChatInputContainer
|
||||
from vibe.core.agent_loop import AgentLoop
|
||||
from vibe.core.config import SessionLoggingConfig, VibeConfig
|
||||
|
||||
|
||||
@@ -20,7 +21,8 @@ def vibe_config() -> VibeConfig:
|
||||
|
||||
@pytest.fixture
|
||||
def vibe_app(vibe_config: VibeConfig) -> VibeApp:
|
||||
return VibeApp(config=vibe_config)
|
||||
agent_loop = AgentLoop(vibe_config)
|
||||
return VibeApp(agent_loop=agent_loop)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -60,7 +62,7 @@ async def test_pressing_tab_writes_selected_command_and_keeps_popup_visible(
|
||||
await pilot.press(*"/co")
|
||||
await pilot.press("tab")
|
||||
|
||||
assert chat_input.value == "/config"
|
||||
assert chat_input.value == "/compact"
|
||||
assert popup.styles.display == "block"
|
||||
|
||||
|
||||
@@ -88,11 +90,11 @@ async def test_arrow_navigation_updates_selected_suggestion(vibe_app: VibeApp) -
|
||||
|
||||
await pilot.press(*"/c")
|
||||
|
||||
ensure_selected_command(popup, "/config")
|
||||
await pilot.press("down")
|
||||
ensure_selected_command(popup, "/clear")
|
||||
await pilot.press("down")
|
||||
ensure_selected_command(popup, "/compact")
|
||||
await pilot.press("up")
|
||||
ensure_selected_command(popup, "/config")
|
||||
ensure_selected_command(popup, "/clear")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -102,11 +104,11 @@ async def test_arrow_navigation_cycles_through_suggestions(vibe_app: VibeApp) ->
|
||||
|
||||
await pilot.press(*"/co")
|
||||
|
||||
ensure_selected_command(popup, "/config")
|
||||
await pilot.press("down")
|
||||
ensure_selected_command(popup, "/compact")
|
||||
await pilot.press("up")
|
||||
await pilot.press("down")
|
||||
ensure_selected_command(popup, "/config")
|
||||
await pilot.press("up")
|
||||
ensure_selected_command(popup, "/compact")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
32
tests/cli/plan_offer/adapters/fake_whoami_gateway.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from vibe.cli.plan_offer.ports.whoami_gateway import (
|
||||
WhoAmIGatewayError,
|
||||
WhoAmIGatewayUnauthorized,
|
||||
WhoAmIResponse,
|
||||
)
|
||||
|
||||
|
||||
class FakeWhoAmIGateway:
|
||||
def __init__(
|
||||
self,
|
||||
response: WhoAmIResponse | None = None,
|
||||
*,
|
||||
unauthorized: bool = False,
|
||||
error: bool = False,
|
||||
) -> None:
|
||||
self._response = response
|
||||
self._unauthorized = unauthorized
|
||||
self._error = error
|
||||
self.calls: list[str] = []
|
||||
|
||||
async def whoami(self, api_key: str) -> WhoAmIResponse:
|
||||
self.calls.append(api_key)
|
||||
if self._unauthorized:
|
||||
raise WhoAmIGatewayUnauthorized()
|
||||
if self._error:
|
||||
raise WhoAmIGatewayError()
|
||||
if self._response is None:
|
||||
msg = "FakeWhoAmIGateway requires a response when no error is set."
|
||||
raise RuntimeError(msg)
|
||||
return self._response
|
||||
103
tests/cli/plan_offer/test_decide_plan_offer.py
Normal file
@@ -0,0 +1,103 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.cli.plan_offer.adapters.fake_whoami_gateway import FakeWhoAmIGateway
|
||||
from vibe.cli.plan_offer.decide_plan_offer import PlanOfferAction, decide_plan_offer
|
||||
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIResponse
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_proposes_upgrade_without_call_when_api_key_is_empty() -> None:
|
||||
gateway = FakeWhoAmIGateway(
|
||||
WhoAmIResponse(
|
||||
is_pro_plan=False,
|
||||
advertise_pro_plan=False,
|
||||
prompt_switching_to_pro_plan=False,
|
||||
)
|
||||
)
|
||||
action = await decide_plan_offer("", gateway)
|
||||
|
||||
assert action is PlanOfferAction.UPGRADE
|
||||
assert gateway.calls == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("response", "expected"),
|
||||
[
|
||||
(
|
||||
WhoAmIResponse(
|
||||
is_pro_plan=True,
|
||||
advertise_pro_plan=False,
|
||||
prompt_switching_to_pro_plan=False,
|
||||
),
|
||||
PlanOfferAction.NONE,
|
||||
),
|
||||
(
|
||||
WhoAmIResponse(
|
||||
is_pro_plan=False,
|
||||
advertise_pro_plan=True,
|
||||
prompt_switching_to_pro_plan=False,
|
||||
),
|
||||
PlanOfferAction.UPGRADE,
|
||||
),
|
||||
(
|
||||
WhoAmIResponse(
|
||||
is_pro_plan=False,
|
||||
advertise_pro_plan=False,
|
||||
prompt_switching_to_pro_plan=True,
|
||||
),
|
||||
PlanOfferAction.SWITCH_TO_PRO_KEY,
|
||||
),
|
||||
],
|
||||
ids=["with-a-pro-plan", "without-a-pro-plan", "with-a-non-pro-key"],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_proposes_an_action_based_on_current_plan_status(
|
||||
response: WhoAmIResponse, expected: PlanOfferAction
|
||||
) -> None:
|
||||
gateway = FakeWhoAmIGateway(response)
|
||||
action = await decide_plan_offer("api-key", gateway)
|
||||
|
||||
assert action is expected
|
||||
assert gateway.calls == ["api-key"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_proposes_nothing_when_nothing_is_suggested() -> None:
|
||||
gateway = FakeWhoAmIGateway(
|
||||
WhoAmIResponse(
|
||||
is_pro_plan=False,
|
||||
advertise_pro_plan=False,
|
||||
prompt_switching_to_pro_plan=False,
|
||||
)
|
||||
)
|
||||
|
||||
action = await decide_plan_offer("api-key", gateway)
|
||||
|
||||
assert action is PlanOfferAction.NONE
|
||||
assert gateway.calls == ["api-key"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_proposes_upgrade_when_api_key_is_unauthorized() -> None:
|
||||
gateway = FakeWhoAmIGateway(unauthorized=True)
|
||||
action = await decide_plan_offer("bad-key", gateway)
|
||||
|
||||
assert action is PlanOfferAction.UPGRADE
|
||||
assert gateway.calls == ["bad-key"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_proposes_none_and_logs_warning_when_gateway_error_occurs(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
gateway = FakeWhoAmIGateway(error=True)
|
||||
with caplog.at_level(logging.WARNING):
|
||||
action = await decide_plan_offer("api-key", gateway)
|
||||
|
||||
assert action is PlanOfferAction.NONE
|
||||
assert gateway.calls == ["api-key"]
|
||||
assert "Failed to fetch plan status." in caplog.text
|
||||
122
tests/cli/plan_offer/test_http_whoami_gateway.py
Normal file
@@ -0,0 +1,122 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from vibe.cli.plan_offer.adapters.http_whoami_gateway import HttpWhoAmIGateway
|
||||
from vibe.cli.plan_offer.ports.whoami_gateway import (
|
||||
WhoAmIGatewayError,
|
||||
WhoAmIGatewayUnauthorized,
|
||||
WhoAmIResponse,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_plan_flags(respx_mock: respx.MockRouter) -> None:
|
||||
route = respx_mock.get("http://test/api/vibe/whoami").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"is_pro_plan": True,
|
||||
"advertise_pro_plan": False,
|
||||
"prompt_switching_to_pro_plan": False,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
gateway = HttpWhoAmIGateway(base_url="http://test")
|
||||
response = await gateway.whoami("api-key")
|
||||
|
||||
assert route.called
|
||||
request = route.calls.last.request
|
||||
assert request.headers["Authorization"] == "Bearer api-key"
|
||||
assert response.is_pro_plan is True
|
||||
assert response.advertise_pro_plan is False
|
||||
assert response.prompt_switching_to_pro_plan is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("status_code", [401, 403])
|
||||
async def test_raises_on_unauthorized(
|
||||
respx_mock: respx.MockRouter, status_code: int
|
||||
) -> None:
|
||||
respx_mock.get("http://test/api/vibe/whoami").mock(
|
||||
return_value=httpx.Response(status_code, json={"error": "unauthorized"})
|
||||
)
|
||||
|
||||
gateway = HttpWhoAmIGateway(base_url="http://test")
|
||||
|
||||
with pytest.raises(WhoAmIGatewayUnauthorized):
|
||||
await gateway.whoami("bad-key")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raises_on_non_success(respx_mock: respx.MockRouter) -> None:
|
||||
respx_mock.get("http://test/api/vibe/whoami").mock(
|
||||
return_value=httpx.Response(500, json={"error": "boom"})
|
||||
)
|
||||
|
||||
gateway = HttpWhoAmIGateway(base_url="http://test")
|
||||
|
||||
with pytest.raises(WhoAmIGatewayError):
|
||||
await gateway.whoami("api-key")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_incomplete_payload_defaults_missing_flags_to_false(
|
||||
respx_mock: respx.MockRouter,
|
||||
) -> None:
|
||||
respx_mock.get("http://test/api/vibe/whoami").mock(
|
||||
return_value=httpx.Response(200, json={"is_pro_plan": True})
|
||||
)
|
||||
|
||||
gateway = HttpWhoAmIGateway(base_url="http://test")
|
||||
response = await gateway.whoami("api-key")
|
||||
assert response == WhoAmIResponse(
|
||||
is_pro_plan=True, advertise_pro_plan=False, prompt_switching_to_pro_plan=False
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wraps_request_error(respx_mock: respx.MockRouter) -> None:
|
||||
respx_mock.get("http://test/api/vibe/whoami").mock(
|
||||
side_effect=httpx.ConnectError("boom")
|
||||
)
|
||||
|
||||
gateway = HttpWhoAmIGateway(base_url="http://test")
|
||||
|
||||
with pytest.raises(WhoAmIGatewayError):
|
||||
await gateway.whoami("api-key")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parses_boolean_strings(respx_mock: respx.MockRouter) -> None:
|
||||
respx_mock.get("http://test/api/vibe/whoami").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"is_pro_plan": "true",
|
||||
"advertise_pro_plan": "false",
|
||||
"prompt_switching_to_pro_plan": "true",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
gateway = HttpWhoAmIGateway(base_url="http://test")
|
||||
response = await gateway.whoami("api-key")
|
||||
assert response == WhoAmIResponse(
|
||||
is_pro_plan=True, advertise_pro_plan=False, prompt_switching_to_pro_plan=True
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raises_on_invalid_boolean_string(respx_mock: respx.MockRouter) -> None:
|
||||
respx_mock.get("http://test/api/vibe/whoami").mock(
|
||||
return_value=httpx.Response(200, json={"is_pro_plan": "yes"})
|
||||
)
|
||||
|
||||
gateway = HttpWhoAmIGateway(base_url="http://test")
|
||||
|
||||
with pytest.raises(WhoAmIGatewayError):
|
||||
await gateway.whoami("api-key")
|
||||
63
tests/cli/plan_offer/test_plan_offer_in_app.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.cli.plan_offer.adapters.fake_whoami_gateway import FakeWhoAmIGateway
|
||||
from tests.stubs.fake_backend import FakeBackend
|
||||
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIResponse
|
||||
from vibe.cli.textual_ui.app import VibeApp
|
||||
from vibe.cli.textual_ui.widgets.messages import PlanOfferMessage
|
||||
from vibe.core.agent_loop import AgentLoop
|
||||
from vibe.core.config import SessionLoggingConfig, VibeConfig
|
||||
|
||||
|
||||
def _make_app(gateway: FakeWhoAmIGateway, config: VibeConfig | None = None) -> VibeApp:
|
||||
config = config or VibeConfig(
|
||||
session_logging=SessionLoggingConfig(enabled=False), enable_update_checks=False
|
||||
)
|
||||
agent_loop = AgentLoop(config=config, backend=FakeBackend())
|
||||
return VibeApp(agent_loop=agent_loop, plan_offer_gateway=gateway)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_app_shows_upgrade_offer_in_plan_offer_message(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "api-key")
|
||||
gateway = FakeWhoAmIGateway(
|
||||
WhoAmIResponse(
|
||||
is_pro_plan=False,
|
||||
advertise_pro_plan=True,
|
||||
prompt_switching_to_pro_plan=False,
|
||||
)
|
||||
)
|
||||
app = _make_app(gateway)
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
offer = app.query_one(PlanOfferMessage)
|
||||
assert "Upgrade to" in offer.get_text()
|
||||
assert "Pro" in offer.get_text()
|
||||
assert gateway.calls == ["api-key"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_app_shows_switch_to_pro_key_offer_in_plan_offer_message(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "api-key")
|
||||
gateway = FakeWhoAmIGateway(
|
||||
WhoAmIResponse(
|
||||
is_pro_plan=False,
|
||||
advertise_pro_plan=False,
|
||||
prompt_switching_to_pro_plan=True,
|
||||
)
|
||||
)
|
||||
app = _make_app(gateway)
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
offer = app.query_one(PlanOfferMessage)
|
||||
assert "Switch to your" in offer.get_text()
|
||||
assert "Pro API key" in offer.get_text()
|
||||
assert gateway.calls == ["api-key"]
|
||||
@@ -102,7 +102,10 @@ def test_copy_selection_to_clipboard_success(
|
||||
|
||||
mock_copy_fn.assert_called_once_with("selected text")
|
||||
mock_app.notify.assert_called_once_with(
|
||||
'"selected text" copied to clipboard', severity="information", timeout=2
|
||||
'"selected text" copied to clipboard',
|
||||
severity="information",
|
||||
timeout=2,
|
||||
markup=False,
|
||||
)
|
||||
|
||||
|
||||
@@ -126,7 +129,10 @@ def test_copy_selection_to_clipboard_tries_all(
|
||||
fn_2.assert_called_once_with("selected text")
|
||||
fn_3.assert_called_once_with("selected text")
|
||||
mock_app.notify.assert_called_once_with(
|
||||
'"selected text" copied to clipboard', severity="information", timeout=2
|
||||
'"selected text" copied to clipboard',
|
||||
severity="information",
|
||||
timeout=2,
|
||||
markup=False,
|
||||
)
|
||||
|
||||
|
||||
@@ -175,6 +181,7 @@ def test_copy_selection_to_clipboard_multiple_widgets(mock_app: MagicMock) -> No
|
||||
'"first selection⏎second selection" copied to clipboard',
|
||||
severity="information",
|
||||
timeout=2,
|
||||
markup=False,
|
||||
)
|
||||
|
||||
|
||||
@@ -266,8 +273,13 @@ def test_get_copy_fns_no_system_tools(mock_which: MagicMock, mock_app: App) -> N
|
||||
assert copy_fns[2] == mock_app.copy_to_clipboard
|
||||
|
||||
|
||||
@patch("vibe.cli.clipboard.platform.system")
|
||||
@patch("vibe.cli.clipboard.shutil.which")
|
||||
def test_get_copy_fns_with_xclip(mock_which: MagicMock, mock_app: App) -> None:
|
||||
def test_get_copy_fns_with_xclip(
|
||||
mock_which: MagicMock, mock_platform_system: MagicMock, mock_app: App
|
||||
) -> None:
|
||||
mock_platform_system.return_value = "Linux"
|
||||
|
||||
def which_side_effect(cmd: str) -> str | None:
|
||||
return "/usr/bin/xclip" if cmd == "xclip" else None
|
||||
|
||||
@@ -282,8 +294,13 @@ def test_get_copy_fns_with_xclip(mock_which: MagicMock, mock_app: App) -> None:
|
||||
assert copy_fns[3] == mock_app.copy_to_clipboard
|
||||
|
||||
|
||||
@patch("vibe.cli.clipboard.platform.system")
|
||||
@patch("vibe.cli.clipboard.shutil.which")
|
||||
def test_get_copy_fns_with_wl_copy(mock_which: MagicMock, mock_app: App) -> None:
|
||||
def test_get_copy_fns_with_wl_copy(
|
||||
mock_which: MagicMock, mock_platform_system: MagicMock, mock_app: App
|
||||
) -> None:
|
||||
mock_platform_system.return_value = "Linux"
|
||||
|
||||
def which_side_effect(cmd: str) -> str | None:
|
||||
return "/usr/bin/wl-copy" if cmd == "wl-copy" else None
|
||||
|
||||
@@ -298,10 +315,13 @@ def test_get_copy_fns_with_wl_copy(mock_which: MagicMock, mock_app: App) -> None
|
||||
assert copy_fns[3] == mock_app.copy_to_clipboard
|
||||
|
||||
|
||||
@patch("vibe.cli.clipboard.platform.system")
|
||||
@patch("vibe.cli.clipboard.shutil.which")
|
||||
def test_get_copy_fns_with_both_system_tools(
|
||||
mock_which: MagicMock, mock_app: App
|
||||
mock_which: MagicMock, mock_platform_system: MagicMock, mock_app: App
|
||||
) -> None:
|
||||
mock_platform_system.return_value = "Linux"
|
||||
|
||||
def which_side_effect(cmd: str) -> str | None:
|
||||
match cmd:
|
||||
case "wl-copy":
|
||||
|
||||
72
tests/cli/test_external_editor.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Tests for the external editor module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from vibe.cli.textual_ui.external_editor import ExternalEditor
|
||||
|
||||
|
||||
class TestGetEditor:
|
||||
def test_returns_visual_first(self) -> None:
|
||||
with patch.dict("os.environ", {"VISUAL": "vim", "EDITOR": "nvim"}, clear=True):
|
||||
assert ExternalEditor.get_editor() == "vim"
|
||||
|
||||
def test_falls_back_to_editor(self) -> None:
|
||||
with patch.dict("os.environ", {"EDITOR": "nvim"}, clear=True):
|
||||
assert ExternalEditor.get_editor() == "nvim"
|
||||
|
||||
def test_falls_back_when_no_editor(self) -> None:
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
assert ExternalEditor.get_editor() == "nano"
|
||||
|
||||
|
||||
class TestEdit:
|
||||
def test_returns_modified_content(self) -> None:
|
||||
with patch.dict("os.environ", {"VISUAL": "vim"}, clear=True):
|
||||
with patch("subprocess.run") as mock_run:
|
||||
with patch("pathlib.Path.read_text", return_value="modified"):
|
||||
with patch("pathlib.Path.unlink"):
|
||||
editor = ExternalEditor()
|
||||
result = editor.edit("original")
|
||||
assert result == "modified"
|
||||
mock_run.assert_called_once()
|
||||
|
||||
def test_returns_none_when_content_unchanged(self) -> None:
|
||||
with patch.dict("os.environ", {"VISUAL": "vim"}, clear=True):
|
||||
with patch("subprocess.run"):
|
||||
with patch("pathlib.Path.read_text", return_value="same"):
|
||||
with patch("pathlib.Path.unlink"):
|
||||
editor = ExternalEditor()
|
||||
result = editor.edit("same")
|
||||
assert result is None
|
||||
|
||||
def test_strips_trailing_whitespace(self) -> None:
|
||||
with patch.dict("os.environ", {"VISUAL": "vim"}, clear=True):
|
||||
with patch("subprocess.run"):
|
||||
with patch("pathlib.Path.read_text", return_value="content\n\n"):
|
||||
with patch("pathlib.Path.unlink"):
|
||||
editor = ExternalEditor()
|
||||
result = editor.edit("original")
|
||||
assert result == "content"
|
||||
|
||||
def test_handles_editor_with_args(self) -> None:
|
||||
with patch.dict("os.environ", {"VISUAL": "code --wait"}, clear=True):
|
||||
with patch("subprocess.run") as mock_run:
|
||||
with patch("pathlib.Path.read_text", return_value="edited"):
|
||||
with patch("pathlib.Path.unlink"):
|
||||
editor = ExternalEditor()
|
||||
editor.edit("original")
|
||||
call_args = mock_run.call_args[0][0]
|
||||
assert call_args[0] == "code"
|
||||
assert call_args[1] == "--wait"
|
||||
|
||||
def test_returns_none_on_subprocess_error(self) -> None:
|
||||
import subprocess as sp
|
||||
|
||||
with patch.dict("os.environ", {"VISUAL": "vim"}, clear=True):
|
||||
with patch("subprocess.run", side_effect=sp.CalledProcessError(1, "vim")):
|
||||
with patch("pathlib.Path.unlink"):
|
||||
editor = ExternalEditor()
|
||||
result = editor.edit("test")
|
||||
assert result is None
|
||||
513
tests/cli/test_question_app.py
Normal file
@@ -0,0 +1,513 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from vibe.core.tools.builtins.ask_user_question import (
|
||||
AskUserQuestionArgs,
|
||||
Choice,
|
||||
Question,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def single_question_args():
|
||||
return AskUserQuestionArgs(
|
||||
questions=[
|
||||
Question(
|
||||
question="Which database?",
|
||||
header="DB",
|
||||
options=[
|
||||
Choice(label="PostgreSQL", description="Relational DB"),
|
||||
Choice(label="MongoDB", description="Document DB"),
|
||||
],
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def multi_question_args():
|
||||
return AskUserQuestionArgs(
|
||||
questions=[
|
||||
Question(
|
||||
question="Which database?",
|
||||
header="DB",
|
||||
options=[Choice(label="PostgreSQL"), Choice(label="MongoDB")],
|
||||
),
|
||||
Question(
|
||||
question="Which framework?",
|
||||
header="Framework",
|
||||
options=[Choice(label="FastAPI"), Choice(label="Django")],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def multi_select_args():
|
||||
return AskUserQuestionArgs(
|
||||
questions=[
|
||||
Question(
|
||||
question="Which features?",
|
||||
header="Features",
|
||||
options=[
|
||||
Choice(label="Auth"),
|
||||
Choice(label="Caching"),
|
||||
Choice(label="Logging"),
|
||||
],
|
||||
multi_select=True,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class TestQuestionAppState:
|
||||
def test_init_state(self, single_question_args):
|
||||
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
|
||||
|
||||
app = QuestionApp(single_question_args)
|
||||
|
||||
assert app.current_question_idx == 0
|
||||
assert app.selected_option == 0
|
||||
assert len(app.answers) == 0
|
||||
assert len(app.other_texts) == 0
|
||||
|
||||
def test_total_options_single_select(self, single_question_args):
|
||||
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
|
||||
|
||||
app = QuestionApp(single_question_args)
|
||||
|
||||
# 2 options + Other = 3 (no Submit for single-select)
|
||||
assert app._total_options == 3
|
||||
|
||||
def test_total_options_multi_select_includes_submit(self, multi_select_args):
|
||||
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
|
||||
|
||||
app = QuestionApp(multi_select_args)
|
||||
|
||||
# 3 options + Other + Submit = 5
|
||||
assert app._total_options == 5
|
||||
assert app._other_option_idx == 3
|
||||
assert app._submit_option_idx == 4
|
||||
|
||||
def test_is_other_selected(self, single_question_args):
|
||||
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
|
||||
|
||||
app = QuestionApp(single_question_args)
|
||||
|
||||
assert app._is_other_selected is False
|
||||
app.selected_option = 2 # Other option
|
||||
assert app._is_other_selected is True
|
||||
|
||||
def test_is_submit_selected(self, multi_select_args):
|
||||
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
|
||||
|
||||
app = QuestionApp(multi_select_args)
|
||||
|
||||
assert app._is_submit_selected is False
|
||||
app.selected_option = 4 # Submit option
|
||||
assert app._is_submit_selected is True
|
||||
|
||||
def test_is_submit_selected_false_for_single_select(self, single_question_args):
|
||||
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
|
||||
|
||||
app = QuestionApp(single_question_args)
|
||||
|
||||
# Even if selected_option is 3, is_submit_selected is False for single-select
|
||||
app.selected_option = 3
|
||||
assert app._is_submit_selected is False
|
||||
|
||||
def test_store_other_text_per_question(self, multi_question_args):
|
||||
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
|
||||
|
||||
app = QuestionApp(multi_question_args)
|
||||
|
||||
# Store text for question 0
|
||||
app.other_texts[0] = "Custom DB"
|
||||
|
||||
# Switch to question 1
|
||||
app.current_question_idx = 1
|
||||
app.other_texts[1] = "Custom Framework"
|
||||
|
||||
# Verify both stored separately
|
||||
assert app._get_other_text(0) == "Custom DB"
|
||||
assert app._get_other_text(1) == "Custom Framework"
|
||||
|
||||
def test_save_regular_option_answer(self, single_question_args):
|
||||
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
|
||||
|
||||
app = QuestionApp(single_question_args)
|
||||
app.selected_option = 0 # PostgreSQL
|
||||
|
||||
app._save_current_answer()
|
||||
|
||||
assert 0 in app.answers
|
||||
answer_text, is_other = app.answers[0]
|
||||
assert answer_text == "PostgreSQL"
|
||||
assert is_other is False
|
||||
|
||||
def test_save_other_option_answer(self, single_question_args):
|
||||
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
|
||||
|
||||
app = QuestionApp(single_question_args)
|
||||
app.selected_option = 2 # Other
|
||||
app.other_texts[0] = "SQLite"
|
||||
|
||||
app._save_current_answer()
|
||||
|
||||
assert 0 in app.answers
|
||||
answer_text, is_other = app.answers[0]
|
||||
assert answer_text == "SQLite"
|
||||
assert is_other is True
|
||||
|
||||
def test_save_other_option_empty_does_not_save(self, single_question_args):
|
||||
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
|
||||
|
||||
app = QuestionApp(single_question_args)
|
||||
app.selected_option = 2 # Other
|
||||
app.other_texts[0] = "" # Empty
|
||||
|
||||
app._save_current_answer()
|
||||
|
||||
assert 0 not in app.answers
|
||||
|
||||
def test_all_answered_false_initially(self, multi_question_args):
|
||||
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
|
||||
|
||||
app = QuestionApp(multi_question_args)
|
||||
|
||||
assert app._all_answered() is False
|
||||
|
||||
def test_all_answered_true_when_complete(self, multi_question_args):
|
||||
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
|
||||
|
||||
app = QuestionApp(multi_question_args)
|
||||
app.answers[0] = ("PostgreSQL", False)
|
||||
app.answers[1] = ("FastAPI", False)
|
||||
|
||||
assert app._all_answered() is True
|
||||
|
||||
def test_multi_select_toggle(self, multi_select_args):
|
||||
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
|
||||
|
||||
app = QuestionApp(multi_select_args)
|
||||
|
||||
# Initially no selections
|
||||
assert len(app.multi_selections.get(0, set())) == 0
|
||||
|
||||
# Add selection
|
||||
app.multi_selections.setdefault(0, set()).add(0)
|
||||
assert 0 in app.multi_selections[0]
|
||||
|
||||
# Add another
|
||||
app.multi_selections[0].add(2)
|
||||
assert 2 in app.multi_selections[0]
|
||||
|
||||
# Remove first
|
||||
app.multi_selections[0].discard(0)
|
||||
assert 0 not in app.multi_selections[0]
|
||||
assert 2 in app.multi_selections[0]
|
||||
|
||||
def test_multi_select_save_answer(self, multi_select_args):
|
||||
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
|
||||
|
||||
app = QuestionApp(multi_select_args)
|
||||
app.multi_selections[0] = {0, 2} # Auth and Logging
|
||||
|
||||
app._save_current_answer()
|
||||
|
||||
assert 0 in app.answers
|
||||
answer_text, is_other = app.answers[0]
|
||||
assert "Auth" in answer_text
|
||||
assert "Logging" in answer_text
|
||||
assert is_other is False
|
||||
|
||||
def test_multi_select_with_other(self, multi_select_args):
|
||||
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
|
||||
|
||||
app = QuestionApp(multi_select_args)
|
||||
app.multi_selections[0] = {0, 3} # Auth and Other
|
||||
app.other_texts[0] = "Custom Feature"
|
||||
|
||||
app._save_current_answer()
|
||||
|
||||
assert 0 in app.answers
|
||||
answer_text, is_other = app.answers[0]
|
||||
assert "Auth" in answer_text
|
||||
assert "Custom Feature" in answer_text
|
||||
assert is_other is True
|
||||
|
||||
|
||||
class TestQuestionAppActions:
|
||||
def test_action_move_down(self, single_question_args):
|
||||
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
|
||||
|
||||
app = QuestionApp(single_question_args)
|
||||
assert app.selected_option == 0
|
||||
|
||||
app.action_move_down()
|
||||
assert app.selected_option == 1
|
||||
|
||||
app.action_move_down()
|
||||
assert app.selected_option == 2 # Other
|
||||
|
||||
app.action_move_down()
|
||||
assert app.selected_option == 0 # Wraps around
|
||||
|
||||
def test_action_move_up(self, single_question_args):
|
||||
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
|
||||
|
||||
app = QuestionApp(single_question_args)
|
||||
assert app.selected_option == 0
|
||||
|
||||
app.action_move_up()
|
||||
assert app.selected_option == 2 # Wraps to Other
|
||||
|
||||
app.action_move_up()
|
||||
assert app.selected_option == 1
|
||||
|
||||
def test_switch_question_preserves_other_text(self, multi_question_args):
|
||||
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
|
||||
|
||||
app = QuestionApp(multi_question_args)
|
||||
app.other_texts[0] = "Text for Q1"
|
||||
|
||||
app._switch_question(1)
|
||||
|
||||
assert app.current_question_idx == 1
|
||||
assert app._get_other_text(0) == "Text for Q1"
|
||||
assert app._get_other_text(1) == ""
|
||||
|
||||
|
||||
class TestMultiSelectOtherBehavior:
|
||||
def test_multi_select_other_does_not_advance_on_save(self, multi_select_args):
|
||||
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
|
||||
|
||||
app = QuestionApp(multi_select_args)
|
||||
app.selected_option = 3 # Other option (3 options + Other)
|
||||
app.other_texts[0] = "Custom feature"
|
||||
|
||||
# Save should not advance for multi-select
|
||||
app._save_current_answer()
|
||||
|
||||
# Should stay on same question
|
||||
assert app.current_question_idx == 0
|
||||
|
||||
def test_multi_select_other_toggle_adds_to_selections(self, multi_select_args):
|
||||
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
|
||||
|
||||
app = QuestionApp(multi_select_args)
|
||||
other_idx = len(app._current_question.options) # 3
|
||||
|
||||
# Initially no selections
|
||||
assert len(app.multi_selections.get(0, set())) == 0
|
||||
|
||||
# Add Other to selections
|
||||
app.multi_selections.setdefault(0, set()).add(other_idx)
|
||||
app.other_texts[0] = "Custom"
|
||||
|
||||
# Can still add regular options
|
||||
app.multi_selections[0].add(0) # Auth
|
||||
app.multi_selections[0].add(1) # Caching
|
||||
|
||||
assert other_idx in app.multi_selections[0]
|
||||
assert 0 in app.multi_selections[0]
|
||||
assert 1 in app.multi_selections[0]
|
||||
|
||||
def test_multi_select_save_with_other_and_regular_options(self, multi_select_args):
|
||||
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
|
||||
|
||||
app = QuestionApp(multi_select_args)
|
||||
other_idx = len(app._current_question.options)
|
||||
|
||||
# Select Auth (0), Logging (2), and Other (3)
|
||||
app.multi_selections[0] = {0, 2, other_idx}
|
||||
app.other_texts[0] = "Custom Feature"
|
||||
|
||||
app._save_current_answer()
|
||||
|
||||
assert 0 in app.answers
|
||||
answer_text, is_other = app.answers[0]
|
||||
assert "Auth" in answer_text
|
||||
assert "Logging" in answer_text
|
||||
assert "Custom Feature" in answer_text
|
||||
assert is_other is True
|
||||
|
||||
def test_multi_select_other_without_text_not_in_answer(self, multi_select_args):
|
||||
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
|
||||
|
||||
app = QuestionApp(multi_select_args)
|
||||
other_idx = len(app._current_question.options)
|
||||
|
||||
# Select Auth (0) and Other (3) but no text for Other
|
||||
app.multi_selections[0] = {0, other_idx}
|
||||
app.other_texts[0] = ""
|
||||
|
||||
app._save_current_answer()
|
||||
|
||||
assert 0 in app.answers
|
||||
answer_text, is_other = app.answers[0]
|
||||
assert "Auth" in answer_text
|
||||
# Empty Other should not appear
|
||||
assert is_other is False # No valid Other text
|
||||
|
||||
def test_multi_select_can_toggle_after_selecting_other(self, multi_select_args):
|
||||
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
|
||||
|
||||
app = QuestionApp(multi_select_args)
|
||||
other_idx = len(app._current_question.options)
|
||||
|
||||
# Select Other first
|
||||
app.multi_selections[0] = {other_idx}
|
||||
app.other_texts[0] = "Custom"
|
||||
|
||||
# Now navigate to and toggle Auth
|
||||
app.selected_option = 0
|
||||
selections = app.multi_selections.setdefault(0, set())
|
||||
selections.add(0) # Toggle Auth on
|
||||
|
||||
assert 0 in app.multi_selections[0]
|
||||
assert other_idx in app.multi_selections[0]
|
||||
|
||||
# Toggle Auth off
|
||||
selections.discard(0)
|
||||
assert 0 not in app.multi_selections[0]
|
||||
assert other_idx in app.multi_selections[0]
|
||||
|
||||
def test_multi_select_empty_selections_does_not_save(self, multi_select_args):
|
||||
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
|
||||
|
||||
app = QuestionApp(multi_select_args)
|
||||
|
||||
# No selections
|
||||
app._save_current_answer()
|
||||
|
||||
assert 0 not in app.answers
|
||||
|
||||
|
||||
class TestSingleSelectOtherBehavior:
|
||||
def test_single_select_other_with_text_saves(self, single_question_args):
|
||||
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
|
||||
|
||||
app = QuestionApp(single_question_args)
|
||||
app.selected_option = 2 # Other
|
||||
app.other_texts[0] = "Custom DB"
|
||||
|
||||
app._save_current_answer()
|
||||
|
||||
assert 0 in app.answers
|
||||
answer_text, is_other = app.answers[0]
|
||||
assert answer_text == "Custom DB"
|
||||
assert is_other is True
|
||||
|
||||
def test_single_select_other_without_text_does_not_save(self, single_question_args):
|
||||
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
|
||||
|
||||
app = QuestionApp(single_question_args)
|
||||
app.selected_option = 2 # Other
|
||||
app.other_texts[0] = ""
|
||||
|
||||
app._save_current_answer()
|
||||
|
||||
assert 0 not in app.answers
|
||||
|
||||
def test_single_select_regular_option_saves_immediately(self, single_question_args):
|
||||
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
|
||||
|
||||
app = QuestionApp(single_question_args)
|
||||
app.selected_option = 1 # MongoDB
|
||||
|
||||
app._save_current_answer()
|
||||
|
||||
assert 0 in app.answers
|
||||
answer_text, is_other = app.answers[0]
|
||||
assert answer_text == "MongoDB"
|
||||
assert is_other is False
|
||||
|
||||
|
||||
class TestMultiSelectAutoSelect:
|
||||
def test_typing_auto_selects_other(self, multi_select_args):
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
|
||||
|
||||
app = QuestionApp(multi_select_args)
|
||||
app.other_input = MagicMock()
|
||||
app.other_input.value = "Custom text"
|
||||
|
||||
# Initially no selections
|
||||
assert app._other_option_idx not in app.multi_selections.get(0, set())
|
||||
|
||||
# Simulate input change
|
||||
from textual.widgets import Input
|
||||
|
||||
app.on_input_changed(Input.Changed(app.other_input, "Custom text"))
|
||||
|
||||
# Other should now be selected
|
||||
assert app._other_option_idx in app.multi_selections[0]
|
||||
|
||||
def test_clearing_auto_deselects_other(self, multi_select_args):
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
|
||||
|
||||
app = QuestionApp(multi_select_args)
|
||||
app.other_input = MagicMock()
|
||||
|
||||
# Start with Other selected and text
|
||||
app.multi_selections[0] = {app._other_option_idx}
|
||||
app.other_input.value = "" # Cleared
|
||||
|
||||
# Simulate input change with empty value
|
||||
from textual.widgets import Input
|
||||
|
||||
app.on_input_changed(Input.Changed(app.other_input, ""))
|
||||
|
||||
# Other should now be deselected
|
||||
assert app._other_option_idx not in app.multi_selections[0]
|
||||
|
||||
def test_auto_select_preserves_other_selections(self, multi_select_args):
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
|
||||
|
||||
app = QuestionApp(multi_select_args)
|
||||
app.other_input = MagicMock()
|
||||
app.other_input.value = "Custom"
|
||||
|
||||
# Pre-select Auth and Logging
|
||||
app.multi_selections[0] = {0, 2}
|
||||
|
||||
# Simulate typing
|
||||
from textual.widgets import Input
|
||||
|
||||
app.on_input_changed(Input.Changed(app.other_input, "Custom"))
|
||||
|
||||
# All selections should be preserved plus Other
|
||||
assert 0 in app.multi_selections[0]
|
||||
assert 2 in app.multi_selections[0]
|
||||
assert app._other_option_idx in app.multi_selections[0]
|
||||
|
||||
|
||||
class TestMultiSelectSubmit:
|
||||
def test_navigate_to_submit(self, multi_select_args):
|
||||
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
|
||||
|
||||
app = QuestionApp(multi_select_args)
|
||||
|
||||
# Navigate down through all options to Submit
|
||||
for _ in range(4): # 0->1->2->3(Other)->4(Submit)
|
||||
app.action_move_down()
|
||||
|
||||
assert app.selected_option == 4
|
||||
assert app._is_submit_selected is True
|
||||
|
||||
def test_submit_wraps_around(self, multi_select_args):
|
||||
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
|
||||
|
||||
app = QuestionApp(multi_select_args)
|
||||
app.selected_option = 4 # Submit
|
||||
|
||||
app.action_move_down()
|
||||
|
||||
assert app.selected_option == 0
|
||||
51
tests/cli/test_ui_clipboard_notifications.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from textual.selection import Selection
|
||||
from textual.widget import Widget
|
||||
|
||||
from vibe.cli.clipboard import copy_selection_to_clipboard
|
||||
from vibe.cli.textual_ui.app import VibeApp
|
||||
from vibe.core.agent_loop import AgentLoop
|
||||
from vibe.core.config import SessionLoggingConfig, VibeConfig
|
||||
|
||||
|
||||
class ClipboardSelectionWidget(Widget):
|
||||
def __init__(self, selected_text: str) -> None:
|
||||
super().__init__()
|
||||
self._selected_text = selected_text
|
||||
|
||||
@property
|
||||
def text_selection(self) -> Selection | None:
|
||||
return Selection(None, None)
|
||||
|
||||
def get_selection(self, selection: Selection) -> tuple[str, str] | None:
|
||||
return (self._selected_text, "\n")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ui_clipboard_notification_does_not_crash_on_markup_text(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
agent_loop = AgentLoop(
|
||||
config=VibeConfig(
|
||||
session_logging=SessionLoggingConfig(enabled=False),
|
||||
enable_update_checks=False,
|
||||
)
|
||||
)
|
||||
app = VibeApp(agent_loop=agent_loop)
|
||||
|
||||
async with app.run_test(notifications=True) as pilot:
|
||||
await app.mount(ClipboardSelectionWidget("[/]"))
|
||||
with patch("vibe.cli.clipboard._get_copy_fns") as mock_get_copy_fns:
|
||||
mock_get_copy_fns.return_value = [MagicMock()]
|
||||
copy_selection_to_clipboard(app)
|
||||
|
||||
await pilot.pause(0.1)
|
||||
notifications = list(app._notifications)
|
||||
assert notifications
|
||||
notification = notifications[-1]
|
||||
assert notification.markup is False
|
||||
assert "copied to clipboard" in notification.message
|
||||
135
tests/cli/test_ui_session_resume.py
Normal file
@@ -0,0 +1,135 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.cli.plan_offer.adapters.fake_whoami_gateway import FakeWhoAmIGateway
|
||||
from vibe.cli.textual_ui.app import VibeApp
|
||||
from vibe.cli.textual_ui.widgets.messages import AssistantMessage, UserMessage
|
||||
from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage
|
||||
from vibe.core.agent_loop import AgentLoop
|
||||
from vibe.core.config import SessionLoggingConfig, VibeConfig
|
||||
from vibe.core.types import FunctionCall, LLMMessage, Role, ToolCall
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def vibe_config() -> VibeConfig:
|
||||
return VibeConfig(
|
||||
session_logging=SessionLoggingConfig(enabled=False), enable_update_checks=False
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ui_displays_messages_when_resuming_session(
|
||||
vibe_config: VibeConfig,
|
||||
) -> None:
|
||||
"""Test that messages are properly displayed when resuming a session."""
|
||||
agent_loop = AgentLoop(config=vibe_config, enable_streaming=False)
|
||||
|
||||
# Simulate a previous session with messages
|
||||
user_msg = LLMMessage(role=Role.user, content="Hello, how are you?")
|
||||
assistant_msg = LLMMessage(
|
||||
role=Role.assistant,
|
||||
content="I'm doing well, thank you!",
|
||||
tool_calls=[
|
||||
ToolCall(
|
||||
id="tool_call_1",
|
||||
index=0,
|
||||
function=FunctionCall(
|
||||
name="read_file", arguments='{"path": "test.txt"}'
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
tool_result_msg = LLMMessage(
|
||||
role=Role.tool,
|
||||
content="File content here",
|
||||
name="read_file",
|
||||
tool_call_id="tool_call_1",
|
||||
)
|
||||
|
||||
agent_loop.messages.extend([user_msg, assistant_msg, tool_result_msg])
|
||||
|
||||
app = VibeApp(agent_loop=agent_loop, plan_offer_gateway=FakeWhoAmIGateway())
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
# Wait for the app to initialize and rebuild history
|
||||
await pilot.pause(0.5)
|
||||
|
||||
# Verify user message is displayed
|
||||
user_messages = app.query(UserMessage)
|
||||
assert len(user_messages) == 1
|
||||
assert user_messages[0]._content == "Hello, how are you?"
|
||||
|
||||
# Verify assistant message is displayed
|
||||
assistant_messages = app.query(AssistantMessage)
|
||||
assert len(assistant_messages) == 1
|
||||
assert assistant_messages[0]._content == "I'm doing well, thank you!"
|
||||
|
||||
# Verify tool call message is displayed
|
||||
tool_call_messages = app.query(ToolCallMessage)
|
||||
assert len(tool_call_messages) == 1
|
||||
assert tool_call_messages[0]._tool_name == "read_file"
|
||||
|
||||
# Verify tool result message is displayed
|
||||
tool_result_messages = app.query(ToolResultMessage)
|
||||
assert len(tool_result_messages) == 1
|
||||
assert tool_result_messages[0].tool_name == "read_file"
|
||||
assert tool_result_messages[0]._content == "File content here"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ui_does_not_display_messages_when_only_system_messages_exist(
|
||||
vibe_config: VibeConfig,
|
||||
) -> None:
|
||||
"""Test that no messages are displayed when only system messages exist."""
|
||||
agent_loop = AgentLoop(config=vibe_config, enable_streaming=False)
|
||||
|
||||
# Only system messages
|
||||
system_msg = LLMMessage(role=Role.system, content="System prompt")
|
||||
agent_loop.messages.append(system_msg)
|
||||
|
||||
app = VibeApp(agent_loop=agent_loop, plan_offer_gateway=FakeWhoAmIGateway())
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.5)
|
||||
|
||||
# Verify no user or assistant messages are displayed
|
||||
user_messages = app.query(UserMessage)
|
||||
assert len(user_messages) == 0
|
||||
|
||||
assistant_messages = app.query(AssistantMessage)
|
||||
assert len(assistant_messages) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ui_displays_multiple_user_assistant_turns(
|
||||
vibe_config: VibeConfig,
|
||||
) -> None:
|
||||
"""Test that multiple conversation turns are properly displayed."""
|
||||
agent_loop = AgentLoop(config=vibe_config, enable_streaming=False)
|
||||
|
||||
# Multiple conversation turns
|
||||
messages = [
|
||||
LLMMessage(role=Role.user, content="First question"),
|
||||
LLMMessage(role=Role.assistant, content="First answer"),
|
||||
LLMMessage(role=Role.user, content="Second question"),
|
||||
LLMMessage(role=Role.assistant, content="Second answer"),
|
||||
]
|
||||
|
||||
agent_loop.messages.extend(messages)
|
||||
|
||||
app = VibeApp(agent_loop=agent_loop, plan_offer_gateway=FakeWhoAmIGateway())
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.5)
|
||||
|
||||
# Verify all messages are displayed
|
||||
user_messages = app.query(UserMessage)
|
||||
assert len(user_messages) == 2
|
||||
assert user_messages[0]._content == "First question"
|
||||
assert user_messages[1]._content == "Second question"
|
||||
|
||||
assistant_messages = app.query(AssistantMessage)
|
||||
assert len(assistant_messages) == 2
|
||||
assert assistant_messages[0]._content == "First answer"
|
||||
assert assistant_messages[1]._content == "Second answer"
|
||||
@@ -29,6 +29,7 @@ def get_base_config() -> dict[str, Any]:
|
||||
"alias": "devstral-latest",
|
||||
}
|
||||
],
|
||||
"enable_auto_update": False,
|
||||
}
|
||||
|
||||
|
||||
@@ -74,3 +75,8 @@ def _mock_platform(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""
|
||||
monkeypatch.setattr(sys, "platform", "linux")
|
||||
monkeypatch.setenv("SHELL", "/bin/sh")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_update_commands(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("vibe.cli.update_notifier.update.UPDATE_COMMANDS", ["true"])
|
||||
|
||||
78
tests/core/test_agents.py
Normal file
@@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from vibe.core.agents.manager import AgentManager
|
||||
from vibe.core.agents.models import BUILTIN_AGENTS, EXPLORE, AgentSafety, AgentType
|
||||
from vibe.core.config import VibeConfig
|
||||
|
||||
|
||||
class TestAgentProfile:
|
||||
def test_explore_agent_is_subagent(self) -> None:
|
||||
"""Test that EXPLORE agent has SUBAGENT type."""
|
||||
assert EXPLORE.agent_type == AgentType.SUBAGENT
|
||||
|
||||
def test_explore_agent_has_safe_safety(self) -> None:
|
||||
"""Test that EXPLORE agent has SAFE safety level."""
|
||||
assert EXPLORE.safety == AgentSafety.SAFE
|
||||
|
||||
def test_explore_agent_has_enabled_tools(self) -> None:
|
||||
"""Test that EXPLORE agent has expected enabled tools."""
|
||||
enabled_tools = EXPLORE.overrides.get("enabled_tools", [])
|
||||
assert "grep" in enabled_tools
|
||||
assert "read_file" in enabled_tools
|
||||
|
||||
def test_builtin_agents_contains_explore(self) -> None:
|
||||
"""Test that BUILTIN_AGENTS includes explore."""
|
||||
assert "explore" in BUILTIN_AGENTS
|
||||
assert BUILTIN_AGENTS["explore"] is EXPLORE
|
||||
|
||||
|
||||
class TestAgentManager:
|
||||
@pytest.fixture
|
||||
def manager(self) -> AgentManager:
|
||||
config = VibeConfig(include_project_context=False, include_prompt_detail=False)
|
||||
return AgentManager(lambda: config)
|
||||
|
||||
def test_get_subagents_returns_only_subagents(self, manager: AgentManager) -> None:
|
||||
"""Test that only SUBAGENT type agents are returned."""
|
||||
subagents = manager.get_subagents()
|
||||
|
||||
for agent in subagents:
|
||||
assert agent.agent_type == AgentType.SUBAGENT
|
||||
|
||||
def test_get_subagents_includes_explore(self, manager: AgentManager) -> None:
|
||||
"""Test that EXPLORE is included in subagents."""
|
||||
subagents = manager.get_subagents()
|
||||
names = [a.name for a in subagents]
|
||||
|
||||
assert "explore" in names
|
||||
|
||||
def test_get_subagents_excludes_agents(self, manager: AgentManager) -> None:
|
||||
"""Test that AGENT type agents are not returned."""
|
||||
subagents = manager.get_subagents()
|
||||
names = [a.name for a in subagents]
|
||||
|
||||
# These are AGENT type
|
||||
assert "default" not in names
|
||||
assert "plan" not in names
|
||||
assert "auto-approve" not in names
|
||||
|
||||
def test_get_builtin_agent(self, manager: AgentManager) -> None:
|
||||
"""Test getting a builtin agent by name."""
|
||||
agent = manager.get_agent("explore")
|
||||
|
||||
assert agent is EXPLORE
|
||||
assert agent.agent_type == AgentType.SUBAGENT
|
||||
|
||||
def test_get_nonexistent_agent_raises(self, manager: AgentManager) -> None:
|
||||
"""Test that getting a nonexistent agent raises ValueError."""
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
manager.get_agent("nonexistent-agent")
|
||||
|
||||
def test_get_default_agent(self, manager: AgentManager) -> None:
|
||||
"""Test getting the default agent."""
|
||||
agent = manager.get_agent("default")
|
||||
|
||||
assert agent.name == "default"
|
||||
assert agent.agent_type == AgentType.AGENT
|
||||
@@ -1,8 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
import json
|
||||
|
||||
from vibe.core.types import LLMChunk, LLMMessage, LLMUsage, Role, ToolCall
|
||||
from vibe.core.types import (
|
||||
LLMChunk,
|
||||
LLMMessage,
|
||||
LLMUsage,
|
||||
Role,
|
||||
ToolCall,
|
||||
ToolStreamEvent,
|
||||
)
|
||||
|
||||
MOCK_DATA_ENV_VAR = "VIBE_MOCK_LLM_DATA"
|
||||
|
||||
@@ -39,4 +47,15 @@ def get_mocking_env(mock_chunks: list[LLMChunk] | None = None) -> dict[str, str]
|
||||
|
||||
mock_data = [LLMChunk.model_dump(mock_chunk) for mock_chunk in mock_chunks]
|
||||
|
||||
return {MOCK_DATA_ENV_VAR: json.dumps(mock_data)}
|
||||
return {MOCK_DATA_ENV_VAR: json.dumps(mock_data, ensure_ascii=False)}
|
||||
|
||||
|
||||
async def collect_result[T](gen: AsyncGenerator[ToolStreamEvent | T, None]) -> T:
|
||||
"""Collect the final result from an AsyncGenerator, filtering out stream events."""
|
||||
result = None
|
||||
async for item in gen:
|
||||
if not isinstance(item, ToolStreamEvent):
|
||||
result = item
|
||||
if result is None:
|
||||
raise RuntimeError("Generator did not yield a result")
|
||||
return result
|
||||
|
||||
@@ -57,4 +57,4 @@ def test_successfully_completes(
|
||||
onboarding.run_onboarding(StubApp("completed"))
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert out == ""
|
||||
assert 'Setup complete 🎉. Run "vibe" to start using the Mistral Vibe CLI.' in out
|
||||
|
||||
539
tests/session/test_session_loader.py
Normal file
@@ -0,0 +1,539 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
import json
|
||||
from pathlib import Path
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from vibe.core.config import SessionLoggingConfig
|
||||
from vibe.core.session.session_loader import SessionLoader
|
||||
from vibe.core.types import LLMMessage, Role, ToolCall
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_session_dir(tmp_path: Path) -> Path:
|
||||
"""Create a temporary directory for session loader tests."""
|
||||
session_dir = tmp_path / "sessions"
|
||||
session_dir.mkdir()
|
||||
return session_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_config(temp_session_dir: Path) -> SessionLoggingConfig:
|
||||
"""Create a session logging config for testing."""
|
||||
return SessionLoggingConfig(
|
||||
save_dir=str(temp_session_dir), session_prefix="test", enabled=True
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_test_session():
|
||||
"""Helper fixture to create a test session with messages and metadata."""
|
||||
|
||||
def _create_test_session(
|
||||
session_dir: Path,
|
||||
session_id: str,
|
||||
messages: list[LLMMessage] | None = None,
|
||||
metadata: dict | None = None,
|
||||
) -> Path:
|
||||
"""Create a test session directory with messages and metadata files."""
|
||||
# Create session directory
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
session_folder = session_dir / f"test_{timestamp}_{session_id[:8]}"
|
||||
session_folder.mkdir(exist_ok=True)
|
||||
|
||||
# Create messages file
|
||||
messages_file = session_folder / "messages.jsonl"
|
||||
if messages is None:
|
||||
messages = [
|
||||
LLMMessage(role=Role.system, content="System prompt"),
|
||||
LLMMessage(role=Role.user, content="Hello"),
|
||||
LLMMessage(role=Role.assistant, content="Hi there!"),
|
||||
]
|
||||
|
||||
with messages_file.open("w", encoding="utf-8") as f:
|
||||
for message in messages:
|
||||
f.write(
|
||||
json.dumps(
|
||||
message.model_dump(exclude_none=True), ensure_ascii=False
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
# Create metadata file
|
||||
metadata_file = session_folder / "meta.json"
|
||||
if metadata is None:
|
||||
metadata = {
|
||||
"session_id": session_id,
|
||||
"start_time": "2023-01-01T12:00:00",
|
||||
"end_time": "2023-01-01T12:05:00",
|
||||
"total_messages": 2,
|
||||
"stats": {
|
||||
"steps": 1,
|
||||
"session_prompt_tokens": 10,
|
||||
"session_completion_tokens": 20,
|
||||
},
|
||||
"system_prompt": {"content": "System prompt", "role": "system"},
|
||||
}
|
||||
|
||||
with metadata_file.open("w", encoding="utf-8") as f:
|
||||
json.dump(metadata, f, indent=2)
|
||||
|
||||
return session_folder
|
||||
|
||||
return _create_test_session
|
||||
|
||||
|
||||
class TestSessionLoaderFindLatestSession:
|
||||
def test_find_latest_session_no_sessions(
|
||||
self, session_config: SessionLoggingConfig
|
||||
) -> None:
|
||||
"""Test finding latest session when no sessions exist."""
|
||||
result = SessionLoader.find_latest_session(session_config)
|
||||
assert result is None
|
||||
|
||||
def test_find_latest_session_single_session(
|
||||
self, session_config: SessionLoggingConfig, create_test_session
|
||||
) -> None:
|
||||
"""Test finding latest session with a single session."""
|
||||
session_dir = Path(session_config.save_dir)
|
||||
session = create_test_session(session_dir, "session-123")
|
||||
|
||||
result = SessionLoader.find_latest_session(session_config)
|
||||
assert result is not None
|
||||
assert result.exists()
|
||||
assert result == session
|
||||
|
||||
def test_find_latest_session_multiple_sessions(
|
||||
self, session_config: SessionLoggingConfig, create_test_session
|
||||
) -> None:
|
||||
"""Test finding latest session with multiple sessions."""
|
||||
session_dir = Path(session_config.save_dir)
|
||||
|
||||
create_test_session(session_dir, "session-123")
|
||||
time.sleep(0.01)
|
||||
create_test_session(session_dir, "session-456")
|
||||
time.sleep(0.01)
|
||||
latest = create_test_session(session_dir, "session-789")
|
||||
|
||||
result = SessionLoader.find_latest_session(session_config)
|
||||
assert result is not None
|
||||
assert result.exists()
|
||||
assert result == latest
|
||||
|
||||
def test_find_latest_session_nonexistent_save_dir(self) -> None:
|
||||
"""Test finding latest session when save directory doesn't exist."""
|
||||
# Modify config to point to non-existent directory
|
||||
bad_config = SessionLoggingConfig(
|
||||
save_dir="/nonexistent/path", session_prefix="test", enabled=True
|
||||
)
|
||||
|
||||
result = SessionLoader.find_latest_session(bad_config)
|
||||
assert result is None
|
||||
|
||||
def test_find_latest_session_with_invalid_sessions(
|
||||
self, session_config: SessionLoggingConfig
|
||||
) -> None:
|
||||
"""Test finding latest session when only invalid sessions exist."""
|
||||
session_dir = Path(session_config.save_dir)
|
||||
|
||||
invalid_session1 = session_dir / "test_20230101_120000_invalid1"
|
||||
invalid_session1.mkdir()
|
||||
(invalid_session1 / "messages.jsonl").write_text("[]") # Missing meta.json
|
||||
|
||||
invalid_session2 = session_dir / "test_20230101_120001_invalid2"
|
||||
invalid_session2.mkdir()
|
||||
(invalid_session2 / "meta.json").write_text('{"session_id": "invalid"}')
|
||||
|
||||
result = SessionLoader.find_latest_session(session_config)
|
||||
assert result is None # Should return None when no valid sessions exist
|
||||
|
||||
def test_find_latest_session_with_mixed_valid_invalid(
|
||||
self, session_config: SessionLoggingConfig, create_test_session
|
||||
) -> None:
|
||||
"""Test finding latest session when both valid and invalid sessions exist."""
|
||||
session_dir = Path(session_config.save_dir)
|
||||
|
||||
invalid_session = session_dir / "test_20230101_120000_invalid"
|
||||
invalid_session.mkdir()
|
||||
(invalid_session / "messages.jsonl").write_text(
|
||||
'{"role": "user", "content": "test"}\n'
|
||||
)
|
||||
|
||||
time.sleep(0.01)
|
||||
|
||||
valid_session = create_test_session(session_dir, "test_20230101_120001_valid")
|
||||
|
||||
time.sleep(0.01)
|
||||
|
||||
newest_invalid = session_dir / "test_20230101_120002_newest"
|
||||
newest_invalid.mkdir()
|
||||
(newest_invalid / "messages.jsonl").write_text(
|
||||
'{"role": "user", "content": "test"}\n'
|
||||
)
|
||||
|
||||
result = SessionLoader.find_latest_session(session_config)
|
||||
assert result is not None
|
||||
assert result == valid_session
|
||||
|
||||
def test_find_latest_session_with_invalid_json(
|
||||
self, session_config: SessionLoggingConfig, create_test_session
|
||||
) -> None:
|
||||
"""Test finding latest session when sessions have invalid JSON."""
|
||||
session_dir = Path(session_config.save_dir)
|
||||
|
||||
invalid_meta_session = session_dir / "test_20230101_120000_invalid_meta"
|
||||
invalid_meta_session.mkdir()
|
||||
(invalid_meta_session / "messages.jsonl").write_text(
|
||||
'{"role": "user", "content": "test"}\n'
|
||||
)
|
||||
(invalid_meta_session / "meta.json").write_text("{invalid json}")
|
||||
|
||||
time.sleep(0.01)
|
||||
|
||||
invalid_msg_session = session_dir / "test_20230101_120001_invalid_msg"
|
||||
invalid_msg_session.mkdir()
|
||||
(invalid_msg_session / "messages.jsonl").write_text("{invalid json}")
|
||||
(invalid_msg_session / "meta.json").write_text('{"session_id": "invalid"}')
|
||||
|
||||
time.sleep(0.01)
|
||||
|
||||
valid_session = create_test_session(session_dir, "test_20230101_120002_valid")
|
||||
|
||||
result = SessionLoader.find_latest_session(session_config)
|
||||
assert result is not None
|
||||
assert result == valid_session
|
||||
|
||||
|
||||
class TestSessionLoaderFindSessionById:
|
||||
def test_find_session_by_id_exact_match(
|
||||
self, session_config: SessionLoggingConfig, create_test_session
|
||||
) -> None:
|
||||
"""Test finding session by exact ID match."""
|
||||
session_dir = Path(session_config.save_dir)
|
||||
session_folder = create_test_session(session_dir, "test-session-123")
|
||||
|
||||
# Test with full UUID format
|
||||
result = SessionLoader.find_session_by_id("test-session-123", session_config)
|
||||
assert result is not None
|
||||
assert result == session_folder
|
||||
|
||||
def test_find_session_by_id_short_uuid(
|
||||
self, session_config: SessionLoggingConfig, create_test_session
|
||||
) -> None:
|
||||
"""Test finding session by short UUID."""
|
||||
session_dir = Path(session_config.save_dir)
|
||||
session_folder = create_test_session(
|
||||
session_dir, "abc12345-6789-0123-4567-89abcdef0123"
|
||||
)
|
||||
|
||||
# Test with short UUID
|
||||
result = SessionLoader.find_session_by_id("abc12345", session_config)
|
||||
assert result is not None
|
||||
assert result == session_folder
|
||||
|
||||
def test_find_session_by_id_partial_match(
|
||||
self, session_config: SessionLoggingConfig, create_test_session
|
||||
) -> None:
|
||||
"""Test finding session by partial ID match"""
|
||||
session_dir = Path(session_config.save_dir)
|
||||
session_folder = create_test_session(session_dir, "abc12345678")
|
||||
|
||||
# Test with partial match
|
||||
result = SessionLoader.find_session_by_id("abc12345", session_config)
|
||||
assert result is not None
|
||||
assert result == session_folder
|
||||
|
||||
def test_find_session_by_id_multiple_matches(
|
||||
self, session_config: SessionLoggingConfig, create_test_session
|
||||
) -> None:
|
||||
"""Test finding session when multiple sessions match (should return most recent)."""
|
||||
session_dir = Path(session_config.save_dir)
|
||||
|
||||
# Create first session
|
||||
create_test_session(session_dir, "abcd1234")
|
||||
|
||||
# Sleep to ensure different modification times
|
||||
time.sleep(0.01)
|
||||
|
||||
# Create second session with similar ID prefix
|
||||
session_2 = create_test_session(session_dir, "abcd1234")
|
||||
|
||||
result = SessionLoader.find_session_by_id("abcd1234", session_config)
|
||||
assert result is not None
|
||||
assert result == session_2
|
||||
|
||||
def test_find_session_by_id_no_match(
|
||||
self, session_config: SessionLoggingConfig, create_test_session
|
||||
) -> None:
|
||||
"""Test finding session by ID when no match exists."""
|
||||
session_dir = Path(session_config.save_dir)
|
||||
create_test_session(session_dir, "test-session-123")
|
||||
|
||||
result = SessionLoader.find_session_by_id("nonexistent", session_config)
|
||||
assert result is None
|
||||
|
||||
def test_find_session_by_id_nonexistent_save_dir(self) -> None:
|
||||
"""Test finding session by ID when save directory doesn't exist."""
|
||||
bad_config = SessionLoggingConfig(
|
||||
save_dir="/nonexistent/path", session_prefix="test", enabled=True
|
||||
)
|
||||
|
||||
result = SessionLoader.find_session_by_id("test-session", bad_config)
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestSessionLoaderLoadSession:
|
||||
def test_load_session_success(
|
||||
self, session_config: SessionLoggingConfig, create_test_session
|
||||
) -> None:
|
||||
"""Test successfully loading a session."""
|
||||
session_dir = Path(session_config.save_dir)
|
||||
session_folder = create_test_session(session_dir, "test-session-123")
|
||||
|
||||
messages, metadata = SessionLoader.load_session(session_folder)
|
||||
|
||||
# Verify messages
|
||||
assert len(messages) == 2
|
||||
assert messages[0].role == "user"
|
||||
assert messages[0].content == "Hello"
|
||||
assert messages[1].role == "assistant"
|
||||
assert messages[1].content == "Hi there!"
|
||||
|
||||
# Verify metadata
|
||||
assert metadata["session_id"] == "test-session-123"
|
||||
assert metadata["total_messages"] == 2
|
||||
assert "stats" in metadata
|
||||
assert "system_prompt" in metadata
|
||||
|
||||
def test_load_session_empty_messages(
|
||||
self, session_config: SessionLoggingConfig
|
||||
) -> None:
|
||||
"""Test loading session with empty messages file."""
|
||||
session_dir = Path(session_config.save_dir)
|
||||
session_folder = session_dir / "test_20230101_120000_test123"
|
||||
session_folder.mkdir()
|
||||
|
||||
# Create empty messages file
|
||||
messages_file = session_folder / "messages.jsonl"
|
||||
messages_file.write_text("")
|
||||
|
||||
# Create metadata file
|
||||
metadata_file = session_folder / "meta.json"
|
||||
metadata_file.write_text('{"session_id": "test-session"}')
|
||||
|
||||
with pytest.raises(ValueError, match="Session messages file is empty"):
|
||||
SessionLoader.load_session(session_folder)
|
||||
|
||||
def test_load_session_invalid_json_messages(
|
||||
self, session_config: SessionLoggingConfig
|
||||
) -> None:
|
||||
"""Test loading session with invalid JSON in messages file."""
|
||||
session_dir = Path(session_config.save_dir)
|
||||
session_folder = session_dir / "test_20230101_120000_test123"
|
||||
session_folder.mkdir()
|
||||
|
||||
# Create messages file with invalid JSON
|
||||
messages_file = session_folder / "messages.jsonl"
|
||||
messages_file.write_text("{invalid json}")
|
||||
|
||||
# Create metadata file
|
||||
metadata_file = session_folder / "meta.json"
|
||||
metadata_file.write_text('{"session_id": "test-session"}')
|
||||
|
||||
with pytest.raises(ValueError, match="Session messages contain invalid JSON"):
|
||||
SessionLoader.load_session(session_folder)
|
||||
|
||||
def test_load_session_invalid_json_metadata(
|
||||
self, session_config: SessionLoggingConfig
|
||||
) -> None:
|
||||
"""Test loading session with invalid JSON in metadata file."""
|
||||
session_dir = Path(session_config.save_dir)
|
||||
session_folder = session_dir / "test_20230101_120000_test123"
|
||||
session_folder.mkdir()
|
||||
|
||||
# Create valid messages file
|
||||
messages_file = session_folder / "messages.jsonl"
|
||||
messages_file.write_text('{"role": "user", "content": "Hello"}\n')
|
||||
|
||||
# Create metadata file with invalid JSON
|
||||
metadata_file = session_folder / "meta.json"
|
||||
metadata_file.write_text("{invalid json}")
|
||||
|
||||
with pytest.raises(ValueError, match="Session metadata contains invalid JSON"):
|
||||
SessionLoader.load_session(session_folder)
|
||||
|
||||
def test_load_session_no_metadata_file(
|
||||
self, session_config: SessionLoggingConfig
|
||||
) -> None:
|
||||
"""Test loading session when metadata file doesn't exist."""
|
||||
session_dir = Path(session_config.save_dir)
|
||||
session_folder = session_dir / "test_20230101_120000_test123"
|
||||
session_folder.mkdir()
|
||||
|
||||
# Create valid messages file using the same format as create_test_session
|
||||
messages = [
|
||||
LLMMessage(role=Role.system, content="System prompt"),
|
||||
LLMMessage(role=Role.user, content="Hello"),
|
||||
LLMMessage(role=Role.assistant, content="Hi there!"),
|
||||
]
|
||||
|
||||
messages_file = session_folder / "messages.jsonl"
|
||||
with messages_file.open("w", encoding="utf-8") as f:
|
||||
for message in messages:
|
||||
f.write(
|
||||
json.dumps(
|
||||
message.model_dump(exclude_none=True), ensure_ascii=False
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
loaded_messages, metadata = SessionLoader.load_session(session_folder)
|
||||
|
||||
assert len(loaded_messages) == 2
|
||||
assert loaded_messages[0].content == "Hello"
|
||||
assert loaded_messages[0].role == Role.user
|
||||
assert loaded_messages[1].content == "Hi there!"
|
||||
assert loaded_messages[1].role == Role.assistant
|
||||
|
||||
assert metadata == {}
|
||||
|
||||
def test_load_session_nonexistent_directory(
|
||||
self, session_config: SessionLoggingConfig
|
||||
) -> None:
|
||||
"""Test loading session from non-existent directory."""
|
||||
nonexistent_dir = Path(session_config.save_dir) / "nonexistent"
|
||||
|
||||
with pytest.raises(ValueError, match="Error reading session messages"):
|
||||
SessionLoader.load_session(nonexistent_dir)
|
||||
|
||||
|
||||
class TestSessionLoaderEdgeCases:
|
||||
def test_find_latest_session_with_different_prefixes(
|
||||
self, session_config: SessionLoggingConfig
|
||||
) -> None:
|
||||
"""Test finding latest session when sessions have different prefixes."""
|
||||
session_dir = Path(session_config.save_dir)
|
||||
|
||||
# Create sessions with different prefixes
|
||||
other_session = session_dir / "other_20230101_120000_test123"
|
||||
other_session.mkdir()
|
||||
(other_session / "messages.jsonl").write_text(
|
||||
'{"role": "user", "content": "test"}\n'
|
||||
)
|
||||
|
||||
test_session = session_dir / "test_20230101_120000_test456"
|
||||
test_session.mkdir()
|
||||
(test_session / "messages.jsonl").write_text(
|
||||
'{"role": "user", "content": "test"}\n'
|
||||
)
|
||||
(test_session / "meta.json").write_text('{"session_id": "test456"}')
|
||||
|
||||
result = SessionLoader.find_latest_session(session_config)
|
||||
assert result is not None
|
||||
assert result.name == "test_20230101_120000_test456"
|
||||
|
||||
def test_find_session_by_id_with_special_characters(
|
||||
self, session_config: SessionLoggingConfig, create_test_session
|
||||
) -> None:
|
||||
"""Test finding session by ID containing special characters."""
|
||||
session_dir = Path(session_config.save_dir)
|
||||
session_folder = create_test_session(
|
||||
session_dir, "test-session_with-special.chars"
|
||||
)
|
||||
|
||||
result = SessionLoader.find_session_by_id(
|
||||
"test-session_with-special.chars", session_config
|
||||
)
|
||||
assert result is not None
|
||||
assert result == session_folder
|
||||
|
||||
def test_load_session_with_complex_messages(
|
||||
self, session_config: SessionLoggingConfig
|
||||
) -> None:
|
||||
"""Test loading session with complex message structures."""
|
||||
session_dir = Path(session_config.save_dir)
|
||||
session_folder = session_dir / "test_20230101_120000_test123"
|
||||
session_folder.mkdir()
|
||||
|
||||
# Create messages with complex structure
|
||||
complex_messages = [
|
||||
LLMMessage(role=Role.system, content="System prompt"),
|
||||
LLMMessage(
|
||||
role=Role.user,
|
||||
content="Complex message",
|
||||
reasoning_content="Some reasoning",
|
||||
tool_calls=[ToolCall(id="call1", index=1, type="function")],
|
||||
),
|
||||
LLMMessage(
|
||||
role=Role.assistant,
|
||||
content="Response",
|
||||
tool_calls=[ToolCall(id="call2", index=2, type="function")],
|
||||
),
|
||||
]
|
||||
|
||||
messages_file = session_folder / "messages.jsonl"
|
||||
with messages_file.open("w", encoding="utf-8") as f:
|
||||
for message in complex_messages:
|
||||
f.write(
|
||||
json.dumps(
|
||||
message.model_dump(exclude_none=True), ensure_ascii=False
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
# Create metadata file
|
||||
metadata_file = session_folder / "meta.json"
|
||||
metadata_file.write_text('{"session_id": "complex-session"}')
|
||||
|
||||
messages, _ = SessionLoader.load_session(session_folder)
|
||||
|
||||
# Verify complex messages are loaded correctly
|
||||
assert len(messages) == 2
|
||||
assert messages[0].role == Role.user
|
||||
assert messages[0].content == "Complex message"
|
||||
assert messages[0].reasoning_content == "Some reasoning"
|
||||
assert len(messages[0].tool_calls or []) == 1
|
||||
assert messages[1].role == Role.assistant
|
||||
assert len(messages[1].tool_calls or []) == 1
|
||||
assert messages[1].content == "Response"
|
||||
|
||||
def test_load_session_system_prompt_ignored_in_messages(
|
||||
self, session_config: SessionLoggingConfig
|
||||
) -> None:
|
||||
"""Test that system prompt is ignored when written in messages.jsonl."""
|
||||
session_dir = Path(session_config.save_dir)
|
||||
session_folder = session_dir / "test_20230101_120000_test123"
|
||||
session_folder.mkdir()
|
||||
|
||||
messages_with_system = [
|
||||
LLMMessage(role=Role.system, content="System prompt from messages"),
|
||||
LLMMessage(role=Role.user, content="Hello"),
|
||||
LLMMessage(role=Role.assistant, content="Hi there!"),
|
||||
]
|
||||
|
||||
messages_file = session_folder / "messages.jsonl"
|
||||
with messages_file.open("w", encoding="utf-8") as f:
|
||||
for message in messages_with_system:
|
||||
f.write(
|
||||
json.dumps(
|
||||
message.model_dump(exclude_none=True), ensure_ascii=False
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
metadata_file = session_folder / "meta.json"
|
||||
metadata_file.write_text(
|
||||
json.dumps({"session_id": "test-session", "total_messages": 3})
|
||||
)
|
||||
|
||||
messages, metadata = SessionLoader.load_session(session_folder)
|
||||
|
||||
# Verify that system prompt from messages.jsonl is ignored
|
||||
assert len(messages) == 2
|
||||
assert messages[0].role == Role.user
|
||||
assert messages[0].content == "Hello"
|
||||
assert messages[1].role == Role.assistant
|
||||
assert messages[1].content == "Hi there!"
|
||||
641
tests/session/test_session_logger.py
Normal file
@@ -0,0 +1,641 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from vibe.core.agents.models import AgentProfile, AgentSafety
|
||||
from vibe.core.config import SessionLoggingConfig, VibeConfig
|
||||
from vibe.core.session.session_logger import SessionLogger
|
||||
from vibe.core.tools.manager import ToolManager
|
||||
from vibe.core.types import AgentStats, LLMMessage, Role, SessionMetadata
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_session_dir(tmp_path: Path) -> Path:
|
||||
"""Create a temporary directory for session logging tests."""
|
||||
session_dir = tmp_path / "sessions"
|
||||
session_dir.mkdir()
|
||||
return session_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_config(temp_session_dir: Path) -> SessionLoggingConfig:
|
||||
"""Create a session logging config for testing."""
|
||||
return SessionLoggingConfig(
|
||||
save_dir=str(temp_session_dir), session_prefix="test", enabled=True
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def disabled_session_config() -> SessionLoggingConfig:
|
||||
"""Create a disabled session logging config for testing."""
|
||||
return SessionLoggingConfig(
|
||||
save_dir="/tmp/test", session_prefix="test", enabled=False
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_agent_profile() -> AgentProfile:
|
||||
"""Create a mock agent profile for testing."""
|
||||
return AgentProfile(
|
||||
name="test-agent",
|
||||
display_name="Test Agent",
|
||||
description="A test agent",
|
||||
safety=AgentSafety.NEUTRAL,
|
||||
overrides={},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_tool_manager() -> ToolManager:
|
||||
"""Create a mock tool manager for testing."""
|
||||
manager = MagicMock(spec=ToolManager)
|
||||
manager.available_tools = {}
|
||||
return manager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_vibe_config() -> VibeConfig:
|
||||
"""Create a mock vibe config for testing."""
|
||||
return VibeConfig(active_model="test-model", models=[], providers=[])
|
||||
|
||||
|
||||
class TestSessionLoggerInitialization:
|
||||
def test_enabled_session_logger_initialization(
|
||||
self, session_config: SessionLoggingConfig
|
||||
) -> None:
|
||||
"""Test that SessionLogger initializes correctly when enabled."""
|
||||
session_id = "test-session-123"
|
||||
logger = SessionLogger(session_config, session_id)
|
||||
|
||||
assert logger.enabled is True
|
||||
assert logger.session_id == session_id
|
||||
assert logger.save_dir == Path(session_config.save_dir)
|
||||
assert logger.session_prefix == session_config.session_prefix
|
||||
assert logger.session_dir is not None
|
||||
assert logger.session_metadata is not None
|
||||
assert isinstance(logger.session_metadata, SessionMetadata)
|
||||
|
||||
# Check that session directory was created
|
||||
assert logger.session_dir is not None
|
||||
assert str(logger.session_dir).startswith(str(session_config.save_dir))
|
||||
|
||||
# Check session directory name format
|
||||
dir_name = logger.session_dir.name
|
||||
assert dir_name.startswith(f"{session_config.session_prefix}_")
|
||||
assert session_id[:8] in dir_name
|
||||
|
||||
def test_disabled_session_logger_initialization(
|
||||
self, disabled_session_config: SessionLoggingConfig
|
||||
) -> None:
|
||||
"""Test that SessionLogger initializes correctly when disabled."""
|
||||
session_id = "test-session-123"
|
||||
logger = SessionLogger(disabled_session_config, session_id)
|
||||
|
||||
assert logger.enabled is False
|
||||
assert logger.session_id == "disabled"
|
||||
assert logger.save_dir is None
|
||||
assert logger.session_prefix is None
|
||||
assert logger.session_dir is None
|
||||
assert logger.session_metadata is None
|
||||
|
||||
|
||||
class TestSessionLoggerMetadata:
|
||||
@patch("vibe.core.session.session_logger.subprocess.run")
|
||||
@patch("vibe.core.session.session_logger.getpass.getuser")
|
||||
def test_session_metadata_initialization(
|
||||
self, mock_getuser, mock_subprocess, session_config: SessionLoggingConfig
|
||||
) -> None:
|
||||
"""Test that session metadata is correctly initialized."""
|
||||
# Mock git commands
|
||||
git_commit_mock = MagicMock()
|
||||
git_commit_mock.returncode = 0
|
||||
git_commit_mock.stdout = "abc123\n"
|
||||
|
||||
git_branch_mock = MagicMock()
|
||||
git_branch_mock.returncode = 0
|
||||
git_branch_mock.stdout = "main\n"
|
||||
|
||||
mock_subprocess.side_effect = [git_commit_mock, git_branch_mock]
|
||||
mock_getuser.return_value = "testuser"
|
||||
|
||||
session_id = "test-session-123"
|
||||
logger = SessionLogger(session_config, session_id)
|
||||
|
||||
assert logger.session_metadata is not None
|
||||
metadata = logger.session_metadata
|
||||
|
||||
assert metadata.session_id == session_id
|
||||
assert metadata.start_time == logger.session_start_time
|
||||
assert metadata.end_time is None
|
||||
assert metadata.git_commit == "abc123"
|
||||
assert metadata.git_branch == "main"
|
||||
assert metadata.username == "testuser"
|
||||
assert "working_directory" in metadata.environment
|
||||
assert metadata.environment["working_directory"] == str(Path.cwd())
|
||||
|
||||
@patch("vibe.core.session.session_logger.subprocess.run")
|
||||
@patch("vibe.core.session.session_logger.getpass.getuser")
|
||||
def test_session_metadata_with_git_errors(
|
||||
self, mock_getuser, mock_subprocess, session_config: SessionLoggingConfig
|
||||
) -> None:
|
||||
"""Test that session metadata handles git command errors gracefully."""
|
||||
# Mock git commands to fail
|
||||
mock_subprocess.side_effect = FileNotFoundError("git not found")
|
||||
mock_getuser.return_value = "testuser"
|
||||
|
||||
session_id = "test-session-123"
|
||||
logger = SessionLogger(session_config, session_id)
|
||||
|
||||
assert logger.session_metadata is not None
|
||||
metadata = logger.session_metadata
|
||||
|
||||
assert metadata.git_commit is None
|
||||
assert metadata.git_branch is None
|
||||
assert metadata.username == "testuser"
|
||||
|
||||
|
||||
class TestSessionLoggerSaveInteraction:
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_interaction_disabled(
|
||||
self, disabled_session_config: SessionLoggingConfig
|
||||
) -> None:
|
||||
"""Test that save_interaction returns None when logging is disabled."""
|
||||
logger = SessionLogger(disabled_session_config, "test-session")
|
||||
|
||||
result = await logger.save_interaction(
|
||||
messages=[],
|
||||
stats=AgentStats(),
|
||||
base_config=VibeConfig(active_model="test", models=[], providers=[]),
|
||||
tool_manager=MagicMock(),
|
||||
agent_profile=AgentProfile(
|
||||
name="test",
|
||||
display_name="Test",
|
||||
description="Test agent",
|
||||
safety=AgentSafety.NEUTRAL,
|
||||
overrides={},
|
||||
),
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_interaction_success(
|
||||
self,
|
||||
session_config: SessionLoggingConfig,
|
||||
mock_vibe_config: VibeConfig,
|
||||
mock_tool_manager: ToolManager,
|
||||
mock_agent_profile: AgentProfile,
|
||||
) -> None:
|
||||
"""Test that save_interaction successfully saves session data."""
|
||||
session_id = "test-session-123"
|
||||
logger = SessionLogger(session_config, session_id)
|
||||
|
||||
# Create test messages
|
||||
messages = [
|
||||
LLMMessage(role=Role.system, content="System prompt"),
|
||||
LLMMessage(role=Role.user, content="Hello"),
|
||||
LLMMessage(role=Role.assistant, content="Hi there!"),
|
||||
]
|
||||
|
||||
# Create test stats
|
||||
stats = AgentStats(
|
||||
steps=1, session_prompt_tokens=10, session_completion_tokens=20
|
||||
)
|
||||
|
||||
# Test that save_interaction returns a path when enabled
|
||||
result = await logger.save_interaction(
|
||||
messages=messages,
|
||||
stats=stats,
|
||||
base_config=mock_vibe_config,
|
||||
tool_manager=mock_tool_manager,
|
||||
agent_profile=mock_agent_profile,
|
||||
)
|
||||
|
||||
# Verify the result
|
||||
assert result is not None
|
||||
assert str(logger.session_dir) in result
|
||||
|
||||
# Verify that files were created
|
||||
assert logger.session_dir is not None
|
||||
messages_file = logger.session_dir / "messages.jsonl"
|
||||
metadata_file = logger.session_dir / "meta.json"
|
||||
|
||||
assert messages_file.exists()
|
||||
assert metadata_file.exists()
|
||||
|
||||
# Verify that metadata contains expected data
|
||||
with open(metadata_file) as f:
|
||||
metadata = json.load(f)
|
||||
assert metadata["session_id"] == session_id
|
||||
assert metadata["total_messages"] == 2
|
||||
assert metadata["stats"]["steps"] == stats.steps
|
||||
assert "title" in metadata
|
||||
assert metadata["title"] == "Hello"
|
||||
assert "system_prompt" in metadata
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_interaction_system_prompt_in_metadata(
|
||||
self,
|
||||
session_config: SessionLoggingConfig,
|
||||
mock_vibe_config: VibeConfig,
|
||||
mock_tool_manager: ToolManager,
|
||||
mock_agent_profile: AgentProfile,
|
||||
) -> None:
|
||||
"""Test that system prompt is saved in metadata and not in messages."""
|
||||
session_id = "test-session-123"
|
||||
logger = SessionLogger(session_config, session_id)
|
||||
|
||||
messages = [
|
||||
LLMMessage(role=Role.system, content="System prompt"),
|
||||
LLMMessage(role=Role.user, content="Hello"),
|
||||
LLMMessage(role=Role.assistant, content="Hi there!"),
|
||||
]
|
||||
|
||||
stats = AgentStats(
|
||||
steps=1, session_prompt_tokens=10, session_completion_tokens=20
|
||||
)
|
||||
|
||||
result = await logger.save_interaction(
|
||||
messages=messages,
|
||||
stats=stats,
|
||||
base_config=mock_vibe_config,
|
||||
tool_manager=mock_tool_manager,
|
||||
agent_profile=mock_agent_profile,
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert logger.session_dir is not None
|
||||
|
||||
metadata_file = logger.session_dir / "meta.json"
|
||||
with open(metadata_file) as f:
|
||||
metadata = json.load(f)
|
||||
assert "system_prompt" in metadata
|
||||
assert metadata["system_prompt"]["content"] == "System prompt"
|
||||
assert metadata["system_prompt"]["role"] == "system"
|
||||
|
||||
messages_file = logger.session_dir / "messages.jsonl"
|
||||
with open(messages_file) as f:
|
||||
lines = f.readlines()
|
||||
messages_data = [json.loads(line) for line in lines]
|
||||
|
||||
assert len(messages_data) == 2
|
||||
assert messages_data[0]["role"] == "user"
|
||||
assert messages_data[1]["role"] == "assistant"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_interaction_with_existing_messages(
|
||||
self,
|
||||
session_config: SessionLoggingConfig,
|
||||
mock_vibe_config: VibeConfig,
|
||||
mock_tool_manager: ToolManager,
|
||||
mock_agent_profile: AgentProfile,
|
||||
) -> None:
|
||||
"""Test that save_interaction correctly handles existing messages."""
|
||||
session_id = "test-session-123"
|
||||
logger = SessionLogger(session_config, session_id)
|
||||
|
||||
# First save - create initial session
|
||||
initial_messages = [
|
||||
LLMMessage(role=Role.system, content="System prompt"),
|
||||
LLMMessage(role=Role.user, content="Hello"),
|
||||
LLMMessage(role=Role.assistant, content="Hi there!"),
|
||||
]
|
||||
|
||||
stats = AgentStats(
|
||||
steps=1, session_prompt_tokens=10, session_completion_tokens=20
|
||||
)
|
||||
|
||||
await logger.save_interaction(
|
||||
messages=initial_messages,
|
||||
stats=stats,
|
||||
base_config=mock_vibe_config,
|
||||
tool_manager=mock_tool_manager,
|
||||
agent_profile=mock_agent_profile,
|
||||
)
|
||||
|
||||
# Second save - add more messages
|
||||
new_messages = [
|
||||
LLMMessage(role=Role.user, content="How are you?"),
|
||||
LLMMessage(role=Role.assistant, content="I'm fine, thanks!"),
|
||||
]
|
||||
|
||||
all_messages = initial_messages + new_messages
|
||||
updated_stats = AgentStats(
|
||||
steps=2, session_prompt_tokens=20, session_completion_tokens=40
|
||||
)
|
||||
|
||||
result = await logger.save_interaction(
|
||||
messages=all_messages,
|
||||
stats=updated_stats,
|
||||
base_config=mock_vibe_config,
|
||||
tool_manager=mock_tool_manager,
|
||||
agent_profile=mock_agent_profile,
|
||||
)
|
||||
|
||||
# Verify that the result is not None
|
||||
assert result is not None
|
||||
|
||||
# Verify that metadata was updated
|
||||
assert logger.session_dir is not None
|
||||
metadata_file = logger.session_dir / "meta.json"
|
||||
with open(metadata_file) as f:
|
||||
metadata = json.load(f)
|
||||
assert metadata["total_messages"] == 4
|
||||
assert metadata["stats"]["steps"] == updated_stats.steps
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_interaction_no_user_messages(
|
||||
self,
|
||||
session_config: SessionLoggingConfig,
|
||||
mock_vibe_config: VibeConfig,
|
||||
mock_tool_manager: ToolManager,
|
||||
mock_agent_profile: AgentProfile,
|
||||
) -> None:
|
||||
"""Test that save_interaction handles sessions with no user messages."""
|
||||
session_id = "test-session-123"
|
||||
logger = SessionLogger(session_config, session_id)
|
||||
|
||||
# Create messages with no user messages (only system and assistant)
|
||||
messages = [
|
||||
LLMMessage(role=Role.system, content="System prompt"),
|
||||
LLMMessage(role=Role.assistant, content="Hi there!"),
|
||||
]
|
||||
|
||||
stats = AgentStats(
|
||||
steps=1, session_prompt_tokens=10, session_completion_tokens=20
|
||||
)
|
||||
|
||||
result = await logger.save_interaction(
|
||||
messages=messages,
|
||||
stats=stats,
|
||||
base_config=mock_vibe_config,
|
||||
tool_manager=mock_tool_manager,
|
||||
agent_profile=mock_agent_profile,
|
||||
)
|
||||
|
||||
# Verify the result
|
||||
assert result is not None
|
||||
assert str(logger.session_dir) in result
|
||||
|
||||
# Verify that metadata contains expected data
|
||||
assert logger.session_dir is not None
|
||||
metadata_file = logger.session_dir / "meta.json"
|
||||
with open(metadata_file) as f:
|
||||
metadata = json.load(f)
|
||||
assert metadata["session_id"] == session_id
|
||||
assert metadata["total_messages"] == 1
|
||||
assert metadata["stats"]["steps"] == stats.steps
|
||||
assert metadata["title"] == "Untitled session"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_interaction_long_user_message(
|
||||
self,
|
||||
session_config: SessionLoggingConfig,
|
||||
mock_vibe_config: VibeConfig,
|
||||
mock_tool_manager: ToolManager,
|
||||
mock_agent_profile: AgentProfile,
|
||||
) -> None:
|
||||
"""Test that save_interaction truncates long user messages for title."""
|
||||
session_id = "test-session-123"
|
||||
logger = SessionLogger(session_config, session_id)
|
||||
|
||||
# Create a long user message (more than 50 characters)
|
||||
long_message = "This is a very long user message that exceeds fifty characters and should be truncated"
|
||||
messages = [
|
||||
LLMMessage(role=Role.system, content="System prompt"),
|
||||
LLMMessage(role=Role.user, content=long_message),
|
||||
LLMMessage(role=Role.assistant, content="Response"),
|
||||
]
|
||||
|
||||
stats = AgentStats(
|
||||
steps=1, session_prompt_tokens=10, session_completion_tokens=20
|
||||
)
|
||||
|
||||
result = await logger.save_interaction(
|
||||
messages=messages,
|
||||
stats=stats,
|
||||
base_config=mock_vibe_config,
|
||||
tool_manager=mock_tool_manager,
|
||||
agent_profile=mock_agent_profile,
|
||||
)
|
||||
|
||||
# Verify the result
|
||||
assert result is not None
|
||||
assert str(logger.session_dir) in result
|
||||
|
||||
# Verify that metadata contains expected data
|
||||
assert logger.session_dir is not None
|
||||
metadata_file = logger.session_dir / "meta.json"
|
||||
with open(metadata_file) as f:
|
||||
metadata = json.load(f)
|
||||
assert metadata["session_id"] == session_id
|
||||
assert metadata["total_messages"] == 2
|
||||
assert metadata["stats"]["steps"] == stats.steps
|
||||
expected_title = long_message[:50] + "…"
|
||||
assert metadata["title"] == expected_title
|
||||
|
||||
|
||||
class TestSessionLoggerResetSession:
|
||||
def test_reset_session(self, session_config: SessionLoggingConfig) -> None:
|
||||
"""Test that reset_session correctly resets session information."""
|
||||
session_id = "test-session-123"
|
||||
logger = SessionLogger(session_config, session_id)
|
||||
|
||||
# Store original session info
|
||||
original_session_id = logger.session_id
|
||||
original_metadata = logger.session_metadata
|
||||
|
||||
# Reset session
|
||||
new_session_id = "test-session-456"
|
||||
logger.reset_session(new_session_id)
|
||||
|
||||
# Verify session was reset
|
||||
assert logger.session_id == new_session_id
|
||||
assert logger.session_start_time != "N/A" # Should be a valid timestamp
|
||||
assert logger.session_metadata is not None
|
||||
assert logger.session_metadata.session_id == new_session_id
|
||||
|
||||
# Verify that metadata was recreated (different object)
|
||||
assert logger.session_metadata is not original_metadata
|
||||
|
||||
assert logger.session_id != original_session_id
|
||||
|
||||
def test_reset_session_disabled(
|
||||
self, disabled_session_config: SessionLoggingConfig
|
||||
) -> None:
|
||||
"""Test that reset_session does nothing when logging is disabled."""
|
||||
logger = SessionLogger(disabled_session_config, "test-session")
|
||||
|
||||
# Reset session should not raise any errors
|
||||
logger.reset_session("new-session")
|
||||
|
||||
# Verify state is unchanged
|
||||
assert logger.enabled is False
|
||||
assert logger.session_id == "disabled"
|
||||
|
||||
|
||||
class TestSessionLoggerFileOperations:
|
||||
def test_save_folder(self, session_config: SessionLoggingConfig) -> None:
|
||||
"""Test that save_folder creates correct folder name."""
|
||||
session_id = "test-session-123"
|
||||
logger = SessionLogger(session_config, session_id)
|
||||
|
||||
folder = logger.save_folder
|
||||
|
||||
assert folder.parent == Path(session_config.save_dir)
|
||||
assert folder.name.startswith(f"{session_config.session_prefix}_")
|
||||
assert session_id[:8] in folder.name
|
||||
|
||||
def test_metadata_filepath(self, session_config: SessionLoggingConfig) -> None:
|
||||
"""Test that metadata_filepath returns correct path."""
|
||||
session_id = "test-session-123"
|
||||
logger = SessionLogger(session_config, session_id)
|
||||
|
||||
metadata_file = logger.metadata_filepath
|
||||
|
||||
assert logger.session_dir is not None
|
||||
assert metadata_file == logger.session_dir / "meta.json"
|
||||
|
||||
def test_messages_filepath(self, session_config: SessionLoggingConfig) -> None:
|
||||
"""Test that messages_filepath returns correct path."""
|
||||
session_id = "test-session-123"
|
||||
logger = SessionLogger(session_config, session_id)
|
||||
|
||||
messages_file = logger.messages_filepath
|
||||
|
||||
assert logger.session_dir is not None
|
||||
assert messages_file == logger.session_dir / "messages.jsonl"
|
||||
|
||||
def test_disabled_file_operations_raise_errors(
|
||||
self, disabled_session_config: SessionLoggingConfig
|
||||
) -> None:
|
||||
"""Test that file operations raise errors when logging is disabled."""
|
||||
logger = SessionLogger(disabled_session_config, "test-session")
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="Cannot get session save folder when logging is disabled",
|
||||
):
|
||||
assert logger.save_folder is None
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="Cannot get session metadata filepath when logging is disabled",
|
||||
):
|
||||
assert logger.metadata_filepath is None
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="Cannot get session messages filepath when logging is disabled",
|
||||
):
|
||||
assert logger.messages_filepath is None
|
||||
|
||||
|
||||
def create_temp_file_ago(tmp_path: Path, filename: str, minutes_ago: int = 0) -> Path:
|
||||
"""Create a file with a modification time of `minutes_ago` minutes ago."""
|
||||
file = tmp_path / filename
|
||||
file.touch()
|
||||
old_time = datetime.now() - timedelta(minutes=minutes_ago)
|
||||
os.utime(file, (old_time.timestamp(), old_time.timestamp()))
|
||||
return file
|
||||
|
||||
|
||||
class TestSessionLoggerCleanupTmpFiles:
|
||||
def test_cleanup_tmp_files_disabled(
|
||||
self, disabled_session_config: SessionLoggingConfig
|
||||
) -> None:
|
||||
"""Test that cleanup_tmp_files returns early when logging is disabled."""
|
||||
logger = SessionLogger(disabled_session_config, "test-session")
|
||||
|
||||
logger.cleanup_tmp_files()
|
||||
|
||||
def test_cleanup_tmp_files_no_tmp_files(
|
||||
self, session_config: SessionLoggingConfig
|
||||
) -> None:
|
||||
"""Test that cleanup_tmp_files handles no tmp files gracefully."""
|
||||
session_id = "test-session-123"
|
||||
logger = SessionLogger(session_config, session_id)
|
||||
|
||||
logger.cleanup_tmp_files()
|
||||
|
||||
def test_cleanup_tmp_files_deletes_old_files(
|
||||
self, session_config: SessionLoggingConfig
|
||||
) -> None:
|
||||
"""Test that cleanup_tmp_files deletes tmp files older than 5 minutes."""
|
||||
session_id = "test-session-123"
|
||||
logger = SessionLogger(session_config, session_id)
|
||||
|
||||
assert logger.session_dir is not None
|
||||
logger.session_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
old_tmp_file = create_temp_file_ago(
|
||||
logger.session_dir, "session-123.json.tmp", 10
|
||||
)
|
||||
new_tmp_file = create_temp_file_ago(logger.session_dir, "session-123.json")
|
||||
|
||||
logger.cleanup_tmp_files()
|
||||
|
||||
assert not old_tmp_file.exists()
|
||||
assert new_tmp_file.exists()
|
||||
|
||||
def test_cleanup_tmp_files_recursive(
|
||||
self, session_config: SessionLoggingConfig
|
||||
) -> None:
|
||||
"""Test that cleanup_tmp_files works recursively in subdirectories."""
|
||||
session_id = "test-session-123"
|
||||
logger = SessionLogger(session_config, session_id)
|
||||
|
||||
assert logger.session_dir is not None
|
||||
logger.session_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
subdir_1 = logger.session_dir / "session-123"
|
||||
subdir_1.mkdir()
|
||||
|
||||
old_tmp_file = create_temp_file_ago(subdir_1, "meta.json.tmp", 10)
|
||||
new_tmp_file = create_temp_file_ago(subdir_1, "meta.json")
|
||||
|
||||
subdir_2 = logger.session_dir / "session-456"
|
||||
subdir_2.mkdir()
|
||||
|
||||
old_tmp_file_2 = create_temp_file_ago(subdir_2, "meta.json.tmp", 10)
|
||||
|
||||
logger.cleanup_tmp_files()
|
||||
|
||||
assert not old_tmp_file.exists()
|
||||
assert not old_tmp_file_2.exists()
|
||||
assert new_tmp_file.exists()
|
||||
|
||||
def test_cleanup_tmp_files_handles_exceptions(
|
||||
self, session_config: SessionLoggingConfig
|
||||
) -> None:
|
||||
"""Test that cleanup_tmp_files handles exceptions gracefully."""
|
||||
session_id = "test-session-123"
|
||||
logger = SessionLogger(session_config, session_id)
|
||||
|
||||
assert logger.session_dir is not None
|
||||
logger.session_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
old_tmp_file = create_temp_file_ago(logger.session_dir, "meta.json.tmp", 10)
|
||||
another_old_tmp_file = create_temp_file_ago(
|
||||
logger.session_dir, "meta-002.json.tmp", 10
|
||||
)
|
||||
|
||||
# Mock the unlink method to raise an exception for the first file
|
||||
original_unlink = Path.unlink
|
||||
|
||||
def mock_unlink(self):
|
||||
if str(self) == str(old_tmp_file):
|
||||
raise OSError("Mocked error")
|
||||
return original_unlink(self)
|
||||
|
||||
with patch.object(Path, "unlink", mock_unlink):
|
||||
logger.cleanup_tmp_files()
|
||||
|
||||
assert old_tmp_file.exists()
|
||||
assert not another_old_tmp_file.exists()
|
||||
177
tests/session/test_session_migration.py
Normal file
@@ -0,0 +1,177 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from vibe.core.config import SessionLoggingConfig
|
||||
from vibe.core.session.session_migration import migrate_sessions
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_session_dir(tmp_path: Path) -> Path:
|
||||
session_dir = tmp_path / "sessions"
|
||||
session_dir.mkdir()
|
||||
return session_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_config(temp_session_dir: Path) -> SessionLoggingConfig:
|
||||
return SessionLoggingConfig(
|
||||
save_dir=str(temp_session_dir), session_prefix="test", enabled=True
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def disabled_session_config() -> SessionLoggingConfig:
|
||||
return SessionLoggingConfig(
|
||||
save_dir="/tmp/test", session_prefix="test", enabled=False
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def old_session_data() -> dict:
|
||||
return {
|
||||
"metadata": {
|
||||
"session_id": "test-session-123",
|
||||
"start_time": "2023-01-01T00:00:00",
|
||||
"end_time": "2023-01-01T01:00:00",
|
||||
"git_commit": "abc123",
|
||||
"git_branch": "main",
|
||||
"username": "testuser",
|
||||
"environment": {"working_directory": "/test"},
|
||||
},
|
||||
"messages": [
|
||||
{"role": "system", "content": "System prompt"},
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi there!"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class TestSessionMigration:
|
||||
@pytest.mark.asyncio
|
||||
async def test_migrate_sessions_disabled_config(
|
||||
self, disabled_session_config: SessionLoggingConfig
|
||||
) -> None:
|
||||
"""Test that migration does nothing when config is disabled."""
|
||||
result = await migrate_sessions(disabled_session_config)
|
||||
assert result == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migrate_sessions_no_save_dir(
|
||||
self, session_config: SessionLoggingConfig
|
||||
) -> None:
|
||||
"""Test that migration handles missing save_dir gracefully."""
|
||||
config = SessionLoggingConfig(save_dir="", session_prefix="test", enabled=True)
|
||||
result = await migrate_sessions(config)
|
||||
assert result == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migrate_sessions_no_old_files(
|
||||
self, session_config: SessionLoggingConfig
|
||||
) -> None:
|
||||
"""Test that migration handles no old session files gracefully."""
|
||||
session_dir = Path(session_config.save_dir)
|
||||
session_dir.mkdir(exist_ok=True)
|
||||
|
||||
result = await migrate_sessions(session_config)
|
||||
assert result == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migrate_sessions_successful_migration(
|
||||
self, session_config: SessionLoggingConfig, old_session_data: dict
|
||||
) -> None:
|
||||
"""Test successful migration of old session files."""
|
||||
session_dir = Path(session_config.save_dir)
|
||||
|
||||
old_session_file = session_dir / "test_session-123.json"
|
||||
with open(old_session_file, "w") as f:
|
||||
json.dump(old_session_data, f)
|
||||
|
||||
result = await migrate_sessions(session_config)
|
||||
assert result == 1
|
||||
|
||||
assert not old_session_file.exists()
|
||||
session_subdir = session_dir / "test_session-123"
|
||||
assert session_subdir.is_dir()
|
||||
|
||||
metadata_file = session_subdir / "meta.json"
|
||||
assert metadata_file.is_file()
|
||||
|
||||
with open(metadata_file) as f:
|
||||
metadata = json.load(f)
|
||||
assert metadata == old_session_data["metadata"]
|
||||
|
||||
messages_file = session_subdir / "messages.jsonl"
|
||||
assert messages_file.exists()
|
||||
|
||||
with open(messages_file) as f:
|
||||
lines = f.readlines()
|
||||
assert len(lines) == len(old_session_data["messages"])
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
message = json.loads(line.strip())
|
||||
assert message == old_session_data["messages"][i]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migrate_sessions_multiple_files(
|
||||
self, session_config: SessionLoggingConfig, old_session_data: dict
|
||||
) -> None:
|
||||
"""Test migration of multiple old session files."""
|
||||
session_dir = Path(session_config.save_dir)
|
||||
|
||||
session_files = []
|
||||
for i in range(3):
|
||||
session_file = session_dir / f"test_session-{i:03d}.json"
|
||||
session_files.append(session_file)
|
||||
|
||||
modified_data = old_session_data.copy()
|
||||
modified_data["metadata"]["session_id"] = f"test-session-{i}"
|
||||
|
||||
with open(session_file, "w") as f:
|
||||
json.dump(modified_data, f)
|
||||
|
||||
result = await migrate_sessions(session_config)
|
||||
assert result == 3
|
||||
|
||||
for session_file in session_files:
|
||||
assert not session_file.exists()
|
||||
|
||||
for i in range(3):
|
||||
session_subdir = session_dir / f"test_session-{i:03d}"
|
||||
assert session_subdir.exists()
|
||||
assert session_subdir.is_dir()
|
||||
|
||||
metadata_file = session_subdir / "meta.json"
|
||||
messages_file = session_subdir / "messages.jsonl"
|
||||
assert metadata_file.exists()
|
||||
assert messages_file.exists()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migrate_sessions_error_handling(
|
||||
self, session_config: SessionLoggingConfig
|
||||
) -> None:
|
||||
"""Test that migration handles errors gracefully and continues."""
|
||||
session_dir = Path(session_config.save_dir)
|
||||
|
||||
valid_session_file = session_dir / "test_session-valid.json"
|
||||
valid_data = {
|
||||
"metadata": {"session_id": "valid-session"},
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
}
|
||||
with open(valid_session_file, "w") as f:
|
||||
json.dump(valid_data, f)
|
||||
|
||||
invalid_session_file = session_dir / "test_session-invalid.json"
|
||||
with open(invalid_session_file, "w") as f:
|
||||
f.write("{invalid json}")
|
||||
|
||||
result = await migrate_sessions(session_config)
|
||||
assert result == 1
|
||||
|
||||
valid_session_subdir = session_dir / "test_session-valid"
|
||||
assert valid_session_subdir.exists()
|
||||
assert not valid_session_file.exists()
|
||||
assert invalid_session_file.exists()
|
||||
@@ -35,6 +35,7 @@ def create_skill(
|
||||
compatibility: str | None = None,
|
||||
metadata: dict[str, str] | None = None,
|
||||
allowed_tools: str | None = None,
|
||||
user_invocable: bool | None = None,
|
||||
body: str = "## Instructions\n\nTest instructions here.",
|
||||
) -> Path:
|
||||
skill_dir = skills_dir / name
|
||||
@@ -49,6 +50,8 @@ def create_skill(
|
||||
frontmatter["metadata"] = metadata
|
||||
if allowed_tools:
|
||||
frontmatter["allowed-tools"] = allowed_tools
|
||||
if user_invocable is not None:
|
||||
frontmatter["user-invocable"] = user_invocable
|
||||
|
||||
yaml_str = yaml.dump(frontmatter, default_flow_style=False, allow_unicode=True)
|
||||
content = f"---\n{yaml_str}---\n\n{body}"
|
||||
|
||||
@@ -273,3 +273,191 @@ class TestSkillManagerGetSkill:
|
||||
|
||||
def test_returns_none_for_unknown_skill(self, skill_manager: SkillManager) -> None:
|
||||
assert skill_manager.get_skill("nonexistent-skill") is None
|
||||
|
||||
|
||||
class TestSkillManagerFiltering:
|
||||
def test_enabled_skills_filters_to_only_enabled(self, skills_dir: Path) -> None:
|
||||
create_skill(skills_dir, "skill-a", "Skill A")
|
||||
create_skill(skills_dir, "skill-b", "Skill B")
|
||||
create_skill(skills_dir, "skill-c", "Skill C")
|
||||
|
||||
config = VibeConfig(
|
||||
session_logging=SessionLoggingConfig(enabled=False),
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
skill_paths=[skills_dir],
|
||||
enabled_skills=["skill-a", "skill-c"],
|
||||
)
|
||||
manager = SkillManager(lambda: config)
|
||||
|
||||
skills = manager.available_skills
|
||||
assert len(skills) == 2
|
||||
assert "skill-a" in skills
|
||||
assert "skill-b" not in skills
|
||||
assert "skill-c" in skills
|
||||
|
||||
def test_disabled_skills_excludes_disabled(self, skills_dir: Path) -> None:
|
||||
create_skill(skills_dir, "skill-a", "Skill A")
|
||||
create_skill(skills_dir, "skill-b", "Skill B")
|
||||
create_skill(skills_dir, "skill-c", "Skill C")
|
||||
|
||||
config = VibeConfig(
|
||||
session_logging=SessionLoggingConfig(enabled=False),
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
skill_paths=[skills_dir],
|
||||
disabled_skills=["skill-b"],
|
||||
)
|
||||
manager = SkillManager(lambda: config)
|
||||
|
||||
skills = manager.available_skills
|
||||
assert len(skills) == 2
|
||||
assert "skill-a" in skills
|
||||
assert "skill-b" not in skills
|
||||
assert "skill-c" in skills
|
||||
|
||||
def test_enabled_skills_takes_precedence_over_disabled(
|
||||
self, skills_dir: Path
|
||||
) -> None:
|
||||
create_skill(skills_dir, "skill-a", "Skill A")
|
||||
create_skill(skills_dir, "skill-b", "Skill B")
|
||||
|
||||
config = VibeConfig(
|
||||
session_logging=SessionLoggingConfig(enabled=False),
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
skill_paths=[skills_dir],
|
||||
enabled_skills=["skill-a"],
|
||||
disabled_skills=["skill-a"], # Should be ignored
|
||||
)
|
||||
manager = SkillManager(lambda: config)
|
||||
|
||||
skills = manager.available_skills
|
||||
assert len(skills) == 1
|
||||
assert "skill-a" in skills
|
||||
|
||||
def test_glob_pattern_matching(self, skills_dir: Path) -> None:
|
||||
create_skill(skills_dir, "search-code", "Search code")
|
||||
create_skill(skills_dir, "search-docs", "Search docs")
|
||||
create_skill(skills_dir, "other-skill", "Other skill")
|
||||
|
||||
config = VibeConfig(
|
||||
session_logging=SessionLoggingConfig(enabled=False),
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
skill_paths=[skills_dir],
|
||||
enabled_skills=["search-*"],
|
||||
)
|
||||
manager = SkillManager(lambda: config)
|
||||
|
||||
skills = manager.available_skills
|
||||
assert len(skills) == 2
|
||||
assert "search-code" in skills
|
||||
assert "search-docs" in skills
|
||||
assert "other-skill" not in skills
|
||||
|
||||
def test_regex_pattern_matching(self, skills_dir: Path) -> None:
|
||||
create_skill(skills_dir, "skill-v1", "Skill v1")
|
||||
create_skill(skills_dir, "skill-v2", "Skill v2")
|
||||
create_skill(skills_dir, "other-skill", "Other skill")
|
||||
|
||||
config = VibeConfig(
|
||||
session_logging=SessionLoggingConfig(enabled=False),
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
skill_paths=[skills_dir],
|
||||
enabled_skills=["re:skill-v\\d+"],
|
||||
)
|
||||
manager = SkillManager(lambda: config)
|
||||
|
||||
skills = manager.available_skills
|
||||
assert len(skills) == 2
|
||||
assert "skill-v1" in skills
|
||||
assert "skill-v2" in skills
|
||||
assert "other-skill" not in skills
|
||||
|
||||
def test_get_skill_respects_filtering(self, skills_dir: Path) -> None:
|
||||
create_skill(skills_dir, "enabled-skill", "Enabled")
|
||||
create_skill(skills_dir, "disabled-skill", "Disabled")
|
||||
|
||||
config = VibeConfig(
|
||||
session_logging=SessionLoggingConfig(enabled=False),
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
skill_paths=[skills_dir],
|
||||
disabled_skills=["disabled-skill"],
|
||||
)
|
||||
manager = SkillManager(lambda: config)
|
||||
|
||||
assert manager.get_skill("enabled-skill") is not None
|
||||
assert manager.get_skill("disabled-skill") is None
|
||||
|
||||
|
||||
class TestSkillUserInvocable:
|
||||
def test_user_invocable_defaults_to_true(self, skills_dir: Path) -> None:
|
||||
create_skill(skills_dir, "default-skill", "A default skill")
|
||||
|
||||
config = VibeConfig(
|
||||
session_logging=SessionLoggingConfig(enabled=False),
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
skill_paths=[skills_dir],
|
||||
)
|
||||
manager = SkillManager(lambda: config)
|
||||
|
||||
skill = manager.get_skill("default-skill")
|
||||
assert skill is not None
|
||||
assert skill.user_invocable is True
|
||||
|
||||
def test_user_invocable_can_be_set_to_false(self, skills_dir: Path) -> None:
|
||||
create_skill(skills_dir, "hidden-skill", "A hidden skill", user_invocable=False)
|
||||
|
||||
config = VibeConfig(
|
||||
session_logging=SessionLoggingConfig(enabled=False),
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
skill_paths=[skills_dir],
|
||||
)
|
||||
manager = SkillManager(lambda: config)
|
||||
|
||||
skill = manager.get_skill("hidden-skill")
|
||||
assert skill is not None
|
||||
assert skill.user_invocable is False
|
||||
|
||||
def test_user_invocable_can_be_explicitly_set_to_true(
|
||||
self, skills_dir: Path
|
||||
) -> None:
|
||||
create_skill(
|
||||
skills_dir, "explicit-skill", "An explicit skill", user_invocable=True
|
||||
)
|
||||
|
||||
config = VibeConfig(
|
||||
session_logging=SessionLoggingConfig(enabled=False),
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
skill_paths=[skills_dir],
|
||||
)
|
||||
manager = SkillManager(lambda: config)
|
||||
|
||||
skill = manager.get_skill("explicit-skill")
|
||||
assert skill is not None
|
||||
assert skill.user_invocable is True
|
||||
|
||||
def test_mixed_user_invocable_skills(self, skills_dir: Path) -> None:
|
||||
create_skill(skills_dir, "visible-skill", "Visible", user_invocable=True)
|
||||
create_skill(skills_dir, "hidden-skill", "Hidden", user_invocable=False)
|
||||
create_skill(skills_dir, "default-skill", "Default")
|
||||
|
||||
config = VibeConfig(
|
||||
session_logging=SessionLoggingConfig(enabled=False),
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
skill_paths=[skills_dir],
|
||||
)
|
||||
manager = SkillManager(lambda: config)
|
||||
|
||||
skills = manager.available_skills
|
||||
assert len(skills) == 3
|
||||
assert skills["visible-skill"].user_invocable is True
|
||||
assert skills["hidden-skill"].user_invocable is False
|
||||
assert skills["default-skill"].user_invocable is True
|
||||
|
||||
@@ -18,6 +18,7 @@ class TestSkillMetadata:
|
||||
assert meta.compatibility is None
|
||||
assert meta.metadata == {}
|
||||
assert meta.allowed_tools == []
|
||||
assert meta.user_invocable is True
|
||||
|
||||
def test_creates_with_all_fields(self) -> None:
|
||||
meta = SkillMetadata(
|
||||
@@ -27,6 +28,7 @@ class TestSkillMetadata:
|
||||
compatibility="Requires git",
|
||||
metadata={"author": "Test Author", "version": "1.0"},
|
||||
allowed_tools=["bash", "read_file"],
|
||||
user_invocable=False,
|
||||
)
|
||||
|
||||
assert meta.name == "full-skill"
|
||||
@@ -35,6 +37,7 @@ class TestSkillMetadata:
|
||||
assert meta.compatibility == "Requires git"
|
||||
assert meta.metadata == {"author": "Test Author", "version": "1.0"}
|
||||
assert meta.allowed_tools == ["bash", "read_file"]
|
||||
assert meta.user_invocable is False
|
||||
|
||||
def test_raises_error_for_uppercase_name(self) -> None:
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
@@ -144,6 +147,7 @@ class TestSkillInfo:
|
||||
compatibility="git, docker",
|
||||
metadata={"author": "Test"},
|
||||
allowed_tools=["bash"],
|
||||
user_invocable=False,
|
||||
skill_path=skill_path,
|
||||
)
|
||||
|
||||
@@ -153,6 +157,7 @@ class TestSkillInfo:
|
||||
assert info.compatibility == "git, docker"
|
||||
assert info.metadata == {"author": "Test"}
|
||||
assert info.allowed_tools == ["bash"]
|
||||
assert info.user_invocable is False
|
||||
assert info.skill_path == skill_path
|
||||
assert info.skill_dir == skill_path.parent.resolve()
|
||||
|
||||
@@ -179,6 +184,7 @@ class TestSkillInfo:
|
||||
compatibility="Requires Python 3.12",
|
||||
metadata={"key": "value"},
|
||||
allowed_tools=["bash", "grep"],
|
||||
user_invocable=False,
|
||||
)
|
||||
info = SkillInfo.from_metadata(meta, skill_path)
|
||||
|
||||
@@ -186,3 +192,4 @@ class TestSkillInfo:
|
||||
assert info.compatibility == meta.compatibility
|
||||
assert info.metadata == meta.metadata
|
||||
assert info.allowed_tools == meta.allowed_tools
|
||||
assert info.user_invocable == meta.user_invocable
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
<svg class="rich-terminal" viewBox="0 0 1482 538.0" 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: #3c3836 }
|
||||
.terminal-r3 { fill: #fbf1c7 }
|
||||
.terminal-r4 { fill: #a6a087 }
|
||||
.terminal-r5 { fill: #a9a9a9 }
|
||||
.terminal-r6 { fill: #85a598;font-weight: bold }
|
||||
.terminal-r7 { fill: #7e7e7e }
|
||||
.terminal-r8 { fill: #85a598 }
|
||||
</style>
|
||||
|
||||
<defs>
|
||||
<clipPath id="terminal-clip-terminal">
|
||||
<rect x="0" y="0" width="1463.0" height="487.0" />
|
||||
</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>
|
||||
</defs>
|
||||
|
||||
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1480" height="536" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="740" y="27">AskUserQuestionResultApp</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)">
|
||||
<rect fill="#282828" x="0" y="1.5" width="1464" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="0" y="25.9" width="1464" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="0" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="12.2" y="50.3" width="1439.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="1451.8" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="0" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="12.2" y="74.7" width="1439.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="1451.8" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="0" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="12.2" y="99.1" width="707.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="719.8" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="732" y="99.1" width="719.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="1451.8" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="0" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="12.2" y="123.5" width="1439.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="1451.8" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="0" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="12.2" y="147.9" width="1439.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="1451.8" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="0" y="172.3" width="1464" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="0" y="196.7" width="1464" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="0" y="221.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="24.4" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="36.6" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="48.8" y="221.1" width="451.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="500.2" y="221.1" width="963.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="0" y="245.5" width="1464" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="0" y="269.9" width="1464" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="0" y="294.3" width="1464" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="0" y="318.7" width="0" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="0" y="318.7" width="1024.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="1024.8" y="318.7" width="439.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="0" y="343.1" width="0" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="0" y="343.1" width="1464" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="0" y="367.5" width="1464" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="0" y="391.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="12.2" y="391.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="24.4" y="391.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="36.6" y="391.9" width="183" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="219.6" y="391.9" width="1244.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="0" y="416.3" width="1464" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="0" y="440.7" width="158.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="158.6" y="440.7" width="0" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="158.6" y="440.7" width="1098" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="1256.6" y="440.7" width="207.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="0" y="465.1" width="1464" 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-r2" x="0" y="44.4" textLength="1464" clip-path="url(#terminal-line-1)">╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-r1" x="1464" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
|
||||
</text><text class="terminal-r2" x="0" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">│</text><text class="terminal-r2" x="1451.8" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">│</text><text class="terminal-r1" x="1464" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
|
||||
</text><text class="terminal-r2" x="0" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">│</text><text class="terminal-r2" x="1451.8" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">│</text><text class="terminal-r1" x="1464" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
|
||||
</text><text class="terminal-r2" x="0" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">│</text><text class="terminal-r2" x="1451.8" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">│</text><text class="terminal-r1" x="1464" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
|
||||
</text><text class="terminal-r2" x="0" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">│</text><text class="terminal-r2" x="1451.8" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">│</text><text class="terminal-r1" x="1464" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
|
||||
</text><text class="terminal-r2" x="0" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">│</text><text class="terminal-r2" x="1451.8" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">│</text><text class="terminal-r1" x="1464" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
|
||||
</text><text class="terminal-r2" x="0" y="190.8" textLength="1464" clip-path="url(#terminal-line-7)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</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-r4" x="24.4" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">⎣</text><text class="terminal-r3" x="48.8" y="239.6" textLength="451.4" clip-path="url(#terminal-line-9)">3 answers received (ctrl+o to expand)</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-r5" x="1024.8" y="337.2" textLength="439.2" clip-path="url(#terminal-line-13)">⏵ default agent (shift+tab to cycle)</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-r4" x="0" y="386" textLength="1464" 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-r6" x="12.2" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">></text><text class="terminal-r7" x="36.6" y="410.4" textLength="183" clip-path="url(#terminal-line-16)">Ask anything...</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="1464" 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-r8" x="0" y="459.2" textLength="158.6" clip-path="url(#terminal-line-18)">/test/workdir</text><text class="terminal-r3" x="1256.6" y="459.2" textLength="207.4" clip-path="url(#terminal-line-18)">0% of 200k tokens</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 23 KiB |
@@ -0,0 +1,137 @@
|
||||
<svg class="rich-terminal" viewBox="0 0 994 538.0" 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: #8d8d8d }
|
||||
.terminal-r2 { fill: #c5c8c6 }
|
||||
.terminal-r3 { fill: #0178d4 }
|
||||
.terminal-r4 { fill: #0178d4;font-weight: bold }
|
||||
.terminal-r5 { fill: #e0e0e0 }
|
||||
</style>
|
||||
|
||||
<defs>
|
||||
<clipPath id="terminal-clip-terminal">
|
||||
<rect x="0" y="0" width="975.0" height="487.0" />
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-0">
|
||||
<rect x="0" y="1.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-1">
|
||||
<rect x="0" y="25.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-2">
|
||||
<rect x="0" y="50.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-3">
|
||||
<rect x="0" y="74.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-4">
|
||||
<rect x="0" y="99.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-5">
|
||||
<rect x="0" y="123.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-6">
|
||||
<rect x="0" y="147.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-7">
|
||||
<rect x="0" y="172.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-8">
|
||||
<rect x="0" y="196.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-9">
|
||||
<rect x="0" y="221.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-10">
|
||||
<rect x="0" y="245.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-11">
|
||||
<rect x="0" y="269.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-12">
|
||||
<rect x="0" y="294.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-13">
|
||||
<rect x="0" y="318.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-14">
|
||||
<rect x="0" y="343.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-15">
|
||||
<rect x="0" y="367.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-16">
|
||||
<rect x="0" y="391.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-17">
|
||||
<rect x="0" y="416.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-18">
|
||||
<rect x="0" y="440.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="536" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">MultiQuestionApp</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)">
|
||||
<rect fill="#121212" x="0" y="1.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="25.9" width="231.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="256.2" y="25.9" width="707.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="50.3" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="74.7" width="195.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="219.6" y="74.7" width="744.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="99.1" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="123.5" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="170.8" y="123.5" width="793" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="147.9" width="134.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="158.6" y="147.9" width="805.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="172.3" width="292.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="317.2" y="172.3" width="646.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="196.7" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="221.1" width="622.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="646.6" y="221.1" width="317.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="245.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="269.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="294.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="318.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="343.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="367.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="391.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="416.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="440.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="465.1" width="976" height="24.65" shape-rendering="crispEdges"/>
|
||||
<g class="terminal-matrix">
|
||||
<text class="terminal-r1" x="0" y="20" textLength="976" clip-path="url(#terminal-line-0)">╭──────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-r2" x="976" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
</text><text class="terminal-r1" x="0" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">│</text><text class="terminal-r3" x="24.4" y="44.4" textLength="231.8" clip-path="url(#terminal-line-1)"> DB ✓   [Framework]</text><text class="terminal-r1" x="963.8" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">│</text><text class="terminal-r2" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
|
||||
</text><text class="terminal-r1" x="0" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">│</text><text class="terminal-r1" x="963.8" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">│</text><text class="terminal-r2" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
|
||||
</text><text class="terminal-r1" x="0" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">│</text><text class="terminal-r4" x="24.4" y="93.2" textLength="195.2" clip-path="url(#terminal-line-3)">Which framework?</text><text class="terminal-r1" x="963.8" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">│</text><text class="terminal-r2" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
|
||||
</text><text class="terminal-r1" x="0" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">│</text><text class="terminal-r1" x="963.8" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">│</text><text class="terminal-r2" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
|
||||
</text><text class="terminal-r1" x="0" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">│</text><text class="terminal-r4" x="24.4" y="142" textLength="146.4" clip-path="url(#terminal-line-5)">› 1. FastAPI</text><text class="terminal-r1" x="963.8" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">│</text><text class="terminal-r2" x="976" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
|
||||
</text><text class="terminal-r1" x="0" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">│</text><text class="terminal-r5" x="24.4" y="166.4" textLength="134.2" clip-path="url(#terminal-line-6)">  2. Django</text><text class="terminal-r1" x="963.8" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">│</text><text class="terminal-r2" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
|
||||
</text><text class="terminal-r1" x="0" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">│</text><text class="terminal-r5" x="24.4" y="190.8" textLength="292.8" clip-path="url(#terminal-line-7)">  3. Type your answer...</text><text class="terminal-r1" x="963.8" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">│</text><text class="terminal-r2" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
|
||||
</text><text class="terminal-r1" x="0" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">│</text><text class="terminal-r1" x="963.8" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">│</text><text class="terminal-r2" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
|
||||
</text><text class="terminal-r1" x="0" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">│</text><text class="terminal-r1" x="24.4" y="239.6" textLength="622.2" clip-path="url(#terminal-line-9)">←→ questions  ↑↓ navigate  Enter select  Esc cancel</text><text class="terminal-r1" x="963.8" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">│</text><text class="terminal-r2" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
|
||||
</text><text class="terminal-r1" x="0" y="264" textLength="976" clip-path="url(#terminal-line-10)">╰──────────────────────────────────────────────────────────────────────────────╯</text><text class="terminal-r2" x="976" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
|
||||
</text><text class="terminal-r2" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
|
||||
</text><text class="terminal-r2" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
|
||||
</text><text class="terminal-r2" x="976" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
|
||||
</text><text class="terminal-r2" x="976" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
|
||||
</text><text class="terminal-r2" x="976" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
|
||||
</text><text class="terminal-r2" x="976" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
|
||||
</text><text class="terminal-r2" x="976" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
|
||||
</text><text class="terminal-r2" x="976" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,137 @@
|
||||
<svg class="rich-terminal" viewBox="0 0 994 538.0" 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: #8d8d8d }
|
||||
.terminal-r2 { fill: #c5c8c6 }
|
||||
.terminal-r3 { fill: #0178d4 }
|
||||
.terminal-r4 { fill: #0178d4;font-weight: bold }
|
||||
.terminal-r5 { fill: #e0e0e0 }
|
||||
</style>
|
||||
|
||||
<defs>
|
||||
<clipPath id="terminal-clip-terminal">
|
||||
<rect x="0" y="0" width="975.0" height="487.0" />
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-0">
|
||||
<rect x="0" y="1.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-1">
|
||||
<rect x="0" y="25.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-2">
|
||||
<rect x="0" y="50.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-3">
|
||||
<rect x="0" y="74.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-4">
|
||||
<rect x="0" y="99.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-5">
|
||||
<rect x="0" y="123.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-6">
|
||||
<rect x="0" y="147.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-7">
|
||||
<rect x="0" y="172.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-8">
|
||||
<rect x="0" y="196.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-9">
|
||||
<rect x="0" y="221.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-10">
|
||||
<rect x="0" y="245.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-11">
|
||||
<rect x="0" y="269.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-12">
|
||||
<rect x="0" y="294.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-13">
|
||||
<rect x="0" y="318.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-14">
|
||||
<rect x="0" y="343.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-15">
|
||||
<rect x="0" y="367.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-16">
|
||||
<rect x="0" y="391.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-17">
|
||||
<rect x="0" y="416.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-18">
|
||||
<rect x="0" y="440.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="536" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">MultiQuestionApp</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)">
|
||||
<rect fill="#121212" x="0" y="1.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="25.9" width="231.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="256.2" y="25.9" width="707.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="50.3" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="74.7" width="195.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="219.6" y="74.7" width="744.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="99.1" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="123.5" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="170.8" y="123.5" width="793" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="147.9" width="134.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="158.6" y="147.9" width="805.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="172.3" width="292.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="317.2" y="172.3" width="646.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="196.7" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="221.1" width="622.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="646.6" y="221.1" width="317.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="245.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="269.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="294.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="318.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="343.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="367.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="391.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="416.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="440.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="465.1" width="976" height="24.65" shape-rendering="crispEdges"/>
|
||||
<g class="terminal-matrix">
|
||||
<text class="terminal-r1" x="0" y="20" textLength="976" clip-path="url(#terminal-line-0)">╭──────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-r2" x="976" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
</text><text class="terminal-r1" x="0" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">│</text><text class="terminal-r3" x="24.4" y="44.4" textLength="231.8" clip-path="url(#terminal-line-1)"> DB ✓   [Framework]</text><text class="terminal-r1" x="963.8" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">│</text><text class="terminal-r2" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
|
||||
</text><text class="terminal-r1" x="0" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">│</text><text class="terminal-r1" x="963.8" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">│</text><text class="terminal-r2" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
|
||||
</text><text class="terminal-r1" x="0" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">│</text><text class="terminal-r4" x="24.4" y="93.2" textLength="195.2" clip-path="url(#terminal-line-3)">Which framework?</text><text class="terminal-r1" x="963.8" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">│</text><text class="terminal-r2" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
|
||||
</text><text class="terminal-r1" x="0" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">│</text><text class="terminal-r1" x="963.8" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">│</text><text class="terminal-r2" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
|
||||
</text><text class="terminal-r1" x="0" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">│</text><text class="terminal-r4" x="24.4" y="142" textLength="146.4" clip-path="url(#terminal-line-5)">› 1. FastAPI</text><text class="terminal-r1" x="963.8" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">│</text><text class="terminal-r2" x="976" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
|
||||
</text><text class="terminal-r1" x="0" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">│</text><text class="terminal-r5" x="24.4" y="166.4" textLength="134.2" clip-path="url(#terminal-line-6)">  2. Django</text><text class="terminal-r1" x="963.8" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">│</text><text class="terminal-r2" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
|
||||
</text><text class="terminal-r1" x="0" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">│</text><text class="terminal-r5" x="24.4" y="190.8" textLength="292.8" clip-path="url(#terminal-line-7)">  3. Type your answer...</text><text class="terminal-r1" x="963.8" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">│</text><text class="terminal-r2" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
|
||||
</text><text class="terminal-r1" x="0" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">│</text><text class="terminal-r1" x="963.8" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">│</text><text class="terminal-r2" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
|
||||
</text><text class="terminal-r1" x="0" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">│</text><text class="terminal-r1" x="24.4" y="239.6" textLength="622.2" clip-path="url(#terminal-line-9)">←→ questions  ↑↓ navigate  Enter select  Esc cancel</text><text class="terminal-r1" x="963.8" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">│</text><text class="terminal-r2" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
|
||||
</text><text class="terminal-r1" x="0" y="264" textLength="976" clip-path="url(#terminal-line-10)">╰──────────────────────────────────────────────────────────────────────────────╯</text><text class="terminal-r2" x="976" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
|
||||
</text><text class="terminal-r2" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
|
||||
</text><text class="terminal-r2" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
|
||||
</text><text class="terminal-r2" x="976" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
|
||||
</text><text class="terminal-r2" x="976" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
|
||||
</text><text class="terminal-r2" x="976" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
|
||||
</text><text class="terminal-r2" x="976" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
|
||||
</text><text class="terminal-r2" x="976" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
|
||||
</text><text class="terminal-r2" x="976" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,137 @@
|
||||
<svg class="rich-terminal" viewBox="0 0 994 538.0" 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: #8d8d8d }
|
||||
.terminal-r2 { fill: #c5c8c6 }
|
||||
.terminal-r3 { fill: #0178d4 }
|
||||
.terminal-r4 { fill: #0178d4;font-weight: bold }
|
||||
.terminal-r5 { fill: #e0e0e0 }
|
||||
</style>
|
||||
|
||||
<defs>
|
||||
<clipPath id="terminal-clip-terminal">
|
||||
<rect x="0" y="0" width="975.0" height="487.0" />
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-0">
|
||||
<rect x="0" y="1.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-1">
|
||||
<rect x="0" y="25.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-2">
|
||||
<rect x="0" y="50.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-3">
|
||||
<rect x="0" y="74.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-4">
|
||||
<rect x="0" y="99.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-5">
|
||||
<rect x="0" y="123.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-6">
|
||||
<rect x="0" y="147.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-7">
|
||||
<rect x="0" y="172.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-8">
|
||||
<rect x="0" y="196.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-9">
|
||||
<rect x="0" y="221.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-10">
|
||||
<rect x="0" y="245.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-11">
|
||||
<rect x="0" y="269.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-12">
|
||||
<rect x="0" y="294.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-13">
|
||||
<rect x="0" y="318.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-14">
|
||||
<rect x="0" y="343.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-15">
|
||||
<rect x="0" y="367.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-16">
|
||||
<rect x="0" y="391.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-17">
|
||||
<rect x="0" y="416.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-18">
|
||||
<rect x="0" y="440.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="536" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">MultiQuestionApp</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)">
|
||||
<rect fill="#121212" x="0" y="1.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="25.9" width="207.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="231.8" y="25.9" width="732" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="50.3" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="74.7" width="183" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="207.4" y="74.7" width="756.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="99.1" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="123.5" width="183" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="207.4" y="123.5" width="756.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="147.9" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="170.8" y="147.9" width="793" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="172.3" width="292.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="317.2" y="172.3" width="646.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="196.7" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="221.1" width="622.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="646.6" y="221.1" width="317.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="245.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="269.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="294.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="318.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="343.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="367.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="391.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="416.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="440.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="465.1" width="976" height="24.65" shape-rendering="crispEdges"/>
|
||||
<g class="terminal-matrix">
|
||||
<text class="terminal-r1" x="0" y="20" textLength="976" clip-path="url(#terminal-line-0)">╭──────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-r2" x="976" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
</text><text class="terminal-r1" x="0" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">│</text><text class="terminal-r3" x="24.4" y="44.4" textLength="207.4" clip-path="url(#terminal-line-1)">[DB]   Framework </text><text class="terminal-r1" x="963.8" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">│</text><text class="terminal-r2" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
|
||||
</text><text class="terminal-r1" x="0" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">│</text><text class="terminal-r1" x="963.8" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">│</text><text class="terminal-r2" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
|
||||
</text><text class="terminal-r1" x="0" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">│</text><text class="terminal-r4" x="24.4" y="93.2" textLength="183" clip-path="url(#terminal-line-3)">Which database?</text><text class="terminal-r1" x="963.8" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">│</text><text class="terminal-r2" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
|
||||
</text><text class="terminal-r1" x="0" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">│</text><text class="terminal-r1" x="963.8" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">│</text><text class="terminal-r2" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
|
||||
</text><text class="terminal-r1" x="0" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">│</text><text class="terminal-r4" x="24.4" y="142" textLength="183" clip-path="url(#terminal-line-5)">› 1. PostgreSQL</text><text class="terminal-r1" x="963.8" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">│</text><text class="terminal-r2" x="976" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
|
||||
</text><text class="terminal-r1" x="0" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">│</text><text class="terminal-r5" x="24.4" y="166.4" textLength="146.4" clip-path="url(#terminal-line-6)">  2. MongoDB</text><text class="terminal-r1" x="963.8" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">│</text><text class="terminal-r2" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
|
||||
</text><text class="terminal-r1" x="0" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">│</text><text class="terminal-r5" x="24.4" y="190.8" textLength="292.8" clip-path="url(#terminal-line-7)">  3. Type your answer...</text><text class="terminal-r1" x="963.8" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">│</text><text class="terminal-r2" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
|
||||
</text><text class="terminal-r1" x="0" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">│</text><text class="terminal-r1" x="963.8" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">│</text><text class="terminal-r2" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
|
||||
</text><text class="terminal-r1" x="0" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">│</text><text class="terminal-r1" x="24.4" y="239.6" textLength="622.2" clip-path="url(#terminal-line-9)">←→ questions  ↑↓ navigate  Enter select  Esc cancel</text><text class="terminal-r1" x="963.8" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">│</text><text class="terminal-r2" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
|
||||
</text><text class="terminal-r1" x="0" y="264" textLength="976" clip-path="url(#terminal-line-10)">╰──────────────────────────────────────────────────────────────────────────────╯</text><text class="terminal-r2" x="976" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
|
||||
</text><text class="terminal-r2" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
|
||||
</text><text class="terminal-r2" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
|
||||
</text><text class="terminal-r2" x="976" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
|
||||
</text><text class="terminal-r2" x="976" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
|
||||
</text><text class="terminal-r2" x="976" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
|
||||
</text><text class="terminal-r2" x="976" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
|
||||
</text><text class="terminal-r2" x="976" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
|
||||
</text><text class="terminal-r2" x="976" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,137 @@
|
||||
<svg class="rich-terminal" viewBox="0 0 994 538.0" 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: #8d8d8d }
|
||||
.terminal-r2 { fill: #c5c8c6 }
|
||||
.terminal-r3 { fill: #0178d4 }
|
||||
.terminal-r4 { fill: #0178d4;font-weight: bold }
|
||||
.terminal-r5 { fill: #e0e0e0 }
|
||||
</style>
|
||||
|
||||
<defs>
|
||||
<clipPath id="terminal-clip-terminal">
|
||||
<rect x="0" y="0" width="975.0" height="487.0" />
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-0">
|
||||
<rect x="0" y="1.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-1">
|
||||
<rect x="0" y="25.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-2">
|
||||
<rect x="0" y="50.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-3">
|
||||
<rect x="0" y="74.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-4">
|
||||
<rect x="0" y="99.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-5">
|
||||
<rect x="0" y="123.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-6">
|
||||
<rect x="0" y="147.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-7">
|
||||
<rect x="0" y="172.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-8">
|
||||
<rect x="0" y="196.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-9">
|
||||
<rect x="0" y="221.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-10">
|
||||
<rect x="0" y="245.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-11">
|
||||
<rect x="0" y="269.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-12">
|
||||
<rect x="0" y="294.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-13">
|
||||
<rect x="0" y="318.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-14">
|
||||
<rect x="0" y="343.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-15">
|
||||
<rect x="0" y="367.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-16">
|
||||
<rect x="0" y="391.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-17">
|
||||
<rect x="0" y="416.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-18">
|
||||
<rect x="0" y="440.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="536" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">MultiQuestionApp</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)">
|
||||
<rect fill="#121212" x="0" y="1.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="25.9" width="207.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="231.8" y="25.9" width="732" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="50.3" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="74.7" width="195.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="219.6" y="74.7" width="744.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="99.1" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="123.5" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="170.8" y="123.5" width="793" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="147.9" width="134.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="158.6" y="147.9" width="805.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="172.3" width="292.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="317.2" y="172.3" width="646.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="196.7" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="221.1" width="622.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="646.6" y="221.1" width="317.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="245.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="269.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="294.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="318.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="343.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="367.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="391.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="416.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="440.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="465.1" width="976" height="24.65" shape-rendering="crispEdges"/>
|
||||
<g class="terminal-matrix">
|
||||
<text class="terminal-r1" x="0" y="20" textLength="976" clip-path="url(#terminal-line-0)">╭──────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-r2" x="976" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
</text><text class="terminal-r1" x="0" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">│</text><text class="terminal-r3" x="24.4" y="44.4" textLength="207.4" clip-path="url(#terminal-line-1)"> DB   [Framework]</text><text class="terminal-r1" x="963.8" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">│</text><text class="terminal-r2" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
|
||||
</text><text class="terminal-r1" x="0" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">│</text><text class="terminal-r1" x="963.8" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">│</text><text class="terminal-r2" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
|
||||
</text><text class="terminal-r1" x="0" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">│</text><text class="terminal-r4" x="24.4" y="93.2" textLength="195.2" clip-path="url(#terminal-line-3)">Which framework?</text><text class="terminal-r1" x="963.8" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">│</text><text class="terminal-r2" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
|
||||
</text><text class="terminal-r1" x="0" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">│</text><text class="terminal-r1" x="963.8" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">│</text><text class="terminal-r2" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
|
||||
</text><text class="terminal-r1" x="0" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">│</text><text class="terminal-r4" x="24.4" y="142" textLength="146.4" clip-path="url(#terminal-line-5)">› 1. FastAPI</text><text class="terminal-r1" x="963.8" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">│</text><text class="terminal-r2" x="976" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
|
||||
</text><text class="terminal-r1" x="0" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">│</text><text class="terminal-r5" x="24.4" y="166.4" textLength="134.2" clip-path="url(#terminal-line-6)">  2. Django</text><text class="terminal-r1" x="963.8" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">│</text><text class="terminal-r2" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
|
||||
</text><text class="terminal-r1" x="0" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">│</text><text class="terminal-r5" x="24.4" y="190.8" textLength="292.8" clip-path="url(#terminal-line-7)">  3. Type your answer...</text><text class="terminal-r1" x="963.8" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">│</text><text class="terminal-r2" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
|
||||
</text><text class="terminal-r1" x="0" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">│</text><text class="terminal-r1" x="963.8" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">│</text><text class="terminal-r2" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
|
||||
</text><text class="terminal-r1" x="0" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">│</text><text class="terminal-r1" x="24.4" y="239.6" textLength="622.2" clip-path="url(#terminal-line-9)">←→ questions  ↑↓ navigate  Enter select  Esc cancel</text><text class="terminal-r1" x="963.8" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">│</text><text class="terminal-r2" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
|
||||
</text><text class="terminal-r1" x="0" y="264" textLength="976" clip-path="url(#terminal-line-10)">╰──────────────────────────────────────────────────────────────────────────────╯</text><text class="terminal-r2" x="976" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
|
||||
</text><text class="terminal-r2" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
|
||||
</text><text class="terminal-r2" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
|
||||
</text><text class="terminal-r2" x="976" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
|
||||
</text><text class="terminal-r2" x="976" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
|
||||
</text><text class="terminal-r2" x="976" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
|
||||
</text><text class="terminal-r2" x="976" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
|
||||
</text><text class="terminal-r2" x="976" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
|
||||
</text><text class="terminal-r2" x="976" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,137 @@
|
||||
<svg class="rich-terminal" viewBox="0 0 994 538.0" 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: #8d8d8d }
|
||||
.terminal-r2 { fill: #c5c8c6 }
|
||||
.terminal-r3 { fill: #0178d4 }
|
||||
.terminal-r4 { fill: #0178d4;font-weight: bold }
|
||||
.terminal-r5 { fill: #e0e0e0 }
|
||||
</style>
|
||||
|
||||
<defs>
|
||||
<clipPath id="terminal-clip-terminal">
|
||||
<rect x="0" y="0" width="975.0" height="487.0" />
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-0">
|
||||
<rect x="0" y="1.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-1">
|
||||
<rect x="0" y="25.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-2">
|
||||
<rect x="0" y="50.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-3">
|
||||
<rect x="0" y="74.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-4">
|
||||
<rect x="0" y="99.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-5">
|
||||
<rect x="0" y="123.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-6">
|
||||
<rect x="0" y="147.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-7">
|
||||
<rect x="0" y="172.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-8">
|
||||
<rect x="0" y="196.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-9">
|
||||
<rect x="0" y="221.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-10">
|
||||
<rect x="0" y="245.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-11">
|
||||
<rect x="0" y="269.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-12">
|
||||
<rect x="0" y="294.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-13">
|
||||
<rect x="0" y="318.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-14">
|
||||
<rect x="0" y="343.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-15">
|
||||
<rect x="0" y="367.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-16">
|
||||
<rect x="0" y="391.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-17">
|
||||
<rect x="0" y="416.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-18">
|
||||
<rect x="0" y="440.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="536" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">MultiQuestionApp</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)">
|
||||
<rect fill="#121212" x="0" y="1.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="25.9" width="207.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="231.8" y="25.9" width="732" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="50.3" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="74.7" width="195.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="219.6" y="74.7" width="744.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="99.1" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="123.5" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="170.8" y="123.5" width="793" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="147.9" width="134.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="158.6" y="147.9" width="805.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="172.3" width="292.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="317.2" y="172.3" width="646.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="196.7" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="221.1" width="622.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="646.6" y="221.1" width="317.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="245.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="269.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="294.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="318.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="343.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="367.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="391.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="416.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="440.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="465.1" width="976" height="24.65" shape-rendering="crispEdges"/>
|
||||
<g class="terminal-matrix">
|
||||
<text class="terminal-r1" x="0" y="20" textLength="976" clip-path="url(#terminal-line-0)">╭──────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-r2" x="976" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
</text><text class="terminal-r1" x="0" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">│</text><text class="terminal-r3" x="24.4" y="44.4" textLength="207.4" clip-path="url(#terminal-line-1)"> DB   [Framework]</text><text class="terminal-r1" x="963.8" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">│</text><text class="terminal-r2" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
|
||||
</text><text class="terminal-r1" x="0" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">│</text><text class="terminal-r1" x="963.8" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">│</text><text class="terminal-r2" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
|
||||
</text><text class="terminal-r1" x="0" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">│</text><text class="terminal-r4" x="24.4" y="93.2" textLength="195.2" clip-path="url(#terminal-line-3)">Which framework?</text><text class="terminal-r1" x="963.8" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">│</text><text class="terminal-r2" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
|
||||
</text><text class="terminal-r1" x="0" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">│</text><text class="terminal-r1" x="963.8" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">│</text><text class="terminal-r2" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
|
||||
</text><text class="terminal-r1" x="0" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">│</text><text class="terminal-r4" x="24.4" y="142" textLength="146.4" clip-path="url(#terminal-line-5)">› 1. FastAPI</text><text class="terminal-r1" x="963.8" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">│</text><text class="terminal-r2" x="976" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
|
||||
</text><text class="terminal-r1" x="0" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">│</text><text class="terminal-r5" x="24.4" y="166.4" textLength="134.2" clip-path="url(#terminal-line-6)">  2. Django</text><text class="terminal-r1" x="963.8" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">│</text><text class="terminal-r2" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
|
||||
</text><text class="terminal-r1" x="0" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">│</text><text class="terminal-r5" x="24.4" y="190.8" textLength="292.8" clip-path="url(#terminal-line-7)">  3. Type your answer...</text><text class="terminal-r1" x="963.8" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">│</text><text class="terminal-r2" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
|
||||
</text><text class="terminal-r1" x="0" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">│</text><text class="terminal-r1" x="963.8" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">│</text><text class="terminal-r2" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
|
||||
</text><text class="terminal-r1" x="0" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">│</text><text class="terminal-r1" x="24.4" y="239.6" textLength="622.2" clip-path="url(#terminal-line-9)">←→ questions  ↑↓ navigate  Enter select  Esc cancel</text><text class="terminal-r1" x="963.8" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">│</text><text class="terminal-r2" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
|
||||
</text><text class="terminal-r1" x="0" y="264" textLength="976" clip-path="url(#terminal-line-10)">╰──────────────────────────────────────────────────────────────────────────────╯</text><text class="terminal-r2" x="976" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
|
||||
</text><text class="terminal-r2" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
|
||||
</text><text class="terminal-r2" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
|
||||
</text><text class="terminal-r2" x="976" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
|
||||
</text><text class="terminal-r2" x="976" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
|
||||
</text><text class="terminal-r2" x="976" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
|
||||
</text><text class="terminal-r2" x="976" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
|
||||
</text><text class="terminal-r2" x="976" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
|
||||
</text><text class="terminal-r2" x="976" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,137 @@
|
||||
<svg class="rich-terminal" viewBox="0 0 994 538.0" 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: #8d8d8d }
|
||||
.terminal-r2 { fill: #c5c8c6 }
|
||||
.terminal-r3 { fill: #0178d4 }
|
||||
.terminal-r4 { fill: #0178d4;font-weight: bold }
|
||||
.terminal-r5 { fill: #e0e0e0 }
|
||||
</style>
|
||||
|
||||
<defs>
|
||||
<clipPath id="terminal-clip-terminal">
|
||||
<rect x="0" y="0" width="975.0" height="487.0" />
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-0">
|
||||
<rect x="0" y="1.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-1">
|
||||
<rect x="0" y="25.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-2">
|
||||
<rect x="0" y="50.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-3">
|
||||
<rect x="0" y="74.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-4">
|
||||
<rect x="0" y="99.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-5">
|
||||
<rect x="0" y="123.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-6">
|
||||
<rect x="0" y="147.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-7">
|
||||
<rect x="0" y="172.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-8">
|
||||
<rect x="0" y="196.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-9">
|
||||
<rect x="0" y="221.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-10">
|
||||
<rect x="0" y="245.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-11">
|
||||
<rect x="0" y="269.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-12">
|
||||
<rect x="0" y="294.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-13">
|
||||
<rect x="0" y="318.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-14">
|
||||
<rect x="0" y="343.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-15">
|
||||
<rect x="0" y="367.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-16">
|
||||
<rect x="0" y="391.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-17">
|
||||
<rect x="0" y="416.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-18">
|
||||
<rect x="0" y="440.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="536" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">MultiQuestionApp</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)">
|
||||
<rect fill="#121212" x="0" y="1.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="25.9" width="207.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="231.8" y="25.9" width="732" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="50.3" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="74.7" width="183" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="207.4" y="74.7" width="756.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="99.1" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="123.5" width="183" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="207.4" y="123.5" width="756.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="147.9" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="170.8" y="147.9" width="793" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="172.3" width="292.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="317.2" y="172.3" width="646.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="196.7" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="221.1" width="622.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="646.6" y="221.1" width="317.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="245.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="269.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="294.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="318.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="343.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="367.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="391.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="416.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="440.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="465.1" width="976" height="24.65" shape-rendering="crispEdges"/>
|
||||
<g class="terminal-matrix">
|
||||
<text class="terminal-r1" x="0" y="20" textLength="976" clip-path="url(#terminal-line-0)">╭──────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-r2" x="976" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
</text><text class="terminal-r1" x="0" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">│</text><text class="terminal-r3" x="24.4" y="44.4" textLength="207.4" clip-path="url(#terminal-line-1)">[DB]   Framework </text><text class="terminal-r1" x="963.8" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">│</text><text class="terminal-r2" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
|
||||
</text><text class="terminal-r1" x="0" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">│</text><text class="terminal-r1" x="963.8" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">│</text><text class="terminal-r2" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
|
||||
</text><text class="terminal-r1" x="0" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">│</text><text class="terminal-r4" x="24.4" y="93.2" textLength="183" clip-path="url(#terminal-line-3)">Which database?</text><text class="terminal-r1" x="963.8" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">│</text><text class="terminal-r2" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
|
||||
</text><text class="terminal-r1" x="0" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">│</text><text class="terminal-r1" x="963.8" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">│</text><text class="terminal-r2" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
|
||||
</text><text class="terminal-r1" x="0" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">│</text><text class="terminal-r4" x="24.4" y="142" textLength="183" clip-path="url(#terminal-line-5)">› 1. PostgreSQL</text><text class="terminal-r1" x="963.8" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">│</text><text class="terminal-r2" x="976" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
|
||||
</text><text class="terminal-r1" x="0" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">│</text><text class="terminal-r5" x="24.4" y="166.4" textLength="146.4" clip-path="url(#terminal-line-6)">  2. MongoDB</text><text class="terminal-r1" x="963.8" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">│</text><text class="terminal-r2" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
|
||||
</text><text class="terminal-r1" x="0" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">│</text><text class="terminal-r5" x="24.4" y="190.8" textLength="292.8" clip-path="url(#terminal-line-7)">  3. Type your answer...</text><text class="terminal-r1" x="963.8" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">│</text><text class="terminal-r2" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
|
||||
</text><text class="terminal-r1" x="0" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">│</text><text class="terminal-r1" x="963.8" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">│</text><text class="terminal-r2" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
|
||||
</text><text class="terminal-r1" x="0" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">│</text><text class="terminal-r1" x="24.4" y="239.6" textLength="622.2" clip-path="url(#terminal-line-9)">←→ questions  ↑↓ navigate  Enter select  Esc cancel</text><text class="terminal-r1" x="963.8" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">│</text><text class="terminal-r2" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
|
||||
</text><text class="terminal-r1" x="0" y="264" textLength="976" clip-path="url(#terminal-line-10)">╰──────────────────────────────────────────────────────────────────────────────╯</text><text class="terminal-r2" x="976" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
|
||||
</text><text class="terminal-r2" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
|
||||
</text><text class="terminal-r2" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
|
||||
</text><text class="terminal-r2" x="976" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
|
||||
</text><text class="terminal-r2" x="976" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
|
||||
</text><text class="terminal-r2" x="976" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
|
||||
</text><text class="terminal-r2" x="976" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
|
||||
</text><text class="terminal-r2" x="976" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
|
||||
</text><text class="terminal-r2" x="976" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,136 @@
|
||||
<svg class="rich-terminal" viewBox="0 0 994 538.0" 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: #8d8d8d }
|
||||
.terminal-r2 { fill: #c5c8c6 }
|
||||
.terminal-r3 { fill: #0178d4;font-weight: bold }
|
||||
.terminal-r4 { fill: #e0e0e0 }
|
||||
</style>
|
||||
|
||||
<defs>
|
||||
<clipPath id="terminal-clip-terminal">
|
||||
<rect x="0" y="0" width="975.0" height="487.0" />
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-0">
|
||||
<rect x="0" y="1.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-1">
|
||||
<rect x="0" y="25.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-2">
|
||||
<rect x="0" y="50.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-3">
|
||||
<rect x="0" y="74.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-4">
|
||||
<rect x="0" y="99.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-5">
|
||||
<rect x="0" y="123.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-6">
|
||||
<rect x="0" y="147.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-7">
|
||||
<rect x="0" y="172.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-8">
|
||||
<rect x="0" y="196.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-9">
|
||||
<rect x="0" y="221.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-10">
|
||||
<rect x="0" y="245.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-11">
|
||||
<rect x="0" y="269.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-12">
|
||||
<rect x="0" y="294.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-13">
|
||||
<rect x="0" y="318.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-14">
|
||||
<rect x="0" y="343.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-15">
|
||||
<rect x="0" y="367.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-16">
|
||||
<rect x="0" y="391.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-17">
|
||||
<rect x="0" y="416.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-18">
|
||||
<rect x="0" y="440.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="536" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">SingleQuestionApp</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)">
|
||||
<rect fill="#121212" x="0" y="1.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="25.9" width="561.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="585.6" y="25.9" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="50.3" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="74.7" width="451.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="475.8" y="74.7" width="488" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="99.1" width="390.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="414.8" y="99.1" width="549" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="123.5" width="341.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="366" y="123.5" width="597.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="147.9" width="292.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="317.2" y="147.9" width="646.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="172.3" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="196.7" width="451.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="475.8" y="196.7" width="488" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="221.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="245.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="269.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="294.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="318.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="343.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="367.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="391.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="416.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="440.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="465.1" width="976" height="24.65" shape-rendering="crispEdges"/>
|
||||
<g class="terminal-matrix">
|
||||
<text class="terminal-r1" x="0" y="20" textLength="976" clip-path="url(#terminal-line-0)">╭──────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-r2" x="976" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
</text><text class="terminal-r1" x="0" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">│</text><text class="terminal-r3" x="24.4" y="44.4" textLength="561.2" clip-path="url(#terminal-line-1)">Which database should we use for this project?</text><text class="terminal-r1" x="963.8" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">│</text><text class="terminal-r2" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
|
||||
</text><text class="terminal-r1" x="0" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">│</text><text class="terminal-r1" x="963.8" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">│</text><text class="terminal-r2" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
|
||||
</text><text class="terminal-r1" x="0" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">│</text><text class="terminal-r3" x="24.4" y="93.2" textLength="451.4" clip-path="url(#terminal-line-3)">› 1. PostgreSQL - Relational database</text><text class="terminal-r1" x="963.8" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">│</text><text class="terminal-r2" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
|
||||
</text><text class="terminal-r1" x="0" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">│</text><text class="terminal-r4" x="24.4" y="117.6" textLength="390.4" clip-path="url(#terminal-line-4)">  2. MongoDB - Document database</text><text class="terminal-r1" x="963.8" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">│</text><text class="terminal-r2" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
|
||||
</text><text class="terminal-r1" x="0" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">│</text><text class="terminal-r4" x="24.4" y="142" textLength="341.6" clip-path="url(#terminal-line-5)">  3. Redis - In-memory store</text><text class="terminal-r1" x="963.8" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">│</text><text class="terminal-r2" x="976" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
|
||||
</text><text class="terminal-r1" x="0" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">│</text><text class="terminal-r4" x="24.4" y="166.4" textLength="292.8" clip-path="url(#terminal-line-6)">  4. Type your answer...</text><text class="terminal-r1" x="963.8" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">│</text><text class="terminal-r2" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
|
||||
</text><text class="terminal-r1" x="0" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">│</text><text class="terminal-r1" x="963.8" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">│</text><text class="terminal-r2" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
|
||||
</text><text class="terminal-r1" x="0" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">│</text><text class="terminal-r1" x="24.4" y="215.2" textLength="451.4" clip-path="url(#terminal-line-8)">↑↓ navigate  Enter select  Esc cancel</text><text class="terminal-r1" x="963.8" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">│</text><text class="terminal-r2" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
|
||||
</text><text class="terminal-r1" x="0" y="239.6" textLength="976" clip-path="url(#terminal-line-9)">╰──────────────────────────────────────────────────────────────────────────────╯</text><text class="terminal-r2" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
|
||||
</text><text class="terminal-r2" x="976" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
|
||||
</text><text class="terminal-r2" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
|
||||
</text><text class="terminal-r2" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
|
||||
</text><text class="terminal-r2" x="976" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
|
||||
</text><text class="terminal-r2" x="976" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
|
||||
</text><text class="terminal-r2" x="976" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
|
||||
</text><text class="terminal-r2" x="976" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
|
||||
</text><text class="terminal-r2" x="976" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
|
||||
</text><text class="terminal-r2" x="976" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,136 @@
|
||||
<svg class="rich-terminal" viewBox="0 0 994 538.0" 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: #8d8d8d }
|
||||
.terminal-r2 { fill: #c5c8c6 }
|
||||
.terminal-r3 { fill: #0178d4;font-weight: bold }
|
||||
.terminal-r4 { fill: #e0e0e0 }
|
||||
</style>
|
||||
|
||||
<defs>
|
||||
<clipPath id="terminal-clip-terminal">
|
||||
<rect x="0" y="0" width="975.0" height="487.0" />
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-0">
|
||||
<rect x="0" y="1.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-1">
|
||||
<rect x="0" y="25.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-2">
|
||||
<rect x="0" y="50.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-3">
|
||||
<rect x="0" y="74.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-4">
|
||||
<rect x="0" y="99.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-5">
|
||||
<rect x="0" y="123.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-6">
|
||||
<rect x="0" y="147.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-7">
|
||||
<rect x="0" y="172.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-8">
|
||||
<rect x="0" y="196.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-9">
|
||||
<rect x="0" y="221.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-10">
|
||||
<rect x="0" y="245.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-11">
|
||||
<rect x="0" y="269.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-12">
|
||||
<rect x="0" y="294.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-13">
|
||||
<rect x="0" y="318.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-14">
|
||||
<rect x="0" y="343.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-15">
|
||||
<rect x="0" y="367.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-16">
|
||||
<rect x="0" y="391.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-17">
|
||||
<rect x="0" y="416.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-18">
|
||||
<rect x="0" y="440.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="536" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">SingleQuestionApp</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)">
|
||||
<rect fill="#121212" x="0" y="1.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="25.9" width="561.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="585.6" y="25.9" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="50.3" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="74.7" width="451.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="475.8" y="74.7" width="488" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="99.1" width="390.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="414.8" y="99.1" width="549" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="123.5" width="341.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="366" y="123.5" width="597.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="147.9" width="292.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="317.2" y="147.9" width="646.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="172.3" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="196.7" width="451.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="475.8" y="196.7" width="488" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="221.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="245.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="269.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="294.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="318.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="343.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="367.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="391.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="416.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="440.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="465.1" width="976" height="24.65" shape-rendering="crispEdges"/>
|
||||
<g class="terminal-matrix">
|
||||
<text class="terminal-r1" x="0" y="20" textLength="976" clip-path="url(#terminal-line-0)">╭──────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-r2" x="976" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
</text><text class="terminal-r1" x="0" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">│</text><text class="terminal-r3" x="24.4" y="44.4" textLength="561.2" clip-path="url(#terminal-line-1)">Which database should we use for this project?</text><text class="terminal-r1" x="963.8" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">│</text><text class="terminal-r2" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
|
||||
</text><text class="terminal-r1" x="0" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">│</text><text class="terminal-r1" x="963.8" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">│</text><text class="terminal-r2" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
|
||||
</text><text class="terminal-r1" x="0" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">│</text><text class="terminal-r4" x="24.4" y="93.2" textLength="451.4" clip-path="url(#terminal-line-3)">  1. PostgreSQL - Relational database</text><text class="terminal-r1" x="963.8" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">│</text><text class="terminal-r2" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
|
||||
</text><text class="terminal-r1" x="0" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">│</text><text class="terminal-r3" x="24.4" y="117.6" textLength="390.4" clip-path="url(#terminal-line-4)">› 2. MongoDB - Document database</text><text class="terminal-r1" x="963.8" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">│</text><text class="terminal-r2" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
|
||||
</text><text class="terminal-r1" x="0" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">│</text><text class="terminal-r4" x="24.4" y="142" textLength="341.6" clip-path="url(#terminal-line-5)">  3. Redis - In-memory store</text><text class="terminal-r1" x="963.8" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">│</text><text class="terminal-r2" x="976" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
|
||||
</text><text class="terminal-r1" x="0" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">│</text><text class="terminal-r4" x="24.4" y="166.4" textLength="292.8" clip-path="url(#terminal-line-6)">  4. Type your answer...</text><text class="terminal-r1" x="963.8" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">│</text><text class="terminal-r2" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
|
||||
</text><text class="terminal-r1" x="0" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">│</text><text class="terminal-r1" x="963.8" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">│</text><text class="terminal-r2" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
|
||||
</text><text class="terminal-r1" x="0" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">│</text><text class="terminal-r1" x="24.4" y="215.2" textLength="451.4" clip-path="url(#terminal-line-8)">↑↓ navigate  Enter select  Esc cancel</text><text class="terminal-r1" x="963.8" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">│</text><text class="terminal-r2" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
|
||||
</text><text class="terminal-r1" x="0" y="239.6" textLength="976" clip-path="url(#terminal-line-9)">╰──────────────────────────────────────────────────────────────────────────────╯</text><text class="terminal-r2" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
|
||||
</text><text class="terminal-r2" x="976" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
|
||||
</text><text class="terminal-r2" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
|
||||
</text><text class="terminal-r2" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
|
||||
</text><text class="terminal-r2" x="976" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
|
||||
</text><text class="terminal-r2" x="976" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
|
||||
</text><text class="terminal-r2" x="976" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
|
||||
</text><text class="terminal-r2" x="976" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
|
||||
</text><text class="terminal-r2" x="976" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
|
||||
</text><text class="terminal-r2" x="976" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,139 @@
|
||||
<svg class="rich-terminal" viewBox="0 0 994 538.0" 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: #8d8d8d }
|
||||
.terminal-r2 { fill: #c5c8c6 }
|
||||
.terminal-r3 { fill: #0178d4;font-weight: bold }
|
||||
.terminal-r4 { fill: #e0e0e0 }
|
||||
.terminal-r5 { fill: #e0e0e0;font-weight: bold }
|
||||
.terminal-r6 { fill: #121212 }
|
||||
.terminal-r7 { fill: #6c6c6c }
|
||||
</style>
|
||||
|
||||
<defs>
|
||||
<clipPath id="terminal-clip-terminal">
|
||||
<rect x="0" y="0" width="975.0" height="487.0" />
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-0">
|
||||
<rect x="0" y="1.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-1">
|
||||
<rect x="0" y="25.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-2">
|
||||
<rect x="0" y="50.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-3">
|
||||
<rect x="0" y="74.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-4">
|
||||
<rect x="0" y="99.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-5">
|
||||
<rect x="0" y="123.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-6">
|
||||
<rect x="0" y="147.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-7">
|
||||
<rect x="0" y="172.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-8">
|
||||
<rect x="0" y="196.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-9">
|
||||
<rect x="0" y="221.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-10">
|
||||
<rect x="0" y="245.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-11">
|
||||
<rect x="0" y="269.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-12">
|
||||
<rect x="0" y="294.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-13">
|
||||
<rect x="0" y="318.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-14">
|
||||
<rect x="0" y="343.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-15">
|
||||
<rect x="0" y="367.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-16">
|
||||
<rect x="0" y="391.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-17">
|
||||
<rect x="0" y="416.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-18">
|
||||
<rect x="0" y="440.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="536" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">SingleQuestionApp</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)">
|
||||
<rect fill="#121212" x="0" y="1.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="25.9" width="561.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="585.6" y="25.9" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="50.3" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="74.7" width="451.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="475.8" y="74.7" width="488" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="99.1" width="390.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="414.8" y="99.1" width="549" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="123.5" width="341.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="366" y="123.5" width="597.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="147.9" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#e0e0e0" x="85.4" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="97.6" y="147.9" width="219.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="317.2" y="147.9" width="634.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="951.6" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="172.3" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="196.7" width="451.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="475.8" y="196.7" width="488" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="221.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="245.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="269.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="294.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="318.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="343.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="367.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="391.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="416.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="440.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="465.1" width="976" height="24.65" shape-rendering="crispEdges"/>
|
||||
<g class="terminal-matrix">
|
||||
<text class="terminal-r1" x="0" y="20" textLength="976" clip-path="url(#terminal-line-0)">╭──────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-r2" x="976" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
</text><text class="terminal-r1" x="0" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">│</text><text class="terminal-r3" x="24.4" y="44.4" textLength="561.2" clip-path="url(#terminal-line-1)">Which database should we use for this project?</text><text class="terminal-r1" x="963.8" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">│</text><text class="terminal-r2" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
|
||||
</text><text class="terminal-r1" x="0" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">│</text><text class="terminal-r1" x="963.8" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">│</text><text class="terminal-r2" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
|
||||
</text><text class="terminal-r1" x="0" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">│</text><text class="terminal-r4" x="24.4" y="93.2" textLength="451.4" clip-path="url(#terminal-line-3)">  1. PostgreSQL - Relational database</text><text class="terminal-r1" x="963.8" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">│</text><text class="terminal-r2" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
|
||||
</text><text class="terminal-r1" x="0" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">│</text><text class="terminal-r4" x="24.4" y="117.6" textLength="390.4" clip-path="url(#terminal-line-4)">  2. MongoDB - Document database</text><text class="terminal-r1" x="963.8" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">│</text><text class="terminal-r2" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
|
||||
</text><text class="terminal-r1" x="0" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">│</text><text class="terminal-r4" x="24.4" y="142" textLength="341.6" clip-path="url(#terminal-line-5)">  3. Redis - In-memory store</text><text class="terminal-r1" x="963.8" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">│</text><text class="terminal-r2" x="976" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
|
||||
</text><text class="terminal-r1" x="0" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">│</text><text class="terminal-r5" x="24.4" y="166.4" textLength="61" clip-path="url(#terminal-line-6)">› 4. </text><text class="terminal-r6" x="85.4" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">T</text><text class="terminal-r7" x="97.6" y="166.4" textLength="219.6" clip-path="url(#terminal-line-6)">ype your answer...</text><text class="terminal-r1" x="963.8" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">│</text><text class="terminal-r2" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
|
||||
</text><text class="terminal-r1" x="0" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">│</text><text class="terminal-r1" x="963.8" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">│</text><text class="terminal-r2" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
|
||||
</text><text class="terminal-r1" x="0" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">│</text><text class="terminal-r1" x="24.4" y="215.2" textLength="451.4" clip-path="url(#terminal-line-8)">↑↓ navigate  Enter select  Esc cancel</text><text class="terminal-r1" x="963.8" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">│</text><text class="terminal-r2" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
|
||||
</text><text class="terminal-r1" x="0" y="239.6" textLength="976" clip-path="url(#terminal-line-9)">╰──────────────────────────────────────────────────────────────────────────────╯</text><text class="terminal-r2" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
|
||||
</text><text class="terminal-r2" x="976" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
|
||||
</text><text class="terminal-r2" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
|
||||
</text><text class="terminal-r2" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
|
||||
</text><text class="terminal-r2" x="976" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
|
||||
</text><text class="terminal-r2" x="976" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
|
||||
</text><text class="terminal-r2" x="976" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
|
||||
</text><text class="terminal-r2" x="976" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
|
||||
</text><text class="terminal-r2" x="976" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
|
||||
</text><text class="terminal-r2" x="976" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,136 @@
|
||||
<svg class="rich-terminal" viewBox="0 0 994 538.0" 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: #8d8d8d }
|
||||
.terminal-r2 { fill: #c5c8c6 }
|
||||
.terminal-r3 { fill: #0178d4;font-weight: bold }
|
||||
.terminal-r4 { fill: #e0e0e0 }
|
||||
</style>
|
||||
|
||||
<defs>
|
||||
<clipPath id="terminal-clip-terminal">
|
||||
<rect x="0" y="0" width="975.0" height="487.0" />
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-0">
|
||||
<rect x="0" y="1.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-1">
|
||||
<rect x="0" y="25.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-2">
|
||||
<rect x="0" y="50.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-3">
|
||||
<rect x="0" y="74.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-4">
|
||||
<rect x="0" y="99.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-5">
|
||||
<rect x="0" y="123.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-6">
|
||||
<rect x="0" y="147.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-7">
|
||||
<rect x="0" y="172.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-8">
|
||||
<rect x="0" y="196.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-9">
|
||||
<rect x="0" y="221.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-10">
|
||||
<rect x="0" y="245.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-11">
|
||||
<rect x="0" y="269.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-12">
|
||||
<rect x="0" y="294.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-13">
|
||||
<rect x="0" y="318.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-14">
|
||||
<rect x="0" y="343.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-15">
|
||||
<rect x="0" y="367.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-16">
|
||||
<rect x="0" y="391.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-17">
|
||||
<rect x="0" y="416.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-18">
|
||||
<rect x="0" y="440.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="536" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">SingleQuestionApp</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)">
|
||||
<rect fill="#121212" x="0" y="1.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="25.9" width="561.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="585.6" y="25.9" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="50.3" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="74.7" width="451.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="475.8" y="74.7" width="488" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="99.1" width="390.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="414.8" y="99.1" width="549" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="123.5" width="341.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="366" y="123.5" width="597.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="147.9" width="292.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="317.2" y="147.9" width="646.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="172.3" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="196.7" width="451.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="475.8" y="196.7" width="488" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="221.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="245.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="269.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="294.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="318.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="343.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="367.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="391.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="416.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="440.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="465.1" width="976" height="24.65" shape-rendering="crispEdges"/>
|
||||
<g class="terminal-matrix">
|
||||
<text class="terminal-r1" x="0" y="20" textLength="976" clip-path="url(#terminal-line-0)">╭──────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-r2" x="976" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
</text><text class="terminal-r1" x="0" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">│</text><text class="terminal-r3" x="24.4" y="44.4" textLength="561.2" clip-path="url(#terminal-line-1)">Which database should we use for this project?</text><text class="terminal-r1" x="963.8" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">│</text><text class="terminal-r2" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
|
||||
</text><text class="terminal-r1" x="0" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">│</text><text class="terminal-r1" x="963.8" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">│</text><text class="terminal-r2" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
|
||||
</text><text class="terminal-r1" x="0" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">│</text><text class="terminal-r4" x="24.4" y="93.2" textLength="451.4" clip-path="url(#terminal-line-3)">  1. PostgreSQL - Relational database</text><text class="terminal-r1" x="963.8" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">│</text><text class="terminal-r2" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
|
||||
</text><text class="terminal-r1" x="0" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">│</text><text class="terminal-r4" x="24.4" y="117.6" textLength="390.4" clip-path="url(#terminal-line-4)">  2. MongoDB - Document database</text><text class="terminal-r1" x="963.8" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">│</text><text class="terminal-r2" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
|
||||
</text><text class="terminal-r1" x="0" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">│</text><text class="terminal-r3" x="24.4" y="142" textLength="341.6" clip-path="url(#terminal-line-5)">› 3. Redis - In-memory store</text><text class="terminal-r1" x="963.8" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">│</text><text class="terminal-r2" x="976" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
|
||||
</text><text class="terminal-r1" x="0" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">│</text><text class="terminal-r4" x="24.4" y="166.4" textLength="292.8" clip-path="url(#terminal-line-6)">  4. Type your answer...</text><text class="terminal-r1" x="963.8" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">│</text><text class="terminal-r2" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
|
||||
</text><text class="terminal-r1" x="0" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">│</text><text class="terminal-r1" x="963.8" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">│</text><text class="terminal-r2" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
|
||||
</text><text class="terminal-r1" x="0" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">│</text><text class="terminal-r1" x="24.4" y="215.2" textLength="451.4" clip-path="url(#terminal-line-8)">↑↓ navigate  Enter select  Esc cancel</text><text class="terminal-r1" x="963.8" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">│</text><text class="terminal-r2" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
|
||||
</text><text class="terminal-r1" x="0" y="239.6" textLength="976" clip-path="url(#terminal-line-9)">╰──────────────────────────────────────────────────────────────────────────────╯</text><text class="terminal-r2" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
|
||||
</text><text class="terminal-r2" x="976" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
|
||||
</text><text class="terminal-r2" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
|
||||
</text><text class="terminal-r2" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
|
||||
</text><text class="terminal-r2" x="976" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
|
||||
</text><text class="terminal-r2" x="976" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
|
||||
</text><text class="terminal-r2" x="976" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
|
||||
</text><text class="terminal-r2" x="976" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
|
||||
</text><text class="terminal-r2" x="976" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
|
||||
</text><text class="terminal-r2" x="976" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,139 @@
|
||||
<svg class="rich-terminal" viewBox="0 0 994 538.0" 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: #8d8d8d }
|
||||
.terminal-r2 { fill: #c5c8c6 }
|
||||
.terminal-r3 { fill: #0178d4;font-weight: bold }
|
||||
.terminal-r4 { fill: #e0e0e0 }
|
||||
.terminal-r5 { fill: #e0e0e0;font-weight: bold }
|
||||
.terminal-r6 { fill: #121212 }
|
||||
.terminal-r7 { fill: #6c6c6c }
|
||||
</style>
|
||||
|
||||
<defs>
|
||||
<clipPath id="terminal-clip-terminal">
|
||||
<rect x="0" y="0" width="975.0" height="487.0" />
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-0">
|
||||
<rect x="0" y="1.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-1">
|
||||
<rect x="0" y="25.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-2">
|
||||
<rect x="0" y="50.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-3">
|
||||
<rect x="0" y="74.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-4">
|
||||
<rect x="0" y="99.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-5">
|
||||
<rect x="0" y="123.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-6">
|
||||
<rect x="0" y="147.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-7">
|
||||
<rect x="0" y="172.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-8">
|
||||
<rect x="0" y="196.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-9">
|
||||
<rect x="0" y="221.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-10">
|
||||
<rect x="0" y="245.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-11">
|
||||
<rect x="0" y="269.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-12">
|
||||
<rect x="0" y="294.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-13">
|
||||
<rect x="0" y="318.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-14">
|
||||
<rect x="0" y="343.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-15">
|
||||
<rect x="0" y="367.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-16">
|
||||
<rect x="0" y="391.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-17">
|
||||
<rect x="0" y="416.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-18">
|
||||
<rect x="0" y="440.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="536" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">SingleQuestionApp</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)">
|
||||
<rect fill="#121212" x="0" y="1.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="25.9" width="561.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="585.6" y="25.9" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="50.3" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="74.7" width="451.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="475.8" y="74.7" width="488" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="99.1" width="390.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="414.8" y="99.1" width="549" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="123.5" width="341.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="366" y="123.5" width="597.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="147.9" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#e0e0e0" x="85.4" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="97.6" y="147.9" width="219.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="317.2" y="147.9" width="634.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="951.6" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="172.3" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="196.7" width="451.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="475.8" y="196.7" width="488" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="221.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="245.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="269.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="294.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="318.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="343.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="367.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="391.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="416.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="440.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="465.1" width="976" height="24.65" shape-rendering="crispEdges"/>
|
||||
<g class="terminal-matrix">
|
||||
<text class="terminal-r1" x="0" y="20" textLength="976" clip-path="url(#terminal-line-0)">╭──────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-r2" x="976" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
</text><text class="terminal-r1" x="0" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">│</text><text class="terminal-r3" x="24.4" y="44.4" textLength="561.2" clip-path="url(#terminal-line-1)">Which database should we use for this project?</text><text class="terminal-r1" x="963.8" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">│</text><text class="terminal-r2" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
|
||||
</text><text class="terminal-r1" x="0" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">│</text><text class="terminal-r1" x="963.8" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">│</text><text class="terminal-r2" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
|
||||
</text><text class="terminal-r1" x="0" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">│</text><text class="terminal-r4" x="24.4" y="93.2" textLength="451.4" clip-path="url(#terminal-line-3)">  1. PostgreSQL - Relational database</text><text class="terminal-r1" x="963.8" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">│</text><text class="terminal-r2" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
|
||||
</text><text class="terminal-r1" x="0" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">│</text><text class="terminal-r4" x="24.4" y="117.6" textLength="390.4" clip-path="url(#terminal-line-4)">  2. MongoDB - Document database</text><text class="terminal-r1" x="963.8" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">│</text><text class="terminal-r2" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
|
||||
</text><text class="terminal-r1" x="0" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">│</text><text class="terminal-r4" x="24.4" y="142" textLength="341.6" clip-path="url(#terminal-line-5)">  3. Redis - In-memory store</text><text class="terminal-r1" x="963.8" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">│</text><text class="terminal-r2" x="976" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
|
||||
</text><text class="terminal-r1" x="0" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">│</text><text class="terminal-r5" x="24.4" y="166.4" textLength="61" clip-path="url(#terminal-line-6)">› 4. </text><text class="terminal-r6" x="85.4" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">T</text><text class="terminal-r7" x="97.6" y="166.4" textLength="219.6" clip-path="url(#terminal-line-6)">ype your answer...</text><text class="terminal-r1" x="963.8" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">│</text><text class="terminal-r2" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
|
||||
</text><text class="terminal-r1" x="0" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">│</text><text class="terminal-r1" x="963.8" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">│</text><text class="terminal-r2" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
|
||||
</text><text class="terminal-r1" x="0" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">│</text><text class="terminal-r1" x="24.4" y="215.2" textLength="451.4" clip-path="url(#terminal-line-8)">↑↓ navigate  Enter select  Esc cancel</text><text class="terminal-r1" x="963.8" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">│</text><text class="terminal-r2" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
|
||||
</text><text class="terminal-r1" x="0" y="239.6" textLength="976" clip-path="url(#terminal-line-9)">╰──────────────────────────────────────────────────────────────────────────────╯</text><text class="terminal-r2" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
|
||||
</text><text class="terminal-r2" x="976" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
|
||||
</text><text class="terminal-r2" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
|
||||
</text><text class="terminal-r2" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
|
||||
</text><text class="terminal-r2" x="976" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
|
||||
</text><text class="terminal-r2" x="976" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
|
||||
</text><text class="terminal-r2" x="976" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
|
||||
</text><text class="terminal-r2" x="976" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
|
||||
</text><text class="terminal-r2" x="976" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
|
||||
</text><text class="terminal-r2" x="976" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,138 @@
|
||||
<svg class="rich-terminal" viewBox="0 0 994 538.0" 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: #8d8d8d }
|
||||
.terminal-r2 { fill: #c5c8c6 }
|
||||
.terminal-r3 { fill: #0178d4;font-weight: bold }
|
||||
.terminal-r4 { fill: #e0e0e0 }
|
||||
.terminal-r5 { fill: #e0e0e0;font-weight: bold }
|
||||
.terminal-r6 { fill: #121212 }
|
||||
</style>
|
||||
|
||||
<defs>
|
||||
<clipPath id="terminal-clip-terminal">
|
||||
<rect x="0" y="0" width="975.0" height="487.0" />
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-0">
|
||||
<rect x="0" y="1.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-1">
|
||||
<rect x="0" y="25.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-2">
|
||||
<rect x="0" y="50.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-3">
|
||||
<rect x="0" y="74.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-4">
|
||||
<rect x="0" y="99.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-5">
|
||||
<rect x="0" y="123.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-6">
|
||||
<rect x="0" y="147.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-7">
|
||||
<rect x="0" y="172.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-8">
|
||||
<rect x="0" y="196.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-9">
|
||||
<rect x="0" y="221.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-10">
|
||||
<rect x="0" y="245.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-11">
|
||||
<rect x="0" y="269.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-12">
|
||||
<rect x="0" y="294.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-13">
|
||||
<rect x="0" y="318.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-14">
|
||||
<rect x="0" y="343.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-15">
|
||||
<rect x="0" y="367.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-16">
|
||||
<rect x="0" y="391.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-17">
|
||||
<rect x="0" y="416.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-18">
|
||||
<rect x="0" y="440.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="536" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">SingleQuestionApp</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)">
|
||||
<rect fill="#121212" x="0" y="1.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="25.9" width="561.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="585.6" y="25.9" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="50.3" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="74.7" width="451.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="475.8" y="74.7" width="488" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="99.1" width="390.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="414.8" y="99.1" width="549" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="123.5" width="341.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="366" y="123.5" width="597.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="147.9" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="85.4" y="147.9" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#e0e0e0" x="158.6" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="170.8" y="147.9" width="780.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="951.6" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="172.3" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="196.7" width="451.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="475.8" y="196.7" width="488" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="221.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="245.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="269.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="294.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="318.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="343.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="367.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="391.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="416.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="440.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="465.1" width="976" height="24.65" shape-rendering="crispEdges"/>
|
||||
<g class="terminal-matrix">
|
||||
<text class="terminal-r1" x="0" y="20" textLength="976" clip-path="url(#terminal-line-0)">╭──────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-r2" x="976" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
</text><text class="terminal-r1" x="0" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">│</text><text class="terminal-r3" x="24.4" y="44.4" textLength="561.2" clip-path="url(#terminal-line-1)">Which database should we use for this project?</text><text class="terminal-r1" x="963.8" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">│</text><text class="terminal-r2" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
|
||||
</text><text class="terminal-r1" x="0" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">│</text><text class="terminal-r1" x="963.8" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">│</text><text class="terminal-r2" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
|
||||
</text><text class="terminal-r1" x="0" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">│</text><text class="terminal-r4" x="24.4" y="93.2" textLength="451.4" clip-path="url(#terminal-line-3)">  1. PostgreSQL - Relational database</text><text class="terminal-r1" x="963.8" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">│</text><text class="terminal-r2" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
|
||||
</text><text class="terminal-r1" x="0" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">│</text><text class="terminal-r4" x="24.4" y="117.6" textLength="390.4" clip-path="url(#terminal-line-4)">  2. MongoDB - Document database</text><text class="terminal-r1" x="963.8" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">│</text><text class="terminal-r2" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
|
||||
</text><text class="terminal-r1" x="0" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">│</text><text class="terminal-r4" x="24.4" y="142" textLength="341.6" clip-path="url(#terminal-line-5)">  3. Redis - In-memory store</text><text class="terminal-r1" x="963.8" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">│</text><text class="terminal-r2" x="976" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
|
||||
</text><text class="terminal-r1" x="0" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">│</text><text class="terminal-r5" x="24.4" y="166.4" textLength="61" clip-path="url(#terminal-line-6)">› 4. </text><text class="terminal-r4" x="85.4" y="166.4" textLength="73.2" clip-path="url(#terminal-line-6)">SQLite</text><text class="terminal-r1" x="963.8" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">│</text><text class="terminal-r2" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
|
||||
</text><text class="terminal-r1" x="0" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">│</text><text class="terminal-r1" x="963.8" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">│</text><text class="terminal-r2" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
|
||||
</text><text class="terminal-r1" x="0" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">│</text><text class="terminal-r1" x="24.4" y="215.2" textLength="451.4" clip-path="url(#terminal-line-8)">↑↓ navigate  Enter select  Esc cancel</text><text class="terminal-r1" x="963.8" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">│</text><text class="terminal-r2" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
|
||||
</text><text class="terminal-r1" x="0" y="239.6" textLength="976" clip-path="url(#terminal-line-9)">╰──────────────────────────────────────────────────────────────────────────────╯</text><text class="terminal-r2" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
|
||||
</text><text class="terminal-r2" x="976" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
|
||||
</text><text class="terminal-r2" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
|
||||
</text><text class="terminal-r2" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
|
||||
</text><text class="terminal-r2" x="976" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
|
||||
</text><text class="terminal-r2" x="976" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
|
||||
</text><text class="terminal-r2" x="976" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
|
||||
</text><text class="terminal-r2" x="976" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
|
||||
</text><text class="terminal-r2" x="976" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
|
||||
</text><text class="terminal-r2" x="976" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 22 KiB |
@@ -3,10 +3,12 @@ from __future__ import annotations
|
||||
from rich.style import Style
|
||||
from textual.widgets.text_area import TextAreaTheme
|
||||
|
||||
from tests.cli.plan_offer.adapters.fake_whoami_gateway import FakeWhoAmIGateway
|
||||
from tests.stubs.fake_backend import FakeBackend
|
||||
from vibe.cli.textual_ui.app import VibeApp
|
||||
from vibe.cli.textual_ui.widgets.chat_input import ChatTextArea
|
||||
from vibe.core.agent import Agent
|
||||
from vibe.core.agent_loop import AgentLoop
|
||||
from vibe.core.agents.models import BuiltinAgentName
|
||||
from vibe.core.config import SessionLoggingConfig, VibeConfig
|
||||
|
||||
|
||||
@@ -29,17 +31,27 @@ def default_config() -> VibeConfig:
|
||||
|
||||
class BaseSnapshotTestApp(VibeApp):
|
||||
CSS_PATH = "../../vibe/cli/textual_ui/app.tcss"
|
||||
_current_agent_name: str = BuiltinAgentName.DEFAULT
|
||||
|
||||
def __init__(self, config: VibeConfig | None = None, **kwargs):
|
||||
def __init__(
|
||||
self,
|
||||
config: VibeConfig | None = None,
|
||||
backend: FakeBackend | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
config = config or default_config()
|
||||
|
||||
super().__init__(config=config, **kwargs)
|
||||
|
||||
self.agent = Agent(
|
||||
agent_loop = AgentLoop(
|
||||
config,
|
||||
mode=self._current_agent_mode,
|
||||
enable_streaming=self.enable_streaming,
|
||||
backend=FakeBackend(),
|
||||
agent_name=self._current_agent_name,
|
||||
enable_streaming=kwargs.get("enable_streaming", False),
|
||||
backend=backend or FakeBackend(),
|
||||
)
|
||||
|
||||
plan_offer_gateway = kwargs.pop("plan_offer_gateway", FakeWhoAmIGateway())
|
||||
|
||||
super().__init__(
|
||||
agent_loop=agent_loop, plan_offer_gateway=plan_offer_gateway, **kwargs
|
||||
)
|
||||
|
||||
async def on_mount(self) -> None:
|
||||
|
||||
80
tests/snapshots/test_ui_snapshot_ask_user_question.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from textual.pilot import Pilot
|
||||
|
||||
from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp
|
||||
from tests.snapshots.snap_compare import SnapCompare
|
||||
from vibe.cli.textual_ui.widgets.tools import ToolResultMessage
|
||||
from vibe.core.tools.builtins.ask_user_question import (
|
||||
Answer,
|
||||
AskUserQuestion,
|
||||
AskUserQuestionResult,
|
||||
)
|
||||
from vibe.core.types import ToolResultEvent
|
||||
|
||||
|
||||
class AskUserQuestionResultApp(BaseSnapshotTestApp):
|
||||
"""Test app that displays an AskUserQuestion tool result."""
|
||||
|
||||
async def on_mount(self) -> None:
|
||||
await super().on_mount()
|
||||
|
||||
result = AskUserQuestionResult(
|
||||
answers=[
|
||||
Answer(
|
||||
question="What programming language are you currently working with?",
|
||||
answer="Rust",
|
||||
is_other=False,
|
||||
),
|
||||
Answer(
|
||||
question="What type of project are you building?",
|
||||
answer="Web Application",
|
||||
is_other=False,
|
||||
),
|
||||
Answer(
|
||||
question="What editor or IDE do you prefer?",
|
||||
answer="VS Code",
|
||||
is_other=True,
|
||||
),
|
||||
],
|
||||
cancelled=False,
|
||||
)
|
||||
|
||||
event = ToolResultEvent(
|
||||
tool_name="ask_user_question",
|
||||
tool_class=AskUserQuestion,
|
||||
result=result,
|
||||
tool_call_id="test_call_id",
|
||||
)
|
||||
|
||||
messages_area = self.query_one("#messages")
|
||||
tool_result = ToolResultMessage(event, collapsed=True)
|
||||
await messages_area.mount(tool_result)
|
||||
|
||||
|
||||
def test_snapshot_ask_user_question_collapsed(snap_compare: SnapCompare) -> None:
|
||||
"""Test collapsed AskUserQuestion result shows summary."""
|
||||
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_ask_user_question.py:AskUserQuestionResultApp",
|
||||
terminal_size=(120, 20),
|
||||
run_before=run_before,
|
||||
)
|
||||
|
||||
|
||||
def test_snapshot_ask_user_question_expanded(snap_compare: SnapCompare) -> None:
|
||||
"""Test expanded AskUserQuestion result shows formatted Q&A pairs."""
|
||||
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
await pilot.pause(0.1)
|
||||
await pilot.press("ctrl+o")
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_ask_user_question.py:AskUserQuestionResultApp",
|
||||
terminal_size=(120, 30),
|
||||
run_before=run_before,
|
||||
)
|
||||
@@ -3,15 +3,13 @@ from __future__ import annotations
|
||||
from textual.pilot import Pilot
|
||||
|
||||
from tests.mock.utils import mock_llm_chunk
|
||||
from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp, default_config
|
||||
from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp
|
||||
from tests.snapshots.snap_compare import SnapCompare
|
||||
from tests.stubs.fake_backend import FakeBackend
|
||||
from vibe.core.agent import Agent
|
||||
|
||||
|
||||
class SnapshotTestAppWithConversation(BaseSnapshotTestApp):
|
||||
def __init__(self) -> None:
|
||||
config = default_config()
|
||||
fake_backend = FakeBackend(
|
||||
mock_llm_chunk(
|
||||
content="I'm the Vibe agent and I'm ready to help.",
|
||||
@@ -19,13 +17,7 @@ class SnapshotTestAppWithConversation(BaseSnapshotTestApp):
|
||||
completion_tokens=2_500,
|
||||
)
|
||||
)
|
||||
super().__init__(config=config)
|
||||
self.agent = Agent(
|
||||
config,
|
||||
mode=self._current_agent_mode,
|
||||
enable_streaming=self.enable_streaming,
|
||||
backend=fake_backend,
|
||||
)
|
||||
super().__init__(backend=fake_backend)
|
||||
|
||||
|
||||
def test_snapshot_shows_basic_conversation(snap_compare: SnapCompare) -> None:
|
||||
|
||||
132
tests/snapshots/test_ui_snapshot_plan_offer.py
Normal file
@@ -0,0 +1,132 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
from textual.pilot import Pilot
|
||||
|
||||
from tests.cli.plan_offer.adapters.fake_whoami_gateway import FakeWhoAmIGateway
|
||||
from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp, default_config
|
||||
from tests.snapshots.snap_compare import SnapCompare
|
||||
from tests.update_notifier.adapters.fake_update_cache_repository import (
|
||||
FakeUpdateCacheRepository,
|
||||
)
|
||||
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIResponse
|
||||
from vibe.cli.update_notifier import UpdateCache
|
||||
|
||||
|
||||
class PlanOfferSnapshotApp(BaseSnapshotTestApp):
|
||||
def __init__(self, gateway: FakeWhoAmIGateway):
|
||||
self._previous_api_key = os.environ.get("MISTRAL_API_KEY")
|
||||
os.environ["MISTRAL_API_KEY"] = "snapshot-api-key"
|
||||
super().__init__(
|
||||
config=default_config(),
|
||||
plan_offer_gateway=gateway,
|
||||
update_cache_repository=FakeUpdateCacheRepository(),
|
||||
)
|
||||
|
||||
def on_unmount(self) -> None:
|
||||
if self._previous_api_key is None:
|
||||
os.environ.pop("MISTRAL_API_KEY", None)
|
||||
else:
|
||||
os.environ["MISTRAL_API_KEY"] = self._previous_api_key
|
||||
return None
|
||||
|
||||
|
||||
class SnapshotAppPlanOfferUpgrade(PlanOfferSnapshotApp):
|
||||
def __init__(self) -> None:
|
||||
gateway = FakeWhoAmIGateway(
|
||||
WhoAmIResponse(
|
||||
is_pro_plan=False,
|
||||
advertise_pro_plan=True,
|
||||
prompt_switching_to_pro_plan=False,
|
||||
)
|
||||
)
|
||||
super().__init__(gateway=gateway)
|
||||
|
||||
|
||||
class SnapshotAppPlanOfferSwitchKey(PlanOfferSnapshotApp):
|
||||
def __init__(self) -> None:
|
||||
gateway = FakeWhoAmIGateway(
|
||||
WhoAmIResponse(
|
||||
is_pro_plan=False,
|
||||
advertise_pro_plan=False,
|
||||
prompt_switching_to_pro_plan=True,
|
||||
)
|
||||
)
|
||||
super().__init__(gateway=gateway)
|
||||
|
||||
|
||||
class SnapshotAppPlanOfferNone(PlanOfferSnapshotApp):
|
||||
def __init__(self) -> None:
|
||||
gateway = FakeWhoAmIGateway(
|
||||
WhoAmIResponse(
|
||||
is_pro_plan=True,
|
||||
advertise_pro_plan=False,
|
||||
prompt_switching_to_pro_plan=False,
|
||||
)
|
||||
)
|
||||
super().__init__(gateway=gateway)
|
||||
|
||||
|
||||
class SnapshotAppWhatsNewAndPlanOffer(PlanOfferSnapshotApp):
|
||||
def __init__(self) -> None:
|
||||
gateway = FakeWhoAmIGateway(
|
||||
WhoAmIResponse(
|
||||
is_pro_plan=False,
|
||||
advertise_pro_plan=True,
|
||||
prompt_switching_to_pro_plan=False,
|
||||
)
|
||||
)
|
||||
super().__init__(gateway=gateway)
|
||||
cache = UpdateCache(
|
||||
latest_version="1.0.0",
|
||||
stored_at_timestamp=int(time.time()),
|
||||
seen_whats_new_version=None,
|
||||
)
|
||||
self._update_cache_repository = FakeUpdateCacheRepository(update_cache=cache)
|
||||
self._current_version = "1.0.0"
|
||||
|
||||
|
||||
async def _pause_for_plan_offer_task(pilot: Pilot) -> None:
|
||||
await pilot.pause(0.1)
|
||||
|
||||
|
||||
def test_snapshot_shows_upgrade_plan_offer(snap_compare: SnapCompare) -> None:
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_plan_offer.py:SnapshotAppPlanOfferUpgrade",
|
||||
terminal_size=(120, 36),
|
||||
run_before=_pause_for_plan_offer_task,
|
||||
)
|
||||
|
||||
|
||||
def test_snapshot_shows_switch_key_plan_offer(snap_compare: SnapCompare) -> None:
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_plan_offer.py:SnapshotAppPlanOfferSwitchKey",
|
||||
terminal_size=(120, 36),
|
||||
run_before=_pause_for_plan_offer_task,
|
||||
)
|
||||
|
||||
|
||||
def test_snapshot_shows_no_plan_offer(snap_compare: SnapCompare) -> None:
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_plan_offer.py:SnapshotAppPlanOfferNone",
|
||||
terminal_size=(120, 36),
|
||||
run_before=_pause_for_plan_offer_task,
|
||||
)
|
||||
|
||||
|
||||
def test_snapshot_shows_whats_new_and_plan_offer(
|
||||
snap_compare: SnapCompare, tmp_path: Path
|
||||
) -> None:
|
||||
whats_new_file = tmp_path / "whats_new.md"
|
||||
whats_new_file.write_text("# What's New\n\n- Feature 1\n- Feature 2")
|
||||
|
||||
with patch("vibe.cli.update_notifier.whats_new.VIBE_ROOT", tmp_path):
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_plan_offer.py:SnapshotAppWhatsNewAndPlanOffer",
|
||||
terminal_size=(120, 36),
|
||||
run_before=_pause_for_plan_offer_task,
|
||||
)
|
||||
365
tests/snapshots/test_ui_snapshot_question_app.py
Normal file
@@ -0,0 +1,365 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Container
|
||||
from textual.pilot import Pilot
|
||||
|
||||
from tests.snapshots.snap_compare import SnapCompare
|
||||
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
|
||||
from vibe.core.tools.builtins.ask_user_question import (
|
||||
AskUserQuestionArgs,
|
||||
Choice,
|
||||
Question,
|
||||
)
|
||||
|
||||
|
||||
def single_question_args() -> AskUserQuestionArgs:
|
||||
return AskUserQuestionArgs(
|
||||
questions=[
|
||||
Question(
|
||||
question="Which database should we use for this project?",
|
||||
header="Database",
|
||||
options=[
|
||||
Choice(label="PostgreSQL", description="Relational database"),
|
||||
Choice(label="MongoDB", description="Document database"),
|
||||
Choice(label="Redis", description="In-memory store"),
|
||||
],
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def multi_question_args() -> AskUserQuestionArgs:
|
||||
return AskUserQuestionArgs(
|
||||
questions=[
|
||||
Question(
|
||||
question="Which database?",
|
||||
header="DB",
|
||||
options=[Choice(label="PostgreSQL"), Choice(label="MongoDB")],
|
||||
),
|
||||
Question(
|
||||
question="Which framework?",
|
||||
header="Framework",
|
||||
options=[Choice(label="FastAPI"), Choice(label="Django")],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def multi_select_args() -> AskUserQuestionArgs:
|
||||
return AskUserQuestionArgs(
|
||||
questions=[
|
||||
Question(
|
||||
question="Which features do you want to enable?",
|
||||
header="Features",
|
||||
options=[
|
||||
Choice(label="Authentication", description="User login/logout"),
|
||||
Choice(label="Caching", description="Redis caching layer"),
|
||||
Choice(label="Logging", description="Structured logging"),
|
||||
],
|
||||
multi_select=True,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class QuestionAppTestApp(App):
|
||||
CSS_PATH = "../../vibe/cli/textual_ui/app.tcss"
|
||||
|
||||
def __init__(self, args: AskUserQuestionArgs):
|
||||
super().__init__()
|
||||
self.question_args = args
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Container(id="bottom-app-container"):
|
||||
yield QuestionApp(args=self.question_args)
|
||||
|
||||
|
||||
class SingleQuestionApp(QuestionAppTestApp):
|
||||
def __init__(self):
|
||||
super().__init__(single_question_args())
|
||||
|
||||
|
||||
class MultiQuestionApp(QuestionAppTestApp):
|
||||
def __init__(self):
|
||||
super().__init__(multi_question_args())
|
||||
|
||||
|
||||
class MultiSelectApp(QuestionAppTestApp):
|
||||
def __init__(self):
|
||||
super().__init__(multi_select_args())
|
||||
|
||||
|
||||
# Single question tests
|
||||
|
||||
|
||||
def test_snapshot_question_app_initial(snap_compare: SnapCompare) -> None:
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_question_app.py:SingleQuestionApp",
|
||||
terminal_size=(80, 20),
|
||||
run_before=run_before,
|
||||
)
|
||||
|
||||
|
||||
def test_snapshot_question_app_navigate_down(snap_compare: SnapCompare) -> None:
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
await pilot.pause(0.1)
|
||||
await pilot.press("down")
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_question_app.py:SingleQuestionApp",
|
||||
terminal_size=(80, 20),
|
||||
run_before=run_before,
|
||||
)
|
||||
|
||||
|
||||
def test_snapshot_question_app_navigate_to_third_option(
|
||||
snap_compare: SnapCompare,
|
||||
) -> None:
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
await pilot.pause(0.1)
|
||||
await pilot.press("down")
|
||||
await pilot.press("down")
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_question_app.py:SingleQuestionApp",
|
||||
terminal_size=(80, 20),
|
||||
run_before=run_before,
|
||||
)
|
||||
|
||||
|
||||
def test_snapshot_question_app_navigate_to_other(snap_compare: SnapCompare) -> None:
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
await pilot.pause(0.1)
|
||||
await pilot.press("down")
|
||||
await pilot.press("down")
|
||||
await pilot.press("down")
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_question_app.py:SingleQuestionApp",
|
||||
terminal_size=(80, 20),
|
||||
run_before=run_before,
|
||||
)
|
||||
|
||||
|
||||
def test_snapshot_question_app_navigate_up_wraps(snap_compare: SnapCompare) -> None:
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
await pilot.pause(0.1)
|
||||
await pilot.press("up")
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_question_app.py:SingleQuestionApp",
|
||||
terminal_size=(80, 20),
|
||||
run_before=run_before,
|
||||
)
|
||||
|
||||
|
||||
def test_snapshot_question_app_other_typing(snap_compare: SnapCompare) -> None:
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
await pilot.pause(0.1)
|
||||
await pilot.press("down", "down", "down")
|
||||
await pilot.press("enter")
|
||||
await pilot.pause(0.1)
|
||||
await pilot.press(*"SQLite")
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_question_app.py:SingleQuestionApp",
|
||||
terminal_size=(80, 20),
|
||||
run_before=run_before,
|
||||
)
|
||||
|
||||
|
||||
# Multi-question tests
|
||||
|
||||
|
||||
def test_snapshot_multi_question_initial(snap_compare: SnapCompare) -> None:
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_question_app.py:MultiQuestionApp",
|
||||
terminal_size=(80, 20),
|
||||
run_before=run_before,
|
||||
)
|
||||
|
||||
|
||||
def test_snapshot_multi_question_tab_to_second(snap_compare: SnapCompare) -> None:
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
await pilot.pause(0.1)
|
||||
await pilot.press("tab")
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_question_app.py:MultiQuestionApp",
|
||||
terminal_size=(80, 20),
|
||||
run_before=run_before,
|
||||
)
|
||||
|
||||
|
||||
def test_snapshot_multi_question_answer_first_advance(
|
||||
snap_compare: SnapCompare,
|
||||
) -> None:
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
await pilot.pause(0.1)
|
||||
await pilot.press("enter")
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_question_app.py:MultiQuestionApp",
|
||||
terminal_size=(80, 20),
|
||||
run_before=run_before,
|
||||
)
|
||||
|
||||
|
||||
def test_snapshot_multi_question_navigate_right(snap_compare: SnapCompare) -> None:
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
await pilot.pause(0.1)
|
||||
await pilot.press("right")
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_question_app.py:MultiQuestionApp",
|
||||
terminal_size=(80, 20),
|
||||
run_before=run_before,
|
||||
)
|
||||
|
||||
|
||||
def test_snapshot_multi_question_navigate_left_wraps(snap_compare: SnapCompare) -> None:
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
await pilot.pause(0.1)
|
||||
await pilot.press("left")
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_question_app.py:MultiQuestionApp",
|
||||
terminal_size=(80, 20),
|
||||
run_before=run_before,
|
||||
)
|
||||
|
||||
|
||||
def test_snapshot_multi_question_first_answered_checkmark(
|
||||
snap_compare: SnapCompare,
|
||||
) -> None:
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
await pilot.pause(0.1)
|
||||
await pilot.press("down")
|
||||
await pilot.press("enter")
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_question_app.py:MultiQuestionApp",
|
||||
terminal_size=(80, 20),
|
||||
run_before=run_before,
|
||||
)
|
||||
|
||||
|
||||
# Multi-select tests
|
||||
|
||||
|
||||
def test_snapshot_multi_select_initial(snap_compare: SnapCompare) -> None:
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_question_app.py:MultiSelectApp",
|
||||
terminal_size=(80, 20),
|
||||
run_before=run_before,
|
||||
)
|
||||
|
||||
|
||||
def test_snapshot_multi_select_toggle_first(snap_compare: SnapCompare) -> None:
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
await pilot.pause(0.1)
|
||||
await pilot.press("enter")
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_question_app.py:MultiSelectApp",
|
||||
terminal_size=(80, 20),
|
||||
run_before=run_before,
|
||||
)
|
||||
|
||||
|
||||
def test_snapshot_multi_select_toggle_multiple(snap_compare: SnapCompare) -> None:
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
await pilot.pause(0.1)
|
||||
await pilot.press("enter")
|
||||
await pilot.press("down", "down")
|
||||
await pilot.press("enter")
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_question_app.py:MultiSelectApp",
|
||||
terminal_size=(80, 20),
|
||||
run_before=run_before,
|
||||
)
|
||||
|
||||
|
||||
def test_snapshot_multi_select_navigate_to_submit(snap_compare: SnapCompare) -> None:
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
await pilot.pause(0.1)
|
||||
await pilot.press("down", "down", "down", "down")
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_question_app.py:MultiSelectApp",
|
||||
terminal_size=(80, 20),
|
||||
run_before=run_before,
|
||||
)
|
||||
|
||||
|
||||
def test_snapshot_multi_select_other_with_text(snap_compare: SnapCompare) -> None:
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
await pilot.pause(0.1)
|
||||
await pilot.press("down", "down", "down")
|
||||
await pilot.press("enter")
|
||||
await pilot.pause(0.1)
|
||||
await pilot.press(*"Custom feature")
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_question_app.py:MultiSelectApp",
|
||||
terminal_size=(80, 20),
|
||||
run_before=run_before,
|
||||
)
|
||||
|
||||
|
||||
def test_snapshot_multi_select_mixed_selection(snap_compare: SnapCompare) -> None:
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
await pilot.pause(0.1)
|
||||
await pilot.press("enter")
|
||||
await pilot.press("down", "down")
|
||||
await pilot.press("enter")
|
||||
await pilot.press("down")
|
||||
await pilot.press("enter")
|
||||
await pilot.pause(0.1)
|
||||
await pilot.press(*"Extra")
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_question_app.py:MultiSelectApp",
|
||||
terminal_size=(80, 20),
|
||||
run_before=run_before,
|
||||
)
|
||||
|
||||
|
||||
def test_snapshot_multi_select_untoggle(snap_compare: SnapCompare) -> None:
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
await pilot.pause(0.1)
|
||||
await pilot.press("enter")
|
||||
await pilot.press("enter")
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_question_app.py:MultiSelectApp",
|
||||
terminal_size=(80, 20),
|
||||
run_before=run_before,
|
||||
)
|
||||
@@ -7,7 +7,7 @@ from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp, default_
|
||||
from tests.snapshots.snap_compare import SnapCompare
|
||||
from tests.stubs.fake_backend import FakeBackend
|
||||
from vibe.cli.textual_ui.widgets.messages import ReasoningMessage
|
||||
from vibe.core.agent import Agent
|
||||
from vibe.core.agent_loop import AgentLoop
|
||||
|
||||
|
||||
class SnapshotTestAppWithReasoningContent(BaseSnapshotTestApp):
|
||||
@@ -31,9 +31,9 @@ class SnapshotTestAppWithReasoningContent(BaseSnapshotTestApp):
|
||||
]
|
||||
)
|
||||
super().__init__(config=config)
|
||||
self.agent = Agent(
|
||||
self.agent_loop = AgentLoop(
|
||||
config,
|
||||
mode=self._current_agent_mode,
|
||||
agent_name=self._current_agent_name,
|
||||
enable_streaming=True,
|
||||
backend=fake_backend,
|
||||
)
|
||||
@@ -57,9 +57,9 @@ class SnapshotTestAppWithInterleavedReasoning(BaseSnapshotTestApp):
|
||||
]
|
||||
)
|
||||
super().__init__(config=config)
|
||||
self.agent = Agent(
|
||||
self.agent_loop = AgentLoop(
|
||||
config,
|
||||
mode=self._current_agent_mode,
|
||||
agent_name=self._current_agent_name,
|
||||
enable_streaming=True,
|
||||
backend=fake_backend,
|
||||
)
|
||||
@@ -123,9 +123,9 @@ class SnapshotTestAppWithBufferedReasoningTransition(BaseSnapshotTestApp):
|
||||
]
|
||||
)
|
||||
super().__init__(config=config)
|
||||
self.agent = Agent(
|
||||
self.agent_loop = AgentLoop(
|
||||
config,
|
||||
mode=self._current_agent_mode,
|
||||
agent_name=self._current_agent_name,
|
||||
enable_streaming=True,
|
||||
backend=fake_backend,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from textual.pilot import Pilot
|
||||
|
||||
from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp, default_config
|
||||
@@ -7,34 +10,36 @@ from tests.snapshots.snap_compare import SnapCompare
|
||||
from tests.update_notifier.adapters.fake_update_cache_repository import (
|
||||
FakeUpdateCacheRepository,
|
||||
)
|
||||
from tests.update_notifier.adapters.fake_version_update_gateway import (
|
||||
FakeVersionUpdateGateway,
|
||||
)
|
||||
from vibe.cli.update_notifier import VersionUpdate
|
||||
from tests.update_notifier.adapters.fake_update_gateway import FakeUpdateGateway
|
||||
from vibe.cli.update_notifier import Update
|
||||
|
||||
|
||||
class SnapshotTestAppWithUpdate(BaseSnapshotTestApp):
|
||||
def __init__(self):
|
||||
config = default_config()
|
||||
config.enable_update_checks = True
|
||||
version_update_notifier = FakeVersionUpdateGateway(
|
||||
update=VersionUpdate(latest_version="1000.2.0")
|
||||
)
|
||||
update_notifier = FakeUpdateGateway(update=Update(latest_version="1000.2.0"))
|
||||
update_cache_repository = FakeUpdateCacheRepository()
|
||||
super().__init__(
|
||||
config=config,
|
||||
version_update_notifier=version_update_notifier,
|
||||
update_notifier=update_notifier,
|
||||
update_cache_repository=update_cache_repository,
|
||||
current_version="1.0.4",
|
||||
)
|
||||
|
||||
|
||||
def test_snapshot_shows_release_update_notification(snap_compare: SnapCompare) -> None:
|
||||
def test_snapshot_shows_release_update_notification(
|
||||
snap_compare: SnapCompare, tmp_path: Path
|
||||
) -> None:
|
||||
whats_new_file = tmp_path / "whats_new.md"
|
||||
whats_new_file.write_text("# What's New\n\n- Feature 1\n- Feature 2")
|
||||
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
await pilot.pause(0.2)
|
||||
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_release_update_notification.py:SnapshotTestAppWithUpdate",
|
||||
terminal_size=(120, 36),
|
||||
run_before=run_before,
|
||||
)
|
||||
with patch("vibe.cli.update_notifier.whats_new.VIBE_ROOT", tmp_path):
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_release_update_notification.py:SnapshotTestAppWithUpdate",
|
||||
terminal_size=(120, 36),
|
||||
run_before=run_before,
|
||||
)
|
||||
|
||||
47
tests/snapshots/test_ui_snapshot_session_resume.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from textual.pilot import Pilot
|
||||
|
||||
from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp
|
||||
from tests.snapshots.snap_compare import SnapCompare
|
||||
from vibe.core.types import FunctionCall, LLMMessage, Role, ToolCall
|
||||
|
||||
|
||||
class SnapshotTestAppWithResumedSession(BaseSnapshotTestApp):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
# Simulate a previous session with messages
|
||||
user_msg = LLMMessage(role=Role.user, content="Hello, how are you?")
|
||||
assistant_msg = LLMMessage(
|
||||
role=Role.assistant,
|
||||
content="I'm doing well, thank you! Let me read that file for you.",
|
||||
tool_calls=[
|
||||
ToolCall(
|
||||
id="tool_call_1",
|
||||
index=0,
|
||||
function=FunctionCall(
|
||||
name="read_file", arguments='{"path": "test.txt"}'
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
tool_result_msg = LLMMessage(
|
||||
role=Role.tool,
|
||||
content="File content: This is a test file with some content.",
|
||||
name="read_file",
|
||||
tool_call_id="tool_call_1",
|
||||
)
|
||||
|
||||
self.agent_loop.messages.extend([user_msg, assistant_msg, tool_result_msg])
|
||||
|
||||
|
||||
def test_snapshot_shows_resumed_session_messages(snap_compare: SnapCompare) -> None:
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
# Wait for the app to initialize and rebuild history
|
||||
await pilot.pause(0.5)
|
||||
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_session_resume.py:SnapshotTestAppWithResumedSession",
|
||||
terminal_size=(120, 36),
|
||||
run_before=run_before,
|
||||
)
|
||||
52
tests/snapshots/test_ui_snapshot_whats_new.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
from textual.pilot import Pilot
|
||||
|
||||
from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp, default_config
|
||||
from tests.snapshots.snap_compare import SnapCompare
|
||||
from tests.update_notifier.adapters.fake_update_cache_repository import (
|
||||
FakeUpdateCacheRepository,
|
||||
)
|
||||
from tests.update_notifier.adapters.fake_update_gateway import FakeUpdateGateway
|
||||
from vibe.cli.update_notifier import UpdateCache
|
||||
|
||||
|
||||
class SnapshotTestAppWithWhatsNew(BaseSnapshotTestApp):
|
||||
def __init__(self):
|
||||
config = default_config()
|
||||
config.enable_update_checks = False
|
||||
update_notifier = FakeUpdateGateway(update=None)
|
||||
cache = UpdateCache(
|
||||
latest_version="1.0.0",
|
||||
stored_at_timestamp=int(time.time()),
|
||||
seen_whats_new_version=None,
|
||||
)
|
||||
update_cache_repository = FakeUpdateCacheRepository(update_cache=cache)
|
||||
super().__init__(
|
||||
config=config,
|
||||
update_notifier=update_notifier,
|
||||
update_cache_repository=update_cache_repository,
|
||||
current_version="1.0.0",
|
||||
)
|
||||
|
||||
|
||||
def test_snapshot_shows_whats_new_message(
|
||||
snap_compare: SnapCompare, tmp_path: Path
|
||||
) -> None:
|
||||
# Create whats_new.md file before the app starts
|
||||
whats_new_file = tmp_path / "whats_new.md"
|
||||
whats_new_file.write_text("# What's New\n\n- Feature 1\n- Feature 2\n- Feature 3")
|
||||
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
await pilot.pause(0.5)
|
||||
|
||||
with patch("vibe.cli.update_notifier.whats_new.VIBE_ROOT", tmp_path):
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_whats_new.py:SnapshotTestAppWithWhatsNew",
|
||||
terminal_size=(120, 36),
|
||||
run_before=run_before,
|
||||
)
|
||||
130
tests/stubs/fake_client.py
Normal file
@@ -0,0 +1,130 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from acp import (
|
||||
Agent as AcpAgent,
|
||||
Client,
|
||||
KillTerminalCommandResponse,
|
||||
ReadTextFileResponse,
|
||||
ReleaseTerminalResponse,
|
||||
RequestPermissionResponse,
|
||||
SessionNotification,
|
||||
TerminalHandle,
|
||||
TerminalOutputResponse,
|
||||
WaitForTerminalExitResponse,
|
||||
WriteTextFileResponse,
|
||||
)
|
||||
from acp.schema import (
|
||||
AgentMessageChunk,
|
||||
AgentPlanUpdate,
|
||||
AgentThoughtChunk,
|
||||
AvailableCommandsUpdate,
|
||||
CurrentModeUpdate,
|
||||
EnvVariable,
|
||||
PermissionOption,
|
||||
SessionInfoUpdate,
|
||||
ToolCallProgress,
|
||||
ToolCallStart,
|
||||
ToolCallUpdate,
|
||||
UserMessageChunk,
|
||||
)
|
||||
|
||||
|
||||
class FakeClient(Client):
|
||||
agent: AcpAgent
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._session_updates = []
|
||||
|
||||
async def session_update(
|
||||
self,
|
||||
session_id: str,
|
||||
update: UserMessageChunk
|
||||
| AgentMessageChunk
|
||||
| AgentThoughtChunk
|
||||
| ToolCallStart
|
||||
| ToolCallProgress
|
||||
| AgentPlanUpdate
|
||||
| AvailableCommandsUpdate
|
||||
| CurrentModeUpdate
|
||||
| SessionInfoUpdate,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
self._session_updates.append(
|
||||
SessionNotification(session_id=session_id, update=update)
|
||||
)
|
||||
|
||||
async def request_permission(
|
||||
self,
|
||||
options: list[PermissionOption],
|
||||
session_id: str,
|
||||
tool_call: ToolCallUpdate,
|
||||
**kwargs: Any,
|
||||
) -> RequestPermissionResponse:
|
||||
raise NotImplementedError()
|
||||
|
||||
async def read_text_file(
|
||||
self,
|
||||
path: str,
|
||||
session_id: str,
|
||||
limit: int | None = None,
|
||||
line: int | None = None,
|
||||
**kwargs: Any,
|
||||
) -> ReadTextFileResponse:
|
||||
raise NotImplementedError()
|
||||
|
||||
async def write_text_file(
|
||||
self, content: str, path: str, session_id: str, **kwargs: Any
|
||||
) -> WriteTextFileResponse | None:
|
||||
raise NotImplementedError()
|
||||
|
||||
async def create_terminal(
|
||||
self,
|
||||
command: str,
|
||||
session_id: str,
|
||||
args: list[str] | None = None,
|
||||
cwd: str | None = None,
|
||||
env: list[EnvVariable] | None = None,
|
||||
output_byte_limit: int | None = None,
|
||||
**kwargs: Any,
|
||||
) -> TerminalHandle:
|
||||
raise NotImplementedError()
|
||||
|
||||
async def terminal_output(
|
||||
self, session_id: str, terminal_id: str, **kwargs: Any
|
||||
) -> TerminalOutputResponse:
|
||||
raise NotImplementedError()
|
||||
|
||||
async def release_terminal(
|
||||
self, session_id: str, terminal_id: str, **kwargs: Any
|
||||
) -> ReleaseTerminalResponse | None:
|
||||
raise NotImplementedError()
|
||||
|
||||
async def wait_for_terminal_exit(
|
||||
self, session_id: str, terminal_id: str, **kwargs: Any
|
||||
) -> WaitForTerminalExitResponse:
|
||||
raise NotImplementedError()
|
||||
|
||||
async def kill_terminal(
|
||||
self, session_id: str, terminal_id: str, **kwargs: Any
|
||||
) -> KillTerminalCommandResponse | None:
|
||||
raise NotImplementedError()
|
||||
|
||||
async def ext_method(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
||||
raise NotImplementedError()
|
||||
|
||||
async def ext_notification(self, method: str, params: dict[str, Any]) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
async def close(self) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
def on_connect(self, conn: AcpAgent) -> None:
|
||||
self.agent = conn
|
||||
|
||||
async def __aenter__(self) -> FakeClient:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb) -> None:
|
||||
await self.close()
|
||||
@@ -1,86 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from acp import (
|
||||
Agent,
|
||||
AgentSideConnection,
|
||||
CreateTerminalRequest,
|
||||
KillTerminalCommandRequest,
|
||||
KillTerminalCommandResponse,
|
||||
ReadTextFileRequest,
|
||||
ReadTextFileResponse,
|
||||
ReleaseTerminalRequest,
|
||||
ReleaseTerminalResponse,
|
||||
RequestPermissionRequest,
|
||||
RequestPermissionResponse,
|
||||
SessionNotification,
|
||||
TerminalHandle,
|
||||
TerminalOutputRequest,
|
||||
TerminalOutputResponse,
|
||||
WaitForTerminalExitRequest,
|
||||
WaitForTerminalExitResponse,
|
||||
WriteTextFileRequest,
|
||||
WriteTextFileResponse,
|
||||
)
|
||||
|
||||
|
||||
class FakeAgentSideConnection(AgentSideConnection):
|
||||
def __init__(self, to_agent: Callable[[AgentSideConnection], Agent]) -> None:
|
||||
self._session_updates = []
|
||||
to_agent(self)
|
||||
|
||||
async def sessionUpdate(self, params: SessionNotification) -> None:
|
||||
self._session_updates.append(params)
|
||||
|
||||
async def requestPermission(
|
||||
self, params: RequestPermissionRequest
|
||||
) -> RequestPermissionResponse:
|
||||
raise NotImplementedError()
|
||||
|
||||
async def readTextFile(self, params: ReadTextFileRequest) -> ReadTextFileResponse:
|
||||
raise NotImplementedError()
|
||||
|
||||
async def writeTextFile(
|
||||
self, params: WriteTextFileRequest
|
||||
) -> WriteTextFileResponse | None:
|
||||
raise NotImplementedError()
|
||||
|
||||
async def createTerminal(self, params: CreateTerminalRequest) -> TerminalHandle:
|
||||
raise NotImplementedError()
|
||||
|
||||
async def terminalOutput(
|
||||
self, params: TerminalOutputRequest
|
||||
) -> TerminalOutputResponse:
|
||||
raise NotImplementedError()
|
||||
|
||||
async def releaseTerminal(
|
||||
self, params: ReleaseTerminalRequest
|
||||
) -> ReleaseTerminalResponse | None:
|
||||
raise NotImplementedError()
|
||||
|
||||
async def waitForTerminalExit(
|
||||
self, params: WaitForTerminalExitRequest
|
||||
) -> WaitForTerminalExitResponse:
|
||||
raise NotImplementedError()
|
||||
|
||||
async def killTerminal(
|
||||
self, params: KillTerminalCommandRequest
|
||||
) -> KillTerminalCommandResponse | None:
|
||||
raise NotImplementedError()
|
||||
|
||||
async def extMethod(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
||||
raise NotImplementedError()
|
||||
|
||||
async def extNotification(self, method: str, params: dict[str, Any]) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
async def close(self) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
async def __aenter__(self) -> AgentSideConnection:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb) -> None:
|
||||
await self.close()
|
||||
@@ -1,8 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from vibe.core.tools.base import BaseTool, BaseToolConfig, BaseToolState
|
||||
from vibe.core.tools.base import BaseTool, BaseToolConfig, BaseToolState, InvokeContext
|
||||
from vibe.core.types import ToolStreamEvent
|
||||
|
||||
|
||||
class FakeToolArgs(BaseModel):
|
||||
@@ -24,7 +27,9 @@ class FakeTool(BaseTool[FakeToolArgs, FakeToolResult, BaseToolConfig, FakeToolSt
|
||||
def get_name(cls) -> str:
|
||||
return "stub_tool"
|
||||
|
||||
async def run(self, args: FakeToolArgs) -> FakeToolResult:
|
||||
async def run(
|
||||
self, args: FakeToolArgs, ctx: InvokeContext | None = None
|
||||
) -> AsyncGenerator[ToolStreamEvent | FakeToolResult, None]:
|
||||
if self._exception_to_raise:
|
||||
raise self._exception_to_raise
|
||||
return FakeToolResult()
|
||||
yield FakeToolResult()
|
||||
|
||||
@@ -4,7 +4,7 @@ import pytest
|
||||
|
||||
from tests.mock.utils import mock_llm_chunk
|
||||
from tests.stubs.fake_backend import FakeBackend
|
||||
from vibe.core.agent import Agent
|
||||
from vibe.core.agent_loop import AgentLoop
|
||||
from vibe.core.config import SessionLoggingConfig, VibeConfig
|
||||
from vibe.core.types import (
|
||||
AssistantEvent,
|
||||
@@ -12,6 +12,7 @@ from vibe.core.types import (
|
||||
CompactStartEvent,
|
||||
LLMMessage,
|
||||
Role,
|
||||
UserMessageEvent,
|
||||
)
|
||||
|
||||
|
||||
@@ -29,18 +30,19 @@ async def test_auto_compact_triggers_and_batches_observer() -> None:
|
||||
cfg = VibeConfig(
|
||||
session_logging=SessionLoggingConfig(enabled=False), auto_compact_threshold=1
|
||||
)
|
||||
agent = Agent(cfg, message_observer=observer, backend=backend)
|
||||
agent = AgentLoop(cfg, message_observer=observer, backend=backend)
|
||||
agent.stats.context_tokens = 2
|
||||
|
||||
events = [ev async for ev in agent.act("Hello")]
|
||||
|
||||
assert len(events) == 3
|
||||
assert isinstance(events[0], CompactStartEvent)
|
||||
assert isinstance(events[1], CompactEndEvent)
|
||||
assert isinstance(events[2], AssistantEvent)
|
||||
start: CompactStartEvent = events[0]
|
||||
end: CompactEndEvent = events[1]
|
||||
final: AssistantEvent = events[2]
|
||||
assert len(events) == 4
|
||||
assert isinstance(events[0], UserMessageEvent)
|
||||
assert isinstance(events[1], CompactStartEvent)
|
||||
assert isinstance(events[2], CompactEndEvent)
|
||||
assert isinstance(events[3], AssistantEvent)
|
||||
start: CompactStartEvent = events[1]
|
||||
end: CompactEndEvent = events[2]
|
||||
final: AssistantEvent = events[3]
|
||||
assert start.current_context_tokens == 2
|
||||
assert start.threshold == 1
|
||||
assert end.old_context_tokens == 2
|
||||
@@ -49,8 +51,5 @@ async def test_auto_compact_triggers_and_batches_observer() -> None:
|
||||
|
||||
roles = [r for r, _ in observed]
|
||||
assert roles == [Role.system, Role.user, Role.assistant]
|
||||
assert (
|
||||
observed[1][1] is not None
|
||||
and "Last request from user was: Hello" in observed[1][1]
|
||||
)
|
||||
assert observed[1][1] is not None and "<summary>" in observed[1][1]
|
||||
assert observed[2][1] == "<final>"
|
||||
|
||||
@@ -4,7 +4,7 @@ import pytest
|
||||
|
||||
from tests.mock.utils import mock_llm_chunk
|
||||
from tests.stubs.fake_backend import FakeBackend
|
||||
from vibe.core.agent import Agent
|
||||
from vibe.core.agent_loop import AgentLoop
|
||||
from vibe.core.config import SessionLoggingConfig, VibeConfig
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ def vibe_config() -> VibeConfig:
|
||||
@pytest.mark.asyncio
|
||||
async def test_passes_x_affinity_header_when_asking_an_answer(vibe_config: VibeConfig):
|
||||
backend = FakeBackend([mock_llm_chunk(content="Response")])
|
||||
agent = Agent(vibe_config, backend=backend)
|
||||
agent = AgentLoop(vibe_config, backend=backend)
|
||||
|
||||
[_ async for _ in agent.act("Hello")]
|
||||
|
||||
@@ -32,7 +32,7 @@ async def test_passes_x_affinity_header_when_asking_an_answer_streaming(
|
||||
vibe_config: VibeConfig,
|
||||
):
|
||||
backend = FakeBackend([mock_llm_chunk(content="Response")])
|
||||
agent = Agent(vibe_config, backend=backend, enable_streaming=True)
|
||||
agent = AgentLoop(vibe_config, backend=backend, enable_streaming=True)
|
||||
|
||||
[_ async for _ in agent.act("Hello")]
|
||||
|
||||
@@ -47,7 +47,7 @@ async def test_passes_x_affinity_header_when_asking_an_answer_streaming(
|
||||
async def test_updates_tokens_stats_based_on_backend_response(vibe_config: VibeConfig):
|
||||
chunk = mock_llm_chunk(content="Response", prompt_tokens=100, completion_tokens=50)
|
||||
backend = FakeBackend([chunk])
|
||||
agent = Agent(vibe_config, backend=backend)
|
||||
agent = AgentLoop(vibe_config, backend=backend)
|
||||
|
||||
[_ async for _ in agent.act("Hello")]
|
||||
|
||||
@@ -62,7 +62,7 @@ async def test_updates_tokens_stats_based_on_backend_response_streaming(
|
||||
content="Complete", prompt_tokens=200, completion_tokens=75
|
||||
)
|
||||
backend = FakeBackend([final_chunk])
|
||||
agent = Agent(vibe_config, backend=backend, enable_streaming=True)
|
||||
agent = AgentLoop(vibe_config, backend=backend, enable_streaming=True)
|
||||
|
||||
[_ async for _ in agent.act("Hello")]
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ import pytest
|
||||
|
||||
from tests.mock.utils import mock_llm_chunk
|
||||
from tests.stubs.fake_backend import FakeBackend
|
||||
from vibe.core.agent import Agent
|
||||
from vibe.core.agent_loop import AgentLoop
|
||||
from vibe.core.agents.models import BuiltinAgentName
|
||||
from vibe.core.config import SessionLoggingConfig, VibeConfig
|
||||
from vibe.core.middleware import (
|
||||
ConversationContext,
|
||||
@@ -17,7 +18,6 @@ from vibe.core.middleware import (
|
||||
MiddlewareResult,
|
||||
ResetReason,
|
||||
)
|
||||
from vibe.core.modes import AgentMode
|
||||
from vibe.core.tools.base import BaseToolConfig, ToolPermission
|
||||
from vibe.core.tools.builtins.todo import TodoArgs
|
||||
from vibe.core.types import (
|
||||
@@ -30,17 +30,18 @@ from vibe.core.types import (
|
||||
ToolCall,
|
||||
ToolCallEvent,
|
||||
ToolResultEvent,
|
||||
UserMessageEvent,
|
||||
)
|
||||
from vibe.core.utils import CancellationReason, get_user_cancellation_message
|
||||
|
||||
|
||||
class InjectBeforeMiddleware:
|
||||
injectedMessage = "<injected>"
|
||||
injected_message = "<injected>"
|
||||
|
||||
async def before_turn(self, context: ConversationContext) -> MiddlewareResult:
|
||||
"Inject a message just before the current step executes."
|
||||
return MiddlewareResult(
|
||||
action=MiddlewareAction.INJECT_MESSAGE, message=self.injectedMessage
|
||||
action=MiddlewareAction.INJECT_MESSAGE, message=self.injected_message
|
||||
)
|
||||
|
||||
async def after_turn(self, context: ConversationContext) -> MiddlewareResult:
|
||||
@@ -89,7 +90,7 @@ async def test_act_flushes_batched_messages_with_injection_middleware(
|
||||
observed, observer = observer_capture
|
||||
|
||||
backend = FakeBackend([mock_llm_chunk(content="I can write very efficient code.")])
|
||||
agent = Agent(make_config(), message_observer=observer, backend=backend)
|
||||
agent = AgentLoop(make_config(), message_observer=observer, backend=backend)
|
||||
agent.middleware_pipeline.add(InjectBeforeMiddleware())
|
||||
|
||||
async for _ in agent.act("How can you help?"):
|
||||
@@ -101,7 +102,7 @@ async def test_act_flushes_batched_messages_with_injection_middleware(
|
||||
# injected content should be appended to the user's message before emission
|
||||
assert (
|
||||
observed[1][1]
|
||||
== f"How can you help?\n\n{InjectBeforeMiddleware.injectedMessage}"
|
||||
== f"How can you help?\n\n{InjectBeforeMiddleware.injected_message}"
|
||||
)
|
||||
assert observed[2][1] == "I can write very efficient code."
|
||||
|
||||
@@ -114,7 +115,7 @@ async def test_stop_action_flushes_user_msg_before_returning(observer_capture) -
|
||||
backend = FakeBackend([
|
||||
mock_llm_chunk(content="My response will never reach you...")
|
||||
])
|
||||
agent = Agent(
|
||||
agent = AgentLoop(
|
||||
make_config(), message_observer=observer, max_turns=0, backend=backend
|
||||
)
|
||||
|
||||
@@ -133,7 +134,7 @@ async def test_act_emits_user_and_assistant_msgs(observer_capture) -> None:
|
||||
observed, observer = observer_capture
|
||||
|
||||
backend = FakeBackend([mock_llm_chunk(content="Pong!")])
|
||||
agent = Agent(make_config(), message_observer=observer, backend=backend)
|
||||
agent = AgentLoop(make_config(), message_observer=observer, backend=backend)
|
||||
|
||||
async for _ in agent.act("Ping?"):
|
||||
pass
|
||||
@@ -155,12 +156,13 @@ async def test_act_streams_batched_chunks_in_order() -> None:
|
||||
mock_llm_chunk(content=" and"),
|
||||
mock_llm_chunk(content=" end"),
|
||||
])
|
||||
agent = Agent(make_config(), backend=backend, enable_streaming=True)
|
||||
agent = AgentLoop(make_config(), backend=backend, enable_streaming=True)
|
||||
|
||||
events = [event async for event in agent.act("Stream, please.")]
|
||||
|
||||
assert len(events) == 2
|
||||
assert [event.content for event in events if isinstance(event, AssistantEvent)] == [
|
||||
assistant_events = [e for e in events if isinstance(e, AssistantEvent)]
|
||||
assert len(assistant_events) == 2
|
||||
assert [event.content for event in assistant_events] == [
|
||||
"Hello from Vibe! More",
|
||||
" and end",
|
||||
]
|
||||
@@ -182,33 +184,35 @@ async def test_act_handles_streaming_with_tool_call_events_in_sequence() -> None
|
||||
],
|
||||
[mock_llm_chunk(content="Done reviewing todos.")],
|
||||
])
|
||||
agent = Agent(
|
||||
agent = AgentLoop(
|
||||
make_config(
|
||||
enabled_tools=["todo"],
|
||||
tools={"todo": BaseToolConfig(permission=ToolPermission.ALWAYS)},
|
||||
),
|
||||
backend=backend,
|
||||
mode=AgentMode.AUTO_APPROVE,
|
||||
agent_name=BuiltinAgentName.AUTO_APPROVE,
|
||||
enable_streaming=True,
|
||||
)
|
||||
|
||||
events = [event async for event in agent.act("What about my todos?")]
|
||||
|
||||
assert [type(event) for event in events] == [
|
||||
UserMessageEvent,
|
||||
AssistantEvent,
|
||||
ToolCallEvent,
|
||||
ToolResultEvent,
|
||||
AssistantEvent,
|
||||
]
|
||||
assert isinstance(events[0], AssistantEvent)
|
||||
assert events[0].content == "Checking your todos."
|
||||
assert isinstance(events[1], ToolCallEvent)
|
||||
assert events[1].tool_name == "todo"
|
||||
assert isinstance(events[2], ToolResultEvent)
|
||||
assert events[2].error is None
|
||||
assert events[2].skipped is False
|
||||
assert isinstance(events[3], AssistantEvent)
|
||||
assert events[3].content == "Done reviewing todos."
|
||||
assert isinstance(events[0], UserMessageEvent)
|
||||
assert isinstance(events[1], AssistantEvent)
|
||||
assert events[1].content == "Checking your todos."
|
||||
assert isinstance(events[2], ToolCallEvent)
|
||||
assert events[2].tool_name == "todo"
|
||||
assert isinstance(events[3], ToolResultEvent)
|
||||
assert events[3].error is None
|
||||
assert events[3].skipped is False
|
||||
assert isinstance(events[4], AssistantEvent)
|
||||
assert events[4].content == "Done reviewing todos."
|
||||
assert agent.messages[-1].content == "Done reviewing todos."
|
||||
|
||||
|
||||
@@ -224,25 +228,27 @@ async def test_act_handles_tool_call_chunk_with_content() -> None:
|
||||
mock_llm_chunk(content="todo request", tool_calls=[todo_tool_call]),
|
||||
mock_llm_chunk(content=" complete"),
|
||||
])
|
||||
agent = Agent(
|
||||
agent = AgentLoop(
|
||||
make_config(
|
||||
enabled_tools=["todo"],
|
||||
tools={"todo": BaseToolConfig(permission=ToolPermission.ALWAYS)},
|
||||
),
|
||||
backend=backend,
|
||||
mode=AgentMode.AUTO_APPROVE,
|
||||
agent_name=BuiltinAgentName.AUTO_APPROVE,
|
||||
enable_streaming=True,
|
||||
)
|
||||
|
||||
events = [event async for event in agent.act("Check todos with content.")]
|
||||
|
||||
assert [type(event) for event in events] == [
|
||||
UserMessageEvent,
|
||||
AssistantEvent,
|
||||
ToolCallEvent,
|
||||
ToolResultEvent,
|
||||
]
|
||||
assert isinstance(events[0], AssistantEvent)
|
||||
assert events[0].content == "Preparing todo request complete"
|
||||
assert isinstance(events[0], UserMessageEvent)
|
||||
assert isinstance(events[1], AssistantEvent)
|
||||
assert events[1].content == "Preparing todo request complete"
|
||||
assert any(
|
||||
m.role == Role.assistant and m.content == "Preparing todo request complete"
|
||||
for m in agent.messages
|
||||
@@ -266,31 +272,33 @@ async def test_act_merges_streamed_tool_call_arguments() -> None:
|
||||
mock_llm_chunk(content="", tool_calls=[tool_call_part_one]),
|
||||
mock_llm_chunk(content="", tool_calls=[tool_call_part_two]),
|
||||
])
|
||||
agent = Agent(
|
||||
agent = AgentLoop(
|
||||
make_config(
|
||||
enabled_tools=["todo"],
|
||||
tools={"todo": BaseToolConfig(permission=ToolPermission.ALWAYS)},
|
||||
),
|
||||
backend=backend,
|
||||
mode=AgentMode.AUTO_APPROVE,
|
||||
agent_name=BuiltinAgentName.AUTO_APPROVE,
|
||||
enable_streaming=True,
|
||||
)
|
||||
|
||||
events = [event async for event in agent.act("Merge streamed tool call args.")]
|
||||
|
||||
assert [type(event) for event in events] == [
|
||||
UserMessageEvent,
|
||||
AssistantEvent,
|
||||
ToolCallEvent,
|
||||
ToolResultEvent,
|
||||
]
|
||||
call_event = events[1]
|
||||
assert isinstance(events[0], UserMessageEvent)
|
||||
call_event = events[2]
|
||||
assert isinstance(call_event, ToolCallEvent)
|
||||
assert call_event.tool_call_id == "tc_merge"
|
||||
call_args = cast(TodoArgs, call_event.args)
|
||||
assert call_args.action == "read"
|
||||
assert isinstance(events[2], ToolResultEvent)
|
||||
assert events[2].error is None
|
||||
assert events[2].skipped is False
|
||||
assert isinstance(events[3], ToolResultEvent)
|
||||
assert events[3].error is None
|
||||
assert events[3].skipped is False
|
||||
assistant_with_calls = next(
|
||||
m for m in agent.messages if m.role == Role.assistant and m.tool_calls
|
||||
)
|
||||
@@ -328,13 +336,13 @@ async def test_act_handles_user_cancellation_during_streaming() -> None:
|
||||
mock_llm_chunk(content="Preparing "),
|
||||
mock_llm_chunk(content="todo request", tool_calls=[todo_tool_call]),
|
||||
])
|
||||
agent = Agent(
|
||||
agent = AgentLoop(
|
||||
make_config(
|
||||
enabled_tools=["todo"],
|
||||
tools={"todo": BaseToolConfig(permission=ToolPermission.ASK)},
|
||||
),
|
||||
backend=backend,
|
||||
mode=AgentMode.DEFAULT,
|
||||
agent_name=BuiltinAgentName.DEFAULT,
|
||||
enable_streaming=True,
|
||||
)
|
||||
middleware = CountingMiddleware()
|
||||
@@ -345,11 +353,12 @@ async def test_act_handles_user_cancellation_during_streaming() -> None:
|
||||
str(get_user_cancellation_message(CancellationReason.OPERATION_CANCELLED)),
|
||||
)
|
||||
)
|
||||
agent.interaction_logger.save_interaction = AsyncMock(return_value=None)
|
||||
agent.session_logger.save_interaction = AsyncMock(return_value=None)
|
||||
|
||||
events = [event async for event in agent.act("Cancel mid stream?")]
|
||||
|
||||
assert [type(event) for event in events] == [
|
||||
UserMessageEvent,
|
||||
AssistantEvent,
|
||||
ToolCallEvent,
|
||||
ToolResultEvent,
|
||||
@@ -360,23 +369,23 @@ async def test_act_handles_user_cancellation_during_streaming() -> None:
|
||||
assert events[-1].skipped is True
|
||||
assert events[-1].skip_reason is not None
|
||||
assert "<user_cancellation>" in events[-1].skip_reason
|
||||
assert agent.interaction_logger.save_interaction.await_count == 1
|
||||
assert agent.session_logger.save_interaction.await_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_act_flushes_and_logs_when_streaming_errors(observer_capture) -> None:
|
||||
observed, observer = observer_capture
|
||||
backend = FakeBackend(exception_to_raise=RuntimeError("boom in streaming"))
|
||||
agent = Agent(
|
||||
agent = AgentLoop(
|
||||
make_config(), backend=backend, message_observer=observer, enable_streaming=True
|
||||
)
|
||||
agent.interaction_logger.save_interaction = AsyncMock(return_value=None)
|
||||
agent.session_logger.save_interaction = AsyncMock(return_value=None)
|
||||
|
||||
with pytest.raises(RuntimeError, match="boom in streaming"):
|
||||
[_ async for _ in agent.act("Trigger stream failure")]
|
||||
|
||||
assert [role for role, _ in observed] == [Role.system, Role.user]
|
||||
assert agent.interaction_logger.save_interaction.await_count == 1
|
||||
assert agent.session_logger.save_interaction.await_count == 1
|
||||
|
||||
|
||||
def _snapshot_events(events: list) -> list[tuple[str, str]]:
|
||||
@@ -395,7 +404,7 @@ async def test_reasoning_buffer_yields_before_content_on_transition() -> None:
|
||||
mock_llm_chunk(content="", reasoning_content=" problem..."),
|
||||
mock_llm_chunk(content="The answer is 42."),
|
||||
])
|
||||
agent = Agent(make_config(), backend=backend, enable_streaming=True)
|
||||
agent = AgentLoop(make_config(), backend=backend, enable_streaming=True)
|
||||
|
||||
events = [event async for event in agent.act("What's the answer?")]
|
||||
|
||||
@@ -417,7 +426,7 @@ async def test_reasoning_buffer_yields_before_content_with_batching() -> None:
|
||||
mock_llm_chunk(content="", reasoning_content=", Final"),
|
||||
mock_llm_chunk(content="Done thinking!"),
|
||||
])
|
||||
agent = Agent(make_config(), backend=backend, enable_streaming=True)
|
||||
agent = AgentLoop(make_config(), backend=backend, enable_streaming=True)
|
||||
|
||||
events = [event async for event in agent.act("Think step by step")]
|
||||
|
||||
@@ -438,7 +447,7 @@ async def test_content_buffer_yields_before_reasoning_on_transition() -> None:
|
||||
mock_llm_chunk(content="", reasoning_content=" this approach..."),
|
||||
mock_llm_chunk(content="Actually, the final answer."),
|
||||
])
|
||||
agent = Agent(make_config(), backend=backend, enable_streaming=True)
|
||||
agent = AgentLoop(make_config(), backend=backend, enable_streaming=True)
|
||||
|
||||
events = [event async for event in agent.act("Give me an answer")]
|
||||
|
||||
@@ -459,7 +468,7 @@ async def test_interleaved_reasoning_content_preserves_order() -> None:
|
||||
mock_llm_chunk(content="", reasoning_content="Think 3"),
|
||||
mock_llm_chunk(content="Answer 3"),
|
||||
])
|
||||
agent = Agent(make_config(), backend=backend, enable_streaming=True)
|
||||
agent = AgentLoop(make_config(), backend=backend, enable_streaming=True)
|
||||
|
||||
events = [event async for event in agent.act("Interleaved test")]
|
||||
|
||||
@@ -483,7 +492,7 @@ async def test_only_reasoning_chunks_yields_reasoning_event() -> None:
|
||||
mock_llm_chunk(content="", reasoning_content="Just thinking..."),
|
||||
mock_llm_chunk(content="", reasoning_content=" nothing to say yet."),
|
||||
])
|
||||
agent = Agent(make_config(), backend=backend, enable_streaming=True)
|
||||
agent = AgentLoop(make_config(), backend=backend, enable_streaming=True)
|
||||
|
||||
events = [event async for event in agent.act("Silent thinking")]
|
||||
|
||||
@@ -498,7 +507,7 @@ async def test_final_buffers_flush_in_correct_order() -> None:
|
||||
mock_llm_chunk(content="", reasoning_content="Final thought"),
|
||||
mock_llm_chunk(content="Final words"),
|
||||
])
|
||||
agent = Agent(make_config(), backend=backend, enable_streaming=True)
|
||||
agent = AgentLoop(make_config(), backend=backend, enable_streaming=True)
|
||||
|
||||
events = [event async for event in agent.act("End buffers test")]
|
||||
|
||||
@@ -516,7 +525,7 @@ async def test_empty_content_chunks_do_not_trigger_false_yields() -> None:
|
||||
mock_llm_chunk(content="", reasoning_content=" more reasoning"),
|
||||
mock_llm_chunk(content="Actual content"),
|
||||
])
|
||||
agent = Agent(make_config(), backend=backend, enable_streaming=True)
|
||||
agent = AgentLoop(make_config(), backend=backend, enable_streaming=True)
|
||||
|
||||
events = [event async for event in agent.act("Empty content test")]
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ import pytest
|
||||
|
||||
from tests.mock.utils import mock_llm_chunk
|
||||
from tests.stubs.fake_backend import FakeBackend
|
||||
from vibe.core.agent import Agent
|
||||
from vibe.core.agent_loop import AgentLoop
|
||||
from vibe.core.agents.models import BuiltinAgentName
|
||||
from vibe.core.config import (
|
||||
Backend,
|
||||
ModelConfig,
|
||||
@@ -14,7 +15,6 @@ from vibe.core.config import (
|
||||
SessionLoggingConfig,
|
||||
VibeConfig,
|
||||
)
|
||||
from vibe.core.modes import AgentMode
|
||||
from vibe.core.tools.base import BaseToolConfig, ToolPermission
|
||||
from vibe.core.types import (
|
||||
AgentStats,
|
||||
@@ -25,6 +25,7 @@ from vibe.core.types import (
|
||||
LLMMessage,
|
||||
Role,
|
||||
ToolCall,
|
||||
UserMessageEvent,
|
||||
)
|
||||
|
||||
|
||||
@@ -160,7 +161,7 @@ class TestReloadPreservesStats:
|
||||
@pytest.mark.asyncio
|
||||
async def test_reload_preserves_session_tokens(self) -> None:
|
||||
backend = FakeBackend(mock_llm_chunk(content="First response"))
|
||||
agent = Agent(make_config(), backend=backend)
|
||||
agent = AgentLoop(make_config(), backend=backend)
|
||||
|
||||
async for _ in agent.act("Hello"):
|
||||
pass
|
||||
@@ -193,7 +194,9 @@ class TestReloadPreservesStats:
|
||||
mock_llm_chunk(content="Done"),
|
||||
])
|
||||
config = make_config(enabled_tools=["todo"])
|
||||
agent = Agent(config, mode=AgentMode.AUTO_APPROVE, backend=backend)
|
||||
agent = AgentLoop(
|
||||
config, agent_name=BuiltinAgentName.AUTO_APPROVE, backend=backend
|
||||
)
|
||||
|
||||
async for _ in agent.act("Check todos"):
|
||||
pass
|
||||
@@ -212,7 +215,7 @@ class TestReloadPreservesStats:
|
||||
[mock_llm_chunk(content="R1")],
|
||||
[mock_llm_chunk(content="R2")],
|
||||
])
|
||||
agent = Agent(make_config(), backend=backend)
|
||||
agent = AgentLoop(make_config(), backend=backend)
|
||||
|
||||
async for _ in agent.act("First"):
|
||||
pass
|
||||
@@ -231,7 +234,7 @@ class TestReloadPreservesStats:
|
||||
self,
|
||||
) -> None:
|
||||
backend = FakeBackend(mock_llm_chunk(content="Response"))
|
||||
agent = Agent(make_config(), backend=backend)
|
||||
agent = AgentLoop(make_config(), backend=backend)
|
||||
[_ async for _ in agent.act("Hello")]
|
||||
assert agent.stats.context_tokens > 0
|
||||
initial_context_tokens = agent.stats.context_tokens
|
||||
@@ -245,7 +248,7 @@ class TestReloadPreservesStats:
|
||||
@pytest.mark.asyncio
|
||||
async def test_reload_resets_context_tokens_when_no_messages(self) -> None:
|
||||
backend = FakeBackend([])
|
||||
agent = Agent(make_config(), backend=backend)
|
||||
agent = AgentLoop(make_config(), backend=backend)
|
||||
assert len(agent.messages) == 1
|
||||
assert agent.stats.context_tokens == 0
|
||||
|
||||
@@ -261,13 +264,13 @@ class TestReloadPreservesStats:
|
||||
backend = FakeBackend(mock_llm_chunk(content="Response"))
|
||||
config1 = make_config(system_prompt_id="tests")
|
||||
config2 = make_config(system_prompt_id="cli")
|
||||
agent = Agent(config1, backend=backend)
|
||||
agent = AgentLoop(config1, backend=backend)
|
||||
[_ async for _ in agent.act("Hello")]
|
||||
original_context_tokens = agent.stats.context_tokens
|
||||
assert original_context_tokens > 0
|
||||
assert len(agent.messages) > 1
|
||||
|
||||
await agent.reload_with_initial_messages(config=config2)
|
||||
await agent.reload_with_initial_messages(base_config=config2)
|
||||
|
||||
assert len(agent.messages) > 1
|
||||
assert agent.stats.context_tokens == original_context_tokens
|
||||
@@ -278,7 +281,7 @@ class TestReloadPreservesStats:
|
||||
|
||||
backend = FakeBackend(mock_llm_chunk(content="Response"))
|
||||
config_mistral = make_config(active_model="devstral-latest")
|
||||
agent = Agent(config_mistral, backend=backend)
|
||||
agent = AgentLoop(config_mistral, backend=backend)
|
||||
|
||||
async for _ in agent.act("Hello"):
|
||||
pass
|
||||
@@ -287,7 +290,7 @@ class TestReloadPreservesStats:
|
||||
assert agent.stats.output_price_per_million == 2.0
|
||||
|
||||
config_other = make_config(active_model="strawberry")
|
||||
await agent.reload_with_initial_messages(config=config_other)
|
||||
await agent.reload_with_initial_messages(base_config=config_other)
|
||||
|
||||
assert agent.stats.input_price_per_million == 2.5
|
||||
assert agent.stats.output_price_per_million == 10.0
|
||||
@@ -301,7 +304,7 @@ class TestReloadPreservesStats:
|
||||
[mock_llm_chunk(content="After reload")],
|
||||
])
|
||||
config1 = make_config(active_model="devstral-latest")
|
||||
agent = Agent(config1, backend=backend)
|
||||
agent = AgentLoop(config1, backend=backend)
|
||||
|
||||
async for _ in agent.act("Hello"):
|
||||
pass
|
||||
@@ -311,7 +314,7 @@ class TestReloadPreservesStats:
|
||||
)
|
||||
|
||||
config2 = make_config(active_model="strawberry")
|
||||
await agent.reload_with_initial_messages(config=config2)
|
||||
await agent.reload_with_initial_messages(base_config=config2)
|
||||
|
||||
async for _ in agent.act("Continue"):
|
||||
pass
|
||||
@@ -326,7 +329,7 @@ class TestReloadPreservesMessages:
|
||||
@pytest.mark.asyncio
|
||||
async def test_reload_preserves_conversation_messages(self) -> None:
|
||||
backend = FakeBackend(mock_llm_chunk(content="Response"))
|
||||
agent = Agent(make_config(), backend=backend)
|
||||
agent = AgentLoop(make_config(), backend=backend)
|
||||
|
||||
async for _ in agent.act("Hello"):
|
||||
pass
|
||||
@@ -348,7 +351,7 @@ class TestReloadPreservesMessages:
|
||||
async def test_reload_updates_system_prompt_preserves_rest(self) -> None:
|
||||
backend = FakeBackend(mock_llm_chunk(content="Response"))
|
||||
config1 = make_config(system_prompt_id="tests")
|
||||
agent = Agent(config1, backend=backend)
|
||||
agent = AgentLoop(config1, backend=backend)
|
||||
|
||||
async for _ in agent.act("Hello"):
|
||||
pass
|
||||
@@ -357,7 +360,7 @@ class TestReloadPreservesMessages:
|
||||
old_user = agent.messages[1].content
|
||||
|
||||
config2 = make_config(system_prompt_id="cli")
|
||||
await agent.reload_with_initial_messages(config=config2)
|
||||
await agent.reload_with_initial_messages(base_config=config2)
|
||||
|
||||
assert agent.messages[0].content != old_system
|
||||
assert agent.messages[1].content == old_user
|
||||
@@ -365,7 +368,7 @@ class TestReloadPreservesMessages:
|
||||
@pytest.mark.asyncio
|
||||
async def test_reload_with_no_messages_stays_empty(self) -> None:
|
||||
backend = FakeBackend([])
|
||||
agent = Agent(make_config(), backend=backend)
|
||||
agent = AgentLoop(make_config(), backend=backend)
|
||||
|
||||
assert len(agent.messages) == 1
|
||||
|
||||
@@ -380,7 +383,7 @@ class TestReloadPreservesMessages:
|
||||
) -> None:
|
||||
observed, observer = observer_capture
|
||||
backend = FakeBackend(mock_llm_chunk(content="Response"))
|
||||
agent = Agent(make_config(), message_observer=observer, backend=backend)
|
||||
agent = AgentLoop(make_config(), message_observer=observer, backend=backend)
|
||||
|
||||
async for _ in agent.act("Hello"):
|
||||
pass
|
||||
@@ -402,7 +405,7 @@ class TestCompactStatsHandling:
|
||||
[mock_llm_chunk(content="First response")],
|
||||
[mock_llm_chunk(content="<summary>")],
|
||||
])
|
||||
agent = Agent(make_config(), backend=backend)
|
||||
agent = AgentLoop(make_config(), backend=backend)
|
||||
|
||||
async for _ in agent.act("Build something"):
|
||||
pass
|
||||
@@ -424,7 +427,7 @@ class TestCompactStatsHandling:
|
||||
[mock_llm_chunk(content="Long response " * 100)],
|
||||
[mock_llm_chunk(content="<summary>")],
|
||||
])
|
||||
agent = Agent(make_config(), backend=backend)
|
||||
agent = AgentLoop(make_config(), backend=backend)
|
||||
|
||||
async for _ in agent.act("Do something complex"):
|
||||
pass
|
||||
@@ -456,7 +459,9 @@ class TestCompactStatsHandling:
|
||||
[mock_llm_chunk(content="<summary>")],
|
||||
])
|
||||
config = make_config(enabled_tools=["todo"])
|
||||
agent = Agent(config, mode=AgentMode.AUTO_APPROVE, backend=backend)
|
||||
agent = AgentLoop(
|
||||
config, agent_name=BuiltinAgentName.AUTO_APPROVE, backend=backend
|
||||
)
|
||||
|
||||
async for _ in agent.act("Check todos"):
|
||||
pass
|
||||
@@ -473,10 +478,10 @@ class TestCompactStatsHandling:
|
||||
[mock_llm_chunk(content="Long response " * 100)],
|
||||
[mock_llm_chunk(content="<summary>")],
|
||||
])
|
||||
agent = Agent(make_config(disable_logging=False), backend=backend)
|
||||
agent = AgentLoop(make_config(disable_logging=False), backend=backend)
|
||||
|
||||
original_session_id = agent.session_id
|
||||
original_logger_session_id = agent.interaction_logger.session_id
|
||||
original_logger_session_id = agent.session_logger.session_id
|
||||
|
||||
assert agent.session_id == original_logger_session_id
|
||||
|
||||
@@ -486,7 +491,7 @@ class TestCompactStatsHandling:
|
||||
await agent.compact()
|
||||
|
||||
assert agent.session_id != original_session_id
|
||||
assert agent.session_id == agent.interaction_logger.session_id
|
||||
assert agent.session_id == agent.session_logger.session_id
|
||||
|
||||
|
||||
class TestAutoCompactIntegration:
|
||||
@@ -505,19 +510,20 @@ class TestAutoCompactIntegration:
|
||||
session_logging=SessionLoggingConfig(enabled=False),
|
||||
auto_compact_threshold=1,
|
||||
)
|
||||
agent = Agent(cfg, message_observer=observer, backend=backend)
|
||||
agent = AgentLoop(cfg, message_observer=observer, backend=backend)
|
||||
agent.stats.context_tokens = 2
|
||||
|
||||
events = [ev async for ev in agent.act("Hello")]
|
||||
|
||||
assert len(events) == 3
|
||||
assert isinstance(events[0], CompactStartEvent)
|
||||
assert isinstance(events[1], CompactEndEvent)
|
||||
assert isinstance(events[2], AssistantEvent)
|
||||
assert len(events) == 4
|
||||
assert isinstance(events[0], UserMessageEvent)
|
||||
assert isinstance(events[1], CompactStartEvent)
|
||||
assert isinstance(events[2], CompactEndEvent)
|
||||
assert isinstance(events[3], AssistantEvent)
|
||||
|
||||
start: CompactStartEvent = events[0]
|
||||
end: CompactEndEvent = events[1]
|
||||
final: AssistantEvent = events[2]
|
||||
start: CompactStartEvent = events[1]
|
||||
end: CompactEndEvent = events[2]
|
||||
final: AssistantEvent = events[3]
|
||||
|
||||
assert start.current_context_tokens == 2
|
||||
assert start.threshold == 1
|
||||
@@ -527,17 +533,14 @@ class TestAutoCompactIntegration:
|
||||
|
||||
roles = [r for r, _ in observed]
|
||||
assert roles == [Role.system, Role.user, Role.assistant]
|
||||
assert (
|
||||
observed[1][1] is not None
|
||||
and "Last request from user was: Hello" in observed[1][1]
|
||||
)
|
||||
assert observed[1][1] is not None and "<summary>" in observed[1][1]
|
||||
|
||||
|
||||
class TestClearHistoryFullReset:
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_history_fully_resets_stats(self) -> None:
|
||||
backend = FakeBackend(mock_llm_chunk(content="Response"))
|
||||
agent = Agent(make_config(), backend=backend)
|
||||
agent = AgentLoop(make_config(), backend=backend)
|
||||
|
||||
async for _ in agent.act("Hello"):
|
||||
pass
|
||||
@@ -555,7 +558,7 @@ class TestClearHistoryFullReset:
|
||||
async def test_clear_history_preserves_pricing(self) -> None:
|
||||
backend = FakeBackend(mock_llm_chunk(content="Response"))
|
||||
config = make_config(input_price=0.4, output_price=2.0)
|
||||
agent = Agent(config, backend=backend)
|
||||
agent = AgentLoop(config, backend=backend)
|
||||
|
||||
async for _ in agent.act("Hello"):
|
||||
pass
|
||||
@@ -568,7 +571,7 @@ class TestClearHistoryFullReset:
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_history_removes_messages(self) -> None:
|
||||
backend = FakeBackend(mock_llm_chunk(content="Response"))
|
||||
agent = Agent(make_config(), backend=backend)
|
||||
agent = AgentLoop(make_config(), backend=backend)
|
||||
|
||||
async for _ in agent.act("Hello"):
|
||||
pass
|
||||
@@ -583,10 +586,10 @@ class TestClearHistoryFullReset:
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_history_resets_session_id(self) -> None:
|
||||
backend = FakeBackend(mock_llm_chunk(content="Response"))
|
||||
agent = Agent(make_config(disable_logging=False), backend=backend)
|
||||
agent = AgentLoop(make_config(disable_logging=False), backend=backend)
|
||||
|
||||
original_session_id = agent.session_id
|
||||
original_logger_session_id = agent.interaction_logger.session_id
|
||||
original_logger_session_id = agent.session_logger.session_id
|
||||
|
||||
assert agent.session_id == original_logger_session_id
|
||||
|
||||
@@ -596,7 +599,7 @@ class TestClearHistoryFullReset:
|
||||
await agent.clear_history()
|
||||
|
||||
assert agent.session_id != original_session_id
|
||||
assert agent.session_id == agent.interaction_logger.session_id
|
||||
assert agent.session_id == agent.session_logger.session_id
|
||||
|
||||
|
||||
class TestStatsEdgeCases:
|
||||
@@ -608,7 +611,7 @@ class TestStatsEdgeCases:
|
||||
|
||||
backend = FakeBackend(mock_llm_chunk(content="Response"))
|
||||
config1 = make_config(active_model="devstral-latest")
|
||||
agent = Agent(config1, backend=backend)
|
||||
agent = AgentLoop(config1, backend=backend)
|
||||
|
||||
async for _ in agent.act("Hello"):
|
||||
pass
|
||||
@@ -616,7 +619,7 @@ class TestStatsEdgeCases:
|
||||
cost_before = agent.stats.session_cost
|
||||
|
||||
config2 = make_config(active_model="strawberry")
|
||||
await agent.reload_with_initial_messages(config=config2)
|
||||
await agent.reload_with_initial_messages(base_config=config2)
|
||||
|
||||
cost_after = agent.stats.session_cost
|
||||
|
||||
@@ -629,7 +632,7 @@ class TestStatsEdgeCases:
|
||||
[mock_llm_chunk(content="R2")],
|
||||
[mock_llm_chunk(content="R3")],
|
||||
])
|
||||
agent = Agent(make_config(), backend=backend)
|
||||
agent = AgentLoop(make_config(), backend=backend)
|
||||
|
||||
async for _ in agent.act("First"):
|
||||
pass
|
||||
@@ -654,7 +657,7 @@ class TestStatsEdgeCases:
|
||||
[mock_llm_chunk(content="<summary>")],
|
||||
[mock_llm_chunk(content="After reload")],
|
||||
])
|
||||
agent = Agent(make_config(), backend=backend)
|
||||
agent = AgentLoop(make_config(), backend=backend)
|
||||
|
||||
async for _ in agent.act("Build something"):
|
||||
pass
|
||||
@@ -675,9 +678,9 @@ class TestStatsEdgeCases:
|
||||
async def test_reload_without_config_preserves_current(self) -> None:
|
||||
backend = FakeBackend([])
|
||||
original_config = make_config(active_model="devstral-latest")
|
||||
agent = Agent(original_config, backend=backend)
|
||||
agent = AgentLoop(original_config, backend=backend)
|
||||
|
||||
await agent.reload_with_initial_messages(config=None)
|
||||
await agent.reload_with_initial_messages(base_config=None)
|
||||
|
||||
assert agent.config.active_model == "devstral-latest"
|
||||
|
||||
@@ -685,9 +688,9 @@ class TestStatsEdgeCases:
|
||||
async def test_reload_with_new_config_updates_it(self) -> None:
|
||||
backend = FakeBackend([])
|
||||
original_config = make_config(active_model="devstral-latest")
|
||||
agent = Agent(original_config, backend=backend)
|
||||
agent = AgentLoop(original_config, backend=backend)
|
||||
|
||||
new_config = make_config(active_model="devstral-small")
|
||||
await agent.reload_with_initial_messages(config=new_config)
|
||||
await agent.reload_with_initial_messages(base_config=new_config)
|
||||
|
||||
assert agent.config.active_model == "devstral-small"
|
||||
|
||||
@@ -9,9 +9,9 @@ import pytest
|
||||
from tests.mock.utils import mock_llm_chunk
|
||||
from tests.stubs.fake_backend import FakeBackend
|
||||
from tests.stubs.fake_tool import FakeTool
|
||||
from vibe.core.agent import Agent
|
||||
from vibe.core.agent_loop import AgentLoop
|
||||
from vibe.core.agents.models import BuiltinAgentName
|
||||
from vibe.core.config import SessionLoggingConfig, VibeConfig
|
||||
from vibe.core.modes import AgentMode
|
||||
from vibe.core.tools.base import BaseToolConfig, ToolPermission
|
||||
from vibe.core.tools.builtins.todo import TodoItem
|
||||
from vibe.core.types import (
|
||||
@@ -25,11 +25,12 @@ from vibe.core.types import (
|
||||
ToolCall,
|
||||
ToolCallEvent,
|
||||
ToolResultEvent,
|
||||
UserMessageEvent,
|
||||
)
|
||||
|
||||
|
||||
async def act_and_collect_events(agent: Agent, prompt: str) -> list[BaseEvent]:
|
||||
return [ev async for ev in agent.act(prompt)]
|
||||
async def act_and_collect_events(agent_loop: AgentLoop, prompt: str) -> list[BaseEvent]:
|
||||
return [ev async for ev in agent_loop.act(prompt)]
|
||||
|
||||
|
||||
def make_config(todo_permission: ToolPermission = ToolPermission.ALWAYS) -> VibeConfig:
|
||||
@@ -53,20 +54,24 @@ def make_todo_tool_call(
|
||||
)
|
||||
|
||||
|
||||
def make_agent(
|
||||
def make_agent_loop(
|
||||
*,
|
||||
auto_approve: bool = True,
|
||||
todo_permission: ToolPermission = ToolPermission.ALWAYS,
|
||||
backend: FakeBackend,
|
||||
approval_callback: SyncApprovalCallback | None = None,
|
||||
) -> Agent:
|
||||
mode = AgentMode.AUTO_APPROVE if auto_approve else AgentMode.DEFAULT
|
||||
agent = Agent(
|
||||
make_config(todo_permission=todo_permission), mode=mode, backend=backend
|
||||
) -> AgentLoop:
|
||||
agent_name = (
|
||||
BuiltinAgentName.AUTO_APPROVE if auto_approve else BuiltinAgentName.DEFAULT
|
||||
)
|
||||
agent_loop = AgentLoop(
|
||||
make_config(todo_permission=todo_permission),
|
||||
agent_name=agent_name,
|
||||
backend=backend,
|
||||
)
|
||||
if approval_callback:
|
||||
agent.set_approval_callback(approval_callback)
|
||||
return agent
|
||||
agent_loop.set_approval_callback(approval_callback)
|
||||
return agent_loop
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -77,28 +82,30 @@ async def test_single_tool_call_executes_under_auto_approve() -> None:
|
||||
[mock_llm_chunk(content="Let me check your todos.", tool_calls=[tool_call])],
|
||||
[mock_llm_chunk(content="I retrieved 0 todos.")],
|
||||
])
|
||||
agent = make_agent(auto_approve=True, backend=backend)
|
||||
agent_loop = make_agent_loop(auto_approve=True, backend=backend)
|
||||
|
||||
events = await act_and_collect_events(agent, "What's my todo list?")
|
||||
events = await act_and_collect_events(agent_loop, "What's my todo list?")
|
||||
|
||||
assert [type(e) for e in events] == [
|
||||
UserMessageEvent,
|
||||
AssistantEvent,
|
||||
ToolCallEvent,
|
||||
ToolResultEvent,
|
||||
AssistantEvent,
|
||||
]
|
||||
assert isinstance(events[0], AssistantEvent)
|
||||
assert events[0].content == "Let me check your todos."
|
||||
assert isinstance(events[1], ToolCallEvent)
|
||||
assert events[1].tool_name == "todo"
|
||||
assert isinstance(events[2], ToolResultEvent)
|
||||
assert events[2].error is None
|
||||
assert events[2].skipped is False
|
||||
assert events[2].result is not None
|
||||
assert isinstance(events[3], AssistantEvent)
|
||||
assert events[3].content == "I retrieved 0 todos."
|
||||
assert isinstance(events[0], UserMessageEvent)
|
||||
assert isinstance(events[1], AssistantEvent)
|
||||
assert events[1].content == "Let me check your todos."
|
||||
assert isinstance(events[2], ToolCallEvent)
|
||||
assert events[2].tool_name == "todo"
|
||||
assert isinstance(events[3], ToolResultEvent)
|
||||
assert events[3].error is None
|
||||
assert events[3].skipped is False
|
||||
assert events[3].result is not None
|
||||
assert isinstance(events[4], AssistantEvent)
|
||||
assert events[4].content == "I retrieved 0 todos."
|
||||
# check conversation history
|
||||
tool_msgs = [m for m in agent.messages if m.role == Role.tool]
|
||||
tool_msgs = [m for m in agent_loop.messages if m.role == Role.tool]
|
||||
assert len(tool_msgs) == 1
|
||||
assert tool_msgs[-1].tool_call_id == mocked_tool_call_id
|
||||
assert "total_count" in (tool_msgs[-1].content or "")
|
||||
@@ -106,7 +113,7 @@ async def test_single_tool_call_executes_under_auto_approve() -> None:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_call_requires_approval_if_not_auto_approved() -> None:
|
||||
agent = make_agent(
|
||||
agent_loop = make_agent_loop(
|
||||
auto_approve=False,
|
||||
todo_permission=ToolPermission.ASK,
|
||||
backend=FakeBackend([
|
||||
@@ -120,21 +127,23 @@ async def test_tool_call_requires_approval_if_not_auto_approved() -> None:
|
||||
]),
|
||||
)
|
||||
|
||||
events = await act_and_collect_events(agent, "What's my todo list?")
|
||||
events = await act_and_collect_events(agent_loop, "What's my todo list?")
|
||||
|
||||
assert isinstance(events[1], ToolCallEvent)
|
||||
assert events[1].tool_name == "todo"
|
||||
assert isinstance(events[2], ToolResultEvent)
|
||||
assert events[2].skipped is True
|
||||
assert events[2].error is None
|
||||
assert events[2].result is None
|
||||
assert events[2].skip_reason is not None
|
||||
assert "not permitted" in events[2].skip_reason.lower()
|
||||
assert isinstance(events[3], AssistantEvent)
|
||||
assert events[3].content == "I cannot execute the tool without approval."
|
||||
assert agent.stats.tool_calls_rejected == 1
|
||||
assert agent.stats.tool_calls_agreed == 0
|
||||
assert agent.stats.tool_calls_succeeded == 0
|
||||
assert isinstance(events[0], UserMessageEvent)
|
||||
assert isinstance(events[1], AssistantEvent)
|
||||
assert isinstance(events[2], ToolCallEvent)
|
||||
assert events[2].tool_name == "todo"
|
||||
assert isinstance(events[3], ToolResultEvent)
|
||||
assert events[3].skipped is True
|
||||
assert events[3].error is None
|
||||
assert events[3].result is None
|
||||
assert events[3].skip_reason is not None
|
||||
assert "not permitted" in events[3].skip_reason.lower()
|
||||
assert isinstance(events[4], AssistantEvent)
|
||||
assert events[4].content == "I cannot execute the tool without approval."
|
||||
assert agent_loop.stats.tool_calls_rejected == 1
|
||||
assert agent_loop.stats.tool_calls_agreed == 0
|
||||
assert agent_loop.stats.tool_calls_succeeded == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -144,7 +153,7 @@ async def test_tool_call_approved_by_callback() -> None:
|
||||
) -> tuple[ApprovalResponse, str | None]:
|
||||
return (ApprovalResponse.YES, None)
|
||||
|
||||
agent = make_agent(
|
||||
agent_loop = make_agent_loop(
|
||||
auto_approve=False,
|
||||
todo_permission=ToolPermission.ASK,
|
||||
approval_callback=approval_callback,
|
||||
@@ -159,15 +168,16 @@ async def test_tool_call_approved_by_callback() -> None:
|
||||
]),
|
||||
)
|
||||
|
||||
events = await act_and_collect_events(agent, "What's my todo list?")
|
||||
events = await act_and_collect_events(agent_loop, "What's my todo list?")
|
||||
|
||||
assert isinstance(events[2], ToolResultEvent)
|
||||
assert events[2].skipped is False
|
||||
assert events[2].error is None
|
||||
assert events[2].result is not None
|
||||
assert agent.stats.tool_calls_agreed == 1
|
||||
assert agent.stats.tool_calls_rejected == 0
|
||||
assert agent.stats.tool_calls_succeeded == 1
|
||||
assert isinstance(events[0], UserMessageEvent)
|
||||
assert isinstance(events[3], ToolResultEvent)
|
||||
assert events[3].skipped is False
|
||||
assert events[3].error is None
|
||||
assert events[3].result is not None
|
||||
assert agent_loop.stats.tool_calls_agreed == 1
|
||||
assert agent_loop.stats.tool_calls_rejected == 0
|
||||
assert agent_loop.stats.tool_calls_succeeded == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -181,7 +191,7 @@ async def test_tool_call_rejected_when_auto_approve_disabled_and_rejected_by_cal
|
||||
) -> tuple[ApprovalResponse, str | None]:
|
||||
return (ApprovalResponse.NO, custom_feedback)
|
||||
|
||||
agent = make_agent(
|
||||
agent_loop = make_agent_loop(
|
||||
auto_approve=False,
|
||||
todo_permission=ToolPermission.ASK,
|
||||
approval_callback=approval_callback,
|
||||
@@ -196,21 +206,22 @@ async def test_tool_call_rejected_when_auto_approve_disabled_and_rejected_by_cal
|
||||
]),
|
||||
)
|
||||
|
||||
events = await act_and_collect_events(agent, "What's my todo list?")
|
||||
events = await act_and_collect_events(agent_loop, "What's my todo list?")
|
||||
|
||||
assert isinstance(events[2], ToolResultEvent)
|
||||
assert events[2].skipped is True
|
||||
assert events[2].error is None
|
||||
assert events[2].result is None
|
||||
assert events[2].skip_reason == custom_feedback
|
||||
assert agent.stats.tool_calls_rejected == 1
|
||||
assert agent.stats.tool_calls_agreed == 0
|
||||
assert agent.stats.tool_calls_succeeded == 0
|
||||
assert isinstance(events[0], UserMessageEvent)
|
||||
assert isinstance(events[3], ToolResultEvent)
|
||||
assert events[3].skipped is True
|
||||
assert events[3].error is None
|
||||
assert events[3].result is None
|
||||
assert events[3].skip_reason == custom_feedback
|
||||
assert agent_loop.stats.tool_calls_rejected == 1
|
||||
assert agent_loop.stats.tool_calls_agreed == 0
|
||||
assert agent_loop.stats.tool_calls_succeeded == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_call_skipped_when_permission_is_never() -> None:
|
||||
agent = make_agent(
|
||||
agent_loop = make_agent_loop(
|
||||
auto_approve=False,
|
||||
todo_permission=ToolPermission.NEVER,
|
||||
backend=FakeBackend([
|
||||
@@ -224,27 +235,30 @@ async def test_tool_call_skipped_when_permission_is_never() -> None:
|
||||
]),
|
||||
)
|
||||
|
||||
events = await act_and_collect_events(agent, "What's my todo list?")
|
||||
events = await act_and_collect_events(agent_loop, "What's my todo list?")
|
||||
|
||||
assert isinstance(events[2], ToolResultEvent)
|
||||
assert events[2].skipped is True
|
||||
assert events[2].error is None
|
||||
assert events[2].result is None
|
||||
assert events[2].skip_reason is not None
|
||||
assert "permanently disabled" in events[2].skip_reason.lower()
|
||||
tool_msgs = [m for m in agent.messages if m.role == Role.tool and m.name == "todo"]
|
||||
assert isinstance(events[0], UserMessageEvent)
|
||||
assert isinstance(events[3], ToolResultEvent)
|
||||
assert events[3].skipped is True
|
||||
assert events[3].error is None
|
||||
assert events[3].result is None
|
||||
assert events[3].skip_reason is not None
|
||||
assert "permanently disabled" in events[3].skip_reason.lower()
|
||||
tool_msgs = [
|
||||
m for m in agent_loop.messages if m.role == Role.tool and m.name == "todo"
|
||||
]
|
||||
assert len(tool_msgs) == 1
|
||||
assert tool_msgs[0].name == "todo"
|
||||
assert events[2].skip_reason in (tool_msgs[-1].content or "")
|
||||
assert agent.stats.tool_calls_rejected == 1
|
||||
assert agent.stats.tool_calls_agreed == 0
|
||||
assert agent.stats.tool_calls_succeeded == 0
|
||||
assert events[3].skip_reason in (tool_msgs[-1].content or "")
|
||||
assert agent_loop.stats.tool_calls_rejected == 1
|
||||
assert agent_loop.stats.tool_calls_agreed == 0
|
||||
assert agent_loop.stats.tool_calls_succeeded == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approval_always_sets_tool_permission_for_subsequent_calls() -> None:
|
||||
callback_invocations = []
|
||||
agent_ref: Agent | None = None
|
||||
agent_ref: AgentLoop | None = None
|
||||
|
||||
def approval_callback(
|
||||
tool_name: str, _args: BaseModel, _tool_call_id: str
|
||||
@@ -257,7 +271,7 @@ async def test_approval_always_sets_tool_permission_for_subsequent_calls() -> No
|
||||
agent_ref.config.tools[tool_name].permission = ToolPermission.ALWAYS
|
||||
return (ApprovalResponse.YES, None)
|
||||
|
||||
agent = make_agent(
|
||||
agent_loop = make_agent_loop(
|
||||
auto_approve=False,
|
||||
todo_permission=ToolPermission.ASK,
|
||||
approval_callback=approval_callback,
|
||||
@@ -278,32 +292,34 @@ async def test_approval_always_sets_tool_permission_for_subsequent_calls() -> No
|
||||
[mock_llm_chunk(content="Second done.")],
|
||||
]),
|
||||
)
|
||||
agent_ref = agent
|
||||
agent_ref = agent_loop
|
||||
|
||||
events1 = await act_and_collect_events(agent, "First request")
|
||||
events2 = await act_and_collect_events(agent, "Second request")
|
||||
events1 = await act_and_collect_events(agent_loop, "First request")
|
||||
events2 = await act_and_collect_events(agent_loop, "Second request")
|
||||
|
||||
tool_config_todo = agent.tool_manager.get_tool_config("todo")
|
||||
tool_config_todo = agent_loop.tool_manager.get_tool_config("todo")
|
||||
assert tool_config_todo.permission is ToolPermission.ALWAYS
|
||||
tool_config_help = agent.tool_manager.get_tool_config("bash")
|
||||
tool_config_help = agent_loop.tool_manager.get_tool_config("bash")
|
||||
assert tool_config_help.permission is not ToolPermission.ALWAYS
|
||||
assert agent.auto_approve is False
|
||||
assert agent_loop.auto_approve is False
|
||||
assert len(callback_invocations) == 1
|
||||
assert callback_invocations[0] == "todo"
|
||||
assert isinstance(events1[2], ToolResultEvent)
|
||||
assert events1[2].skipped is False
|
||||
assert events1[2].result is not None
|
||||
assert isinstance(events2[2], ToolResultEvent)
|
||||
assert events2[2].skipped is False
|
||||
assert events2[2].result is not None
|
||||
assert agent.stats.tool_calls_rejected == 0
|
||||
assert agent.stats.tool_calls_succeeded == 2
|
||||
assert isinstance(events1[0], UserMessageEvent)
|
||||
assert isinstance(events1[3], ToolResultEvent)
|
||||
assert events1[3].skipped is False
|
||||
assert events1[3].result is not None
|
||||
assert isinstance(events2[0], UserMessageEvent)
|
||||
assert isinstance(events2[3], ToolResultEvent)
|
||||
assert events2[3].skipped is False
|
||||
assert events2[3].result is not None
|
||||
assert agent_loop.stats.tool_calls_rejected == 0
|
||||
assert agent_loop.stats.tool_calls_succeeded == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_call_with_invalid_action() -> None:
|
||||
tool_call = make_todo_tool_call("call_5", arguments='{"action": "invalid_action"}')
|
||||
agent = make_agent(
|
||||
agent_loop = make_agent_loop(
|
||||
auto_approve=True,
|
||||
backend=FakeBackend([
|
||||
[
|
||||
@@ -315,13 +331,14 @@ async def test_tool_call_with_invalid_action() -> None:
|
||||
]),
|
||||
)
|
||||
|
||||
events = await act_and_collect_events(agent, "What's my todo list?")
|
||||
events = await act_and_collect_events(agent_loop, "What's my todo list?")
|
||||
|
||||
assert isinstance(events[2], ToolResultEvent)
|
||||
assert events[2].error is not None
|
||||
assert events[2].result is None
|
||||
assert "tool_error" in events[2].error.lower()
|
||||
assert agent.stats.tool_calls_failed == 1
|
||||
assert isinstance(events[0], UserMessageEvent)
|
||||
assert isinstance(events[3], ToolResultEvent)
|
||||
assert events[3].error is not None
|
||||
assert events[3].result is None
|
||||
assert "tool_error" in events[3].error.lower()
|
||||
assert agent_loop.stats.tool_calls_failed == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -337,7 +354,7 @@ async def test_tool_call_with_duplicate_todo_ids() -> None:
|
||||
"todos": [t.model_dump() for t in duplicate_todos],
|
||||
}),
|
||||
)
|
||||
agent = make_agent(
|
||||
agent_loop = make_agent_loop(
|
||||
auto_approve=True,
|
||||
backend=FakeBackend([
|
||||
[mock_llm_chunk(content="Let me write todos.", tool_calls=[tool_call])],
|
||||
@@ -345,13 +362,14 @@ async def test_tool_call_with_duplicate_todo_ids() -> None:
|
||||
]),
|
||||
)
|
||||
|
||||
events = await act_and_collect_events(agent, "Add todos")
|
||||
events = await act_and_collect_events(agent_loop, "Add todos")
|
||||
|
||||
assert isinstance(events[2], ToolResultEvent)
|
||||
assert events[2].error is not None
|
||||
assert events[2].result is None
|
||||
assert "unique" in events[2].error.lower()
|
||||
assert agent.stats.tool_calls_failed == 1
|
||||
assert isinstance(events[0], UserMessageEvent)
|
||||
assert isinstance(events[3], ToolResultEvent)
|
||||
assert events[3].error is not None
|
||||
assert events[3].result is None
|
||||
assert "unique" in events[3].error.lower()
|
||||
assert agent_loop.stats.tool_calls_failed == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -364,7 +382,7 @@ async def test_tool_call_with_exceeding_max_todos() -> None:
|
||||
"todos": [t.model_dump() for t in many_todos],
|
||||
}),
|
||||
)
|
||||
agent = make_agent(
|
||||
agent_loop = make_agent_loop(
|
||||
auto_approve=True,
|
||||
backend=FakeBackend([
|
||||
[mock_llm_chunk(content="Let me write todos.", tool_calls=[tool_call])],
|
||||
@@ -372,26 +390,23 @@ async def test_tool_call_with_exceeding_max_todos() -> None:
|
||||
]),
|
||||
)
|
||||
|
||||
events = await act_and_collect_events(agent, "Add todos")
|
||||
events = await act_and_collect_events(agent_loop, "Add todos")
|
||||
|
||||
assert isinstance(events[2], ToolResultEvent)
|
||||
assert events[2].error is not None
|
||||
assert events[2].result is None
|
||||
assert "100" in events[2].error
|
||||
assert agent.stats.tool_calls_failed == 1
|
||||
assert isinstance(events[0], UserMessageEvent)
|
||||
assert isinstance(events[3], ToolResultEvent)
|
||||
assert events[3].error is not None
|
||||
assert events[3].result is None
|
||||
assert "100" in events[3].error
|
||||
assert agent_loop.stats.tool_calls_failed == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"exception_class",
|
||||
[
|
||||
pytest.param(KeyboardInterrupt, id="keyboard_interrupt"),
|
||||
pytest.param(asyncio.CancelledError, id="asyncio_cancelled"),
|
||||
],
|
||||
)
|
||||
async def test_tool_call_can_be_interrupted(
|
||||
exception_class: type[BaseException],
|
||||
) -> None:
|
||||
async def test_tool_call_can_be_interrupted() -> None:
|
||||
"""Test that tool calls can be interrupted via asyncio.CancelledError.
|
||||
|
||||
Note: KeyboardInterrupt is no longer handled here as ctrl+C now quits the app directly.
|
||||
"""
|
||||
exception_class = asyncio.CancelledError
|
||||
tool_call = ToolCall(
|
||||
id="call_8", index=0, function=FunctionCall(name="stub_tool", arguments="{}")
|
||||
)
|
||||
@@ -400,23 +415,23 @@ async def test_tool_call_can_be_interrupted(
|
||||
auto_compact_threshold=0,
|
||||
enabled_tools=["stub_tool"],
|
||||
)
|
||||
agent = Agent(
|
||||
agent_loop = AgentLoop(
|
||||
config,
|
||||
mode=AgentMode.AUTO_APPROVE,
|
||||
agent_name=BuiltinAgentName.AUTO_APPROVE,
|
||||
backend=FakeBackend([
|
||||
[mock_llm_chunk(content="Let me use the tool.", tool_calls=[tool_call])],
|
||||
[mock_llm_chunk(content="Tool execution completed.")],
|
||||
]),
|
||||
)
|
||||
# no dependency injection available => monkey patch
|
||||
agent.tool_manager._available["stub_tool"] = FakeTool
|
||||
stub_tool_instance = agent.tool_manager.get("stub_tool")
|
||||
agent_loop.tool_manager._available["stub_tool"] = FakeTool
|
||||
stub_tool_instance = agent_loop.tool_manager.get("stub_tool")
|
||||
assert isinstance(stub_tool_instance, FakeTool)
|
||||
stub_tool_instance._exception_to_raise = exception_class()
|
||||
|
||||
events: list[BaseEvent] = []
|
||||
with pytest.raises(exception_class):
|
||||
async for ev in agent.act("Execute tool"):
|
||||
async for ev in agent_loop.act("Execute tool"):
|
||||
events.append(ev)
|
||||
|
||||
tool_result_event = next(
|
||||
@@ -429,9 +444,9 @@ async def test_tool_call_can_be_interrupted(
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fill_missing_tool_responses_inserts_placeholders() -> None:
|
||||
agent = Agent(
|
||||
agent_loop = AgentLoop(
|
||||
make_config(),
|
||||
mode=AgentMode.AUTO_APPROVE,
|
||||
agent_name=BuiltinAgentName.AUTO_APPROVE,
|
||||
backend=FakeBackend(mock_llm_chunk(content="ok")),
|
||||
)
|
||||
tool_calls_messages = [
|
||||
@@ -441,8 +456,8 @@ async def test_fill_missing_tool_responses_inserts_placeholders() -> None:
|
||||
assistant_msg = LLMMessage(
|
||||
role=Role.assistant, content="Calling tools...", tool_calls=tool_calls_messages
|
||||
)
|
||||
agent.messages = [
|
||||
agent.messages[0],
|
||||
agent_loop.messages = [
|
||||
agent_loop.messages[0],
|
||||
assistant_msg,
|
||||
# only one tool responded: the second is missing
|
||||
LLMMessage(
|
||||
@@ -450,9 +465,9 @@ async def test_fill_missing_tool_responses_inserts_placeholders() -> None:
|
||||
),
|
||||
]
|
||||
|
||||
await act_and_collect_events(agent, "Proceed")
|
||||
await act_and_collect_events(agent_loop, "Proceed")
|
||||
|
||||
tool_msgs = [m for m in agent.messages if m.role == Role.tool]
|
||||
tool_msgs = [m for m in agent_loop.messages if m.role == Role.tool]
|
||||
assert any(m.tool_call_id == "tc2" for m in tool_msgs)
|
||||
# find placeholder message for tc2
|
||||
placeholder = next(m for m in tool_msgs if m.tool_call_id == "tc2")
|
||||
@@ -465,19 +480,19 @@ async def test_fill_missing_tool_responses_inserts_placeholders() -> None:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ensure_assistant_after_tool_appends_understood() -> None:
|
||||
agent = Agent(
|
||||
agent_loop = AgentLoop(
|
||||
make_config(),
|
||||
mode=AgentMode.AUTO_APPROVE,
|
||||
agent_name=BuiltinAgentName.AUTO_APPROVE,
|
||||
backend=FakeBackend(mock_llm_chunk(content="ok")),
|
||||
)
|
||||
tool_msg = LLMMessage(
|
||||
role=Role.tool, tool_call_id="tc_z", name="todo", content="Done"
|
||||
)
|
||||
agent.messages = [agent.messages[0], tool_msg]
|
||||
agent_loop.messages = [agent_loop.messages[0], tool_msg]
|
||||
|
||||
await act_and_collect_events(agent, "Next")
|
||||
await act_and_collect_events(agent_loop, "Next")
|
||||
|
||||
# find the seeded tool message and ensure the next message is "Understood."
|
||||
idx = next(i for i, m in enumerate(agent.messages) if m.role == Role.tool)
|
||||
assert agent.messages[idx + 1].role == Role.assistant
|
||||
assert agent.messages[idx + 1].content == "Understood."
|
||||
idx = next(i for i, m in enumerate(agent_loop.messages) if m.role == Role.tool)
|
||||
assert agent_loop.messages[idx + 1].role == Role.assistant
|
||||
assert agent_loop.messages[idx + 1].content == "Understood."
|
||||
|
||||
531
tests/test_agents.py
Normal file
@@ -0,0 +1,531 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.mock.utils import mock_llm_chunk
|
||||
from tests.stubs.fake_backend import FakeBackend
|
||||
from vibe.core.agent_loop import AgentLoop
|
||||
from vibe.core.agents.manager import AgentManager
|
||||
from vibe.core.agents.models import (
|
||||
BUILTIN_AGENTS,
|
||||
PLAN_AGENT_TOOLS,
|
||||
AgentProfile,
|
||||
AgentSafety,
|
||||
AgentType,
|
||||
BuiltinAgentName,
|
||||
_deep_merge,
|
||||
)
|
||||
from vibe.core.config import SessionLoggingConfig, VibeConfig
|
||||
from vibe.core.tools.base import ToolPermission
|
||||
from vibe.core.types import (
|
||||
FunctionCall,
|
||||
LLMChunk,
|
||||
LLMMessage,
|
||||
LLMUsage,
|
||||
Role,
|
||||
ToolCall,
|
||||
ToolResultEvent,
|
||||
)
|
||||
|
||||
|
||||
class TestDeepMerge:
|
||||
def test_simple_merge(self) -> None:
|
||||
base = {"a": 1, "b": 2}
|
||||
override = {"c": 3}
|
||||
result = _deep_merge(base, override)
|
||||
assert result == {"a": 1, "b": 2, "c": 3}
|
||||
|
||||
def test_override_existing_key(self) -> None:
|
||||
base = {"a": 1, "b": 2}
|
||||
override = {"b": 3}
|
||||
result = _deep_merge(base, override)
|
||||
assert result == {"a": 1, "b": 3}
|
||||
|
||||
def test_nested_dict_merge(self) -> None:
|
||||
base = {"a": {"x": 1, "y": 2}}
|
||||
override = {"a": {"y": 3, "z": 4}}
|
||||
result = _deep_merge(base, override)
|
||||
assert result == {"a": {"x": 1, "y": 3, "z": 4}}
|
||||
|
||||
def test_deeply_nested_merge(self) -> None:
|
||||
base = {"a": {"b": {"c": 1}}}
|
||||
override = {"a": {"b": {"d": 2}}}
|
||||
result = _deep_merge(base, override)
|
||||
assert result == {"a": {"b": {"c": 1, "d": 2}}}
|
||||
|
||||
def test_override_dict_with_non_dict(self) -> None:
|
||||
base = {"a": {"x": 1}}
|
||||
override = {"a": "replaced"}
|
||||
result = _deep_merge(base, override)
|
||||
assert result == {"a": "replaced"}
|
||||
|
||||
def test_override_non_dict_with_dict(self) -> None:
|
||||
base = {"a": "string"}
|
||||
override = {"a": {"x": 1}}
|
||||
result = _deep_merge(base, override)
|
||||
assert result == {"a": {"x": 1}}
|
||||
|
||||
def test_preserves_original_base(self) -> None:
|
||||
base = {"a": 1, "b": {"c": 2}}
|
||||
override = {"b": {"d": 3}}
|
||||
_deep_merge(base, override)
|
||||
assert base == {"a": 1, "b": {"c": 2}}
|
||||
|
||||
def test_empty_override(self) -> None:
|
||||
base = {"a": 1, "b": 2}
|
||||
override: dict = {}
|
||||
result = _deep_merge(base, override)
|
||||
assert result == {"a": 1, "b": 2}
|
||||
|
||||
def test_empty_base(self) -> None:
|
||||
base: dict = {}
|
||||
override = {"a": 1}
|
||||
result = _deep_merge(base, override)
|
||||
assert result == {"a": 1}
|
||||
|
||||
def test_lists_are_overridden_not_merged(self) -> None:
|
||||
"""Lists should be replaced entirely, not merged element-by-element."""
|
||||
base = {"tools": ["read_file", "grep", "bash"]}
|
||||
override = {"tools": ["write_file"]}
|
||||
result = _deep_merge(base, override)
|
||||
assert result == {"tools": ["write_file"]}
|
||||
|
||||
def test_nested_lists_are_overridden_not_merged(self) -> None:
|
||||
"""Nested lists in dicts should also be replaced, not merged."""
|
||||
base = {"config": {"enabled_tools": ["a", "b", "c"], "other": 1}}
|
||||
override = {"config": {"enabled_tools": ["x", "y"]}}
|
||||
result = _deep_merge(base, override)
|
||||
assert result == {"config": {"enabled_tools": ["x", "y"], "other": 1}}
|
||||
|
||||
|
||||
class TestAgentSafety:
|
||||
def test_safety_enum_values(self) -> None:
|
||||
assert AgentSafety.SAFE == "safe"
|
||||
assert AgentSafety.NEUTRAL == "neutral"
|
||||
assert AgentSafety.DESTRUCTIVE == "destructive"
|
||||
assert AgentSafety.YOLO == "yolo"
|
||||
|
||||
def test_default_agent_is_neutral(self) -> None:
|
||||
assert BUILTIN_AGENTS[BuiltinAgentName.DEFAULT].safety == AgentSafety.NEUTRAL
|
||||
|
||||
def test_auto_approve_agent_is_yolo(self) -> None:
|
||||
assert BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE].safety == AgentSafety.YOLO
|
||||
|
||||
def test_plan_agent_is_safe(self) -> None:
|
||||
assert BUILTIN_AGENTS[BuiltinAgentName.PLAN].safety == AgentSafety.SAFE
|
||||
|
||||
def test_accept_edits_agent_is_destructive(self) -> None:
|
||||
assert (
|
||||
BUILTIN_AGENTS[BuiltinAgentName.ACCEPT_EDITS].safety
|
||||
== AgentSafety.DESTRUCTIVE
|
||||
)
|
||||
|
||||
|
||||
class TestAgentProfile:
|
||||
def test_all_builtin_agents_exist(self) -> None:
|
||||
assert set(BUILTIN_AGENTS.keys()) == set(BuiltinAgentName)
|
||||
|
||||
def test_display_name_property(self) -> None:
|
||||
assert BUILTIN_AGENTS[BuiltinAgentName.DEFAULT].display_name == "Default"
|
||||
assert (
|
||||
BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE].display_name == "Auto Approve"
|
||||
)
|
||||
assert BUILTIN_AGENTS[BuiltinAgentName.PLAN].display_name == "Plan"
|
||||
assert (
|
||||
BUILTIN_AGENTS[BuiltinAgentName.ACCEPT_EDITS].display_name == "Accept Edits"
|
||||
)
|
||||
|
||||
def test_description_property(self) -> None:
|
||||
assert (
|
||||
"approval" in BUILTIN_AGENTS[BuiltinAgentName.DEFAULT].description.lower()
|
||||
)
|
||||
assert (
|
||||
"auto" in BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE].description.lower()
|
||||
)
|
||||
assert "read-only" in BUILTIN_AGENTS[BuiltinAgentName.PLAN].description.lower()
|
||||
assert (
|
||||
"edits" in BUILTIN_AGENTS[BuiltinAgentName.ACCEPT_EDITS].description.lower()
|
||||
)
|
||||
|
||||
def test_explore_is_subagent(self) -> None:
|
||||
assert BUILTIN_AGENTS[BuiltinAgentName.EXPLORE].agent_type == AgentType.SUBAGENT
|
||||
|
||||
def test_agents(self) -> None:
|
||||
agents = [
|
||||
name
|
||||
for name, profile in BUILTIN_AGENTS.items()
|
||||
if profile.agent_type == AgentType.AGENT
|
||||
]
|
||||
assert set(agents) == {
|
||||
BuiltinAgentName.DEFAULT,
|
||||
BuiltinAgentName.PLAN,
|
||||
BuiltinAgentName.ACCEPT_EDITS,
|
||||
BuiltinAgentName.AUTO_APPROVE,
|
||||
}
|
||||
|
||||
|
||||
class TestAgentProfileOverrides:
|
||||
def test_default_agent_has_no_overrides(self) -> None:
|
||||
assert BUILTIN_AGENTS[BuiltinAgentName.DEFAULT].overrides == {}
|
||||
|
||||
def test_auto_approve_agent_sets_auto_approve(self) -> None:
|
||||
overrides = BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE].overrides
|
||||
assert overrides.get("auto_approve") is True
|
||||
|
||||
def test_plan_agent_restricts_tools(self) -> None:
|
||||
overrides = BUILTIN_AGENTS[BuiltinAgentName.PLAN].overrides
|
||||
assert "enabled_tools" in overrides
|
||||
assert overrides["enabled_tools"] == PLAN_AGENT_TOOLS
|
||||
|
||||
def test_accept_edits_agent_sets_tool_permissions(self) -> None:
|
||||
overrides = BUILTIN_AGENTS[BuiltinAgentName.ACCEPT_EDITS].overrides
|
||||
assert "tools" in overrides
|
||||
tools_config = overrides["tools"]
|
||||
assert "write_file" in tools_config
|
||||
assert "search_replace" in tools_config
|
||||
assert tools_config["write_file"]["permission"] == "always"
|
||||
assert tools_config["search_replace"]["permission"] == "always"
|
||||
|
||||
|
||||
class TestAgentManagerCycling:
|
||||
@pytest.fixture
|
||||
def base_config(self) -> VibeConfig:
|
||||
return VibeConfig(
|
||||
session_logging=SessionLoggingConfig(enabled=False),
|
||||
auto_compact_threshold=0,
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def backend(self) -> FakeBackend:
|
||||
return FakeBackend([
|
||||
LLMChunk(
|
||||
message=LLMMessage(role=Role.assistant, content="Test response"),
|
||||
usage=LLMUsage(prompt_tokens=10, completion_tokens=5),
|
||||
)
|
||||
])
|
||||
|
||||
def test_get_agent_order_includes_primary_agents(
|
||||
self, base_config: VibeConfig, backend: FakeBackend
|
||||
) -> None:
|
||||
agent = AgentLoop(
|
||||
base_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend
|
||||
)
|
||||
order = agent.agent_manager.get_agent_order()
|
||||
assert len(order) == 4
|
||||
assert BuiltinAgentName.DEFAULT in order
|
||||
assert BuiltinAgentName.AUTO_APPROVE in order
|
||||
assert BuiltinAgentName.PLAN in order
|
||||
assert BuiltinAgentName.ACCEPT_EDITS in order
|
||||
|
||||
def test_next_agent_cycles_through_all(
|
||||
self, base_config: VibeConfig, backend: FakeBackend
|
||||
) -> None:
|
||||
agent = AgentLoop(
|
||||
base_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend
|
||||
)
|
||||
order = agent.agent_manager.get_agent_order()
|
||||
current = agent.agent_manager.active_profile
|
||||
visited = [current.name]
|
||||
for _ in range(len(order) - 1):
|
||||
current = agent.agent_manager.next_agent(current)
|
||||
visited.append(current.name)
|
||||
assert len(set(visited)) == len(order)
|
||||
|
||||
def test_next_agent_wraps_around(
|
||||
self, base_config: VibeConfig, backend: FakeBackend
|
||||
) -> None:
|
||||
agent = AgentLoop(
|
||||
base_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend
|
||||
)
|
||||
order = agent.agent_manager.get_agent_order()
|
||||
last_profile = agent.agent_manager.get_agent(order[-1])
|
||||
first_profile = agent.agent_manager.get_agent(order[0])
|
||||
assert agent.agent_manager.next_agent(last_profile).name == first_profile.name
|
||||
|
||||
|
||||
class TestAgentProfileConfig:
|
||||
def test_agent_profile_frozen(self) -> None:
|
||||
profile = AgentProfile(
|
||||
name="test",
|
||||
display_name="Test",
|
||||
description="Test agent",
|
||||
safety=AgentSafety.NEUTRAL,
|
||||
)
|
||||
with pytest.raises(AttributeError):
|
||||
profile.name = "changed" # pyright: ignore[reportAttributeAccessIssue]
|
||||
|
||||
|
||||
class TestAgentSwitchAgent:
|
||||
@pytest.fixture
|
||||
def base_config(self) -> VibeConfig:
|
||||
return VibeConfig(
|
||||
session_logging=SessionLoggingConfig(enabled=False),
|
||||
auto_compact_threshold=0,
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def backend(self) -> FakeBackend:
|
||||
return FakeBackend([
|
||||
LLMChunk(
|
||||
message=LLMMessage(role=Role.assistant, content="Test response"),
|
||||
usage=LLMUsage(prompt_tokens=10, completion_tokens=5),
|
||||
)
|
||||
])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_switch_to_plan_agent_restricts_tools(
|
||||
self, base_config: VibeConfig, backend: FakeBackend
|
||||
) -> None:
|
||||
agent = AgentLoop(
|
||||
base_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend
|
||||
)
|
||||
initial_tool_names = set(agent.tool_manager.available_tools.keys())
|
||||
assert len(initial_tool_names) > len(PLAN_AGENT_TOOLS)
|
||||
|
||||
await agent.switch_agent(BuiltinAgentName.PLAN)
|
||||
|
||||
plan_tool_names = set(agent.tool_manager.available_tools.keys())
|
||||
assert plan_tool_names == set(PLAN_AGENT_TOOLS)
|
||||
assert agent.agent_profile.name == BuiltinAgentName.PLAN
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_switch_from_plan_to_default_restores_tools(
|
||||
self, base_config: VibeConfig, backend: FakeBackend
|
||||
) -> None:
|
||||
agent = AgentLoop(
|
||||
base_config, agent_name=BuiltinAgentName.PLAN, backend=backend
|
||||
)
|
||||
assert len(agent.tool_manager.available_tools) == len(PLAN_AGENT_TOOLS)
|
||||
|
||||
await agent.switch_agent(BuiltinAgentName.DEFAULT)
|
||||
|
||||
assert len(agent.tool_manager.available_tools) > len(PLAN_AGENT_TOOLS)
|
||||
assert agent.agent_profile.name == BuiltinAgentName.DEFAULT
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_switch_agent_preserves_conversation_history(
|
||||
self, base_config: VibeConfig, backend: FakeBackend
|
||||
) -> None:
|
||||
agent = AgentLoop(
|
||||
base_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend
|
||||
)
|
||||
user_msg = LLMMessage(role=Role.user, content="Hello")
|
||||
assistant_msg = LLMMessage(role=Role.assistant, content="Hi there")
|
||||
agent.messages.append(user_msg)
|
||||
agent.messages.append(assistant_msg)
|
||||
|
||||
await agent.switch_agent(BuiltinAgentName.PLAN)
|
||||
|
||||
assert len(agent.messages) == 3 # system + user + assistant
|
||||
assert agent.messages[1].content == "Hello"
|
||||
assert agent.messages[2].content == "Hi there"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_switch_to_same_agent_is_noop(
|
||||
self, base_config: VibeConfig, backend: FakeBackend
|
||||
) -> None:
|
||||
agent = AgentLoop(
|
||||
base_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend
|
||||
)
|
||||
original_config = agent.config
|
||||
|
||||
await agent.switch_agent(BuiltinAgentName.DEFAULT)
|
||||
|
||||
assert agent.config is original_config
|
||||
assert agent.agent_profile.name == BuiltinAgentName.DEFAULT
|
||||
|
||||
|
||||
class TestAcceptEditsAgent:
|
||||
def test_accept_edits_config_sets_write_file_always(self) -> None:
|
||||
overrides = BUILTIN_AGENTS[BuiltinAgentName.ACCEPT_EDITS].overrides
|
||||
assert overrides["tools"]["write_file"]["permission"] == "always"
|
||||
|
||||
def test_accept_edits_config_sets_search_replace_always(self) -> None:
|
||||
overrides = BUILTIN_AGENTS[BuiltinAgentName.ACCEPT_EDITS].overrides
|
||||
assert overrides["tools"]["search_replace"]["permission"] == "always"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_accept_edits_agent_auto_approves_write_file(self) -> None:
|
||||
backend = FakeBackend([])
|
||||
|
||||
config = VibeConfig(
|
||||
session_logging=SessionLoggingConfig(enabled=False),
|
||||
auto_compact_threshold=0,
|
||||
enabled_tools=["write_file"],
|
||||
)
|
||||
agent = AgentLoop(
|
||||
config, agent_name=BuiltinAgentName.ACCEPT_EDITS, backend=backend
|
||||
)
|
||||
|
||||
perm = agent.tool_manager.get_tool_config("write_file").permission
|
||||
assert perm == ToolPermission.ALWAYS
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_accept_edits_agent_requires_approval_for_other_tools(self) -> None:
|
||||
backend = FakeBackend([])
|
||||
|
||||
config = VibeConfig(
|
||||
session_logging=SessionLoggingConfig(enabled=False),
|
||||
auto_compact_threshold=0,
|
||||
enabled_tools=["bash"],
|
||||
)
|
||||
agent = AgentLoop(
|
||||
config, agent_name=BuiltinAgentName.ACCEPT_EDITS, backend=backend
|
||||
)
|
||||
|
||||
perm = agent.tool_manager.get_tool_config("bash").permission
|
||||
assert perm == ToolPermission.ASK
|
||||
|
||||
|
||||
class TestPlanAgentToolRestriction:
|
||||
@pytest.mark.asyncio
|
||||
async def test_plan_agent_only_exposes_read_tools_to_llm(self) -> None:
|
||||
backend = FakeBackend([
|
||||
LLMChunk(
|
||||
message=LLMMessage(role=Role.assistant, content="ok"),
|
||||
usage=LLMUsage(prompt_tokens=10, completion_tokens=5),
|
||||
)
|
||||
])
|
||||
config = VibeConfig(
|
||||
session_logging=SessionLoggingConfig(enabled=False),
|
||||
auto_compact_threshold=0,
|
||||
)
|
||||
agent = AgentLoop(config, agent_name=BuiltinAgentName.PLAN, backend=backend)
|
||||
|
||||
tool_names = set(agent.tool_manager.available_tools.keys())
|
||||
|
||||
assert "bash" not in tool_names
|
||||
assert "write_file" not in tool_names
|
||||
assert "search_replace" not in tool_names
|
||||
for plan_tool in PLAN_AGENT_TOOLS:
|
||||
assert plan_tool in tool_names
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plan_agent_rejects_non_plan_tool_call(self) -> None:
|
||||
tool_call = ToolCall(
|
||||
id="call_1",
|
||||
index=0,
|
||||
function=FunctionCall(name="bash", arguments='{"command": "ls"}'),
|
||||
)
|
||||
backend = FakeBackend([
|
||||
mock_llm_chunk(content="Let me run bash", tool_calls=[tool_call]),
|
||||
mock_llm_chunk(content="Tool not available"),
|
||||
])
|
||||
|
||||
config = VibeConfig(
|
||||
session_logging=SessionLoggingConfig(enabled=False),
|
||||
auto_compact_threshold=0,
|
||||
)
|
||||
agent = AgentLoop(config, agent_name=BuiltinAgentName.PLAN, backend=backend)
|
||||
|
||||
events = [ev async for ev in agent.act("Run ls")]
|
||||
|
||||
tool_result = next((e for e in events if isinstance(e, ToolResultEvent)), None)
|
||||
assert tool_result is not None
|
||||
assert tool_result.error is not None
|
||||
assert (
|
||||
"not found" in tool_result.error.lower()
|
||||
or "error" in tool_result.error.lower()
|
||||
)
|
||||
|
||||
|
||||
class TestAgentManagerFiltering:
|
||||
def test_enabled_agents_filters_to_only_enabled(self) -> None:
|
||||
config = VibeConfig(
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
enabled_agents=["default", "plan"],
|
||||
)
|
||||
manager = AgentManager(lambda: config)
|
||||
|
||||
agents = manager.available_agents
|
||||
assert len(agents) < len(manager._available)
|
||||
assert "default" in agents
|
||||
assert "plan" in agents
|
||||
assert "auto-approve" not in agents
|
||||
assert "accept-edits" not in agents
|
||||
|
||||
def test_disabled_agents_excludes_disabled(self) -> None:
|
||||
config = VibeConfig(
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
disabled_agents=["auto-approve", "accept-edits"],
|
||||
)
|
||||
manager = AgentManager(lambda: config)
|
||||
|
||||
agents = manager.available_agents
|
||||
assert len(agents) < len(manager._available)
|
||||
assert "default" in agents
|
||||
assert "plan" in agents
|
||||
assert "auto-approve" not in agents
|
||||
assert "accept-edits" not in agents
|
||||
|
||||
def test_enabled_agents_takes_precedence_over_disabled(self) -> None:
|
||||
config = VibeConfig(
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
enabled_agents=["default"],
|
||||
disabled_agents=["default"], # Should be ignored
|
||||
)
|
||||
manager = AgentManager(lambda: config)
|
||||
|
||||
agents = manager.available_agents
|
||||
assert len(agents) == 1
|
||||
assert "default" in agents
|
||||
|
||||
def test_glob_pattern_matching(self) -> None:
|
||||
config = VibeConfig(
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
disabled_agents=["auto-*", "accept-*"],
|
||||
)
|
||||
manager = AgentManager(lambda: config)
|
||||
|
||||
agents = manager.available_agents
|
||||
assert "default" in agents
|
||||
assert "plan" in agents
|
||||
assert "auto-approve" not in agents
|
||||
assert "accept-edits" not in agents
|
||||
|
||||
def test_regex_pattern_matching(self) -> None:
|
||||
config = VibeConfig(
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
enabled_agents=["re:^(default|plan)$"],
|
||||
)
|
||||
manager = AgentManager(lambda: config)
|
||||
|
||||
agents = manager.available_agents
|
||||
assert len(agents) == 2
|
||||
assert "default" in agents
|
||||
assert "plan" in agents
|
||||
|
||||
def test_empty_enabled_agents_returns_all(self) -> None:
|
||||
config = VibeConfig(
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
enabled_agents=[],
|
||||
)
|
||||
manager = AgentManager(lambda: config)
|
||||
|
||||
agents = manager.available_agents
|
||||
assert "default" in agents
|
||||
assert "plan" in agents
|
||||
assert "auto-approve" in agents
|
||||
assert "explore" in agents
|
||||
|
||||
def test_get_subagents_respects_filtering(self) -> None:
|
||||
config = VibeConfig(
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
disabled_agents=["explore"],
|
||||
)
|
||||
manager = AgentManager(lambda: config)
|
||||
|
||||
subagents = manager.get_subagents()
|
||||
names = [a.name for a in subagents]
|
||||
assert "explore" not in names
|
||||
162
tests/test_message_id.py
Normal file
@@ -0,0 +1,162 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from uuid import UUID
|
||||
|
||||
from vibe.core.types import (
|
||||
AssistantEvent,
|
||||
LLMMessage,
|
||||
ReasoningEvent,
|
||||
Role,
|
||||
UserMessageEvent,
|
||||
)
|
||||
|
||||
|
||||
class TestLLMMessageId:
|
||||
def test_user_message_gets_message_id(self) -> None:
|
||||
msg = LLMMessage(role=Role.user, content="Hello")
|
||||
assert msg.message_id is not None
|
||||
UUID(msg.message_id) # Validates it's a valid UUID
|
||||
|
||||
def test_assistant_message_gets_message_id(self) -> None:
|
||||
msg = LLMMessage(role=Role.assistant, content="Hi there")
|
||||
assert msg.message_id is not None
|
||||
UUID(msg.message_id)
|
||||
|
||||
def test_system_message_gets_message_id(self) -> None:
|
||||
msg = LLMMessage(role=Role.system, content="You are helpful")
|
||||
assert msg.message_id is not None
|
||||
UUID(msg.message_id)
|
||||
|
||||
def test_tool_message_does_not_get_message_id(self) -> None:
|
||||
msg = LLMMessage(role=Role.tool, content="result", tool_call_id="tc_123")
|
||||
assert msg.message_id is None
|
||||
|
||||
def test_each_message_gets_unique_id(self) -> None:
|
||||
msg1 = LLMMessage(role=Role.user, content="First")
|
||||
msg2 = LLMMessage(role=Role.user, content="Second")
|
||||
assert msg1.message_id != msg2.message_id
|
||||
|
||||
def test_message_id_preserved_from_dict(self) -> None:
|
||||
expected_id = "custom-message-id-123"
|
||||
msg = LLMMessage.model_validate({
|
||||
"role": "user",
|
||||
"content": "Hello",
|
||||
"message_id": expected_id,
|
||||
})
|
||||
assert msg.message_id == expected_id
|
||||
|
||||
def test_message_id_preserved_for_tool_from_dict(self) -> None:
|
||||
expected_id = "tool-message-id"
|
||||
msg = LLMMessage.model_validate({
|
||||
"role": "tool",
|
||||
"content": "result",
|
||||
"tool_call_id": "tc_123",
|
||||
"message_id": expected_id,
|
||||
})
|
||||
assert msg.message_id == expected_id
|
||||
|
||||
def test_tool_message_no_id_from_dict_without_id(self) -> None:
|
||||
msg = LLMMessage.model_validate({
|
||||
"role": "tool",
|
||||
"content": "result",
|
||||
"tool_call_id": "tc_123",
|
||||
})
|
||||
assert msg.message_id is None
|
||||
|
||||
|
||||
class TestLLMMessageAccumulation:
|
||||
def test_message_id_preserved_on_add(self) -> None:
|
||||
msg1 = LLMMessage(role=Role.assistant, content="Hello")
|
||||
msg2 = LLMMessage(role=Role.assistant, content=" world")
|
||||
|
||||
result = msg1 + msg2
|
||||
|
||||
assert result.message_id == msg1.message_id
|
||||
assert result.content == "Hello world"
|
||||
|
||||
def test_message_id_preserved_after_multiple_adds(self) -> None:
|
||||
msg1 = LLMMessage(role=Role.assistant, content="A")
|
||||
msg2 = LLMMessage(role=Role.assistant, content="B")
|
||||
msg3 = LLMMessage(role=Role.assistant, content="C")
|
||||
|
||||
result = msg1 + msg2 + msg3
|
||||
|
||||
assert result.message_id == msg1.message_id
|
||||
assert result.content == "ABC"
|
||||
|
||||
|
||||
class TestEventMessageId:
|
||||
def test_user_message_event_has_message_id(self) -> None:
|
||||
event = UserMessageEvent(content="Hello", message_id="user-msg-id")
|
||||
assert event.message_id == "user-msg-id"
|
||||
assert event.content == "Hello"
|
||||
|
||||
def test_assistant_event_has_message_id(self) -> None:
|
||||
event = AssistantEvent(content="test", message_id="test-id")
|
||||
assert event.message_id == "test-id"
|
||||
|
||||
def test_assistant_event_message_id_optional(self) -> None:
|
||||
event = AssistantEvent(content="test")
|
||||
assert event.message_id is None
|
||||
|
||||
def test_reasoning_event_has_message_id(self) -> None:
|
||||
event = ReasoningEvent(content="thinking...", message_id="reason-id")
|
||||
assert event.message_id == "reason-id"
|
||||
|
||||
def test_reasoning_event_message_id_optional(self) -> None:
|
||||
event = ReasoningEvent(content="thinking...")
|
||||
assert event.message_id is None
|
||||
|
||||
def test_assistant_event_add_preserves_message_id(self) -> None:
|
||||
event1 = AssistantEvent(content="Hello", message_id="first-id")
|
||||
event2 = AssistantEvent(content=" world", message_id="second-id")
|
||||
|
||||
result = event1 + event2
|
||||
|
||||
assert result.message_id == "first-id"
|
||||
assert result.content == "Hello world"
|
||||
|
||||
|
||||
class TestMessageIdExcludedFromAPI:
|
||||
def test_message_id_excluded_with_exclude_param(self) -> None:
|
||||
msg = LLMMessage(role=Role.user, content="Hello")
|
||||
dumped = msg.model_dump(exclude_none=True, exclude={"message_id"})
|
||||
|
||||
assert "message_id" not in dumped
|
||||
assert dumped["role"] == "user"
|
||||
assert dumped["content"] == "Hello"
|
||||
|
||||
def test_message_id_included_in_normal_dump(self) -> None:
|
||||
msg = LLMMessage(role=Role.user, content="Hello")
|
||||
dumped = msg.model_dump(exclude_none=True)
|
||||
|
||||
assert "message_id" in dumped
|
||||
assert dumped["message_id"] == msg.message_id
|
||||
|
||||
|
||||
class TestMessageIdInLogs:
|
||||
def test_message_id_in_json_dump(self) -> None:
|
||||
msg = LLMMessage(role=Role.assistant, content="Response")
|
||||
dumped = msg.model_dump(exclude_none=True)
|
||||
|
||||
json_str = json.dumps(dumped)
|
||||
loaded = json.loads(json_str)
|
||||
|
||||
assert "message_id" in loaded
|
||||
assert loaded["message_id"] == msg.message_id
|
||||
|
||||
def test_message_id_roundtrip(self) -> None:
|
||||
original = LLMMessage(role=Role.user, content="Test")
|
||||
original_id = original.message_id
|
||||
|
||||
dumped = original.model_dump(exclude_none=True)
|
||||
restored = LLMMessage.model_validate(dumped)
|
||||
|
||||
assert restored.message_id == original_id
|
||||
|
||||
def test_tool_message_id_none_in_json(self) -> None:
|
||||
msg = LLMMessage(role=Role.tool, content="result", tool_call_id="tc_1")
|
||||
dumped = msg.model_dump(exclude_none=True)
|
||||
|
||||
assert "message_id" not in dumped
|
||||