mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-02 21:50:52 +00:00
Merge branch 'main' into feat/token_display
This commit is contained in:
commit
b66b390d55
195 changed files with 23765 additions and 2508 deletions
|
|
@ -63,6 +63,7 @@ Creates a new query session with the Qwen Code.
|
||||||
| `permissionMode` | `'default' \| 'plan' \| 'auto-edit' \| 'yolo'` | `'default'` | Permission mode controlling tool execution approval. See [Permission Modes](#permission-modes) for details. |
|
| `permissionMode` | `'default' \| 'plan' \| 'auto-edit' \| 'yolo'` | `'default'` | Permission mode controlling tool execution approval. See [Permission Modes](#permission-modes) for details. |
|
||||||
| `canUseTool` | `CanUseTool` | - | Custom permission handler for tool execution approval. Invoked when a tool requires confirmation. Must respond within 60 seconds or the request will be auto-denied. See [Custom Permission Handler](#custom-permission-handler). |
|
| `canUseTool` | `CanUseTool` | - | Custom permission handler for tool execution approval. Invoked when a tool requires confirmation. Must respond within 60 seconds or the request will be auto-denied. See [Custom Permission Handler](#custom-permission-handler). |
|
||||||
| `env` | `Record<string, string>` | - | Environment variables to pass to the Qwen Code process. Merged with the current process environment. |
|
| `env` | `Record<string, string>` | - | Environment variables to pass to the Qwen Code process. Merged with the current process environment. |
|
||||||
|
| `systemPrompt` | `string \| QuerySystemPromptPreset` | - | System prompt configuration for the main session. Use a string to fully override the built-in Qwen Code system prompt, or a preset object to keep the built-in prompt and append extra instructions. |
|
||||||
| `mcpServers` | `Record<string, McpServerConfig>` | - | MCP (Model Context Protocol) servers to connect. Supports external servers (stdio/SSE/HTTP) and SDK-embedded servers. External servers are configured with transport options like `command`, `args`, `url`, `httpUrl`, etc. SDK servers use `{ type: 'sdk', name: string, instance: Server }`. |
|
| `mcpServers` | `Record<string, McpServerConfig>` | - | MCP (Model Context Protocol) servers to connect. Supports external servers (stdio/SSE/HTTP) and SDK-embedded servers. External servers are configured with transport options like `command`, `args`, `url`, `httpUrl`, etc. SDK servers use `{ type: 'sdk', name: string, instance: Server }`. |
|
||||||
| `abortController` | `AbortController` | - | Controller to cancel the query session. Call `abortController.abort()` to terminate the session and cleanup resources. |
|
| `abortController` | `AbortController` | - | Controller to cancel the query session. Call `abortController.abort()` to terminate the session and cleanup resources. |
|
||||||
| `debug` | `boolean` | `false` | Enable debug mode for verbose logging from the CLI process. |
|
| `debug` | `boolean` | `false` | Enable debug mode for verbose logging from the CLI process. |
|
||||||
|
|
@ -248,6 +249,36 @@ const result = query({
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Override the System Prompt
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { query } from '@qwen-code/sdk';
|
||||||
|
|
||||||
|
const result = query({
|
||||||
|
prompt: 'Say hello in one sentence.',
|
||||||
|
options: {
|
||||||
|
systemPrompt: 'You are a terse assistant. Answer in exactly one sentence.',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Append to the Built-in System Prompt
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { query } from '@qwen-code/sdk';
|
||||||
|
|
||||||
|
const result = query({
|
||||||
|
prompt: 'Review the current directory.',
|
||||||
|
options: {
|
||||||
|
systemPrompt: {
|
||||||
|
type: 'preset',
|
||||||
|
preset: 'qwen_code',
|
||||||
|
append: 'Be terse and focus on concrete findings.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
### With SDK-Embedded MCP Servers
|
### With SDK-Embedded MCP Servers
|
||||||
|
|
||||||
The SDK provides `tool` and `createSdkMcpServer` to create MCP servers that run in the same process as your SDK application. This is useful when you want to expose custom tools to the AI without running a separate server process.
|
The SDK provides `tool` and `createSdkMcpServer` to create MCP servers that run in the same process as your SDK application. This is useful when you want to expose custom tools to the AI without running a separate server process.
|
||||||
|
|
|
||||||
|
|
@ -412,6 +412,8 @@ Arguments passed directly when running the CLI can override other configurations
|
||||||
| `--model` | `-m` | Specifies the Qwen model to use for this session. | Model name | Example: `npm start -- --model qwen3-coder-plus` |
|
| `--model` | `-m` | Specifies the Qwen model to use for this session. | Model name | Example: `npm start -- --model qwen3-coder-plus` |
|
||||||
| `--prompt` | `-p` | Used to pass a prompt directly to the command. This invokes Qwen Code in a non-interactive mode. | Your prompt text | For scripting examples, use the `--output-format json` flag to get structured output. |
|
| `--prompt` | `-p` | Used to pass a prompt directly to the command. This invokes Qwen Code in a non-interactive mode. | Your prompt text | For scripting examples, use the `--output-format json` flag to get structured output. |
|
||||||
| `--prompt-interactive` | `-i` | Starts an interactive session with the provided prompt as the initial input. | Your prompt text | The prompt is processed within the interactive session, not before it. Cannot be used when piping input from stdin. Example: `qwen -i "explain this code"` |
|
| `--prompt-interactive` | `-i` | Starts an interactive session with the provided prompt as the initial input. | Your prompt text | The prompt is processed within the interactive session, not before it. Cannot be used when piping input from stdin. Example: `qwen -i "explain this code"` |
|
||||||
|
| `--system-prompt` | | Overrides the built-in main session system prompt for this run. | Your prompt text | Loaded context files such as `QWEN.md` are still appended after this override. Can be combined with `--append-system-prompt`. |
|
||||||
|
| `--append-system-prompt` | | Appends extra instructions to the main session system prompt for this run. | Your prompt text | Applied after the built-in prompt and loaded context files. Can be combined with `--system-prompt`. See [Headless Mode](../features/headless) for examples. |
|
||||||
| `--output-format` | `-o` | Specifies the format of the CLI output for non-interactive mode. | `text`, `json`, `stream-json` | `text`: (Default) The standard human-readable output. `json`: A machine-readable JSON output emitted at the end of execution. `stream-json`: Streaming JSON messages emitted as they occur during execution. For structured output and scripting, use the `--output-format json` or `--output-format stream-json` flag. See [Headless Mode](../features/headless) for detailed information. |
|
| `--output-format` | `-o` | Specifies the format of the CLI output for non-interactive mode. | `text`, `json`, `stream-json` | `text`: (Default) The standard human-readable output. `json`: A machine-readable JSON output emitted at the end of execution. `stream-json`: Streaming JSON messages emitted as they occur during execution. For structured output and scripting, use the `--output-format json` or `--output-format stream-json` flag. See [Headless Mode](../features/headless) for detailed information. |
|
||||||
| `--input-format` | | Specifies the format consumed from standard input. | `text`, `stream-json` | `text`: (Default) Standard text input from stdin or command-line arguments. `stream-json`: JSON message protocol via stdin for bidirectional communication. Requirement: `--input-format stream-json` requires `--output-format stream-json` to be set. When using `stream-json`, stdin is reserved for protocol messages. See [Headless Mode](../features/headless) for detailed information. |
|
| `--input-format` | | Specifies the format consumed from standard input. | `text`, `stream-json` | `text`: (Default) Standard text input from stdin or command-line arguments. `stream-json`: JSON message protocol via stdin for bidirectional communication. Requirement: `--input-format stream-json` requires `--output-format stream-json` to be set. When using `stream-json`, stdin is reserved for protocol messages. See [Headless Mode](../features/headless) for detailed information. |
|
||||||
| `--include-partial-messages` | | Include partial assistant messages when using `stream-json` output format. When enabled, emits stream events (message_start, content_block_delta, etc.) as they occur during streaming. | | Default: `false`. Requirement: Requires `--output-format stream-json` to be set. See [Headless Mode](../features/headless) for detailed information about stream events. |
|
| `--include-partial-messages` | | Include partial assistant messages when using `stream-json` output format. When enabled, emits stream events (message_start, content_block_delta, etc.) as they occur during streaming. | | Default: `false`. Requirement: Requires `--output-format stream-json` to be set. See [Headless Mode](../features/headless) for detailed information about stream events. |
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
export default {
|
export default {
|
||||||
commands: 'Commands',
|
commands: 'Commands',
|
||||||
'sub-agents': 'SubAgents',
|
'sub-agents': 'SubAgents',
|
||||||
|
arena: 'Agent Arena',
|
||||||
skills: 'Skills',
|
skills: 'Skills',
|
||||||
headless: 'Headless Mode',
|
headless: 'Headless Mode',
|
||||||
checkpointing: {
|
checkpointing: {
|
||||||
|
|
|
||||||
218
docs/users/features/arena.md
Normal file
218
docs/users/features/arena.md
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
# Agent Arena
|
||||||
|
|
||||||
|
> Dispatch multiple AI models simultaneously to execute the same task, compare their solutions side-by-side, and select the best result to apply to your workspace.
|
||||||
|
|
||||||
|
> [!warning]
|
||||||
|
> Agent Arena is experimental. It has [known limitations](#limitations) around display modes and session management.
|
||||||
|
|
||||||
|
Agent Arena lets you pit multiple AI models against each other on the same task. Each model runs as a fully independent agent in its own isolated Git worktree, so file operations never interfere. When all agents finish, you compare results and select a winner to merge back into your main workspace.
|
||||||
|
|
||||||
|
Unlike [subagents](/users/features/sub-agents), which delegate focused subtasks within a single session, Arena agents are complete, top-level agent instances — each with its own model, context window, and full tool access.
|
||||||
|
|
||||||
|
This page covers:
|
||||||
|
|
||||||
|
- [When to use Agent Arena](#when-to-use-agent-arena)
|
||||||
|
- [Starting an arena session](#start-an-arena-session)
|
||||||
|
- [Interacting with agents](#interact-with-agents), including display modes and navigation
|
||||||
|
- [Comparing results and selecting a winner](#compare-results-and-select-a-winner)
|
||||||
|
- [Best practices](#best-practices)
|
||||||
|
|
||||||
|
## When to use Agent Arena
|
||||||
|
|
||||||
|
Agent Arena is most effective when you want to **evaluate or compare** how different models tackle the same problem. The strongest use cases are:
|
||||||
|
|
||||||
|
- **Model benchmarking**: Evaluate different models' capabilities on real tasks in your actual codebase, not synthetic benchmarks
|
||||||
|
- **Best-of-N selection**: Get multiple independent solutions and pick the best implementation
|
||||||
|
- **Exploring approaches**: See how different models reason about and solve the same problem — useful for learning and insight
|
||||||
|
- **Risk reduction**: For critical changes, validate that multiple models converge on a similar approach before committing
|
||||||
|
|
||||||
|
Agent Arena uses significantly more tokens than a single session (each agent has its own context window and model calls). It works best when the value of comparison justifies the cost. For routine tasks where you trust your default model, a single session is more efficient.
|
||||||
|
|
||||||
|
## Start an arena session
|
||||||
|
|
||||||
|
Use the `/arena` slash command to launch a session. Specify the models you want to compete and the task:
|
||||||
|
|
||||||
|
```
|
||||||
|
/arena --models qwen3.5-plus,glm-5,kimi-k2.5 "Refactor the authentication module to use JWT tokens"
|
||||||
|
```
|
||||||
|
|
||||||
|
If you omit `--models`, an interactive model selection dialog appears, letting you pick from your configured providers.
|
||||||
|
|
||||||
|
### What happens when you start
|
||||||
|
|
||||||
|
1. **Worktree setup**: Qwen Code creates isolated Git worktrees for each agent at `~/.qwen/arena/<session-id>/worktrees/<model-name>/`. Each worktree mirrors your current working directory state exactly — including staged changes, unstaged changes, and untracked files.
|
||||||
|
2. **Agent spawning**: Each agent starts in its own worktree with full tool access and its configured model. Agents are launched sequentially but execute in parallel.
|
||||||
|
3. **Execution**: All agents work on the task independently with no shared state or communication. You can monitor their progress and interact with any of them.
|
||||||
|
4. **Completion**: When all agents finish (or fail), you enter the result comparison phase.
|
||||||
|
|
||||||
|
## Interact with agents
|
||||||
|
|
||||||
|
### Display modes
|
||||||
|
|
||||||
|
Agent Arena currently supports **in-process mode**, where all agents run asynchronously within the same terminal process. A tab bar at the bottom of the terminal lets you switch between agents.
|
||||||
|
|
||||||
|
> [!note]
|
||||||
|
> **Split-pane display modes are planned for the future.** We intend to support tmux-based and iTerm2-based split-pane layouts, where each agent gets its own terminal pane for true side-by-side viewing. Currently, only in-process tab switching is available.
|
||||||
|
|
||||||
|
### Navigate between agents
|
||||||
|
|
||||||
|
In in-process mode, use keyboard shortcuts to switch between agent views:
|
||||||
|
|
||||||
|
| Shortcut | Action |
|
||||||
|
| :------- | :-------------------------------- |
|
||||||
|
| `Right` | Switch to the next agent tab |
|
||||||
|
| `Left` | Switch to the previous agent tab |
|
||||||
|
| `Up` | Switch focus to the input box |
|
||||||
|
| `Down` | Switch focus to the agent tab bar |
|
||||||
|
|
||||||
|
The tab bar shows each agent's current status:
|
||||||
|
|
||||||
|
| Indicator | Meaning |
|
||||||
|
| :-------- | :--------------------- |
|
||||||
|
| `●` | Running or idle |
|
||||||
|
| `✓` | Completed successfully |
|
||||||
|
| `✗` | Failed |
|
||||||
|
| `○` | Cancelled |
|
||||||
|
|
||||||
|
### Interact with individual agents
|
||||||
|
|
||||||
|
When viewing an agent's tab, you can:
|
||||||
|
|
||||||
|
- **Send messages** — type in the input area to give the agent additional instructions
|
||||||
|
- **Approve tool calls** — if an agent requests tool approval, the confirmation dialog appears in its tab
|
||||||
|
- **View full history** — scroll through the agent's complete conversation, including model output, tool calls, and results
|
||||||
|
|
||||||
|
Each agent is a full, independent session. Anything you can do with the main agent, you can do with an arena agent.
|
||||||
|
|
||||||
|
## Compare results and select a winner
|
||||||
|
|
||||||
|
When all agents complete, the Arena enters the result comparison phase. You'll see:
|
||||||
|
|
||||||
|
- **Status summary**: Which agents succeeded, failed, or were cancelled
|
||||||
|
- **Execution metrics**: Duration, rounds of reasoning, token usage, and tool call counts for each agent
|
||||||
|
|
||||||
|
A selection dialog presents the successful agents. Choose one to apply its changes to your main workspace, or discard all results.
|
||||||
|
|
||||||
|
### What happens when you select a winner
|
||||||
|
|
||||||
|
1. The winning agent's changes are extracted as a diff against the baseline
|
||||||
|
2. The diff is applied to your main working directory
|
||||||
|
3. All worktrees and temporary branches are cleaned up automatically
|
||||||
|
|
||||||
|
If you want to inspect results before deciding, each agent's full conversation history is available via the tab bar while the selection dialog is active.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Arena behavior can be customized in [settings.json](/users/configuration/settings):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"arena": {
|
||||||
|
"worktreeBaseDir": "~/.qwen/arena",
|
||||||
|
"maxRoundsPerAgent": 50,
|
||||||
|
"timeoutSeconds": 600
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Setting | Description | Default |
|
||||||
|
| :------------------------ | :--------------------------------- | :-------------- |
|
||||||
|
| `arena.worktreeBaseDir` | Base directory for arena worktrees | `~/.qwen/arena` |
|
||||||
|
| `arena.maxRoundsPerAgent` | Maximum reasoning rounds per agent | `50` |
|
||||||
|
| `arena.timeoutSeconds` | Timeout for each agent in seconds | `600` |
|
||||||
|
|
||||||
|
## Best practices
|
||||||
|
|
||||||
|
### Choose models that complement each other
|
||||||
|
|
||||||
|
Arena is most valuable when you compare models with meaningfully different strengths. For example:
|
||||||
|
|
||||||
|
```
|
||||||
|
/arena --models qwen3.5-plus,glm-5,kimi-k2.5 "Optimize the database query layer"
|
||||||
|
```
|
||||||
|
|
||||||
|
Comparing three versions of the same model family yields less insight than comparing across providers.
|
||||||
|
|
||||||
|
### Keep tasks self-contained
|
||||||
|
|
||||||
|
Arena agents work independently with no communication. Tasks should be fully describable in the prompt without requiring back-and-forth:
|
||||||
|
|
||||||
|
**Good**: "Refactor the payment module to use the strategy pattern. Update all tests."
|
||||||
|
|
||||||
|
**Less effective**: "Let's discuss how to improve the payment module" — this benefits from conversation, which is better suited to a single session.
|
||||||
|
|
||||||
|
### Limit the number of agents
|
||||||
|
|
||||||
|
Up to 5 agents can run simultaneously. In practice, 2-3 agents provide the best balance of comparison value to resource cost. More agents means:
|
||||||
|
|
||||||
|
- Higher token costs (each agent has its own context window)
|
||||||
|
- Longer total execution time
|
||||||
|
- More results to compare
|
||||||
|
|
||||||
|
Start with 2-3 and scale up only when the comparison value justifies it.
|
||||||
|
|
||||||
|
### Use Arena for high-impact decisions
|
||||||
|
|
||||||
|
Arena shines when the stakes justify running multiple models:
|
||||||
|
|
||||||
|
- Choosing an architecture for a new module
|
||||||
|
- Selecting an approach for a complex refactor
|
||||||
|
- Validating a critical bug fix from multiple angles
|
||||||
|
|
||||||
|
For routine changes like renaming a variable or updating a config file, a single session is faster and cheaper.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Agents failing to start
|
||||||
|
|
||||||
|
- Verify that each model in `--models` is properly configured with valid API credentials
|
||||||
|
- Check that your working directory is a Git repository (worktrees require Git)
|
||||||
|
- Ensure you have write access to the worktree base directory (`~/.qwen/arena/` by default)
|
||||||
|
|
||||||
|
### Worktree creation fails
|
||||||
|
|
||||||
|
- Run `git worktree list` to check for stale worktrees from previous sessions
|
||||||
|
- Clean up stale worktrees with `git worktree prune`
|
||||||
|
- Ensure your Git version supports worktrees (`git --version`, requires Git 2.5+)
|
||||||
|
|
||||||
|
### Agent takes too long
|
||||||
|
|
||||||
|
- Increase the timeout: set `arena.timeoutSeconds` in settings
|
||||||
|
- Reduce task complexity — Arena tasks should be focused and well-defined
|
||||||
|
- Lower `arena.maxRoundsPerAgent` if agents are spending too many rounds
|
||||||
|
|
||||||
|
### Applying winner fails
|
||||||
|
|
||||||
|
- Check for uncommitted changes in your main working directory that might conflict
|
||||||
|
- The diff is applied as a patch — merge conflicts are possible if your working directory changed during the session
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
Agent Arena is experimental. Current limitations:
|
||||||
|
|
||||||
|
- **In-process mode only**: Split-pane display via tmux or iTerm2 is not yet available. All agents run within a single terminal window with tab switching.
|
||||||
|
- **No diff preview before selection**: You can view each agent's conversation history, but there is no unified diff viewer to compare solutions side-by-side before picking a winner.
|
||||||
|
- **No worktree retention**: Worktrees are always cleaned up after selection. There is no option to preserve them for further inspection.
|
||||||
|
- **No session resumption**: Arena sessions cannot be resumed after exiting. If you close the terminal mid-session, worktrees remain on disk and must be cleaned up manually via `git worktree prune`.
|
||||||
|
- **Maximum 5 agents**: The hard limit of 5 concurrent agents cannot be changed.
|
||||||
|
- **Git repository required**: Arena requires a Git repository for worktree isolation. It cannot be used in non-Git directories.
|
||||||
|
|
||||||
|
## Comparison with other multi-agent modes
|
||||||
|
|
||||||
|
Agent Arena is one of several planned multi-agent modes in Qwen Code. **Agent Team** and **Agent Swarm** are not yet implemented — the table below describes their intended design for reference.
|
||||||
|
|
||||||
|
| | **Agent Arena** | **Agent Team** (planned) | **Agent Swarm** (planned) |
|
||||||
|
| :---------------- | :----------------------------------------------------- | :------------------------------------------------- | :------------------------------------------------------- |
|
||||||
|
| **Goal** | Competitive: Find the best solution to the _same_ task | Collaborative: Tackle _different_ aspects together | Batch parallel: Dynamically spawn workers for bulk tasks |
|
||||||
|
| **Agents** | Pre-configured models compete independently | Teammates collaborate with assigned roles | Workers spawned on-the-fly, destroyed on completion |
|
||||||
|
| **Communication** | No inter-agent communication | Direct peer-to-peer messaging | One-way: results aggregated by parent |
|
||||||
|
| **Isolation** | Full: separate Git worktrees | Independent sessions with shared task list | Lightweight ephemeral context per worker |
|
||||||
|
| **Output** | One selected solution applied to workspace | Synthesized results from multiple perspectives | Aggregated results from parallel processing |
|
||||||
|
| **Best for** | Benchmarking, choosing between model approaches | Research, complex collaboration, cross-layer work | Batch operations, data processing, map-reduce tasks |
|
||||||
|
|
||||||
|
## Next steps
|
||||||
|
|
||||||
|
Explore related approaches for parallel and delegated work:
|
||||||
|
|
||||||
|
- **Lightweight delegation**: [Subagents](/users/features/sub-agents) handle focused subtasks within your session — better when you don't need model comparison
|
||||||
|
- **Manual parallel sessions**: Run multiple Qwen Code sessions yourself in separate terminals with [Git worktrees](https://git-scm.com/docs/git-worktree) for full manual control
|
||||||
|
|
@ -58,6 +58,40 @@ qwen --resume 123e4567-e89b-12d3-a456-426614174000 -p "Apply the follow-up refac
|
||||||
> - Session data is project-scoped JSONL under `~/.qwen/projects/<sanitized-cwd>/chats`.
|
> - Session data is project-scoped JSONL under `~/.qwen/projects/<sanitized-cwd>/chats`.
|
||||||
> - Restores conversation history, tool outputs, and chat-compression checkpoints before sending the new prompt.
|
> - Restores conversation history, tool outputs, and chat-compression checkpoints before sending the new prompt.
|
||||||
|
|
||||||
|
## Customize the Main Session Prompt
|
||||||
|
|
||||||
|
You can change the main session system prompt for a single CLI run without editing shared memory files.
|
||||||
|
|
||||||
|
### Override the Built-in System Prompt
|
||||||
|
|
||||||
|
Use `--system-prompt` to replace Qwen Code's built-in main-session prompt for the current run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
qwen -p "Review this patch" --system-prompt "You are a terse release reviewer. Report only blocking issues."
|
||||||
|
```
|
||||||
|
|
||||||
|
### Append Extra Instructions
|
||||||
|
|
||||||
|
Use `--append-system-prompt` to keep the built-in prompt and add extra instructions for this run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
qwen -p "Review this patch" --append-system-prompt "Be terse and focus on concrete findings."
|
||||||
|
```
|
||||||
|
|
||||||
|
You can combine both flags when you want a custom base prompt plus an extra run-specific instruction:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
qwen -p "Summarize this repository" \
|
||||||
|
--system-prompt "You are a migration planner." \
|
||||||
|
--append-system-prompt "Return exactly three bullets."
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!note]
|
||||||
|
>
|
||||||
|
> - `--system-prompt` applies only to the current run's main session.
|
||||||
|
> - Loaded memory and context files such as `QWEN.md` are still appended after `--system-prompt`.
|
||||||
|
> - `--append-system-prompt` is applied after the built-in prompt and loaded memory, and can be used together with `--system-prompt`.
|
||||||
|
|
||||||
## Output Formats
|
## Output Formats
|
||||||
|
|
||||||
Qwen Code supports multiple output formats for different use cases:
|
Qwen Code supports multiple output formats for different use cases:
|
||||||
|
|
@ -190,11 +224,13 @@ qwen -p "Write code" --output-format stream-json --include-partial-messages | jq
|
||||||
Key command-line options for headless usage:
|
Key command-line options for headless usage:
|
||||||
|
|
||||||
| Option | Description | Example |
|
| Option | Description | Example |
|
||||||
| ---------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------ |
|
| ---------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------ |
|
||||||
| `--prompt`, `-p` | Run in headless mode | `qwen -p "query"` |
|
| `--prompt`, `-p` | Run in headless mode | `qwen -p "query"` |
|
||||||
| `--output-format`, `-o` | Specify output format (text, json, stream-json) | `qwen -p "query" --output-format json` |
|
| `--output-format`, `-o` | Specify output format (text, json, stream-json) | `qwen -p "query" --output-format json` |
|
||||||
| `--input-format` | Specify input format (text, stream-json) | `qwen --input-format text --output-format stream-json` |
|
| `--input-format` | Specify input format (text, stream-json) | `qwen --input-format text --output-format stream-json` |
|
||||||
| `--include-partial-messages` | Include partial messages in stream-json output | `qwen -p "query" --output-format stream-json --include-partial-messages` |
|
| `--include-partial-messages` | Include partial messages in stream-json output | `qwen -p "query" --output-format stream-json --include-partial-messages` |
|
||||||
|
| `--system-prompt` | Override the main session system prompt for this run | `qwen -p "query" --system-prompt "You are a terse reviewer."` |
|
||||||
|
| `--append-system-prompt` | Append extra instructions to the main session system prompt for this run | `qwen -p "query" --append-system-prompt "Focus on concrete findings."` |
|
||||||
| `--debug`, `-d` | Enable debug mode | `qwen -p "query" --debug` |
|
| `--debug`, `-d` | Enable debug mode | `qwen -p "query" --debug` |
|
||||||
| `--all-files`, `-a` | Include all files in context | `qwen -p "query" --all-files` |
|
| `--all-files`, `-a` | Include all files in context | `qwen -p "query" --all-files` |
|
||||||
| `--include-directories` | Include additional directories | `qwen -p "query" --include-directories src,docs` |
|
| `--include-directories` | Include additional directories | `qwen -p "query" --include-directories src,docs` |
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ export default tseslint.config(
|
||||||
...importPlugin.configs.typescript.rules,
|
...importPlugin.configs.typescript.rules,
|
||||||
'import/no-default-export': 'warn',
|
'import/no-default-export': 'warn',
|
||||||
'import/no-unresolved': 'off', // Disable for now, can be noisy with monorepos/paths
|
'import/no-unresolved': 'off', // Disable for now, can be noisy with monorepos/paths
|
||||||
|
'import/namespace': 'off', // Disabled due to https://github.com/import-js/eslint-plugin-import/issues/2866
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
870
integration-tests/sdk-typescript/message-event-pairing.test.ts
Normal file
870
integration-tests/sdk-typescript/message-event-pairing.test.ts
Normal file
|
|
@ -0,0 +1,870 @@
|
||||||
|
/**
|
||||||
|
* E2E tests for message_start and message_stop event pairing
|
||||||
|
* Ensures that message_start and message_stop events are always paired correctly
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
query,
|
||||||
|
isSDKPartialAssistantMessage,
|
||||||
|
isSDKAssistantMessage,
|
||||||
|
type SDKPartialAssistantMessage,
|
||||||
|
type TextBlock,
|
||||||
|
} from '@qwen-code/sdk';
|
||||||
|
import { SDKTestHelper, createSharedTestOptions } from './test-helper.js';
|
||||||
|
|
||||||
|
const SHARED_TEST_OPTIONS = createSharedTestOptions();
|
||||||
|
|
||||||
|
describe('Message Start/Stop Event Pairing (E2E)', () => {
|
||||||
|
let helper: SDKTestHelper;
|
||||||
|
let testDir: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
helper = new SDKTestHelper();
|
||||||
|
testDir = await helper.setup('message-event-pairing');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await helper.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Basic Message Event Pairing', () => {
|
||||||
|
it('should emit paired message_start and message_stop for single turn', async () => {
|
||||||
|
const messageStartEvents: SDKPartialAssistantMessage[] = [];
|
||||||
|
const messageStopEvents: SDKPartialAssistantMessage[] = [];
|
||||||
|
|
||||||
|
const q = query({
|
||||||
|
prompt: 'Say hello',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
includePartialMessages: true,
|
||||||
|
cwd: testDir,
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
if (isSDKPartialAssistantMessage(message)) {
|
||||||
|
if (message.event.type === 'message_start') {
|
||||||
|
messageStartEvents.push(message);
|
||||||
|
} else if (message.event.type === 'message_stop') {
|
||||||
|
messageStopEvents.push(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify message_start and message_stop are paired
|
||||||
|
expect(messageStartEvents.length).toBeGreaterThan(0);
|
||||||
|
expect(messageStopEvents.length).toBe(messageStartEvents.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit message_start before message_stop', async () => {
|
||||||
|
const events: Array<{ type: string; timestamp: number }> = [];
|
||||||
|
|
||||||
|
const q = query({
|
||||||
|
prompt: 'Say hello world',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
includePartialMessages: true,
|
||||||
|
cwd: testDir,
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
if (isSDKPartialAssistantMessage(message)) {
|
||||||
|
if (
|
||||||
|
message.event.type === 'message_start' ||
|
||||||
|
message.event.type === 'message_stop'
|
||||||
|
) {
|
||||||
|
events.push({
|
||||||
|
type: message.event.type,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify message_start comes before message_stop
|
||||||
|
expect(events.length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(events[0].type).toBe('message_start');
|
||||||
|
expect(events[events.length - 1].type).toBe('message_stop');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have matching session_id for paired events', async () => {
|
||||||
|
const messageStartEvents: SDKPartialAssistantMessage[] = [];
|
||||||
|
const messageStopEvents: SDKPartialAssistantMessage[] = [];
|
||||||
|
|
||||||
|
const q = query({
|
||||||
|
prompt: 'Say hello',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
includePartialMessages: true,
|
||||||
|
cwd: testDir,
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
if (isSDKPartialAssistantMessage(message)) {
|
||||||
|
if (message.event.type === 'message_start') {
|
||||||
|
messageStartEvents.push(message);
|
||||||
|
} else if (message.event.type === 'message_stop') {
|
||||||
|
messageStopEvents.push(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify session_id matches between paired events
|
||||||
|
expect(messageStartEvents.length).toBeGreaterThan(0);
|
||||||
|
expect(messageStopEvents.length).toBe(messageStartEvents.length);
|
||||||
|
expect(messageStartEvents[0].session_id).toBe(
|
||||||
|
messageStopEvents[0].session_id,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Multi-turn Message Event Pairing', () => {
|
||||||
|
it('should emit paired events for each turn in multi-turn conversation', async () => {
|
||||||
|
const messageStartEvents: SDKPartialAssistantMessage[] = [];
|
||||||
|
const messageStopEvents: SDKPartialAssistantMessage[] = [];
|
||||||
|
const assistantMessages: string[] = [];
|
||||||
|
|
||||||
|
const sessionId = crypto.randomUUID();
|
||||||
|
|
||||||
|
const q = query({
|
||||||
|
prompt: (async function* () {
|
||||||
|
// First turn
|
||||||
|
yield {
|
||||||
|
type: 'user',
|
||||||
|
session_id: sessionId,
|
||||||
|
message: {
|
||||||
|
role: 'user',
|
||||||
|
content: 'Say "first"',
|
||||||
|
},
|
||||||
|
parent_tool_use_id: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Wait a bit for processing
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
// Second turn
|
||||||
|
yield {
|
||||||
|
type: 'user',
|
||||||
|
session_id: sessionId,
|
||||||
|
message: {
|
||||||
|
role: 'user',
|
||||||
|
content: 'Say "second"',
|
||||||
|
},
|
||||||
|
parent_tool_use_id: null,
|
||||||
|
};
|
||||||
|
})(),
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
includePartialMessages: true,
|
||||||
|
cwd: testDir,
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
if (isSDKPartialAssistantMessage(message)) {
|
||||||
|
if (message.event.type === 'message_start') {
|
||||||
|
messageStartEvents.push(message);
|
||||||
|
} else if (message.event.type === 'message_stop') {
|
||||||
|
messageStopEvents.push(message);
|
||||||
|
}
|
||||||
|
} else if (isSDKAssistantMessage(message)) {
|
||||||
|
const text = message.message.content
|
||||||
|
.filter((block): block is TextBlock => block.type === 'text')
|
||||||
|
.map((block) => block.text)
|
||||||
|
.join('');
|
||||||
|
assistantMessages.push(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify we have paired events for each assistant message
|
||||||
|
expect(messageStartEvents.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(messageStopEvents.length).toBe(messageStartEvents.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Message Event Pairing with Tool Calls', () => {
|
||||||
|
it('should emit paired events when tool is used', async () => {
|
||||||
|
await helper.createFile('test.txt', 'Hello World');
|
||||||
|
|
||||||
|
const messageStartEvents: SDKPartialAssistantMessage[] = [];
|
||||||
|
const messageStopEvents: SDKPartialAssistantMessage[] = [];
|
||||||
|
|
||||||
|
const q = query({
|
||||||
|
prompt: 'Read the content of test.txt',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
includePartialMessages: true,
|
||||||
|
cwd: testDir,
|
||||||
|
coreTools: ['read_file'],
|
||||||
|
permissionMode: 'default',
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
if (isSDKPartialAssistantMessage(message)) {
|
||||||
|
if (message.event.type === 'message_start') {
|
||||||
|
messageStartEvents.push(message);
|
||||||
|
} else if (message.event.type === 'message_stop') {
|
||||||
|
messageStopEvents.push(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify message_start and message_stop are paired even with tool usage
|
||||||
|
expect(messageStartEvents.length).toBeGreaterThan(0);
|
||||||
|
expect(messageStopEvents.length).toBe(messageStartEvents.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should maintain event pairing through multiple tool calls', async () => {
|
||||||
|
await helper.createFile('file1.txt', 'Content 1');
|
||||||
|
await helper.createFile('file2.txt', 'Content 2');
|
||||||
|
|
||||||
|
const messageStartEvents: SDKPartialAssistantMessage[] = [];
|
||||||
|
const messageStopEvents: SDKPartialAssistantMessage[] = [];
|
||||||
|
|
||||||
|
const q = query({
|
||||||
|
prompt: 'Read file1.txt and file2.txt and summarize their contents',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
includePartialMessages: true,
|
||||||
|
cwd: testDir,
|
||||||
|
coreTools: ['read_file'],
|
||||||
|
permissionMode: 'default',
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
if (isSDKPartialAssistantMessage(message)) {
|
||||||
|
if (message.event.type === 'message_start') {
|
||||||
|
messageStartEvents.push(message);
|
||||||
|
} else if (message.event.type === 'message_stop') {
|
||||||
|
messageStopEvents.push(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify events are paired
|
||||||
|
expect(messageStartEvents.length).toBeGreaterThan(0);
|
||||||
|
expect(messageStopEvents.length).toBe(messageStartEvents.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Message Event Structure Validation', () => {
|
||||||
|
it('should have correct message_start event structure', async () => {
|
||||||
|
const messageStartEvents: SDKPartialAssistantMessage[] = [];
|
||||||
|
|
||||||
|
const q = query({
|
||||||
|
prompt: 'Say hello',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
includePartialMessages: true,
|
||||||
|
cwd: testDir,
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
if (
|
||||||
|
isSDKPartialAssistantMessage(message) &&
|
||||||
|
message.event.type === 'message_start'
|
||||||
|
) {
|
||||||
|
messageStartEvents.push(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(messageStartEvents.length).toBeGreaterThan(0);
|
||||||
|
const startEvent = messageStartEvents[0].event;
|
||||||
|
expect(startEvent.type).toBe('message_start');
|
||||||
|
if (startEvent.type === 'message_start') {
|
||||||
|
expect(startEvent.message).toBeDefined();
|
||||||
|
expect(startEvent.message.id).toBeDefined();
|
||||||
|
expect(startEvent.message.role).toBe('assistant');
|
||||||
|
expect(startEvent.message.model).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have correct message_stop event structure', async () => {
|
||||||
|
const messageStopEvents: SDKPartialAssistantMessage[] = [];
|
||||||
|
|
||||||
|
const q = query({
|
||||||
|
prompt: 'Say hello',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
includePartialMessages: true,
|
||||||
|
cwd: testDir,
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
if (
|
||||||
|
isSDKPartialAssistantMessage(message) &&
|
||||||
|
message.event.type === 'message_stop'
|
||||||
|
) {
|
||||||
|
messageStopEvents.push(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(messageStopEvents.length).toBeGreaterThan(0);
|
||||||
|
const event = messageStopEvents[0].event;
|
||||||
|
expect(event.type).toBe('message_stop');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have message_start and message_stop paired by count', async () => {
|
||||||
|
const startEvents: SDKPartialAssistantMessage[] = [];
|
||||||
|
const stopEvents: SDKPartialAssistantMessage[] = [];
|
||||||
|
|
||||||
|
const q = query({
|
||||||
|
prompt: 'Say hello world',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
includePartialMessages: true,
|
||||||
|
cwd: testDir,
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
if (isSDKPartialAssistantMessage(message)) {
|
||||||
|
if (message.event.type === 'message_start') {
|
||||||
|
startEvents.push(message);
|
||||||
|
} else if (message.event.type === 'message_stop') {
|
||||||
|
stopEvents.push(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify message_start and message_stop appear in pairs (same count)
|
||||||
|
expect(startEvents.length).toBeGreaterThan(0);
|
||||||
|
expect(stopEvents.length).toBe(startEvents.length);
|
||||||
|
|
||||||
|
// Verify message_start carries the message id via its nested message.id field
|
||||||
|
for (const e of startEvents) {
|
||||||
|
const event = e.event as {
|
||||||
|
type: 'message_start';
|
||||||
|
message: { id: string };
|
||||||
|
};
|
||||||
|
expect(typeof event.message.id).toBe('string');
|
||||||
|
expect(event.message.id.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error Scenarios', () => {
|
||||||
|
it('should still emit message_stop even when query errors', async () => {
|
||||||
|
const messageStartEvents: SDKPartialAssistantMessage[] = [];
|
||||||
|
const messageStopEvents: SDKPartialAssistantMessage[] = [];
|
||||||
|
|
||||||
|
// Use an invalid tool to trigger an error scenario
|
||||||
|
const q = query({
|
||||||
|
prompt: 'Use a non-existent tool',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
includePartialMessages: true,
|
||||||
|
cwd: testDir,
|
||||||
|
coreTools: [], // No tools available
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
if (isSDKPartialAssistantMessage(message)) {
|
||||||
|
if (message.event.type === 'message_start') {
|
||||||
|
messageStartEvents.push(message);
|
||||||
|
} else if (message.event.type === 'message_stop') {
|
||||||
|
messageStopEvents.push(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Expected to potentially have errors
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Even in error scenarios, if message_start was emitted, message_stop should also be emitted
|
||||||
|
if (messageStartEvents.length > 0) {
|
||||||
|
expect(messageStopEvents.length).toBe(messageStartEvents.length);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Content Block Event Pairing', () => {
|
||||||
|
it('should emit paired content_block_start and content_block_stop for each content block', async () => {
|
||||||
|
const contentBlockStartEvents: SDKPartialAssistantMessage[] = [];
|
||||||
|
const contentBlockStopEvents: SDKPartialAssistantMessage[] = [];
|
||||||
|
|
||||||
|
const q = query({
|
||||||
|
prompt: 'Say hello',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
includePartialMessages: true,
|
||||||
|
cwd: testDir,
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
if (isSDKPartialAssistantMessage(message)) {
|
||||||
|
if (message.event.type === 'content_block_start') {
|
||||||
|
contentBlockStartEvents.push(message);
|
||||||
|
} else if (message.event.type === 'content_block_stop') {
|
||||||
|
contentBlockStopEvents.push(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify content_block_start and content_block_stop are paired
|
||||||
|
expect(contentBlockStartEvents.length).toBeGreaterThan(0);
|
||||||
|
expect(contentBlockStopEvents.length).toBe(
|
||||||
|
contentBlockStartEvents.length,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit content_block_start before content_block_stop', async () => {
|
||||||
|
const events: Array<{ type: string; index: number; timestamp: number }> =
|
||||||
|
[];
|
||||||
|
|
||||||
|
const q = query({
|
||||||
|
prompt: 'Say hello world',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
includePartialMessages: true,
|
||||||
|
cwd: testDir,
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
if (isSDKPartialAssistantMessage(message)) {
|
||||||
|
if (
|
||||||
|
message.event.type === 'content_block_start' ||
|
||||||
|
message.event.type === 'content_block_stop'
|
||||||
|
) {
|
||||||
|
events.push({
|
||||||
|
type: message.event.type,
|
||||||
|
index: message.event.index,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify events exist
|
||||||
|
expect(events.length).toBeGreaterThanOrEqual(2);
|
||||||
|
|
||||||
|
// Group events by index
|
||||||
|
const eventsByIndex = new Map<number, typeof events>();
|
||||||
|
for (const event of events) {
|
||||||
|
if (!eventsByIndex.has(event.index)) {
|
||||||
|
eventsByIndex.set(event.index, []);
|
||||||
|
}
|
||||||
|
eventsByIndex.get(event.index)!.push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For each index, verify content_block_start comes before content_block_stop
|
||||||
|
eventsByIndex.forEach((indexEvents) => {
|
||||||
|
const startIndex = indexEvents.findIndex(
|
||||||
|
(e) => e.type === 'content_block_start',
|
||||||
|
);
|
||||||
|
const stopIndex = indexEvents.findIndex(
|
||||||
|
(e) => e.type === 'content_block_stop',
|
||||||
|
);
|
||||||
|
expect(startIndex).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(stopIndex).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(startIndex).toBeLessThan(stopIndex);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have correct content_block_start event structure', async () => {
|
||||||
|
const contentBlockStartEvents: SDKPartialAssistantMessage[] = [];
|
||||||
|
|
||||||
|
const q = query({
|
||||||
|
prompt: 'Say hello',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
includePartialMessages: true,
|
||||||
|
cwd: testDir,
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
if (
|
||||||
|
isSDKPartialAssistantMessage(message) &&
|
||||||
|
message.event.type === 'content_block_start'
|
||||||
|
) {
|
||||||
|
contentBlockStartEvents.push(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(contentBlockStartEvents.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Verify each content_block_start has correct structure
|
||||||
|
for (const message of contentBlockStartEvents) {
|
||||||
|
const event = message.event as {
|
||||||
|
type: 'content_block_start';
|
||||||
|
index: number;
|
||||||
|
content_block: unknown;
|
||||||
|
};
|
||||||
|
expect(event.type).toBe('content_block_start');
|
||||||
|
expect(event).toHaveProperty('index');
|
||||||
|
expect(typeof event.index).toBe('number');
|
||||||
|
expect(event.index).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(event).toHaveProperty('content_block');
|
||||||
|
expect(event.content_block).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have correct content_block_stop event structure', async () => {
|
||||||
|
const contentBlockStopEvents: SDKPartialAssistantMessage[] = [];
|
||||||
|
|
||||||
|
const q = query({
|
||||||
|
prompt: 'Say hello',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
includePartialMessages: true,
|
||||||
|
cwd: testDir,
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
if (
|
||||||
|
isSDKPartialAssistantMessage(message) &&
|
||||||
|
message.event.type === 'content_block_stop'
|
||||||
|
) {
|
||||||
|
contentBlockStopEvents.push(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(contentBlockStopEvents.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Verify each content_block_stop has correct structure
|
||||||
|
for (const message of contentBlockStopEvents) {
|
||||||
|
const event = message.event as {
|
||||||
|
type: 'content_block_stop';
|
||||||
|
index: number;
|
||||||
|
};
|
||||||
|
expect(event.type).toBe('content_block_stop');
|
||||||
|
expect(event).toHaveProperty('index');
|
||||||
|
expect(typeof event.index).toBe('number');
|
||||||
|
expect(event.index).toBeGreaterThanOrEqual(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have matching index for paired content_block_start and content_block_stop', async () => {
|
||||||
|
const startEvents: SDKPartialAssistantMessage[] = [];
|
||||||
|
const stopEvents: SDKPartialAssistantMessage[] = [];
|
||||||
|
|
||||||
|
const q = query({
|
||||||
|
prompt: 'Say hello world',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
includePartialMessages: true,
|
||||||
|
cwd: testDir,
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
if (isSDKPartialAssistantMessage(message)) {
|
||||||
|
if (message.event.type === 'content_block_start') {
|
||||||
|
startEvents.push(message);
|
||||||
|
} else if (message.event.type === 'content_block_stop') {
|
||||||
|
stopEvents.push(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify events exist and are paired
|
||||||
|
expect(startEvents.length).toBeGreaterThan(0);
|
||||||
|
expect(stopEvents.length).toBe(startEvents.length);
|
||||||
|
|
||||||
|
// Extract indices from start and stop events
|
||||||
|
const startIndices = startEvents.map(
|
||||||
|
(e) => (e.event as { index: number }).index,
|
||||||
|
);
|
||||||
|
const stopIndices = stopEvents.map(
|
||||||
|
(e) => (e.event as { index: number }).index,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify each start index has a matching stop index
|
||||||
|
expect(new Set(stopIndices)).toEqual(new Set(startIndices));
|
||||||
|
|
||||||
|
// Verify each index appears the same number of times in both start and stop events
|
||||||
|
const startIndexCounts = new Map<number, number>();
|
||||||
|
const stopIndexCounts = new Map<number, number>();
|
||||||
|
|
||||||
|
for (const idx of startIndices) {
|
||||||
|
startIndexCounts.set(idx, (startIndexCounts.get(idx) || 0) + 1);
|
||||||
|
}
|
||||||
|
for (const idx of stopIndices) {
|
||||||
|
stopIndexCounts.set(idx, (stopIndexCounts.get(idx) || 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
startIndexCounts.forEach((count, idx) => {
|
||||||
|
expect(stopIndexCounts.get(idx)).toBe(count);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should follow correct event flow: content_block_start -> content_block_delta -> content_block_stop', async () => {
|
||||||
|
const events: Array<{
|
||||||
|
type: string;
|
||||||
|
index: number;
|
||||||
|
position: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
const q = query({
|
||||||
|
prompt: 'Write a short story about a cat',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
includePartialMessages: true,
|
||||||
|
cwd: testDir,
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let pos = 0;
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
if (isSDKPartialAssistantMessage(message)) {
|
||||||
|
const eventType = message.event.type;
|
||||||
|
if (
|
||||||
|
eventType === 'content_block_start' ||
|
||||||
|
eventType === 'content_block_delta' ||
|
||||||
|
eventType === 'content_block_stop'
|
||||||
|
) {
|
||||||
|
events.push({
|
||||||
|
type: eventType,
|
||||||
|
index: (message.event as { index: number }).index,
|
||||||
|
position: pos++,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(events.length).toBeGreaterThanOrEqual(2);
|
||||||
|
|
||||||
|
// Pair content_block_start/stop sequentially (not by index, since
|
||||||
|
// block-type transitions reset the blocks array and reuse index 0).
|
||||||
|
// Each start is matched with the next stop that follows it.
|
||||||
|
const starts = events.filter((e) => e.type === 'content_block_start');
|
||||||
|
const stops = events.filter((e) => e.type === 'content_block_stop');
|
||||||
|
expect(starts.length).toBe(stops.length);
|
||||||
|
|
||||||
|
for (let i = 0; i < starts.length; i++) {
|
||||||
|
const start = starts[i];
|
||||||
|
const stop = stops[i];
|
||||||
|
|
||||||
|
// start must come before the paired stop
|
||||||
|
expect(start.position).toBeLessThan(stop.position);
|
||||||
|
|
||||||
|
// All deltas between this pair must sit between start and stop
|
||||||
|
const deltas = events.filter(
|
||||||
|
(e) =>
|
||||||
|
e.type === 'content_block_delta' &&
|
||||||
|
e.position > start.position &&
|
||||||
|
e.position < stop.position,
|
||||||
|
);
|
||||||
|
for (const delta of deltas) {
|
||||||
|
expect(delta.position).toBeGreaterThan(start.position);
|
||||||
|
expect(delta.position).toBeLessThan(stop.position);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have content_block_start after message_start and before message_stop', async () => {
|
||||||
|
const events: Array<{
|
||||||
|
type: string;
|
||||||
|
timestamp: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
const q = query({
|
||||||
|
prompt: 'Say hello',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
includePartialMessages: true,
|
||||||
|
cwd: testDir,
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
if (isSDKPartialAssistantMessage(message)) {
|
||||||
|
const eventType = message.event.type;
|
||||||
|
if (
|
||||||
|
eventType === 'message_start' ||
|
||||||
|
eventType === 'message_stop' ||
|
||||||
|
eventType === 'content_block_start'
|
||||||
|
) {
|
||||||
|
events.push({
|
||||||
|
type: eventType,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify message_start exists
|
||||||
|
const messageStartIndex = events.findIndex(
|
||||||
|
(e) => e.type === 'message_start',
|
||||||
|
);
|
||||||
|
expect(messageStartIndex).toBeGreaterThanOrEqual(0);
|
||||||
|
|
||||||
|
// Verify message_stop exists
|
||||||
|
const messageStopIndex = events.findIndex(
|
||||||
|
(e) => e.type === 'message_stop',
|
||||||
|
);
|
||||||
|
expect(messageStopIndex).toBeGreaterThanOrEqual(0);
|
||||||
|
|
||||||
|
// Verify content_block_start exists
|
||||||
|
const firstContentBlockStartIndex = events.findIndex(
|
||||||
|
(e) => e.type === 'content_block_start',
|
||||||
|
);
|
||||||
|
expect(firstContentBlockStartIndex).toBeGreaterThanOrEqual(0);
|
||||||
|
|
||||||
|
// content_block_start should be after message_start
|
||||||
|
expect(firstContentBlockStartIndex).toBeGreaterThan(messageStartIndex);
|
||||||
|
|
||||||
|
// content_block_start should be before message_stop
|
||||||
|
expect(firstContentBlockStartIndex).toBeLessThan(messageStopIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have content_block_stop after message_start and before message_stop', async () => {
|
||||||
|
const events: Array<{
|
||||||
|
type: string;
|
||||||
|
timestamp: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
const q = query({
|
||||||
|
prompt: 'Say hello',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
includePartialMessages: true,
|
||||||
|
cwd: testDir,
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
if (isSDKPartialAssistantMessage(message)) {
|
||||||
|
const eventType = message.event.type;
|
||||||
|
if (
|
||||||
|
eventType === 'message_start' ||
|
||||||
|
eventType === 'message_stop' ||
|
||||||
|
eventType === 'content_block_stop'
|
||||||
|
) {
|
||||||
|
events.push({
|
||||||
|
type: eventType,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify message_start exists
|
||||||
|
const messageStartIndex = events.findIndex(
|
||||||
|
(e) => e.type === 'message_start',
|
||||||
|
);
|
||||||
|
expect(messageStartIndex).toBeGreaterThanOrEqual(0);
|
||||||
|
|
||||||
|
// Verify message_stop exists
|
||||||
|
const messageStopIndex = events.findIndex(
|
||||||
|
(e) => e.type === 'message_stop',
|
||||||
|
);
|
||||||
|
expect(messageStopIndex).toBeGreaterThanOrEqual(0);
|
||||||
|
|
||||||
|
// Verify content_block_stop exists (use reverse find for ES compatibility)
|
||||||
|
const lastContentBlockStopIndex =
|
||||||
|
events
|
||||||
|
.map((e, i) => ({ ...e, originalIndex: i }))
|
||||||
|
.reverse()
|
||||||
|
.find((e) => e.type === 'content_block_stop')?.originalIndex ?? -1;
|
||||||
|
expect(lastContentBlockStopIndex).toBeGreaterThanOrEqual(0);
|
||||||
|
|
||||||
|
// content_block_stop should be after message_start
|
||||||
|
expect(lastContentBlockStopIndex).toBeGreaterThan(messageStartIndex);
|
||||||
|
|
||||||
|
// content_block_stop should be before message_stop
|
||||||
|
expect(lastContentBlockStopIndex).toBeLessThan(messageStopIndex);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
import type { ScenarioConfig } from '../scenario-runner.js';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'pr-2371-review',
|
||||||
|
spawn: ['node', 'dist/cli.js', '--yolo'],
|
||||||
|
terminal: { title: 'qwen-code', cwd: '../../..' },
|
||||||
|
flow: [
|
||||||
|
{
|
||||||
|
type: '/review https://github.com/QwenLM/qwen-code/pull/2371',
|
||||||
|
streaming: {
|
||||||
|
delayMs: 5000,
|
||||||
|
intervalMs: 10000, // Every 10s
|
||||||
|
count: 60, // 10 minutes total (60 * 10s)
|
||||||
|
gif: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} satisfies ScenarioConfig;
|
||||||
16
package-lock.json
generated
16
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "@qwen-code/qwen-code",
|
"name": "@qwen-code/qwen-code",
|
||||||
"version": "0.12.5",
|
"version": "0.13.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@qwen-code/qwen-code",
|
"name": "@qwen-code/qwen-code",
|
||||||
"version": "0.12.5",
|
"version": "0.13.0",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
|
|
@ -18784,7 +18784,7 @@
|
||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@qwen-code/qwen-code",
|
"name": "@qwen-code/qwen-code",
|
||||||
"version": "0.12.5",
|
"version": "0.13.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.14.1",
|
"@agentclientprotocol/sdk": "^0.14.1",
|
||||||
"@google/genai": "1.30.0",
|
"@google/genai": "1.30.0",
|
||||||
|
|
@ -19441,7 +19441,7 @@
|
||||||
},
|
},
|
||||||
"packages/core": {
|
"packages/core": {
|
||||||
"name": "@qwen-code/qwen-code-core",
|
"name": "@qwen-code/qwen-code-core",
|
||||||
"version": "0.12.5",
|
"version": "0.13.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.36.1",
|
"@anthropic-ai/sdk": "^0.36.1",
|
||||||
|
|
@ -22872,7 +22872,7 @@
|
||||||
},
|
},
|
||||||
"packages/test-utils": {
|
"packages/test-utils": {
|
||||||
"name": "@qwen-code/qwen-code-test-utils",
|
"name": "@qwen-code/qwen-code-test-utils",
|
||||||
"version": "0.12.5",
|
"version": "0.13.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -22884,7 +22884,7 @@
|
||||||
},
|
},
|
||||||
"packages/vscode-ide-companion": {
|
"packages/vscode-ide-companion": {
|
||||||
"name": "qwen-code-vscode-ide-companion",
|
"name": "qwen-code-vscode-ide-companion",
|
||||||
"version": "0.12.5",
|
"version": "0.13.0",
|
||||||
"license": "LICENSE",
|
"license": "LICENSE",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.14.1",
|
"@agentclientprotocol/sdk": "^0.14.1",
|
||||||
|
|
@ -23132,7 +23132,7 @@
|
||||||
},
|
},
|
||||||
"packages/web-templates": {
|
"packages/web-templates": {
|
||||||
"name": "@qwen-code/web-templates",
|
"name": "@qwen-code/web-templates",
|
||||||
"version": "0.12.5",
|
"version": "0.13.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.2.0",
|
"@types/react": "^18.2.0",
|
||||||
"@types/react-dom": "^18.2.0",
|
"@types/react-dom": "^18.2.0",
|
||||||
|
|
@ -23660,7 +23660,7 @@
|
||||||
},
|
},
|
||||||
"packages/webui": {
|
"packages/webui": {
|
||||||
"name": "@qwen-code/webui",
|
"name": "@qwen-code/webui",
|
||||||
"version": "0.12.5",
|
"version": "0.13.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"markdown-it": "^14.1.0"
|
"markdown-it": "^14.1.0"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@qwen-code/qwen-code",
|
"name": "@qwen-code/qwen-code",
|
||||||
"version": "0.12.5",
|
"version": "0.13.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
},
|
},
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
"url": "git+https://github.com/QwenLM/qwen-code.git"
|
"url": "git+https://github.com/QwenLM/qwen-code.git"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.12.5"
|
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.13.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "cross-env node scripts/start.js",
|
"start": "cross-env node scripts/start.js",
|
||||||
|
|
@ -36,8 +36,8 @@
|
||||||
"test:integration:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests",
|
"test:integration:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests",
|
||||||
"test:integration:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests",
|
"test:integration:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests",
|
||||||
"test:integration:sandbox:podman": "cross-env QWEN_SANDBOX=podman vitest run --root ./integration-tests",
|
"test:integration:sandbox:podman": "cross-env QWEN_SANDBOX=podman vitest run --root ./integration-tests",
|
||||||
"test:integration:sdk:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests sdk-typescript",
|
"test:integration:sdk:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests --poolOptions.threads.maxThreads 2 sdk-typescript",
|
||||||
"test:integration:sdk:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests sdk-typescript",
|
"test:integration:sdk:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests --poolOptions.threads.maxThreads 2 sdk-typescript",
|
||||||
"test:integration:cli:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests --exclude '**/sdk-typescript/**'",
|
"test:integration:cli:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests --exclude '**/sdk-typescript/**'",
|
||||||
"test:integration:cli:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests --exclude '**/sdk-typescript/**'",
|
"test:integration:cli:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests --exclude '**/sdk-typescript/**'",
|
||||||
"test:terminal-bench": "cross-env VERBOSE=true KEEP_OUTPUT=true vitest run --config ./vitest.terminal-bench.config.ts --root ./integration-tests",
|
"test:terminal-bench": "cross-env VERBOSE=true KEEP_OUTPUT=true vitest run --config ./vitest.terminal-bench.config.ts --root ./integration-tests",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@qwen-code/qwen-code",
|
"name": "@qwen-code/qwen-code",
|
||||||
"version": "0.12.5",
|
"version": "0.13.0",
|
||||||
"description": "Qwen Code",
|
"description": "Qwen Code",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|
@ -33,7 +33,7 @@
|
||||||
"dist"
|
"dist"
|
||||||
],
|
],
|
||||||
"config": {
|
"config": {
|
||||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.12.5"
|
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.13.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.14.1",
|
"@agentclientprotocol/sdk": "^0.14.1",
|
||||||
|
|
|
||||||
|
|
@ -58,11 +58,11 @@ import { AcpFileSystemService } from './service/filesystem.js';
|
||||||
import { Readable, Writable } from 'node:stream';
|
import { Readable, Writable } from 'node:stream';
|
||||||
import type { LoadedSettings } from '../config/settings.js';
|
import type { LoadedSettings } from '../config/settings.js';
|
||||||
import { SettingScope } from '../config/settings.js';
|
import { SettingScope } from '../config/settings.js';
|
||||||
|
import type { ApprovalModeValue } from './session/types.js';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import type { CliArgs } from '../config/config.js';
|
import type { CliArgs } from '../config/config.js';
|
||||||
import { loadCliConfig } from '../config/config.js';
|
import { loadCliConfig } from '../config/config.js';
|
||||||
import { Session } from './session/Session.js';
|
import { Session } from './session/Session.js';
|
||||||
import type { ApprovalModeValue } from './session/types.js';
|
|
||||||
import { formatAcpModelId } from '../utils/acpModelUtils.js';
|
import { formatAcpModelId } from '../utils/acpModelUtils.js';
|
||||||
|
|
||||||
const debugLogger = createDebugLogger('ACP_AGENT');
|
const debugLogger = createDebugLogger('ACP_AGENT');
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import type {
|
||||||
ToolCallConfirmationDetails,
|
ToolCallConfirmationDetails,
|
||||||
ToolResult,
|
ToolResult,
|
||||||
ChatRecord,
|
ChatRecord,
|
||||||
SubAgentEventEmitter,
|
AgentEventEmitter,
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
import {
|
import {
|
||||||
AuthType,
|
AuthType,
|
||||||
|
|
@ -530,7 +530,7 @@ export class Session implements SessionContext {
|
||||||
// Access eventEmitter from TaskTool invocation
|
// Access eventEmitter from TaskTool invocation
|
||||||
const taskEventEmitter = (
|
const taskEventEmitter = (
|
||||||
invocation as {
|
invocation as {
|
||||||
eventEmitter: SubAgentEventEmitter;
|
eventEmitter: AgentEventEmitter;
|
||||||
}
|
}
|
||||||
).eventEmitter;
|
).eventEmitter;
|
||||||
|
|
||||||
|
|
@ -539,7 +539,7 @@ export class Session implements SessionContext {
|
||||||
const subagentType = (args['subagent_type'] as string) ?? '';
|
const subagentType = (args['subagent_type'] as string) ?? '';
|
||||||
|
|
||||||
// Create a SubAgentTracker for this tool execution
|
// Create a SubAgentTracker for this tool execution
|
||||||
const subAgentTracker = new SubAgentTracker(
|
const subSubAgentTracker = new SubAgentTracker(
|
||||||
this,
|
this,
|
||||||
this.client,
|
this.client,
|
||||||
parentToolCallId,
|
parentToolCallId,
|
||||||
|
|
@ -547,7 +547,7 @@ export class Session implements SessionContext {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Set up sub-agent tool tracking
|
// Set up sub-agent tool tracking
|
||||||
subAgentCleanupFunctions = subAgentTracker.setup(
|
subAgentCleanupFunctions = subSubAgentTracker.setup(
|
||||||
taskEventEmitter,
|
taskEventEmitter,
|
||||||
abortSignal,
|
abortSignal,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -10,26 +10,26 @@ import type { SessionContext } from './types.js';
|
||||||
import type {
|
import type {
|
||||||
Config,
|
Config,
|
||||||
ToolRegistry,
|
ToolRegistry,
|
||||||
SubAgentEventEmitter,
|
AgentEventEmitter,
|
||||||
SubAgentToolCallEvent,
|
AgentToolCallEvent,
|
||||||
SubAgentToolResultEvent,
|
AgentToolResultEvent,
|
||||||
SubAgentApprovalRequestEvent,
|
AgentApprovalRequestEvent,
|
||||||
SubAgentStreamTextEvent,
|
AgentStreamTextEvent,
|
||||||
ToolEditConfirmationDetails,
|
ToolEditConfirmationDetails,
|
||||||
ToolInfoConfirmationDetails,
|
ToolInfoConfirmationDetails,
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
import {
|
import {
|
||||||
SubAgentEventType,
|
AgentEventType,
|
||||||
ToolConfirmationOutcome,
|
ToolConfirmationOutcome,
|
||||||
TodoWriteTool,
|
TodoWriteTool,
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
import type { AgentSideConnection } from '@agentclientprotocol/sdk';
|
import type { AgentSideConnection } from '@agentclientprotocol/sdk';
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
|
|
||||||
// Helper to create a mock SubAgentToolCallEvent with required fields
|
// Helper to create a mock AgentToolCallEvent with required fields
|
||||||
function createToolCallEvent(
|
function createToolCallEvent(
|
||||||
overrides: Partial<SubAgentToolCallEvent> & { name: string; callId: string },
|
overrides: Partial<AgentToolCallEvent> & { name: string; callId: string },
|
||||||
): SubAgentToolCallEvent {
|
): AgentToolCallEvent {
|
||||||
return {
|
return {
|
||||||
subagentId: 'test-subagent',
|
subagentId: 'test-subagent',
|
||||||
round: 1,
|
round: 1,
|
||||||
|
|
@ -40,14 +40,14 @@ function createToolCallEvent(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to create a mock SubAgentToolResultEvent with required fields
|
// Helper to create a mock AgentToolResultEvent with required fields
|
||||||
function createToolResultEvent(
|
function createToolResultEvent(
|
||||||
overrides: Partial<SubAgentToolResultEvent> & {
|
overrides: Partial<AgentToolResultEvent> & {
|
||||||
name: string;
|
name: string;
|
||||||
callId: string;
|
callId: string;
|
||||||
success: boolean;
|
success: boolean;
|
||||||
},
|
},
|
||||||
): SubAgentToolResultEvent {
|
): AgentToolResultEvent {
|
||||||
return {
|
return {
|
||||||
subagentId: 'test-subagent',
|
subagentId: 'test-subagent',
|
||||||
round: 1,
|
round: 1,
|
||||||
|
|
@ -56,15 +56,15 @@ function createToolResultEvent(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to create a mock SubAgentApprovalRequestEvent with required fields
|
// Helper to create a mock AgentApprovalRequestEvent with required fields
|
||||||
function createApprovalEvent(
|
function createApprovalEvent(
|
||||||
overrides: Partial<SubAgentApprovalRequestEvent> & {
|
overrides: Partial<AgentApprovalRequestEvent> & {
|
||||||
name: string;
|
name: string;
|
||||||
callId: string;
|
callId: string;
|
||||||
confirmationDetails: SubAgentApprovalRequestEvent['confirmationDetails'];
|
confirmationDetails: AgentApprovalRequestEvent['confirmationDetails'];
|
||||||
respond: SubAgentApprovalRequestEvent['respond'];
|
respond: AgentApprovalRequestEvent['respond'];
|
||||||
},
|
},
|
||||||
): SubAgentApprovalRequestEvent {
|
): AgentApprovalRequestEvent {
|
||||||
return {
|
return {
|
||||||
subagentId: 'test-subagent',
|
subagentId: 'test-subagent',
|
||||||
round: 1,
|
round: 1,
|
||||||
|
|
@ -102,10 +102,10 @@ function createInfoConfirmation(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to create a mock SubAgentStreamTextEvent with required fields
|
// Helper to create a mock AgentStreamTextEvent with required fields
|
||||||
function createStreamTextEvent(
|
function createStreamTextEvent(
|
||||||
overrides: Partial<SubAgentStreamTextEvent> & { text: string },
|
overrides: Partial<AgentStreamTextEvent> & { text: string },
|
||||||
): SubAgentStreamTextEvent {
|
): AgentStreamTextEvent {
|
||||||
return {
|
return {
|
||||||
subagentId: 'test-subagent',
|
subagentId: 'test-subagent',
|
||||||
round: 1,
|
round: 1,
|
||||||
|
|
@ -120,7 +120,7 @@ describe('SubAgentTracker', () => {
|
||||||
let sendUpdateSpy: ReturnType<typeof vi.fn>;
|
let sendUpdateSpy: ReturnType<typeof vi.fn>;
|
||||||
let requestPermissionSpy: ReturnType<typeof vi.fn>;
|
let requestPermissionSpy: ReturnType<typeof vi.fn>;
|
||||||
let tracker: SubAgentTracker;
|
let tracker: SubAgentTracker;
|
||||||
let eventEmitter: SubAgentEventEmitter;
|
let eventEmitter: AgentEventEmitter;
|
||||||
let abortController: AbortController;
|
let abortController: AbortController;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|
@ -151,7 +151,7 @@ describe('SubAgentTracker', () => {
|
||||||
'parent-call-123',
|
'parent-call-123',
|
||||||
'test-subagent',
|
'test-subagent',
|
||||||
);
|
);
|
||||||
eventEmitter = new EventEmitter() as unknown as SubAgentEventEmitter;
|
eventEmitter = new EventEmitter() as unknown as AgentEventEmitter;
|
||||||
abortController = new AbortController();
|
abortController = new AbortController();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -169,19 +169,19 @@ describe('SubAgentTracker', () => {
|
||||||
tracker.setup(eventEmitter, abortController.signal);
|
tracker.setup(eventEmitter, abortController.signal);
|
||||||
|
|
||||||
expect(onSpy).toHaveBeenCalledWith(
|
expect(onSpy).toHaveBeenCalledWith(
|
||||||
SubAgentEventType.TOOL_CALL,
|
AgentEventType.TOOL_CALL,
|
||||||
expect.any(Function),
|
expect.any(Function),
|
||||||
);
|
);
|
||||||
expect(onSpy).toHaveBeenCalledWith(
|
expect(onSpy).toHaveBeenCalledWith(
|
||||||
SubAgentEventType.TOOL_RESULT,
|
AgentEventType.TOOL_RESULT,
|
||||||
expect.any(Function),
|
expect.any(Function),
|
||||||
);
|
);
|
||||||
expect(onSpy).toHaveBeenCalledWith(
|
expect(onSpy).toHaveBeenCalledWith(
|
||||||
SubAgentEventType.TOOL_WAITING_APPROVAL,
|
AgentEventType.TOOL_WAITING_APPROVAL,
|
||||||
expect.any(Function),
|
expect.any(Function),
|
||||||
);
|
);
|
||||||
expect(onSpy).toHaveBeenCalledWith(
|
expect(onSpy).toHaveBeenCalledWith(
|
||||||
SubAgentEventType.STREAM_TEXT,
|
AgentEventType.STREAM_TEXT,
|
||||||
expect.any(Function),
|
expect.any(Function),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -193,19 +193,19 @@ describe('SubAgentTracker', () => {
|
||||||
cleanups[0]();
|
cleanups[0]();
|
||||||
|
|
||||||
expect(offSpy).toHaveBeenCalledWith(
|
expect(offSpy).toHaveBeenCalledWith(
|
||||||
SubAgentEventType.TOOL_CALL,
|
AgentEventType.TOOL_CALL,
|
||||||
expect.any(Function),
|
expect.any(Function),
|
||||||
);
|
);
|
||||||
expect(offSpy).toHaveBeenCalledWith(
|
expect(offSpy).toHaveBeenCalledWith(
|
||||||
SubAgentEventType.TOOL_RESULT,
|
AgentEventType.TOOL_RESULT,
|
||||||
expect.any(Function),
|
expect.any(Function),
|
||||||
);
|
);
|
||||||
expect(offSpy).toHaveBeenCalledWith(
|
expect(offSpy).toHaveBeenCalledWith(
|
||||||
SubAgentEventType.TOOL_WAITING_APPROVAL,
|
AgentEventType.TOOL_WAITING_APPROVAL,
|
||||||
expect.any(Function),
|
expect.any(Function),
|
||||||
);
|
);
|
||||||
expect(offSpy).toHaveBeenCalledWith(
|
expect(offSpy).toHaveBeenCalledWith(
|
||||||
SubAgentEventType.STREAM_TEXT,
|
AgentEventType.STREAM_TEXT,
|
||||||
expect.any(Function),
|
expect.any(Function),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -222,7 +222,7 @@ describe('SubAgentTracker', () => {
|
||||||
description: 'Reading file',
|
description: 'Reading file',
|
||||||
});
|
});
|
||||||
|
|
||||||
eventEmitter.emit(SubAgentEventType.TOOL_CALL, event);
|
eventEmitter.emit(AgentEventType.TOOL_CALL, event);
|
||||||
|
|
||||||
// Allow async operations to complete
|
// Allow async operations to complete
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
|
|
@ -258,7 +258,7 @@ describe('SubAgentTracker', () => {
|
||||||
args: { todos: [] },
|
args: { todos: [] },
|
||||||
});
|
});
|
||||||
|
|
||||||
eventEmitter.emit(SubAgentEventType.TOOL_CALL, event);
|
eventEmitter.emit(AgentEventType.TOOL_CALL, event);
|
||||||
|
|
||||||
// Give time for any async operation
|
// Give time for any async operation
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
|
@ -276,7 +276,7 @@ describe('SubAgentTracker', () => {
|
||||||
args: {},
|
args: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
eventEmitter.emit(SubAgentEventType.TOOL_CALL, event);
|
eventEmitter.emit(AgentEventType.TOOL_CALL, event);
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
|
@ -290,7 +290,7 @@ describe('SubAgentTracker', () => {
|
||||||
|
|
||||||
// First emit tool call to store state
|
// First emit tool call to store state
|
||||||
eventEmitter.emit(
|
eventEmitter.emit(
|
||||||
SubAgentEventType.TOOL_CALL,
|
AgentEventType.TOOL_CALL,
|
||||||
createToolCallEvent({
|
createToolCallEvent({
|
||||||
name: 'read_file',
|
name: 'read_file',
|
||||||
callId: 'call-123',
|
callId: 'call-123',
|
||||||
|
|
@ -306,7 +306,7 @@ describe('SubAgentTracker', () => {
|
||||||
resultDisplay: 'File contents',
|
resultDisplay: 'File contents',
|
||||||
});
|
});
|
||||||
|
|
||||||
eventEmitter.emit(SubAgentEventType.TOOL_RESULT, resultEvent);
|
eventEmitter.emit(AgentEventType.TOOL_RESULT, resultEvent);
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(sendUpdateSpy).toHaveBeenCalledWith(
|
expect(sendUpdateSpy).toHaveBeenCalledWith(
|
||||||
|
|
@ -334,7 +334,7 @@ describe('SubAgentTracker', () => {
|
||||||
resultDisplay: undefined,
|
resultDisplay: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
eventEmitter.emit(SubAgentEventType.TOOL_RESULT, resultEvent);
|
eventEmitter.emit(AgentEventType.TOOL_RESULT, resultEvent);
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(sendUpdateSpy).toHaveBeenCalledWith(
|
expect(sendUpdateSpy).toHaveBeenCalledWith(
|
||||||
|
|
@ -356,7 +356,7 @@ describe('SubAgentTracker', () => {
|
||||||
|
|
||||||
// Store args via tool call
|
// Store args via tool call
|
||||||
eventEmitter.emit(
|
eventEmitter.emit(
|
||||||
SubAgentEventType.TOOL_CALL,
|
AgentEventType.TOOL_CALL,
|
||||||
createToolCallEvent({
|
createToolCallEvent({
|
||||||
name: TodoWriteTool.Name,
|
name: TodoWriteTool.Name,
|
||||||
callId: 'call-todo',
|
callId: 'call-todo',
|
||||||
|
|
@ -377,7 +377,7 @@ describe('SubAgentTracker', () => {
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
eventEmitter.emit(SubAgentEventType.TOOL_RESULT, resultEvent);
|
eventEmitter.emit(AgentEventType.TOOL_RESULT, resultEvent);
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||||
|
|
@ -393,7 +393,7 @@ describe('SubAgentTracker', () => {
|
||||||
tracker.setup(eventEmitter, abortController.signal);
|
tracker.setup(eventEmitter, abortController.signal);
|
||||||
|
|
||||||
eventEmitter.emit(
|
eventEmitter.emit(
|
||||||
SubAgentEventType.TOOL_CALL,
|
AgentEventType.TOOL_CALL,
|
||||||
createToolCallEvent({
|
createToolCallEvent({
|
||||||
name: 'test_tool',
|
name: 'test_tool',
|
||||||
callId: 'call-cleanup',
|
callId: 'call-cleanup',
|
||||||
|
|
@ -402,7 +402,7 @@ describe('SubAgentTracker', () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
eventEmitter.emit(
|
eventEmitter.emit(
|
||||||
SubAgentEventType.TOOL_RESULT,
|
AgentEventType.TOOL_RESULT,
|
||||||
createToolResultEvent({
|
createToolResultEvent({
|
||||||
name: 'test_tool',
|
name: 'test_tool',
|
||||||
callId: 'call-cleanup',
|
callId: 'call-cleanup',
|
||||||
|
|
@ -413,7 +413,7 @@ describe('SubAgentTracker', () => {
|
||||||
// Emit another result for same callId - should not have stored args
|
// Emit another result for same callId - should not have stored args
|
||||||
sendUpdateSpy.mockClear();
|
sendUpdateSpy.mockClear();
|
||||||
eventEmitter.emit(
|
eventEmitter.emit(
|
||||||
SubAgentEventType.TOOL_RESULT,
|
AgentEventType.TOOL_RESULT,
|
||||||
createToolResultEvent({
|
createToolResultEvent({
|
||||||
name: 'test_tool',
|
name: 'test_tool',
|
||||||
callId: 'call-cleanup',
|
callId: 'call-cleanup',
|
||||||
|
|
@ -447,7 +447,7 @@ describe('SubAgentTracker', () => {
|
||||||
respond: respondSpy,
|
respond: respondSpy,
|
||||||
});
|
});
|
||||||
|
|
||||||
eventEmitter.emit(SubAgentEventType.TOOL_WAITING_APPROVAL, event);
|
eventEmitter.emit(AgentEventType.TOOL_WAITING_APPROVAL, event);
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(requestPermissionSpy).toHaveBeenCalled();
|
expect(requestPermissionSpy).toHaveBeenCalled();
|
||||||
|
|
@ -483,7 +483,7 @@ describe('SubAgentTracker', () => {
|
||||||
respond: respondSpy,
|
respond: respondSpy,
|
||||||
});
|
});
|
||||||
|
|
||||||
eventEmitter.emit(SubAgentEventType.TOOL_WAITING_APPROVAL, event);
|
eventEmitter.emit(AgentEventType.TOOL_WAITING_APPROVAL, event);
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(respondSpy).toHaveBeenCalledWith(
|
expect(respondSpy).toHaveBeenCalledWith(
|
||||||
|
|
@ -504,7 +504,7 @@ describe('SubAgentTracker', () => {
|
||||||
respond: respondSpy,
|
respond: respondSpy,
|
||||||
});
|
});
|
||||||
|
|
||||||
eventEmitter.emit(SubAgentEventType.TOOL_WAITING_APPROVAL, event);
|
eventEmitter.emit(AgentEventType.TOOL_WAITING_APPROVAL, event);
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(respondSpy).toHaveBeenCalledWith(ToolConfirmationOutcome.Cancel);
|
expect(respondSpy).toHaveBeenCalledWith(ToolConfirmationOutcome.Cancel);
|
||||||
|
|
@ -525,7 +525,7 @@ describe('SubAgentTracker', () => {
|
||||||
respond: respondSpy,
|
respond: respondSpy,
|
||||||
});
|
});
|
||||||
|
|
||||||
eventEmitter.emit(SubAgentEventType.TOOL_WAITING_APPROVAL, event);
|
eventEmitter.emit(AgentEventType.TOOL_WAITING_APPROVAL, event);
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(respondSpy).toHaveBeenCalledWith(ToolConfirmationOutcome.Cancel);
|
expect(respondSpy).toHaveBeenCalledWith(ToolConfirmationOutcome.Cancel);
|
||||||
|
|
@ -548,7 +548,7 @@ describe('SubAgentTracker', () => {
|
||||||
respond: vi.fn(),
|
respond: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
eventEmitter.emit(SubAgentEventType.TOOL_WAITING_APPROVAL, event);
|
eventEmitter.emit(AgentEventType.TOOL_WAITING_APPROVAL, event);
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(requestPermissionSpy).toHaveBeenCalled();
|
expect(requestPermissionSpy).toHaveBeenCalled();
|
||||||
|
|
@ -572,7 +572,7 @@ describe('SubAgentTracker', () => {
|
||||||
text: 'Hello, this is a response from the model.',
|
text: 'Hello, this is a response from the model.',
|
||||||
});
|
});
|
||||||
|
|
||||||
eventEmitter.emit(SubAgentEventType.STREAM_TEXT, event);
|
eventEmitter.emit(AgentEventType.STREAM_TEXT, event);
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(sendUpdateSpy).toHaveBeenCalled();
|
expect(sendUpdateSpy).toHaveBeenCalled();
|
||||||
|
|
@ -593,15 +593,15 @@ describe('SubAgentTracker', () => {
|
||||||
tracker.setup(eventEmitter, abortController.signal);
|
tracker.setup(eventEmitter, abortController.signal);
|
||||||
|
|
||||||
eventEmitter.emit(
|
eventEmitter.emit(
|
||||||
SubAgentEventType.STREAM_TEXT,
|
AgentEventType.STREAM_TEXT,
|
||||||
createStreamTextEvent({ text: 'First chunk ' }),
|
createStreamTextEvent({ text: 'First chunk ' }),
|
||||||
);
|
);
|
||||||
eventEmitter.emit(
|
eventEmitter.emit(
|
||||||
SubAgentEventType.STREAM_TEXT,
|
AgentEventType.STREAM_TEXT,
|
||||||
createStreamTextEvent({ text: 'Second chunk ' }),
|
createStreamTextEvent({ text: 'Second chunk ' }),
|
||||||
);
|
);
|
||||||
eventEmitter.emit(
|
eventEmitter.emit(
|
||||||
SubAgentEventType.STREAM_TEXT,
|
AgentEventType.STREAM_TEXT,
|
||||||
createStreamTextEvent({ text: 'Third chunk' }),
|
createStreamTextEvent({ text: 'Third chunk' }),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -640,7 +640,7 @@ describe('SubAgentTracker', () => {
|
||||||
text: 'This should not be emitted',
|
text: 'This should not be emitted',
|
||||||
});
|
});
|
||||||
|
|
||||||
eventEmitter.emit(SubAgentEventType.STREAM_TEXT, event);
|
eventEmitter.emit(AgentEventType.STREAM_TEXT, event);
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
|
@ -655,7 +655,7 @@ describe('SubAgentTracker', () => {
|
||||||
thought: true,
|
thought: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
eventEmitter.emit(SubAgentEventType.STREAM_TEXT, event);
|
eventEmitter.emit(AgentEventType.STREAM_TEXT, event);
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(sendUpdateSpy).toHaveBeenCalled();
|
expect(sendUpdateSpy).toHaveBeenCalled();
|
||||||
|
|
@ -680,7 +680,7 @@ describe('SubAgentTracker', () => {
|
||||||
thought: false,
|
thought: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
eventEmitter.emit(SubAgentEventType.STREAM_TEXT, event);
|
eventEmitter.emit(AgentEventType.STREAM_TEXT, event);
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(sendUpdateSpy).toHaveBeenCalled();
|
expect(sendUpdateSpy).toHaveBeenCalled();
|
||||||
|
|
@ -705,7 +705,7 @@ describe('SubAgentTracker', () => {
|
||||||
text: 'Default behavior text.',
|
text: 'Default behavior text.',
|
||||||
});
|
});
|
||||||
|
|
||||||
eventEmitter.emit(SubAgentEventType.STREAM_TEXT, event);
|
eventEmitter.emit(AgentEventType.STREAM_TEXT, event);
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(sendUpdateSpy).toHaveBeenCalled();
|
expect(sendUpdateSpy).toHaveBeenCalled();
|
||||||
|
|
|
||||||
|
|
@ -5,18 +5,18 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
SubAgentEventEmitter,
|
AgentEventEmitter,
|
||||||
SubAgentToolCallEvent,
|
AgentToolCallEvent,
|
||||||
SubAgentToolResultEvent,
|
AgentToolResultEvent,
|
||||||
SubAgentApprovalRequestEvent,
|
AgentApprovalRequestEvent,
|
||||||
SubAgentUsageEvent,
|
AgentUsageEvent,
|
||||||
SubAgentStreamTextEvent,
|
AgentStreamTextEvent,
|
||||||
ToolCallConfirmationDetails,
|
ToolCallConfirmationDetails,
|
||||||
AnyDeclarativeTool,
|
AnyDeclarativeTool,
|
||||||
AnyToolInvocation,
|
AnyToolInvocation,
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
import {
|
import {
|
||||||
SubAgentEventType,
|
AgentEventType,
|
||||||
ToolConfirmationOutcome,
|
ToolConfirmationOutcome,
|
||||||
createDebugLogger,
|
createDebugLogger,
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
|
|
@ -106,12 +106,12 @@ export class SubAgentTracker {
|
||||||
/**
|
/**
|
||||||
* Sets up event listeners for a sub-agent's tool events.
|
* Sets up event listeners for a sub-agent's tool events.
|
||||||
*
|
*
|
||||||
* @param eventEmitter - The SubAgentEventEmitter from TaskTool
|
* @param eventEmitter - The AgentEventEmitter from TaskTool
|
||||||
* @param abortSignal - Signal to abort tracking if parent is cancelled
|
* @param abortSignal - Signal to abort tracking if parent is cancelled
|
||||||
* @returns Array of cleanup functions to remove listeners
|
* @returns Array of cleanup functions to remove listeners
|
||||||
*/
|
*/
|
||||||
setup(
|
setup(
|
||||||
eventEmitter: SubAgentEventEmitter,
|
eventEmitter: AgentEventEmitter,
|
||||||
abortSignal: AbortSignal,
|
abortSignal: AbortSignal,
|
||||||
): Array<() => void> {
|
): Array<() => void> {
|
||||||
const onToolCall = this.createToolCallHandler(abortSignal);
|
const onToolCall = this.createToolCallHandler(abortSignal);
|
||||||
|
|
@ -120,19 +120,19 @@ export class SubAgentTracker {
|
||||||
const onUsageMetadata = this.createUsageMetadataHandler(abortSignal);
|
const onUsageMetadata = this.createUsageMetadataHandler(abortSignal);
|
||||||
const onStreamText = this.createStreamTextHandler(abortSignal);
|
const onStreamText = this.createStreamTextHandler(abortSignal);
|
||||||
|
|
||||||
eventEmitter.on(SubAgentEventType.TOOL_CALL, onToolCall);
|
eventEmitter.on(AgentEventType.TOOL_CALL, onToolCall);
|
||||||
eventEmitter.on(SubAgentEventType.TOOL_RESULT, onToolResult);
|
eventEmitter.on(AgentEventType.TOOL_RESULT, onToolResult);
|
||||||
eventEmitter.on(SubAgentEventType.TOOL_WAITING_APPROVAL, onApproval);
|
eventEmitter.on(AgentEventType.TOOL_WAITING_APPROVAL, onApproval);
|
||||||
eventEmitter.on(SubAgentEventType.USAGE_METADATA, onUsageMetadata);
|
eventEmitter.on(AgentEventType.USAGE_METADATA, onUsageMetadata);
|
||||||
eventEmitter.on(SubAgentEventType.STREAM_TEXT, onStreamText);
|
eventEmitter.on(AgentEventType.STREAM_TEXT, onStreamText);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
() => {
|
() => {
|
||||||
eventEmitter.off(SubAgentEventType.TOOL_CALL, onToolCall);
|
eventEmitter.off(AgentEventType.TOOL_CALL, onToolCall);
|
||||||
eventEmitter.off(SubAgentEventType.TOOL_RESULT, onToolResult);
|
eventEmitter.off(AgentEventType.TOOL_RESULT, onToolResult);
|
||||||
eventEmitter.off(SubAgentEventType.TOOL_WAITING_APPROVAL, onApproval);
|
eventEmitter.off(AgentEventType.TOOL_WAITING_APPROVAL, onApproval);
|
||||||
eventEmitter.off(SubAgentEventType.USAGE_METADATA, onUsageMetadata);
|
eventEmitter.off(AgentEventType.USAGE_METADATA, onUsageMetadata);
|
||||||
eventEmitter.off(SubAgentEventType.STREAM_TEXT, onStreamText);
|
eventEmitter.off(AgentEventType.STREAM_TEXT, onStreamText);
|
||||||
// Clean up any remaining states
|
// Clean up any remaining states
|
||||||
this.toolStates.clear();
|
this.toolStates.clear();
|
||||||
},
|
},
|
||||||
|
|
@ -146,7 +146,7 @@ export class SubAgentTracker {
|
||||||
abortSignal: AbortSignal,
|
abortSignal: AbortSignal,
|
||||||
): (...args: unknown[]) => void {
|
): (...args: unknown[]) => void {
|
||||||
return (...args: unknown[]) => {
|
return (...args: unknown[]) => {
|
||||||
const event = args[0] as SubAgentToolCallEvent;
|
const event = args[0] as AgentToolCallEvent;
|
||||||
if (abortSignal.aborted) return;
|
if (abortSignal.aborted) return;
|
||||||
|
|
||||||
// Look up tool and build invocation for metadata
|
// Look up tool and build invocation for metadata
|
||||||
|
|
@ -187,7 +187,7 @@ export class SubAgentTracker {
|
||||||
abortSignal: AbortSignal,
|
abortSignal: AbortSignal,
|
||||||
): (...args: unknown[]) => void {
|
): (...args: unknown[]) => void {
|
||||||
return (...args: unknown[]) => {
|
return (...args: unknown[]) => {
|
||||||
const event = args[0] as SubAgentToolResultEvent;
|
const event = args[0] as AgentToolResultEvent;
|
||||||
if (abortSignal.aborted) return;
|
if (abortSignal.aborted) return;
|
||||||
|
|
||||||
const state = this.toolStates.get(event.callId);
|
const state = this.toolStates.get(event.callId);
|
||||||
|
|
@ -215,7 +215,7 @@ export class SubAgentTracker {
|
||||||
abortSignal: AbortSignal,
|
abortSignal: AbortSignal,
|
||||||
): (...args: unknown[]) => Promise<void> {
|
): (...args: unknown[]) => Promise<void> {
|
||||||
return async (...args: unknown[]) => {
|
return async (...args: unknown[]) => {
|
||||||
const event = args[0] as SubAgentApprovalRequestEvent;
|
const event = args[0] as AgentApprovalRequestEvent;
|
||||||
if (abortSignal.aborted) return;
|
if (abortSignal.aborted) return;
|
||||||
|
|
||||||
const state = this.toolStates.get(event.callId);
|
const state = this.toolStates.get(event.callId);
|
||||||
|
|
@ -292,7 +292,7 @@ export class SubAgentTracker {
|
||||||
abortSignal: AbortSignal,
|
abortSignal: AbortSignal,
|
||||||
): (...args: unknown[]) => void {
|
): (...args: unknown[]) => void {
|
||||||
return (...args: unknown[]) => {
|
return (...args: unknown[]) => {
|
||||||
const event = args[0] as SubAgentUsageEvent;
|
const event = args[0] as AgentUsageEvent;
|
||||||
if (abortSignal.aborted) return;
|
if (abortSignal.aborted) return;
|
||||||
|
|
||||||
this.messageEmitter.emitUsageMetadata(
|
this.messageEmitter.emitUsageMetadata(
|
||||||
|
|
@ -312,7 +312,7 @@ export class SubAgentTracker {
|
||||||
abortSignal: AbortSignal,
|
abortSignal: AbortSignal,
|
||||||
): (...args: unknown[]) => void {
|
): (...args: unknown[]) => void {
|
||||||
return (...args: unknown[]) => {
|
return (...args: unknown[]) => {
|
||||||
const event = args[0] as SubAgentStreamTextEvent;
|
const event = args[0] as AgentStreamTextEvent;
|
||||||
if (abortSignal.aborted) return;
|
if (abortSignal.aborted) return;
|
||||||
|
|
||||||
// Emit streamed text as agent message or thought based on the flag
|
// Emit streamed text as agent message or thought based on the flag
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { GenerateContentResponseUsageMetadata } from '@google/genai';
|
import type { GenerateContentResponseUsageMetadata } from '@google/genai';
|
||||||
|
import type { SubagentMeta } from '../types.js';
|
||||||
import type { Usage } from '@agentclientprotocol/sdk';
|
import type { Usage } from '@agentclientprotocol/sdk';
|
||||||
import { BaseEmitter } from './BaseEmitter.js';
|
import { BaseEmitter } from './BaseEmitter.js';
|
||||||
|
|
||||||
|
|
@ -77,7 +78,7 @@ export class MessageEmitter extends BaseEmitter {
|
||||||
usageMetadata: GenerateContentResponseUsageMetadata,
|
usageMetadata: GenerateContentResponseUsageMetadata,
|
||||||
text: string = '',
|
text: string = '',
|
||||||
durationMs?: number,
|
durationMs?: number,
|
||||||
subagentMeta?: import('../types.js').SubagentMeta,
|
subagentMeta?: SubagentMeta,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const usage: Usage = {
|
const usage: Usage = {
|
||||||
inputTokens: usageMetadata.promptTokenCount ?? 0,
|
inputTokens: usageMetadata.promptTokenCount ?? 0,
|
||||||
|
|
|
||||||
|
|
@ -241,6 +241,30 @@ describe('parseArguments', () => {
|
||||||
expect(argv.prompt).toBeUndefined();
|
expect(argv.prompt).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should parse --system-prompt', async () => {
|
||||||
|
process.argv = [
|
||||||
|
'node',
|
||||||
|
'script.js',
|
||||||
|
'--system-prompt',
|
||||||
|
'You are a test system prompt.',
|
||||||
|
];
|
||||||
|
const argv = await parseArguments();
|
||||||
|
expect(argv.systemPrompt).toBe('You are a test system prompt.');
|
||||||
|
expect(argv.appendSystemPrompt).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse --append-system-prompt', async () => {
|
||||||
|
process.argv = [
|
||||||
|
'node',
|
||||||
|
'script.js',
|
||||||
|
'--append-system-prompt',
|
||||||
|
'Be extra concise.',
|
||||||
|
];
|
||||||
|
const argv = await parseArguments();
|
||||||
|
expect(argv.appendSystemPrompt).toBe('Be extra concise.');
|
||||||
|
expect(argv.systemPrompt).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it('should allow -r flag as alias for --resume', async () => {
|
it('should allow -r flag as alias for --resume', async () => {
|
||||||
process.argv = [
|
process.argv = [
|
||||||
'node',
|
'node',
|
||||||
|
|
@ -432,6 +456,21 @@ describe('parseArguments', () => {
|
||||||
mockExit.mockRestore();
|
mockExit.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should allow --system-prompt and --append-system-prompt together', async () => {
|
||||||
|
process.argv = [
|
||||||
|
'node',
|
||||||
|
'script.js',
|
||||||
|
'--system-prompt',
|
||||||
|
'Override prompt',
|
||||||
|
'--append-system-prompt',
|
||||||
|
'Append prompt',
|
||||||
|
];
|
||||||
|
|
||||||
|
const argv = await parseArguments();
|
||||||
|
expect(argv.systemPrompt).toBe('Override prompt');
|
||||||
|
expect(argv.appendSystemPrompt).toBe('Append prompt');
|
||||||
|
});
|
||||||
|
|
||||||
it('should throw an error when include-partial-messages is used without stream-json output', async () => {
|
it('should throw an error when include-partial-messages is used without stream-json output', async () => {
|
||||||
process.argv = ['node', 'script.js', '--include-partial-messages'];
|
process.argv = ['node', 'script.js', '--include-partial-messages'];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,16 +51,16 @@ import { appEvents } from '../utils/events.js';
|
||||||
import { mcpCommand } from '../commands/mcp.js';
|
import { mcpCommand } from '../commands/mcp.js';
|
||||||
|
|
||||||
// UUID v4 regex pattern for validation
|
// UUID v4 regex pattern for validation
|
||||||
const UUID_REGEX =
|
const SESSION_ID_REGEX =
|
||||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}(-agent-[a-zA-Z0-9_.-]+)?$/i;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates if a string is a valid UUID format
|
* Validates if a string is a valid session ID format.
|
||||||
* @param value - The string to validate
|
* Accepts a standard UUID, or a UUID followed by `-agent-{suffix}`
|
||||||
* @returns True if the string is a valid UUID, false otherwise
|
* (used by Arena to give each agent a deterministic session ID).
|
||||||
*/
|
*/
|
||||||
function isValidUUID(value: string): boolean {
|
function isValidSessionId(value: string): boolean {
|
||||||
return UUID_REGEX.test(value);
|
return SESSION_ID_REGEX.test(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
import { isWorkspaceTrusted } from './trustedFolders.js';
|
import { isWorkspaceTrusted } from './trustedFolders.js';
|
||||||
|
|
@ -110,6 +110,8 @@ export interface CliArgs {
|
||||||
debug: boolean | undefined;
|
debug: boolean | undefined;
|
||||||
prompt: string | undefined;
|
prompt: string | undefined;
|
||||||
promptInteractive: string | undefined;
|
promptInteractive: string | undefined;
|
||||||
|
systemPrompt: string | undefined;
|
||||||
|
appendSystemPrompt: string | undefined;
|
||||||
yolo: boolean | undefined;
|
yolo: boolean | undefined;
|
||||||
approvalMode: string | undefined;
|
approvalMode: string | undefined;
|
||||||
telemetry: boolean | undefined;
|
telemetry: boolean | undefined;
|
||||||
|
|
@ -289,6 +291,16 @@ export async function parseArguments(): Promise<CliArgs> {
|
||||||
description:
|
description:
|
||||||
'Execute the provided prompt and continue in interactive mode',
|
'Execute the provided prompt and continue in interactive mode',
|
||||||
})
|
})
|
||||||
|
.option('system-prompt', {
|
||||||
|
type: 'string',
|
||||||
|
description:
|
||||||
|
'Override the main session system prompt for this run. Can be combined with --append-system-prompt.',
|
||||||
|
})
|
||||||
|
.option('append-system-prompt', {
|
||||||
|
type: 'string',
|
||||||
|
description:
|
||||||
|
'Append instructions to the main session system prompt for this run. Can be combined with --system-prompt.',
|
||||||
|
})
|
||||||
.option('sandbox', {
|
.option('sandbox', {
|
||||||
alias: 's',
|
alias: 's',
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
|
@ -556,10 +568,13 @@ export async function parseArguments(): Promise<CliArgs> {
|
||||||
if (argv['sessionId'] && (argv['continue'] || argv['resume'])) {
|
if (argv['sessionId'] && (argv['continue'] || argv['resume'])) {
|
||||||
return 'Cannot use --session-id with --continue or --resume. Use --session-id to start a new session with a specific ID, or use --continue/--resume to resume an existing session.';
|
return 'Cannot use --session-id with --continue or --resume. Use --session-id to start a new session with a specific ID, or use --continue/--resume to resume an existing session.';
|
||||||
}
|
}
|
||||||
if (argv['sessionId'] && !isValidUUID(argv['sessionId'] as string)) {
|
if (
|
||||||
|
argv['sessionId'] &&
|
||||||
|
!isValidSessionId(argv['sessionId'] as string)
|
||||||
|
) {
|
||||||
return `Invalid --session-id: "${argv['sessionId']}". Must be a valid UUID (e.g., "123e4567-e89b-12d3-a456-426614174000").`;
|
return `Invalid --session-id: "${argv['sessionId']}". Must be a valid UUID (e.g., "123e4567-e89b-12d3-a456-426614174000").`;
|
||||||
}
|
}
|
||||||
if (argv['resume'] && !isValidUUID(argv['resume'] as string)) {
|
if (argv['resume'] && !isValidSessionId(argv['resume'] as string)) {
|
||||||
return `Invalid --resume: "${argv['resume']}". Must be a valid UUID (e.g., "123e4567-e89b-12d3-a456-426614174000").`;
|
return `Invalid --resume: "${argv['resume']}". Must be a valid UUID (e.g., "123e4567-e89b-12d3-a456-426614174000").`;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -961,6 +976,8 @@ export async function loadCliConfig(
|
||||||
importFormat: settings.context?.importFormat || 'tree',
|
importFormat: settings.context?.importFormat || 'tree',
|
||||||
debugMode,
|
debugMode,
|
||||||
question,
|
question,
|
||||||
|
systemPrompt: argv.systemPrompt,
|
||||||
|
appendSystemPrompt: argv.appendSystemPrompt,
|
||||||
coreTools: argv.coreTools || settings.tools?.core || undefined,
|
coreTools: argv.coreTools || settings.tools?.core || undefined,
|
||||||
allowedTools: argv.allowedTools || settings.tools?.allowed || undefined,
|
allowedTools: argv.allowedTools || settings.tools?.allowed || undefined,
|
||||||
excludeTools,
|
excludeTools,
|
||||||
|
|
@ -1044,6 +1061,18 @@ export async function loadCliConfig(
|
||||||
lsp: {
|
lsp: {
|
||||||
enabled: lspEnabled,
|
enabled: lspEnabled,
|
||||||
},
|
},
|
||||||
|
agents: settings.agents
|
||||||
|
? {
|
||||||
|
displayMode: settings.agents.displayMode,
|
||||||
|
arena: settings.agents.arena
|
||||||
|
? {
|
||||||
|
worktreeBaseDir: settings.agents.arena.worktreeBaseDir,
|
||||||
|
preserveArtifacts:
|
||||||
|
settings.agents.arena.preserveArtifacts ?? false,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (lspEnabled) {
|
if (lspEnabled) {
|
||||||
|
|
|
||||||
|
|
@ -1244,6 +1244,104 @@ const SETTINGS_SCHEMA = {
|
||||||
description: 'Configuration for web search providers.',
|
description: 'Configuration for web search providers.',
|
||||||
showInDialog: false,
|
showInDialog: false,
|
||||||
},
|
},
|
||||||
|
agents: {
|
||||||
|
type: 'object',
|
||||||
|
label: 'Agents',
|
||||||
|
category: 'Advanced',
|
||||||
|
requiresRestart: false,
|
||||||
|
default: {},
|
||||||
|
description:
|
||||||
|
'Settings for multi-agent collaboration features (Arena, Team, Swarm).',
|
||||||
|
showInDialog: false,
|
||||||
|
properties: {
|
||||||
|
displayMode: {
|
||||||
|
type: 'enum',
|
||||||
|
label: 'Display Mode',
|
||||||
|
category: 'Advanced',
|
||||||
|
requiresRestart: false,
|
||||||
|
default: undefined as string | undefined,
|
||||||
|
description:
|
||||||
|
'Display mode for multi-agent sessions. Currently only "in-process" is supported.',
|
||||||
|
showInDialog: false,
|
||||||
|
options: [
|
||||||
|
{ value: 'in-process', label: 'In-process' },
|
||||||
|
// { value: 'tmux', label: 'tmux' },
|
||||||
|
// { value: 'iterm2', label: 'iTerm2' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
arena: {
|
||||||
|
type: 'object',
|
||||||
|
label: 'Arena',
|
||||||
|
category: 'Advanced',
|
||||||
|
requiresRestart: false,
|
||||||
|
default: {},
|
||||||
|
description: 'Settings for Arena (multi-model competitive execution).',
|
||||||
|
showInDialog: false,
|
||||||
|
properties: {
|
||||||
|
worktreeBaseDir: {
|
||||||
|
type: 'string',
|
||||||
|
label: 'Worktree Base Directory',
|
||||||
|
category: 'Advanced',
|
||||||
|
requiresRestart: true,
|
||||||
|
default: undefined as string | undefined,
|
||||||
|
description:
|
||||||
|
'Custom base directory for Arena worktrees. Defaults to ~/.qwen/arena.',
|
||||||
|
showInDialog: false,
|
||||||
|
},
|
||||||
|
preserveArtifacts: {
|
||||||
|
type: 'boolean',
|
||||||
|
label: 'Preserve Arena Artifacts',
|
||||||
|
category: 'Advanced',
|
||||||
|
requiresRestart: false,
|
||||||
|
default: false,
|
||||||
|
description:
|
||||||
|
'When enabled, Arena worktrees and session state files are preserved after the session ends or the main agent exits.',
|
||||||
|
showInDialog: true,
|
||||||
|
},
|
||||||
|
maxRoundsPerAgent: {
|
||||||
|
type: 'number',
|
||||||
|
label: 'Max Rounds Per Agent',
|
||||||
|
category: 'Advanced',
|
||||||
|
requiresRestart: false,
|
||||||
|
default: undefined as number | undefined,
|
||||||
|
description:
|
||||||
|
'Maximum number of rounds (turns) each agent can execute. No limit if unset.',
|
||||||
|
showInDialog: false,
|
||||||
|
},
|
||||||
|
timeoutSeconds: {
|
||||||
|
type: 'number',
|
||||||
|
label: 'Timeout (seconds)',
|
||||||
|
category: 'Advanced',
|
||||||
|
requiresRestart: false,
|
||||||
|
default: undefined as number | undefined,
|
||||||
|
description:
|
||||||
|
'Total timeout in seconds for the Arena session. No limit if unset.',
|
||||||
|
showInDialog: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
team: {
|
||||||
|
type: 'object',
|
||||||
|
label: 'Team',
|
||||||
|
category: 'Advanced',
|
||||||
|
requiresRestart: false,
|
||||||
|
default: {},
|
||||||
|
description:
|
||||||
|
'Settings for Agent Team (role-based collaborative execution). Reserved for future use.',
|
||||||
|
showInDialog: false,
|
||||||
|
},
|
||||||
|
swarm: {
|
||||||
|
type: 'object',
|
||||||
|
label: 'Swarm',
|
||||||
|
category: 'Advanced',
|
||||||
|
requiresRestart: false,
|
||||||
|
default: {},
|
||||||
|
description:
|
||||||
|
'Settings for Agent Swarm (parallel sub-agent execution). Reserved for future use.',
|
||||||
|
showInDialog: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
hooksConfig: {
|
hooksConfig: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
|
@ -1315,6 +1413,17 @@ const SETTINGS_SCHEMA = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
experimental: {
|
||||||
|
type: 'object',
|
||||||
|
label: 'Experimental',
|
||||||
|
category: 'Experimental',
|
||||||
|
requiresRestart: true,
|
||||||
|
default: {},
|
||||||
|
description: 'Setting to enable experimental features',
|
||||||
|
showInDialog: false,
|
||||||
|
properties: {},
|
||||||
|
},
|
||||||
} as const satisfies SettingsSchema;
|
} as const satisfies SettingsSchema;
|
||||||
|
|
||||||
export type SettingsSchemaType = typeof SETTINGS_SCHEMA;
|
export type SettingsSchemaType = typeof SETTINGS_SCHEMA;
|
||||||
|
|
|
||||||
|
|
@ -467,6 +467,8 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||||
debug: undefined,
|
debug: undefined,
|
||||||
prompt: undefined,
|
prompt: undefined,
|
||||||
promptInteractive: undefined,
|
promptInteractive: undefined,
|
||||||
|
systemPrompt: undefined,
|
||||||
|
appendSystemPrompt: undefined,
|
||||||
query: undefined,
|
query: undefined,
|
||||||
yolo: undefined,
|
yolo: undefined,
|
||||||
approvalMode: undefined,
|
approvalMode: undefined,
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ import { KeypressProvider } from './ui/contexts/KeypressContext.js';
|
||||||
import { SessionStatsProvider } from './ui/contexts/SessionContext.js';
|
import { SessionStatsProvider } from './ui/contexts/SessionContext.js';
|
||||||
import { SettingsContext } from './ui/contexts/SettingsContext.js';
|
import { SettingsContext } from './ui/contexts/SettingsContext.js';
|
||||||
import { VimModeProvider } from './ui/contexts/VimModeContext.js';
|
import { VimModeProvider } from './ui/contexts/VimModeContext.js';
|
||||||
|
import { AgentViewProvider } from './ui/contexts/AgentViewContext.js';
|
||||||
import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js';
|
import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js';
|
||||||
import { themeManager } from './ui/themes/theme-manager.js';
|
import { themeManager } from './ui/themes/theme-manager.js';
|
||||||
import { detectAndEnableKittyProtocol } from './ui/utils/kittyProtocolDetector.js';
|
import { detectAndEnableKittyProtocol } from './ui/utils/kittyProtocolDetector.js';
|
||||||
|
|
@ -162,6 +163,7 @@ export async function startInteractiveUI(
|
||||||
>
|
>
|
||||||
<SessionStatsProvider sessionId={config.getSessionId()}>
|
<SessionStatsProvider sessionId={config.getSessionId()}>
|
||||||
<VimModeProvider settings={settings}>
|
<VimModeProvider settings={settings}>
|
||||||
|
<AgentViewProvider config={config}>
|
||||||
<AppContainer
|
<AppContainer
|
||||||
config={config}
|
config={config}
|
||||||
settings={settings}
|
settings={settings}
|
||||||
|
|
@ -169,6 +171,7 @@ export async function startInteractiveUI(
|
||||||
version={version}
|
version={version}
|
||||||
initializationResult={initializationResult}
|
initializationResult={initializationResult}
|
||||||
/>
|
/>
|
||||||
|
</AgentViewProvider>
|
||||||
</VimModeProvider>
|
</VimModeProvider>
|
||||||
</SessionStatsProvider>
|
</SessionStatsProvider>
|
||||||
</KeypressProvider>
|
</KeypressProvider>
|
||||||
|
|
|
||||||
|
|
@ -282,12 +282,12 @@ export abstract class BaseJsonOutputAdapter {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lastBlock.type === 'text') {
|
|
||||||
const index = state.blocks.length - 1;
|
|
||||||
this.onBlockClosed(state, index, actualParentToolUseId);
|
|
||||||
this.closeBlock(state, index);
|
|
||||||
} else if (lastBlock.type === 'thinking') {
|
|
||||||
const index = state.blocks.length - 1;
|
const index = state.blocks.length - 1;
|
||||||
|
if (!state.openBlocks.has(index)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastBlock.type === 'text' || lastBlock.type === 'thinking') {
|
||||||
this.onBlockClosed(state, index, actualParentToolUseId);
|
this.onBlockClosed(state, index, actualParentToolUseId);
|
||||||
this.closeBlock(state, index);
|
this.closeBlock(state, index);
|
||||||
}
|
}
|
||||||
|
|
@ -392,7 +392,9 @@ export abstract class BaseJsonOutputAdapter {
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = this.buildMessage(parentToolUseId);
|
const message = this.buildMessage(parentToolUseId);
|
||||||
|
if (state.messageStarted) {
|
||||||
this.emitMessageImpl(message);
|
this.emitMessageImpl(message);
|
||||||
|
}
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -656,12 +658,7 @@ export abstract class BaseJsonOutputAdapter {
|
||||||
parentToolUseId: string,
|
parentToolUseId: string,
|
||||||
): CLIAssistantMessage {
|
): CLIAssistantMessage {
|
||||||
const state = this.getMessageState(parentToolUseId);
|
const state = this.getMessageState(parentToolUseId);
|
||||||
const message = this.finalizeAssistantMessageInternal(
|
return this.finalizeAssistantMessageInternal(state, parentToolUseId);
|
||||||
state,
|
|
||||||
parentToolUseId,
|
|
||||||
);
|
|
||||||
this.updateLastAssistantMessage(message);
|
|
||||||
return message;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -52,12 +52,10 @@ export class JsonOutputAdapter
|
||||||
}
|
}
|
||||||
|
|
||||||
finalizeAssistantMessage(): CLIAssistantMessage {
|
finalizeAssistantMessage(): CLIAssistantMessage {
|
||||||
const message = this.finalizeAssistantMessageInternal(
|
return this.finalizeAssistantMessageInternal(
|
||||||
this.mainAgentMessageState,
|
this.mainAgentMessageState,
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
this.updateLastAssistantMessage(message);
|
|
||||||
return message;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
emitResult(options: ResultOptions): void {
|
emitResult(options: ResultOptions): void {
|
||||||
|
|
|
||||||
|
|
@ -654,6 +654,24 @@ describe('StreamJsonOutputAdapter', () => {
|
||||||
'Message not started',
|
'Message not started',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not emit empty assistant message when started but no content processed', () => {
|
||||||
|
stdoutWriteSpy.mockClear();
|
||||||
|
adapter.finalizeAssistantMessage();
|
||||||
|
|
||||||
|
const assistantCalls = stdoutWriteSpy.mock.calls.filter(
|
||||||
|
(call: unknown[]) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(call[0] as string);
|
||||||
|
return parsed.type === 'assistant';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(assistantCalls).toHaveLength(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('emitResult', () => {
|
describe('emitResult', () => {
|
||||||
|
|
@ -1007,56 +1025,68 @@ describe('StreamJsonOutputAdapter', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('message_id in stream events', () => {
|
describe('content_block event identification', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
adapter = new StreamJsonOutputAdapter(mockConfig, true);
|
adapter = new StreamJsonOutputAdapter(mockConfig, true);
|
||||||
adapter.startAssistantMessage();
|
adapter.startAssistantMessage();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should include message_id in stream events after message starts', () => {
|
it('should not include message_id in content_block events', () => {
|
||||||
adapter.processEvent({
|
adapter.processEvent({
|
||||||
type: GeminiEventType.Content,
|
type: GeminiEventType.Content,
|
||||||
value: 'Text',
|
value: 'Text',
|
||||||
});
|
});
|
||||||
// Process another event to ensure messageStarted is true
|
|
||||||
adapter.processEvent({
|
adapter.processEvent({
|
||||||
type: GeminiEventType.Content,
|
type: GeminiEventType.Content,
|
||||||
value: 'More',
|
value: 'More',
|
||||||
});
|
});
|
||||||
|
|
||||||
const calls = stdoutWriteSpy.mock.calls;
|
const calls = stdoutWriteSpy.mock.calls;
|
||||||
// Find all delta events
|
const contentBlockCalls = calls.filter((call: unknown[]) => {
|
||||||
const deltaCalls = calls.filter((call: unknown[]) => {
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(call[0] as string);
|
const parsed = JSON.parse(call[0] as string);
|
||||||
return (
|
return (
|
||||||
parsed.type === 'stream_event' &&
|
parsed.type === 'stream_event' &&
|
||||||
parsed.event.type === 'content_block_delta'
|
(parsed.event.type === 'content_block_start' ||
|
||||||
|
parsed.event.type === 'content_block_delta' ||
|
||||||
|
parsed.event.type === 'content_block_stop')
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(deltaCalls.length).toBeGreaterThan(0);
|
expect(contentBlockCalls.length).toBeGreaterThan(0);
|
||||||
// The second delta event should have message_id (after messageStarted becomes true)
|
for (const call of contentBlockCalls) {
|
||||||
// message_id is added to the event object, so check parsed.event.message_id
|
const parsed = JSON.parse((call as unknown[])[0] as string);
|
||||||
if (deltaCalls.length > 1) {
|
expect(parsed.event.message_id).toBeUndefined();
|
||||||
const secondDelta = JSON.parse(
|
|
||||||
(deltaCalls[1] as unknown[])[0] as string,
|
|
||||||
);
|
|
||||||
// message_id is on the enriched event object
|
|
||||||
expect(
|
|
||||||
secondDelta.event.message_id || secondDelta.message_id,
|
|
||||||
).toBeTruthy();
|
|
||||||
} else {
|
|
||||||
// If only one delta, check if message_id exists
|
|
||||||
const delta = JSON.parse((deltaCalls[0] as unknown[])[0] as string);
|
|
||||||
// message_id is added when messageStarted is true
|
|
||||||
// First event may or may not have it, but subsequent ones should
|
|
||||||
expect(delta.event.message_id || delta.message_id).toBeTruthy();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should identify content_block events by session_id and index', () => {
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Content,
|
||||||
|
value: 'Text',
|
||||||
|
});
|
||||||
|
|
||||||
|
const calls = stdoutWriteSpy.mock.calls;
|
||||||
|
const blockStartCall = calls.find((call: unknown[]) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(call[0] as string);
|
||||||
|
return (
|
||||||
|
parsed.type === 'stream_event' &&
|
||||||
|
parsed.event.type === 'content_block_start'
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(blockStartCall).toBeDefined();
|
||||||
|
const parsed = JSON.parse((blockStartCall as unknown[])[0] as string);
|
||||||
|
expect(parsed.session_id).toBe('test-session-id');
|
||||||
|
expect(typeof parsed.event.index).toBe('number');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('multiple text blocks', () => {
|
describe('multiple text blocks', () => {
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,8 @@ export class StreamJsonOutputAdapter
|
||||||
extends BaseJsonOutputAdapter
|
extends BaseJsonOutputAdapter
|
||||||
implements JsonOutputAdapterInterface
|
implements JsonOutputAdapterInterface
|
||||||
{
|
{
|
||||||
|
private mainTurnMessageStartEmitted = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
config: Config,
|
config: Config,
|
||||||
private readonly includePartialMessages: boolean,
|
private readonly includePartialMessages: boolean,
|
||||||
|
|
@ -68,29 +70,27 @@ export class StreamJsonOutputAdapter
|
||||||
return this.includePartialMessages;
|
return this.includePartialMessages;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override startAssistantMessage(): void {
|
||||||
|
this.mainTurnMessageStartEmitted = false;
|
||||||
|
super.startAssistantMessage();
|
||||||
|
}
|
||||||
|
|
||||||
finalizeAssistantMessage(): CLIAssistantMessage {
|
finalizeAssistantMessage(): CLIAssistantMessage {
|
||||||
const state = this.mainAgentMessageState;
|
const message = this.finalizeAssistantMessageInternal(
|
||||||
if (state.finalized) {
|
this.mainAgentMessageState,
|
||||||
return this.buildMessage(null);
|
null,
|
||||||
}
|
|
||||||
state.finalized = true;
|
|
||||||
|
|
||||||
this.finalizePendingBlocks(state, null);
|
|
||||||
const orderedOpenBlocks = Array.from(state.openBlocks).sort(
|
|
||||||
(a, b) => a - b,
|
|
||||||
);
|
);
|
||||||
for (const index of orderedOpenBlocks) {
|
if (this.mainTurnMessageStartEmitted && this.includePartialMessages) {
|
||||||
this.onBlockClosed(state, index, null);
|
const partial: CLIPartialAssistantMessage = {
|
||||||
this.closeBlock(state, index);
|
type: 'stream_event',
|
||||||
|
uuid: randomUUID(),
|
||||||
|
session_id: this.getSessionId(),
|
||||||
|
parent_tool_use_id: null,
|
||||||
|
event: { type: 'message_stop' },
|
||||||
|
};
|
||||||
|
this.emitMessageImpl(partial);
|
||||||
}
|
}
|
||||||
|
this.mainTurnMessageStartEmitted = false;
|
||||||
if (state.messageStarted && this.includePartialMessages) {
|
|
||||||
this.emitStreamEventIfEnabled({ type: 'message_stop' }, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = this.buildMessage(null);
|
|
||||||
this.updateLastAssistantMessage(message);
|
|
||||||
this.emitMessageImpl(message);
|
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -249,14 +249,15 @@ export class StreamJsonOutputAdapter
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Overrides base class hook to emit message_start event when message is started.
|
* Overrides base class hook to emit message_start event when message is started.
|
||||||
* Only emits for main agent, not for subagents.
|
* Only emits once per turn for the main agent (guarded by mainTurnMessageStartEmitted),
|
||||||
|
* so block-type transitions inside a single turn do not produce spurious message_start events.
|
||||||
*/
|
*/
|
||||||
protected override onEnsureMessageStarted(
|
protected override onEnsureMessageStarted(
|
||||||
state: MessageState,
|
state: MessageState,
|
||||||
parentToolUseId: string | null,
|
parentToolUseId: string | null,
|
||||||
): void {
|
): void {
|
||||||
// Only emit message_start for main agent, not for subagents
|
if (parentToolUseId === null && !this.mainTurnMessageStartEmitted) {
|
||||||
if (parentToolUseId === null) {
|
this.mainTurnMessageStartEmitted = true;
|
||||||
this.emitStreamEventIfEnabled(
|
this.emitStreamEventIfEnabled(
|
||||||
{
|
{
|
||||||
type: 'message_start',
|
type: 'message_start',
|
||||||
|
|
@ -264,6 +265,7 @@ export class StreamJsonOutputAdapter
|
||||||
id: state.messageId!,
|
id: state.messageId!,
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
model: this.config.getModel(),
|
model: this.config.getModel(),
|
||||||
|
content: [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
|
|
@ -311,19 +313,12 @@ export class StreamJsonOutputAdapter
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = this.getMessageState(parentToolUseId);
|
|
||||||
const enrichedEvent = state.messageStarted
|
|
||||||
? ({ ...event, message_id: state.messageId } as StreamEvent & {
|
|
||||||
message_id: string;
|
|
||||||
})
|
|
||||||
: event;
|
|
||||||
|
|
||||||
const partial: CLIPartialAssistantMessage = {
|
const partial: CLIPartialAssistantMessage = {
|
||||||
type: 'stream_event',
|
type: 'stream_event',
|
||||||
uuid: randomUUID(),
|
uuid: randomUUID(),
|
||||||
session_id: this.getSessionId(),
|
session_id: this.getSessionId(),
|
||||||
parent_tool_use_id: parentToolUseId,
|
parent_tool_use_id: parentToolUseId,
|
||||||
event: enrichedEvent,
|
event,
|
||||||
};
|
};
|
||||||
this.emitMessageImpl(partial);
|
this.emitMessageImpl(partial);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -201,6 +201,7 @@ export interface MessageStartStreamEvent {
|
||||||
id: string;
|
id: string;
|
||||||
role: 'assistant';
|
role: 'assistant';
|
||||||
model: string;
|
model: string;
|
||||||
|
content: [];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -390,6 +390,16 @@ export async function runNonInteractive(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// Ensure message_start / message_stop (and content_block events) are
|
||||||
|
// properly paired even when an error aborts the turn mid-stream.
|
||||||
|
// The call is safe when no message was started (throws → caught) or
|
||||||
|
// when already finalized (idempotent guard inside the adapter).
|
||||||
|
try {
|
||||||
|
adapter.finalizeAssistantMessage();
|
||||||
|
} catch {
|
||||||
|
// Expected when no message was started or already finalized
|
||||||
|
}
|
||||||
|
|
||||||
// For JSON and STREAM_JSON modes, compute usage from metrics
|
// For JSON and STREAM_JSON modes, compute usage from metrics
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
const metrics = uiTelemetryService.getMetrics();
|
const metrics = uiTelemetryService.getMetrics();
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import type { SlashCommand } from '../ui/commands/types.js';
|
||||||
import type { Config } from '@qwen-code/qwen-code-core';
|
import type { Config } from '@qwen-code/qwen-code-core';
|
||||||
import { aboutCommand } from '../ui/commands/aboutCommand.js';
|
import { aboutCommand } from '../ui/commands/aboutCommand.js';
|
||||||
import { agentsCommand } from '../ui/commands/agentsCommand.js';
|
import { agentsCommand } from '../ui/commands/agentsCommand.js';
|
||||||
|
import { arenaCommand } from '../ui/commands/arenaCommand.js';
|
||||||
import { approvalModeCommand } from '../ui/commands/approvalModeCommand.js';
|
import { approvalModeCommand } from '../ui/commands/approvalModeCommand.js';
|
||||||
import { authCommand } from '../ui/commands/authCommand.js';
|
import { authCommand } from '../ui/commands/authCommand.js';
|
||||||
import { bugCommand } from '../ui/commands/bugCommand.js';
|
import { bugCommand } from '../ui/commands/bugCommand.js';
|
||||||
|
|
@ -61,6 +62,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||||
const allDefinitions: Array<SlashCommand | null> = [
|
const allDefinitions: Array<SlashCommand | null> = [
|
||||||
aboutCommand,
|
aboutCommand,
|
||||||
agentsCommand,
|
agentsCommand,
|
||||||
|
arenaCommand,
|
||||||
approvalModeCommand,
|
approvalModeCommand,
|
||||||
authCommand,
|
authCommand,
|
||||||
bugCommand,
|
bugCommand,
|
||||||
|
|
|
||||||
|
|
@ -109,10 +109,9 @@ export class ShellProcessor implements IPromptProcessor {
|
||||||
return { ...injection, resolvedCommand: undefined };
|
return { ...injection, resolvedCommand: undefined };
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedCommand = command.replaceAll(
|
const resolvedCommand = command
|
||||||
SHORTHAND_ARGS_PLACEHOLDER,
|
.replaceAll(SHORTHAND_ARGS_PLACEHOLDER, userArgsEscaped) // Replace {{args}}
|
||||||
userArgsEscaped,
|
.replaceAll('$ARGUMENTS', userArgsEscaped); // Replace $ARGUMENTS
|
||||||
);
|
|
||||||
return { ...injection, resolvedCommand };
|
return { ...injection, resolvedCommand };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,11 @@ import { render } from 'ink-testing-library';
|
||||||
import { Text, useIsScreenReaderEnabled } from 'ink';
|
import { Text, useIsScreenReaderEnabled } from 'ink';
|
||||||
import { App } from './App.js';
|
import { App } from './App.js';
|
||||||
import { UIStateContext, type UIState } from './contexts/UIStateContext.js';
|
import { UIStateContext, type UIState } from './contexts/UIStateContext.js';
|
||||||
|
import {
|
||||||
|
UIActionsContext,
|
||||||
|
type UIActions,
|
||||||
|
} from './contexts/UIActionsContext.js';
|
||||||
|
import { AgentViewProvider } from './contexts/AgentViewContext.js';
|
||||||
import { StreamingState } from './types.js';
|
import { StreamingState } from './types.js';
|
||||||
|
|
||||||
vi.mock('ink', async (importOriginal) => {
|
vi.mock('ink', async (importOriginal) => {
|
||||||
|
|
@ -43,6 +48,10 @@ vi.mock('./components/Footer.js', () => ({
|
||||||
Footer: () => <Text>Footer</Text>,
|
Footer: () => <Text>Footer</Text>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('./components/agent-view/AgentTabBar.js', () => ({
|
||||||
|
AgentTabBar: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
describe('App', () => {
|
describe('App', () => {
|
||||||
const mockUIState: Partial<UIState> = {
|
const mockUIState: Partial<UIState> = {
|
||||||
streamingState: StreamingState.Idle,
|
streamingState: StreamingState.Idle,
|
||||||
|
|
@ -58,13 +67,24 @@ describe('App', () => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
it('should render main content and composer when not quitting', () => {
|
const mockUIActions = {
|
||||||
const { lastFrame } = render(
|
refreshStatic: vi.fn(),
|
||||||
<UIStateContext.Provider value={mockUIState as UIState}>
|
} as unknown as UIActions;
|
||||||
|
|
||||||
|
const renderWithProviders = (uiState: UIState) =>
|
||||||
|
render(
|
||||||
|
<UIActionsContext.Provider value={mockUIActions}>
|
||||||
|
<AgentViewProvider>
|
||||||
|
<UIStateContext.Provider value={uiState}>
|
||||||
<App />
|
<App />
|
||||||
</UIStateContext.Provider>,
|
</UIStateContext.Provider>
|
||||||
|
</AgentViewProvider>
|
||||||
|
</UIActionsContext.Provider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it('should render main content and composer when not quitting', () => {
|
||||||
|
const { lastFrame } = renderWithProviders(mockUIState as UIState);
|
||||||
|
|
||||||
expect(lastFrame()).toContain('MainContent');
|
expect(lastFrame()).toContain('MainContent');
|
||||||
expect(lastFrame()).toContain('Composer');
|
expect(lastFrame()).toContain('Composer');
|
||||||
});
|
});
|
||||||
|
|
@ -75,11 +95,7 @@ describe('App', () => {
|
||||||
quittingMessages: [{ id: 1, type: 'user', text: 'test' }],
|
quittingMessages: [{ id: 1, type: 'user', text: 'test' }],
|
||||||
} as UIState;
|
} as UIState;
|
||||||
|
|
||||||
const { lastFrame } = render(
|
const { lastFrame } = renderWithProviders(quittingUIState);
|
||||||
<UIStateContext.Provider value={quittingUIState}>
|
|
||||||
<App />
|
|
||||||
</UIStateContext.Provider>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(lastFrame()).toContain('Quitting...');
|
expect(lastFrame()).toContain('Quitting...');
|
||||||
});
|
});
|
||||||
|
|
@ -90,11 +106,7 @@ describe('App', () => {
|
||||||
dialogsVisible: true,
|
dialogsVisible: true,
|
||||||
} as UIState;
|
} as UIState;
|
||||||
|
|
||||||
const { lastFrame } = render(
|
const { lastFrame } = renderWithProviders(dialogUIState);
|
||||||
<UIStateContext.Provider value={dialogUIState}>
|
|
||||||
<App />
|
|
||||||
</UIStateContext.Provider>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(lastFrame()).toContain('MainContent');
|
expect(lastFrame()).toContain('MainContent');
|
||||||
expect(lastFrame()).toContain('DialogManager');
|
expect(lastFrame()).toContain('DialogManager');
|
||||||
|
|
@ -107,11 +119,7 @@ describe('App', () => {
|
||||||
ctrlCPressedOnce: true,
|
ctrlCPressedOnce: true,
|
||||||
} as UIState;
|
} as UIState;
|
||||||
|
|
||||||
const { lastFrame } = render(
|
const { lastFrame } = renderWithProviders(ctrlCUIState);
|
||||||
<UIStateContext.Provider value={ctrlCUIState}>
|
|
||||||
<App />
|
|
||||||
</UIStateContext.Provider>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(lastFrame()).toContain('Press Ctrl+C again to exit.');
|
expect(lastFrame()).toContain('Press Ctrl+C again to exit.');
|
||||||
});
|
});
|
||||||
|
|
@ -123,11 +131,7 @@ describe('App', () => {
|
||||||
ctrlDPressedOnce: true,
|
ctrlDPressedOnce: true,
|
||||||
} as UIState;
|
} as UIState;
|
||||||
|
|
||||||
const { lastFrame } = render(
|
const { lastFrame } = renderWithProviders(ctrlDUIState);
|
||||||
<UIStateContext.Provider value={ctrlDUIState}>
|
|
||||||
<App />
|
|
||||||
</UIStateContext.Provider>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(lastFrame()).toContain('Press Ctrl+D again to exit.');
|
expect(lastFrame()).toContain('Press Ctrl+D again to exit.');
|
||||||
});
|
});
|
||||||
|
|
@ -135,11 +139,7 @@ describe('App', () => {
|
||||||
it('should render ScreenReaderAppLayout when screen reader is enabled', () => {
|
it('should render ScreenReaderAppLayout when screen reader is enabled', () => {
|
||||||
(useIsScreenReaderEnabled as vi.Mock).mockReturnValue(true);
|
(useIsScreenReaderEnabled as vi.Mock).mockReturnValue(true);
|
||||||
|
|
||||||
const { lastFrame } = render(
|
const { lastFrame } = renderWithProviders(mockUIState as UIState);
|
||||||
<UIStateContext.Provider value={mockUIState as UIState}>
|
|
||||||
<App />
|
|
||||||
</UIStateContext.Provider>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(lastFrame()).toContain(
|
expect(lastFrame()).toContain(
|
||||||
'Notifications\nFooter\nMainContent\nComposer',
|
'Notifications\nFooter\nMainContent\nComposer',
|
||||||
|
|
@ -149,11 +149,7 @@ describe('App', () => {
|
||||||
it('should render DefaultAppLayout when screen reader is not enabled', () => {
|
it('should render DefaultAppLayout when screen reader is not enabled', () => {
|
||||||
(useIsScreenReaderEnabled as vi.Mock).mockReturnValue(false);
|
(useIsScreenReaderEnabled as vi.Mock).mockReturnValue(false);
|
||||||
|
|
||||||
const { lastFrame } = render(
|
const { lastFrame } = renderWithProviders(mockUIState as UIState);
|
||||||
<UIStateContext.Provider value={mockUIState as UIState}>
|
|
||||||
<App />
|
|
||||||
</UIStateContext.Provider>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(lastFrame()).toContain('MainContent\nComposer');
|
expect(lastFrame()).toContain('MainContent\nComposer');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,21 @@ vi.mock('./hooks/useAutoAcceptIndicator.js');
|
||||||
vi.mock('./hooks/useGitBranchName.js');
|
vi.mock('./hooks/useGitBranchName.js');
|
||||||
vi.mock('./contexts/VimModeContext.js');
|
vi.mock('./contexts/VimModeContext.js');
|
||||||
vi.mock('./contexts/SessionContext.js');
|
vi.mock('./contexts/SessionContext.js');
|
||||||
|
vi.mock('./contexts/AgentViewContext.js', () => ({
|
||||||
|
useAgentViewState: vi.fn(() => ({
|
||||||
|
activeView: 'main',
|
||||||
|
agents: new Map(),
|
||||||
|
})),
|
||||||
|
useAgentViewActions: vi.fn(() => ({
|
||||||
|
switchToMain: vi.fn(),
|
||||||
|
switchToAgent: vi.fn(),
|
||||||
|
switchToNext: vi.fn(),
|
||||||
|
switchToPrevious: vi.fn(),
|
||||||
|
registerAgent: vi.fn(),
|
||||||
|
unregisterAgent: vi.fn(),
|
||||||
|
unregisterAll: vi.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
vi.mock('./components/shared/text-buffer.js');
|
vi.mock('./components/shared/text-buffer.js');
|
||||||
vi.mock('./hooks/useLogger.js');
|
vi.mock('./hooks/useLogger.js');
|
||||||
|
|
||||||
|
|
@ -268,7 +283,7 @@ describe('AppContainer State Management', () => {
|
||||||
listSubagents: vi.fn().mockResolvedValue([]),
|
listSubagents: vi.fn().mockResolvedValue([]),
|
||||||
addChangeListener: vi.fn(),
|
addChangeListener: vi.fn(),
|
||||||
loadSubagent: vi.fn(),
|
loadSubagent: vi.fn(),
|
||||||
createSubagentScope: vi.fn(),
|
createSubagent: vi.fn(),
|
||||||
};
|
};
|
||||||
vi.spyOn(mockConfig, 'getSubagentManager').mockReturnValue(
|
vi.spyOn(mockConfig, 'getSubagentManager').mockReturnValue(
|
||||||
mockSubagentManager as SubagentManager,
|
mockSubagentManager as SubagentManager,
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ import { useAuthCommand } from './auth/useAuth.js';
|
||||||
import { useEditorSettings } from './hooks/useEditorSettings.js';
|
import { useEditorSettings } from './hooks/useEditorSettings.js';
|
||||||
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
|
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
|
||||||
import { useModelCommand } from './hooks/useModelCommand.js';
|
import { useModelCommand } from './hooks/useModelCommand.js';
|
||||||
|
import { useArenaCommand } from './hooks/useArenaCommand.js';
|
||||||
import { useApprovalModeCommand } from './hooks/useApprovalModeCommand.js';
|
import { useApprovalModeCommand } from './hooks/useApprovalModeCommand.js';
|
||||||
import { useResumeCommand } from './hooks/useResumeCommand.js';
|
import { useResumeCommand } from './hooks/useResumeCommand.js';
|
||||||
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
|
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
|
||||||
|
|
@ -96,6 +97,7 @@ import {
|
||||||
} from './hooks/useExtensionUpdates.js';
|
} from './hooks/useExtensionUpdates.js';
|
||||||
import { useCodingPlanUpdates } from './hooks/useCodingPlanUpdates.js';
|
import { useCodingPlanUpdates } from './hooks/useCodingPlanUpdates.js';
|
||||||
import { ShellFocusContext } from './contexts/ShellFocusContext.js';
|
import { ShellFocusContext } from './contexts/ShellFocusContext.js';
|
||||||
|
import { useAgentViewState } from './contexts/AgentViewContext.js';
|
||||||
import { t } from '../i18n/index.js';
|
import { t } from '../i18n/index.js';
|
||||||
import { useWelcomeBack } from './hooks/useWelcomeBack.js';
|
import { useWelcomeBack } from './hooks/useWelcomeBack.js';
|
||||||
import { useDialogClose } from './hooks/useDialogClose.js';
|
import { useDialogClose } from './hooks/useDialogClose.js';
|
||||||
|
|
@ -470,6 +472,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||||
|
|
||||||
const { isModelDialogOpen, openModelDialog, closeModelDialog } =
|
const { isModelDialogOpen, openModelDialog, closeModelDialog } =
|
||||||
useModelCommand();
|
useModelCommand();
|
||||||
|
const { activeArenaDialog, openArenaDialog, closeArenaDialog } =
|
||||||
|
useArenaCommand();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isResumeDialogOpen,
|
isResumeDialogOpen,
|
||||||
|
|
@ -509,6 +513,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||||
openEditorDialog,
|
openEditorDialog,
|
||||||
openSettingsDialog,
|
openSettingsDialog,
|
||||||
openModelDialog,
|
openModelDialog,
|
||||||
|
openArenaDialog,
|
||||||
openPermissionsDialog,
|
openPermissionsDialog,
|
||||||
openApprovalModeDialog,
|
openApprovalModeDialog,
|
||||||
quit: (messages: HistoryItem[]) => {
|
quit: (messages: HistoryItem[]) => {
|
||||||
|
|
@ -533,6 +538,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||||
openEditorDialog,
|
openEditorDialog,
|
||||||
openSettingsDialog,
|
openSettingsDialog,
|
||||||
openModelDialog,
|
openModelDialog,
|
||||||
|
openArenaDialog,
|
||||||
setDebugMessage,
|
setDebugMessage,
|
||||||
dispatchExtensionStateUpdate,
|
dispatchExtensionStateUpdate,
|
||||||
openPermissionsDialog,
|
openPermissionsDialog,
|
||||||
|
|
@ -669,12 +675,15 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||||
// Track whether suggestions are visible for Tab key handling
|
// Track whether suggestions are visible for Tab key handling
|
||||||
const [hasSuggestionsVisible, setHasSuggestionsVisible] = useState(false);
|
const [hasSuggestionsVisible, setHasSuggestionsVisible] = useState(false);
|
||||||
|
|
||||||
// Auto-accept indicator
|
const agentViewState = useAgentViewState();
|
||||||
|
|
||||||
|
// Auto-accept indicator — disabled on agent tabs (agents handle their own)
|
||||||
const showAutoAcceptIndicator = useAutoAcceptIndicator({
|
const showAutoAcceptIndicator = useAutoAcceptIndicator({
|
||||||
config,
|
config,
|
||||||
addItem: historyManager.addItem,
|
addItem: historyManager.addItem,
|
||||||
onApprovalModeChange: handleApprovalModeChange,
|
onApprovalModeChange: handleApprovalModeChange,
|
||||||
shouldBlockTab: () => hasSuggestionsVisible,
|
shouldBlockTab: () => hasSuggestionsVisible,
|
||||||
|
disabled: agentViewState.activeView !== 'main',
|
||||||
});
|
});
|
||||||
|
|
||||||
const { messageQueue, addMessage, clearQueue, getQueuedMessagesText } =
|
const { messageQueue, addMessage, clearQueue, getQueuedMessagesText } =
|
||||||
|
|
@ -687,9 +696,26 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||||
// Callback for handling final submit (must be after addMessage from useMessageQueue)
|
// Callback for handling final submit (must be after addMessage from useMessageQueue)
|
||||||
const handleFinalSubmit = useCallback(
|
const handleFinalSubmit = useCallback(
|
||||||
(submittedValue: string) => {
|
(submittedValue: string) => {
|
||||||
|
// Route to active in-process agent if viewing a sub-agent tab.
|
||||||
|
if (agentViewState.activeView !== 'main') {
|
||||||
|
const agent = agentViewState.agents.get(agentViewState.activeView);
|
||||||
|
if (agent) {
|
||||||
|
agent.interactiveAgent.enqueueMessage(submittedValue.trim());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
addMessage(submittedValue);
|
addMessage(submittedValue);
|
||||||
},
|
},
|
||||||
[addMessage],
|
[addMessage, agentViewState],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleArenaModelsSelected = useCallback(
|
||||||
|
(models: string[]) => {
|
||||||
|
const value = models.join(',');
|
||||||
|
buffer.setText(`/arena start --models ${value} `);
|
||||||
|
closeArenaDialog();
|
||||||
|
},
|
||||||
|
[buffer, closeArenaDialog],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Welcome back functionality (must be after handleFinalSubmit)
|
// Welcome back functionality (must be after handleFinalSubmit)
|
||||||
|
|
@ -765,10 +791,17 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||||
}
|
}
|
||||||
}, [buffer, terminalWidth, terminalHeight]);
|
}, [buffer, terminalWidth, terminalHeight]);
|
||||||
|
|
||||||
// Compute available terminal height based on controls measurement
|
// agentViewState is declared earlier (before handleFinalSubmit) so it
|
||||||
|
// is available for input routing. Referenced here for layout computation.
|
||||||
|
|
||||||
|
// Compute available terminal height based on controls measurement.
|
||||||
|
// When in-process agents are present the AgentTabBar renders an extra
|
||||||
|
// row at the top of the layout; subtract it so downstream consumers
|
||||||
|
// (shell, transcript, etc.) don't overestimate available space.
|
||||||
|
const tabBarHeight = agentViewState.agents.size > 0 ? 1 : 0;
|
||||||
const availableTerminalHeight = Math.max(
|
const availableTerminalHeight = Math.max(
|
||||||
0,
|
0,
|
||||||
terminalHeight - controlsHeight - staticExtraHeight - 2,
|
terminalHeight - controlsHeight - staticExtraHeight - 2 - tabBarHeight,
|
||||||
);
|
);
|
||||||
|
|
||||||
config.setShellExecutionConfig({
|
config.setShellExecutionConfig({
|
||||||
|
|
@ -1053,6 +1086,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||||
exitEditorDialog,
|
exitEditorDialog,
|
||||||
isSettingsDialogOpen,
|
isSettingsDialogOpen,
|
||||||
closeSettingsDialog,
|
closeSettingsDialog,
|
||||||
|
activeArenaDialog,
|
||||||
|
closeArenaDialog,
|
||||||
isFolderTrustDialogOpen,
|
isFolderTrustDialogOpen,
|
||||||
showWelcomeBackDialog,
|
showWelcomeBackDialog,
|
||||||
handleWelcomeBackClose,
|
handleWelcomeBackClose,
|
||||||
|
|
@ -1310,6 +1345,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||||
isThemeDialogOpen ||
|
isThemeDialogOpen ||
|
||||||
isSettingsDialogOpen ||
|
isSettingsDialogOpen ||
|
||||||
isModelDialogOpen ||
|
isModelDialogOpen ||
|
||||||
|
activeArenaDialog !== null ||
|
||||||
isPermissionsDialogOpen ||
|
isPermissionsDialogOpen ||
|
||||||
isAuthDialogOpen ||
|
isAuthDialogOpen ||
|
||||||
isAuthenticating ||
|
isAuthenticating ||
|
||||||
|
|
@ -1360,6 +1396,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||||
quittingMessages,
|
quittingMessages,
|
||||||
isSettingsDialogOpen,
|
isSettingsDialogOpen,
|
||||||
isModelDialogOpen,
|
isModelDialogOpen,
|
||||||
|
activeArenaDialog,
|
||||||
isPermissionsDialogOpen,
|
isPermissionsDialogOpen,
|
||||||
isApprovalModeDialogOpen,
|
isApprovalModeDialogOpen,
|
||||||
isResumeDialogOpen,
|
isResumeDialogOpen,
|
||||||
|
|
@ -1455,6 +1492,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||||
quittingMessages,
|
quittingMessages,
|
||||||
isSettingsDialogOpen,
|
isSettingsDialogOpen,
|
||||||
isModelDialogOpen,
|
isModelDialogOpen,
|
||||||
|
activeArenaDialog,
|
||||||
isPermissionsDialogOpen,
|
isPermissionsDialogOpen,
|
||||||
isApprovalModeDialogOpen,
|
isApprovalModeDialogOpen,
|
||||||
isResumeDialogOpen,
|
isResumeDialogOpen,
|
||||||
|
|
@ -1553,6 +1591,9 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||||
exitEditorDialog,
|
exitEditorDialog,
|
||||||
closeSettingsDialog,
|
closeSettingsDialog,
|
||||||
closeModelDialog,
|
closeModelDialog,
|
||||||
|
openArenaDialog,
|
||||||
|
closeArenaDialog,
|
||||||
|
handleArenaModelsSelected,
|
||||||
dismissCodingPlanUpdate,
|
dismissCodingPlanUpdate,
|
||||||
closePermissionsDialog,
|
closePermissionsDialog,
|
||||||
setShellModeActive,
|
setShellModeActive,
|
||||||
|
|
@ -1602,6 +1643,9 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||||
exitEditorDialog,
|
exitEditorDialog,
|
||||||
closeSettingsDialog,
|
closeSettingsDialog,
|
||||||
closeModelDialog,
|
closeModelDialog,
|
||||||
|
openArenaDialog,
|
||||||
|
closeArenaDialog,
|
||||||
|
handleArenaModelsSelected,
|
||||||
dismissCodingPlanUpdate,
|
dismissCodingPlanUpdate,
|
||||||
closePermissionsDialog,
|
closePermissionsDialog,
|
||||||
setShellModeActive,
|
setShellModeActive,
|
||||||
|
|
|
||||||
395
packages/cli/src/ui/commands/arenaCommand.test.ts
Normal file
395
packages/cli/src/ui/commands/arenaCommand.test.ts
Normal file
|
|
@ -0,0 +1,395 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
type ArenaManager,
|
||||||
|
AgentStatus,
|
||||||
|
ArenaSessionStatus,
|
||||||
|
} from '@qwen-code/qwen-code-core';
|
||||||
|
import { arenaCommand } from './arenaCommand.js';
|
||||||
|
import type {
|
||||||
|
CommandContext,
|
||||||
|
OpenDialogActionReturn,
|
||||||
|
SlashCommand,
|
||||||
|
} from './types.js';
|
||||||
|
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||||
|
|
||||||
|
function getArenaSubCommand(
|
||||||
|
name: 'start' | 'stop' | 'status' | 'select',
|
||||||
|
): SlashCommand {
|
||||||
|
const command = arenaCommand.subCommands?.find((item) => item.name === name);
|
||||||
|
if (!command?.action) {
|
||||||
|
throw new Error(`Arena subcommand "${name}" is missing an action`);
|
||||||
|
}
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('arenaCommand stop subcommand', () => {
|
||||||
|
let mockContext: CommandContext;
|
||||||
|
let mockConfig: {
|
||||||
|
getArenaManager: ReturnType<typeof vi.fn>;
|
||||||
|
setArenaManager: ReturnType<typeof vi.fn>;
|
||||||
|
cleanupArenaRuntime: ReturnType<typeof vi.fn>;
|
||||||
|
getAgentsSettings: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockConfig = {
|
||||||
|
getArenaManager: vi.fn(() => null),
|
||||||
|
setArenaManager: vi.fn(),
|
||||||
|
cleanupArenaRuntime: vi.fn().mockResolvedValue(undefined),
|
||||||
|
getAgentsSettings: vi.fn(() => ({})),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockContext = createMockCommandContext({
|
||||||
|
invocation: {
|
||||||
|
raw: '/arena stop',
|
||||||
|
name: 'arena',
|
||||||
|
args: 'stop',
|
||||||
|
},
|
||||||
|
executionMode: 'interactive',
|
||||||
|
services: {
|
||||||
|
config: mockConfig as never,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an error when no arena session is running', async () => {
|
||||||
|
const stopCommand = getArenaSubCommand('stop');
|
||||||
|
const result = await stopCommand.action!(mockContext, '');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content: 'No running Arena session found.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens stop dialog when a running session exists', async () => {
|
||||||
|
const mockManager = {
|
||||||
|
getSessionStatus: vi.fn(() => ArenaSessionStatus.RUNNING),
|
||||||
|
} as unknown as ArenaManager;
|
||||||
|
mockConfig.getArenaManager = vi.fn(() => mockManager);
|
||||||
|
|
||||||
|
const stopCommand = getArenaSubCommand('stop');
|
||||||
|
const result = (await stopCommand.action!(
|
||||||
|
mockContext,
|
||||||
|
'',
|
||||||
|
)) as OpenDialogActionReturn;
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 'dialog',
|
||||||
|
dialog: 'arena_stop',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens stop dialog when a completed session exists', async () => {
|
||||||
|
const mockManager = {
|
||||||
|
getSessionStatus: vi.fn(() => ArenaSessionStatus.COMPLETED),
|
||||||
|
} as unknown as ArenaManager;
|
||||||
|
mockConfig.getArenaManager = vi.fn(() => mockManager);
|
||||||
|
|
||||||
|
const stopCommand = getArenaSubCommand('stop');
|
||||||
|
const result = (await stopCommand.action!(
|
||||||
|
mockContext,
|
||||||
|
'',
|
||||||
|
)) as OpenDialogActionReturn;
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 'dialog',
|
||||||
|
dialog: 'arena_stop',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('arenaCommand status subcommand', () => {
|
||||||
|
let mockContext: CommandContext;
|
||||||
|
let mockConfig: {
|
||||||
|
getArenaManager: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockConfig = {
|
||||||
|
getArenaManager: vi.fn(() => null),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockContext = createMockCommandContext({
|
||||||
|
invocation: {
|
||||||
|
raw: '/arena status',
|
||||||
|
name: 'arena',
|
||||||
|
args: 'status',
|
||||||
|
},
|
||||||
|
executionMode: 'interactive',
|
||||||
|
services: {
|
||||||
|
config: mockConfig as never,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an error when no arena session exists', async () => {
|
||||||
|
const statusCommand = getArenaSubCommand('status');
|
||||||
|
const result = await statusCommand.action!(mockContext, '');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content: 'No Arena session found. Start one with /arena start.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens status dialog when a session exists', async () => {
|
||||||
|
const mockManager = {
|
||||||
|
getSessionStatus: vi.fn(() => ArenaSessionStatus.RUNNING),
|
||||||
|
} as unknown as ArenaManager;
|
||||||
|
mockConfig.getArenaManager = vi.fn(() => mockManager);
|
||||||
|
|
||||||
|
const statusCommand = getArenaSubCommand('status');
|
||||||
|
const result = (await statusCommand.action!(
|
||||||
|
mockContext,
|
||||||
|
'',
|
||||||
|
)) as OpenDialogActionReturn;
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 'dialog',
|
||||||
|
dialog: 'arena_status',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens status dialog for completed session', async () => {
|
||||||
|
const mockManager = {
|
||||||
|
getSessionStatus: vi.fn(() => ArenaSessionStatus.COMPLETED),
|
||||||
|
} as unknown as ArenaManager;
|
||||||
|
mockConfig.getArenaManager = vi.fn(() => mockManager);
|
||||||
|
|
||||||
|
const statusCommand = getArenaSubCommand('status');
|
||||||
|
const result = (await statusCommand.action!(
|
||||||
|
mockContext,
|
||||||
|
'',
|
||||||
|
)) as OpenDialogActionReturn;
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 'dialog',
|
||||||
|
dialog: 'arena_status',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('arenaCommand select subcommand', () => {
|
||||||
|
let mockContext: CommandContext;
|
||||||
|
let mockConfig: {
|
||||||
|
getArenaManager: ReturnType<typeof vi.fn>;
|
||||||
|
setArenaManager: ReturnType<typeof vi.fn>;
|
||||||
|
cleanupArenaRuntime: ReturnType<typeof vi.fn>;
|
||||||
|
getAgentsSettings: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockConfig = {
|
||||||
|
getArenaManager: vi.fn(() => null),
|
||||||
|
setArenaManager: vi.fn(),
|
||||||
|
cleanupArenaRuntime: vi.fn().mockResolvedValue(undefined),
|
||||||
|
getAgentsSettings: vi.fn(() => ({})),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockContext = createMockCommandContext({
|
||||||
|
invocation: {
|
||||||
|
raw: '/arena select',
|
||||||
|
name: 'arena',
|
||||||
|
args: 'select',
|
||||||
|
},
|
||||||
|
executionMode: 'interactive',
|
||||||
|
services: {
|
||||||
|
config: mockConfig as never,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns error when no arena session exists', async () => {
|
||||||
|
const selectCommand = getArenaSubCommand('select');
|
||||||
|
const result = await selectCommand.action!(mockContext, '');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content: 'No arena session found. Start one with /arena start.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns error when arena is still running', async () => {
|
||||||
|
const mockManager = {
|
||||||
|
getSessionStatus: vi.fn(() => ArenaSessionStatus.RUNNING),
|
||||||
|
} as unknown as ArenaManager;
|
||||||
|
mockConfig.getArenaManager = vi.fn(() => mockManager);
|
||||||
|
|
||||||
|
const selectCommand = getArenaSubCommand('select');
|
||||||
|
const result = await selectCommand.action!(mockContext, '');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content:
|
||||||
|
'Arena session is still running. Wait for it to complete or use /arena stop first.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns error when all agents failed', async () => {
|
||||||
|
const mockManager = {
|
||||||
|
getSessionStatus: vi.fn(() => ArenaSessionStatus.COMPLETED),
|
||||||
|
getAgentStates: vi.fn(() => [
|
||||||
|
{
|
||||||
|
agentId: 'agent-1',
|
||||||
|
status: AgentStatus.FAILED,
|
||||||
|
model: { modelId: 'model-1' },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
} as unknown as ArenaManager;
|
||||||
|
mockConfig.getArenaManager = vi.fn(() => mockManager);
|
||||||
|
|
||||||
|
const selectCommand = getArenaSubCommand('select');
|
||||||
|
const result = await selectCommand.action!(mockContext, '');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content:
|
||||||
|
'No successful agent results to select from. All agents failed or were cancelled.\n' +
|
||||||
|
'Use /arena stop to end the session.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens dialog when no args provided and agents have results', async () => {
|
||||||
|
const mockManager = {
|
||||||
|
getSessionStatus: vi.fn(() => ArenaSessionStatus.COMPLETED),
|
||||||
|
getAgentStates: vi.fn(() => [
|
||||||
|
{
|
||||||
|
agentId: 'agent-1',
|
||||||
|
status: AgentStatus.COMPLETED,
|
||||||
|
model: { modelId: 'model-1' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
agentId: 'agent-2',
|
||||||
|
status: AgentStatus.COMPLETED,
|
||||||
|
model: { modelId: 'model-2' },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
} as unknown as ArenaManager;
|
||||||
|
mockConfig.getArenaManager = vi.fn(() => mockManager);
|
||||||
|
|
||||||
|
const selectCommand = getArenaSubCommand('select');
|
||||||
|
const result = await selectCommand.action!(mockContext, '');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 'dialog',
|
||||||
|
dialog: 'arena_select',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies changes directly when model name is provided', async () => {
|
||||||
|
const mockManager = {
|
||||||
|
getSessionStatus: vi.fn(() => ArenaSessionStatus.COMPLETED),
|
||||||
|
getAgentStates: vi.fn(() => [
|
||||||
|
{
|
||||||
|
agentId: 'agent-1',
|
||||||
|
status: AgentStatus.COMPLETED,
|
||||||
|
model: { modelId: 'gpt-4o', displayName: 'gpt-4o' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
agentId: 'agent-2',
|
||||||
|
status: AgentStatus.COMPLETED,
|
||||||
|
model: { modelId: 'claude-sonnet', displayName: 'claude-sonnet' },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
applyAgentResult: vi.fn().mockResolvedValue({ success: true }),
|
||||||
|
cleanup: vi.fn().mockResolvedValue(undefined),
|
||||||
|
} as unknown as ArenaManager;
|
||||||
|
mockConfig.getArenaManager = vi.fn(() => mockManager);
|
||||||
|
|
||||||
|
const selectCommand = getArenaSubCommand('select');
|
||||||
|
const result = await selectCommand.action!(mockContext, 'gpt-4o');
|
||||||
|
|
||||||
|
expect(mockManager.applyAgentResult).toHaveBeenCalledWith('agent-1');
|
||||||
|
expect(mockConfig.cleanupArenaRuntime).toHaveBeenCalled();
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'info',
|
||||||
|
content:
|
||||||
|
'Applied changes from gpt-4o to workspace. Arena session complete.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns error when specified model not found', async () => {
|
||||||
|
const mockManager = {
|
||||||
|
getSessionStatus: vi.fn(() => ArenaSessionStatus.COMPLETED),
|
||||||
|
getAgentStates: vi.fn(() => [
|
||||||
|
{
|
||||||
|
agentId: 'agent-1',
|
||||||
|
status: AgentStatus.COMPLETED,
|
||||||
|
model: { modelId: 'gpt-4o', displayName: 'gpt-4o' },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
} as unknown as ArenaManager;
|
||||||
|
mockConfig.getArenaManager = vi.fn(() => mockManager);
|
||||||
|
|
||||||
|
const selectCommand = getArenaSubCommand('select');
|
||||||
|
const result = await selectCommand.action!(mockContext, 'nonexistent');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content: 'No idle agent found matching "nonexistent".',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asks for confirmation when --discard flag is used', async () => {
|
||||||
|
const mockManager = {
|
||||||
|
getSessionStatus: vi.fn(() => ArenaSessionStatus.COMPLETED),
|
||||||
|
getAgentStates: vi.fn(() => [
|
||||||
|
{
|
||||||
|
agentId: 'agent-1',
|
||||||
|
status: AgentStatus.COMPLETED,
|
||||||
|
model: { modelId: 'gpt-4o' },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
} as unknown as ArenaManager;
|
||||||
|
mockConfig.getArenaManager = vi.fn(() => mockManager);
|
||||||
|
|
||||||
|
const selectCommand = getArenaSubCommand('select');
|
||||||
|
const result = await selectCommand.action!(mockContext, '--discard');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 'confirm_action',
|
||||||
|
prompt: 'Discard all Arena results and clean up worktrees?',
|
||||||
|
originalInvocation: { raw: '/arena select' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('discards results after --discard confirmation', async () => {
|
||||||
|
const mockManager = {
|
||||||
|
getSessionStatus: vi.fn(() => ArenaSessionStatus.COMPLETED),
|
||||||
|
getAgentStates: vi.fn(() => [
|
||||||
|
{
|
||||||
|
agentId: 'agent-1',
|
||||||
|
status: AgentStatus.COMPLETED,
|
||||||
|
model: { modelId: 'gpt-4o' },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
cleanup: vi.fn().mockResolvedValue(undefined),
|
||||||
|
} as unknown as ArenaManager;
|
||||||
|
mockConfig.getArenaManager = vi.fn(() => mockManager);
|
||||||
|
mockContext.overwriteConfirmed = true;
|
||||||
|
|
||||||
|
const selectCommand = getArenaSubCommand('select');
|
||||||
|
const result = await selectCommand.action!(mockContext, '--discard');
|
||||||
|
|
||||||
|
expect(mockConfig.cleanupArenaRuntime).toHaveBeenCalled();
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'info',
|
||||||
|
content: 'Arena results discarded. All worktrees cleaned up.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
659
packages/cli/src/ui/commands/arenaCommand.ts
Normal file
659
packages/cli/src/ui/commands/arenaCommand.ts
Normal file
|
|
@ -0,0 +1,659 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
SlashCommand,
|
||||||
|
CommandContext,
|
||||||
|
ConfirmActionReturn,
|
||||||
|
MessageActionReturn,
|
||||||
|
OpenDialogActionReturn,
|
||||||
|
SlashCommandActionReturn,
|
||||||
|
} from './types.js';
|
||||||
|
import { CommandKind } from './types.js';
|
||||||
|
import {
|
||||||
|
ArenaManager,
|
||||||
|
ArenaEventType,
|
||||||
|
isTerminalStatus,
|
||||||
|
isSuccessStatus,
|
||||||
|
ArenaSessionStatus,
|
||||||
|
AuthType,
|
||||||
|
createDebugLogger,
|
||||||
|
stripStartupContext,
|
||||||
|
type Config,
|
||||||
|
type ArenaModelConfig,
|
||||||
|
type ArenaAgentErrorEvent,
|
||||||
|
type ArenaAgentCompleteEvent,
|
||||||
|
type ArenaAgentStartEvent,
|
||||||
|
type ArenaSessionCompleteEvent,
|
||||||
|
type ArenaSessionErrorEvent,
|
||||||
|
type ArenaSessionStartEvent,
|
||||||
|
type ArenaSessionUpdateEvent,
|
||||||
|
} from '@qwen-code/qwen-code-core';
|
||||||
|
import {
|
||||||
|
MessageType,
|
||||||
|
type ArenaAgentCardData,
|
||||||
|
type HistoryItemWithoutId,
|
||||||
|
} from '../types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsed model entry with optional auth type.
|
||||||
|
*/
|
||||||
|
interface ParsedModel {
|
||||||
|
authType?: string;
|
||||||
|
modelId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses arena command arguments.
|
||||||
|
*
|
||||||
|
* Supported formats:
|
||||||
|
* /arena start --models model1,model2 <task>
|
||||||
|
* /arena start --models authType1:model1,authType2:model2 <task>
|
||||||
|
*
|
||||||
|
* Model format: [authType:]modelId
|
||||||
|
* - "gpt-4o" → uses default auth type
|
||||||
|
* - "openai:gpt-4o" → uses "openai" auth type
|
||||||
|
*/
|
||||||
|
function parseArenaArgs(args: string): {
|
||||||
|
models: ParsedModel[];
|
||||||
|
task: string;
|
||||||
|
} {
|
||||||
|
const modelsMatch = args.match(/--models\s+(\S+)/);
|
||||||
|
|
||||||
|
let models: ParsedModel[] = [];
|
||||||
|
let task = args;
|
||||||
|
|
||||||
|
if (modelsMatch) {
|
||||||
|
const modelStrings = modelsMatch[1]!.split(',').filter(Boolean);
|
||||||
|
models = modelStrings.map((str) => {
|
||||||
|
// Check for authType:modelId format
|
||||||
|
const colonIndex = str.indexOf(':');
|
||||||
|
if (colonIndex > 0) {
|
||||||
|
return {
|
||||||
|
authType: str.substring(0, colonIndex),
|
||||||
|
modelId: str.substring(colonIndex + 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { modelId: str };
|
||||||
|
});
|
||||||
|
task = task.replace(/--models\s+\S+/, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip surrounding quotes from task
|
||||||
|
task = task.replace(/^["']|["']$/g, '').trim();
|
||||||
|
|
||||||
|
return { models, task };
|
||||||
|
}
|
||||||
|
|
||||||
|
const debugLogger = createDebugLogger('ARENA_COMMAND');
|
||||||
|
|
||||||
|
interface ArenaExecutionInput {
|
||||||
|
task: string;
|
||||||
|
models: ArenaModelConfig[];
|
||||||
|
approvalMode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildArenaExecutionInput(
|
||||||
|
parsed: ReturnType<typeof parseArenaArgs>,
|
||||||
|
config: Config,
|
||||||
|
): ArenaExecutionInput | MessageActionReturn {
|
||||||
|
if (!parsed.task) {
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content:
|
||||||
|
'Usage: /arena start --models model1,model2 <task>\n' +
|
||||||
|
'\n' +
|
||||||
|
'Options:\n' +
|
||||||
|
' --models [authType:]model1,[authType:]model2\n' +
|
||||||
|
' Models to compete (required, at least 2)\n' +
|
||||||
|
' Format: authType:modelId or just modelId\n' +
|
||||||
|
'\n' +
|
||||||
|
'Examples:\n' +
|
||||||
|
' /arena start --models openai:gpt-4o,anthropic:claude-3 "implement sorting"\n' +
|
||||||
|
' /arena start --models qwen-coder-plus,kimi-for-coding "fix the bug"',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.models.length < 2) {
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content:
|
||||||
|
'Arena requires at least 2 models. Use --models model1,model2 to specify.\n' +
|
||||||
|
'Format: [authType:]modelId (e.g., openai:gpt-4o or just gpt-4o)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the current auth type as default for models without explicit auth type
|
||||||
|
const contentGeneratorConfig = config.getContentGeneratorConfig();
|
||||||
|
const defaultAuthType =
|
||||||
|
contentGeneratorConfig?.authType ?? AuthType.USE_OPENAI;
|
||||||
|
|
||||||
|
// Build ArenaModelConfig for each model, resolving display names from
|
||||||
|
// the model registry when available.
|
||||||
|
const modelsConfig = config.getModelsConfig();
|
||||||
|
const models: ArenaModelConfig[] = parsed.models.map((parsedModel) => {
|
||||||
|
const authType =
|
||||||
|
(parsedModel.authType as AuthType | undefined) ?? defaultAuthType;
|
||||||
|
const registryModels = modelsConfig.getAvailableModelsForAuthType(authType);
|
||||||
|
const resolved = registryModels.find((m) => m.id === parsedModel.modelId);
|
||||||
|
return {
|
||||||
|
modelId: parsedModel.modelId,
|
||||||
|
authType,
|
||||||
|
displayName: resolved?.label ?? parsedModel.modelId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
task: parsed.task,
|
||||||
|
models,
|
||||||
|
approvalMode: config.getApprovalMode(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists a single arena history item to the session JSONL file.
|
||||||
|
*
|
||||||
|
* Arena events fire asynchronously (after the slash command's recording
|
||||||
|
* window has closed), so each item must be recorded individually.
|
||||||
|
*/
|
||||||
|
function recordArenaItem(config: Config, item: HistoryItemWithoutId): void {
|
||||||
|
try {
|
||||||
|
const chatRecorder = config.getChatRecordingService();
|
||||||
|
if (!chatRecorder) return;
|
||||||
|
chatRecorder.recordSlashCommand({
|
||||||
|
phase: 'result',
|
||||||
|
rawCommand: '/arena',
|
||||||
|
outputHistoryItems: [{ ...item } as Record<string, unknown>],
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
debugLogger.error('Failed to record arena history item');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function executeArenaCommand(
|
||||||
|
config: Config,
|
||||||
|
ui: CommandContext['ui'],
|
||||||
|
input: ArenaExecutionInput,
|
||||||
|
): void {
|
||||||
|
// Capture the main session's chat history so arena agents start with
|
||||||
|
// conversational context. Strip the leading startup context (env info
|
||||||
|
// user message + model ack) because each agent generates its own for
|
||||||
|
// its worktree directory — keeping the parent's would duplicate it.
|
||||||
|
let chatHistory;
|
||||||
|
try {
|
||||||
|
const fullHistory = config.getGeminiClient().getHistory();
|
||||||
|
chatHistory = stripStartupContext(fullHistory);
|
||||||
|
} catch {
|
||||||
|
debugLogger.debug('Could not retrieve chat history for arena agents');
|
||||||
|
}
|
||||||
|
|
||||||
|
const manager = new ArenaManager(config);
|
||||||
|
const emitter = manager.getEventEmitter();
|
||||||
|
const detachListeners: Array<() => void> = [];
|
||||||
|
const agentLabels = new Map<string, string>();
|
||||||
|
|
||||||
|
const addArenaMessage = (
|
||||||
|
type: 'info' | 'warning' | 'error' | 'success',
|
||||||
|
text: string,
|
||||||
|
) => {
|
||||||
|
ui.addItem({ type, text }, Date.now());
|
||||||
|
};
|
||||||
|
|
||||||
|
const addAndRecordArenaMessage = (
|
||||||
|
type: 'info' | 'warning' | 'error' | 'success',
|
||||||
|
text: string,
|
||||||
|
) => {
|
||||||
|
const item: HistoryItemWithoutId = { type, text };
|
||||||
|
ui.addItem(item, Date.now());
|
||||||
|
recordArenaItem(config, item);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSessionStart = (event: ArenaSessionStartEvent) => {
|
||||||
|
const modelList = event.models
|
||||||
|
.map((model, index) => ` ${index + 1}. ${model.modelId}`)
|
||||||
|
.join('\n');
|
||||||
|
// SESSION_START fires synchronously before the first await in
|
||||||
|
// ArenaManager.start(), so the slash command processor's finally
|
||||||
|
// block already captures this item — no extra recording needed.
|
||||||
|
addArenaMessage(
|
||||||
|
MessageType.INFO,
|
||||||
|
`Arena started with ${event.models.length} agents on task: "${event.task}"\nModels:\n${modelList}`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAgentStart = (event: ArenaAgentStartEvent) => {
|
||||||
|
agentLabels.set(event.agentId, event.model.modelId);
|
||||||
|
debugLogger.debug(
|
||||||
|
`Arena agent started: ${event.model.modelId} (${event.agentId})`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSessionUpdate = (event: ArenaSessionUpdateEvent) => {
|
||||||
|
const attachHintPrefix = 'To view agent panes, run: ';
|
||||||
|
if (event.message.startsWith(attachHintPrefix)) {
|
||||||
|
const command = event.message.slice(attachHintPrefix.length).trim();
|
||||||
|
addAndRecordArenaMessage(
|
||||||
|
MessageType.INFO,
|
||||||
|
`Arena panes are running in tmux. Attach with: \`${command}\``,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === 'success') {
|
||||||
|
addAndRecordArenaMessage(MessageType.SUCCESS, event.message);
|
||||||
|
} else if (event.type === 'info') {
|
||||||
|
addAndRecordArenaMessage(MessageType.INFO, event.message);
|
||||||
|
} else {
|
||||||
|
addAndRecordArenaMessage(MessageType.WARNING, event.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAgentError = (event: ArenaAgentErrorEvent) => {
|
||||||
|
const label = agentLabels.get(event.agentId) || event.agentId;
|
||||||
|
addAndRecordArenaMessage(
|
||||||
|
MessageType.ERROR,
|
||||||
|
`[${label}] failed: ${event.error}`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildAgentCardData = (
|
||||||
|
result: ArenaAgentCompleteEvent['result'],
|
||||||
|
): ArenaAgentCardData => ({
|
||||||
|
label: result.model.modelId,
|
||||||
|
status: result.status,
|
||||||
|
durationMs: result.stats.durationMs,
|
||||||
|
totalTokens: result.stats.totalTokens,
|
||||||
|
inputTokens: result.stats.inputTokens,
|
||||||
|
outputTokens: result.stats.outputTokens,
|
||||||
|
toolCalls: result.stats.toolCalls,
|
||||||
|
successfulToolCalls: result.stats.successfulToolCalls,
|
||||||
|
failedToolCalls: result.stats.failedToolCalls,
|
||||||
|
rounds: result.stats.rounds,
|
||||||
|
error: result.error,
|
||||||
|
diff: result.diff,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleAgentComplete = (event: ArenaAgentCompleteEvent) => {
|
||||||
|
if (!isTerminalStatus(event.result.status)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const agent = buildAgentCardData(event.result);
|
||||||
|
const item = {
|
||||||
|
type: 'arena_agent_complete',
|
||||||
|
agent,
|
||||||
|
} as HistoryItemWithoutId;
|
||||||
|
ui.addItem(item, Date.now());
|
||||||
|
recordArenaItem(config, item);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSessionError = (event: ArenaSessionErrorEvent) => {
|
||||||
|
addAndRecordArenaMessage(MessageType.ERROR, `${event.error}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSessionComplete = (event: ArenaSessionCompleteEvent) => {
|
||||||
|
const item = {
|
||||||
|
type: 'arena_session_complete',
|
||||||
|
sessionStatus: event.result.status,
|
||||||
|
task: event.result.task,
|
||||||
|
totalDurationMs: event.result.totalDurationMs ?? 0,
|
||||||
|
agents: event.result.agents.map(buildAgentCardData),
|
||||||
|
} as HistoryItemWithoutId;
|
||||||
|
ui.addItem(item, Date.now());
|
||||||
|
recordArenaItem(config, item);
|
||||||
|
};
|
||||||
|
|
||||||
|
emitter.on(ArenaEventType.SESSION_START, handleSessionStart);
|
||||||
|
detachListeners.push(() =>
|
||||||
|
emitter.off(ArenaEventType.SESSION_START, handleSessionStart),
|
||||||
|
);
|
||||||
|
emitter.on(ArenaEventType.AGENT_START, handleAgentStart);
|
||||||
|
detachListeners.push(() =>
|
||||||
|
emitter.off(ArenaEventType.AGENT_START, handleAgentStart),
|
||||||
|
);
|
||||||
|
emitter.on(ArenaEventType.SESSION_UPDATE, handleSessionUpdate);
|
||||||
|
detachListeners.push(() =>
|
||||||
|
emitter.off(ArenaEventType.SESSION_UPDATE, handleSessionUpdate),
|
||||||
|
);
|
||||||
|
emitter.on(ArenaEventType.AGENT_ERROR, handleAgentError);
|
||||||
|
detachListeners.push(() =>
|
||||||
|
emitter.off(ArenaEventType.AGENT_ERROR, handleAgentError),
|
||||||
|
);
|
||||||
|
emitter.on(ArenaEventType.AGENT_COMPLETE, handleAgentComplete);
|
||||||
|
detachListeners.push(() =>
|
||||||
|
emitter.off(ArenaEventType.AGENT_COMPLETE, handleAgentComplete),
|
||||||
|
);
|
||||||
|
emitter.on(ArenaEventType.SESSION_ERROR, handleSessionError);
|
||||||
|
detachListeners.push(() =>
|
||||||
|
emitter.off(ArenaEventType.SESSION_ERROR, handleSessionError),
|
||||||
|
);
|
||||||
|
emitter.on(ArenaEventType.SESSION_COMPLETE, handleSessionComplete);
|
||||||
|
detachListeners.push(() =>
|
||||||
|
emitter.off(ArenaEventType.SESSION_COMPLETE, handleSessionComplete),
|
||||||
|
);
|
||||||
|
|
||||||
|
config.setArenaManager(manager);
|
||||||
|
|
||||||
|
const cols = process.stdout.columns || 120;
|
||||||
|
const rows = Math.max((process.stdout.rows || 40) - 2, 1);
|
||||||
|
|
||||||
|
const lifecycle = manager
|
||||||
|
.start({
|
||||||
|
task: input.task,
|
||||||
|
models: input.models,
|
||||||
|
cols,
|
||||||
|
rows,
|
||||||
|
approvalMode: input.approvalMode,
|
||||||
|
chatHistory,
|
||||||
|
})
|
||||||
|
.then(
|
||||||
|
() => {
|
||||||
|
debugLogger.debug('Arena agents settled');
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
addAndRecordArenaMessage(MessageType.ERROR, `${message}`);
|
||||||
|
debugLogger.error('Arena session failed:', error);
|
||||||
|
|
||||||
|
// Clear the stored manager so subsequent /arena start calls
|
||||||
|
// are not blocked by the stale reference after a startup failure.
|
||||||
|
config.setArenaManager(null);
|
||||||
|
|
||||||
|
// Detach listeners on failure — session is done for good.
|
||||||
|
for (const detach of detachListeners) {
|
||||||
|
detach();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// NOTE: listeners are NOT detached when start() resolves because agents
|
||||||
|
// may still be alive (IDLE) and accept follow-up tasks. The listeners
|
||||||
|
// reference this manager's emitter, so they are garbage collected when
|
||||||
|
// the manager is cleaned up and replaced.
|
||||||
|
|
||||||
|
// Store so that stop can wait for start() to fully unwind before cleanup
|
||||||
|
manager.setLifecyclePromise(lifecycle);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const arenaCommand: SlashCommand = {
|
||||||
|
name: 'arena',
|
||||||
|
description: 'Manage Arena sessions',
|
||||||
|
kind: CommandKind.BUILT_IN,
|
||||||
|
subCommands: [
|
||||||
|
{
|
||||||
|
name: 'start',
|
||||||
|
description:
|
||||||
|
'Start an Arena session with multiple models competing on the same task',
|
||||||
|
kind: CommandKind.BUILT_IN,
|
||||||
|
action: async (
|
||||||
|
context: CommandContext,
|
||||||
|
args: string,
|
||||||
|
): Promise<void | MessageActionReturn | OpenDialogActionReturn> => {
|
||||||
|
const executionMode = context.executionMode ?? 'interactive';
|
||||||
|
if (executionMode !== 'interactive') {
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content:
|
||||||
|
'Arena is not supported in non-interactive mode. Use interactive mode to start an Arena session.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { services, ui } = context;
|
||||||
|
const { config } = services;
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content: 'Configuration not available.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refuse to start if a session already exists (regardless of status)
|
||||||
|
const existingManager = config.getArenaManager();
|
||||||
|
if (existingManager) {
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content:
|
||||||
|
'An Arena session exists. Use /arena stop or /arena select to end it before starting a new one.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseArenaArgs(args);
|
||||||
|
if (parsed.models.length === 0) {
|
||||||
|
return {
|
||||||
|
type: 'dialog',
|
||||||
|
dialog: 'arena_start',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const executionInput = buildArenaExecutionInput(parsed, config);
|
||||||
|
if ('type' in executionInput) {
|
||||||
|
return executionInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
executeArenaCommand(config, ui, executionInput);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'stop',
|
||||||
|
description: 'Stop the current Arena session',
|
||||||
|
kind: CommandKind.BUILT_IN,
|
||||||
|
action: async (
|
||||||
|
context: CommandContext,
|
||||||
|
): Promise<void | SlashCommandActionReturn> => {
|
||||||
|
const executionMode = context.executionMode ?? 'interactive';
|
||||||
|
if (executionMode !== 'interactive') {
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content:
|
||||||
|
'Arena is not supported in non-interactive mode. Use interactive mode to stop an Arena session.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { config } = context.services;
|
||||||
|
if (!config) {
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content: 'Configuration not available.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const manager = config.getArenaManager();
|
||||||
|
if (!manager) {
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content: 'No running Arena session found.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'dialog',
|
||||||
|
dialog: 'arena_stop',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'status',
|
||||||
|
description: 'Show the current Arena session status',
|
||||||
|
kind: CommandKind.BUILT_IN,
|
||||||
|
action: async (
|
||||||
|
context: CommandContext,
|
||||||
|
): Promise<void | SlashCommandActionReturn> => {
|
||||||
|
const executionMode = context.executionMode ?? 'interactive';
|
||||||
|
if (executionMode !== 'interactive') {
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content: 'Arena is not supported in non-interactive mode.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { config } = context.services;
|
||||||
|
if (!config) {
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content: 'Configuration not available.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const manager = config.getArenaManager();
|
||||||
|
if (!manager) {
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content: 'No Arena session found. Start one with /arena start.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'dialog',
|
||||||
|
dialog: 'arena_status',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'select',
|
||||||
|
altNames: ['choose'],
|
||||||
|
description:
|
||||||
|
'Select a model result and merge its diff into the current workspace',
|
||||||
|
kind: CommandKind.BUILT_IN,
|
||||||
|
action: async (
|
||||||
|
context: CommandContext,
|
||||||
|
args: string,
|
||||||
|
): Promise<
|
||||||
|
| void
|
||||||
|
| MessageActionReturn
|
||||||
|
| OpenDialogActionReturn
|
||||||
|
| ConfirmActionReturn
|
||||||
|
> => {
|
||||||
|
const executionMode = context.executionMode ?? 'interactive';
|
||||||
|
if (executionMode !== 'interactive') {
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content: 'Arena is not supported in non-interactive mode.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { config } = context.services;
|
||||||
|
if (!config) {
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content: 'Configuration not available.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const manager = config.getArenaManager();
|
||||||
|
|
||||||
|
if (!manager) {
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content: 'No arena session found. Start one with /arena start.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionStatus = manager.getSessionStatus();
|
||||||
|
if (
|
||||||
|
sessionStatus === ArenaSessionStatus.RUNNING ||
|
||||||
|
sessionStatus === ArenaSessionStatus.INITIALIZING
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content:
|
||||||
|
'Arena session is still running. Wait for it to complete or use /arena stop first.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle --discard flag before checking for successful agents,
|
||||||
|
// so users can clean up worktrees even when all agents failed.
|
||||||
|
const trimmedArgs = args.trim();
|
||||||
|
if (trimmedArgs === '--discard') {
|
||||||
|
if (!context.overwriteConfirmed) {
|
||||||
|
return {
|
||||||
|
type: 'confirm_action',
|
||||||
|
prompt: 'Discard all Arena results and clean up worktrees?',
|
||||||
|
originalInvocation: {
|
||||||
|
raw: context.invocation?.raw || '/arena select --discard',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await config.cleanupArenaRuntime(true);
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'info',
|
||||||
|
content: 'Arena results discarded. All worktrees cleaned up.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const agents = manager.getAgentStates();
|
||||||
|
const hasSuccessful = agents.some((a) => isSuccessStatus(a.status));
|
||||||
|
|
||||||
|
if (!hasSuccessful) {
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content:
|
||||||
|
'No successful agent results to select from. All agents failed or were cancelled.\n' +
|
||||||
|
'Use /arena stop to end the session.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle direct model selection via args
|
||||||
|
if (trimmedArgs) {
|
||||||
|
const matchingAgent = agents.find(
|
||||||
|
(a) =>
|
||||||
|
isSuccessStatus(a.status) &&
|
||||||
|
a.model.modelId.toLowerCase() === trimmedArgs.toLowerCase(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!matchingAgent) {
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content: `No idle agent found matching "${trimmedArgs}".`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = matchingAgent.model.modelId;
|
||||||
|
const result = await manager.applyAgentResult(matchingAgent.agentId);
|
||||||
|
if (!result.success) {
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content: `Failed to apply changes from ${label}: ${result.error}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await config.cleanupArenaRuntime(true);
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'info',
|
||||||
|
content: `Applied changes from ${label} to workspace. Arena session complete.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// No args → open the select dialog
|
||||||
|
return {
|
||||||
|
type: 'dialog',
|
||||||
|
dialog: 'arena_select',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
@ -139,6 +139,10 @@ export interface OpenDialogActionReturn {
|
||||||
|
|
||||||
dialog:
|
dialog:
|
||||||
| 'help'
|
| 'help'
|
||||||
|
| 'arena_start'
|
||||||
|
| 'arena_select'
|
||||||
|
| 'arena_stop'
|
||||||
|
| 'arena_status'
|
||||||
| 'auth'
|
| 'auth'
|
||||||
| 'theme'
|
| 'theme'
|
||||||
| 'editor'
|
| 'editor'
|
||||||
|
|
|
||||||
287
packages/cli/src/ui/components/BaseTextInput.tsx
Normal file
287
packages/cli/src/ui/components/BaseTextInput.tsx
Normal file
|
|
@ -0,0 +1,287 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @fileoverview BaseTextInput — shared text input component with rendering
|
||||||
|
* and common readline keyboard handling.
|
||||||
|
*
|
||||||
|
* Provides:
|
||||||
|
* - Viewport line rendering from a TextBuffer with cursor display
|
||||||
|
* - Placeholder support when buffer is empty
|
||||||
|
* - Configurable border/prefix styling
|
||||||
|
* - Standard readline shortcuts (Ctrl+A/E/K/U/W, Escape, etc.)
|
||||||
|
* - An `onKeypress` interceptor so consumers can layer custom behavior
|
||||||
|
*
|
||||||
|
* Used by both InputPrompt (with syntax highlighting + complex key handling)
|
||||||
|
* and AgentComposer (with minimal customization).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type React from 'react';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
import type { TextBuffer } from './shared/text-buffer.js';
|
||||||
|
import type { Key } from '../hooks/useKeypress.js';
|
||||||
|
import { useKeypress } from '../hooks/useKeypress.js';
|
||||||
|
import { keyMatchers, Command } from '../keyMatchers.js';
|
||||||
|
import { cpSlice, cpLen } from '../utils/textUtils.js';
|
||||||
|
import { theme } from '../semantic-colors.js';
|
||||||
|
|
||||||
|
// ─── Types ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface RenderLineOptions {
|
||||||
|
/** The text content of this visual line. */
|
||||||
|
lineText: string;
|
||||||
|
/** Whether the cursor is on this visual line. */
|
||||||
|
isOnCursorLine: boolean;
|
||||||
|
/** The cursor column within this visual line (visual col, not logical). */
|
||||||
|
cursorCol: number;
|
||||||
|
/** Whether the cursor should be rendered. */
|
||||||
|
showCursor: boolean;
|
||||||
|
/** Index of this line within the rendered viewport (0-based). */
|
||||||
|
visualLineIndex: number;
|
||||||
|
/** Absolute visual line index (scrollVisualRow + visualLineIndex). */
|
||||||
|
absoluteVisualIndex: number;
|
||||||
|
/** The underlying text buffer. */
|
||||||
|
buffer: TextBuffer;
|
||||||
|
/** The first visible visual row (scroll offset). */
|
||||||
|
scrollVisualRow: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BaseTextInputProps {
|
||||||
|
/** The text buffer driving this input. */
|
||||||
|
buffer: TextBuffer;
|
||||||
|
/** Called when the user submits (Enter). Buffer is cleared automatically. */
|
||||||
|
onSubmit: (text: string) => void;
|
||||||
|
/**
|
||||||
|
* Optional key interceptor. Called before default readline handling.
|
||||||
|
* Return `true` if the key was handled (skips default processing).
|
||||||
|
*/
|
||||||
|
onKeypress?: (key: Key) => boolean;
|
||||||
|
/** Whether to show the blinking block cursor. Defaults to true. */
|
||||||
|
showCursor?: boolean;
|
||||||
|
/** Placeholder text shown when the buffer is empty. */
|
||||||
|
placeholder?: string;
|
||||||
|
/** Custom prefix node (defaults to `> `). */
|
||||||
|
prefix?: React.ReactNode;
|
||||||
|
/** Border color for the input box. */
|
||||||
|
borderColor?: string;
|
||||||
|
/** Whether keyboard handling is active. Defaults to true. */
|
||||||
|
isActive?: boolean;
|
||||||
|
/**
|
||||||
|
* Custom line renderer for advanced rendering (e.g. syntax highlighting).
|
||||||
|
* When not provided, lines are rendered as plain text with cursor overlay.
|
||||||
|
*/
|
||||||
|
renderLine?: (opts: RenderLineOptions) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Default line renderer ──────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a single visual line with an inverse-video block cursor.
|
||||||
|
* Uses codepoint-aware string operations for Unicode/emoji safety.
|
||||||
|
*/
|
||||||
|
export function defaultRenderLine({
|
||||||
|
lineText,
|
||||||
|
isOnCursorLine,
|
||||||
|
cursorCol,
|
||||||
|
showCursor,
|
||||||
|
}: RenderLineOptions): React.ReactNode {
|
||||||
|
if (!isOnCursorLine || !showCursor) {
|
||||||
|
return <Text>{lineText || ' '}</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const len = cpLen(lineText);
|
||||||
|
|
||||||
|
// Cursor past end of line — append inverse space
|
||||||
|
if (cursorCol >= len) {
|
||||||
|
return (
|
||||||
|
<Text>
|
||||||
|
{lineText}
|
||||||
|
{chalk.inverse(' ') + '\u200B'}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const before = cpSlice(lineText, 0, cursorCol);
|
||||||
|
const cursorChar = cpSlice(lineText, cursorCol, cursorCol + 1);
|
||||||
|
const after = cpSlice(lineText, cursorCol + 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text>
|
||||||
|
{before}
|
||||||
|
{chalk.inverse(cursorChar)}
|
||||||
|
{after}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Component ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const BaseTextInput: React.FC<BaseTextInputProps> = ({
|
||||||
|
buffer,
|
||||||
|
onSubmit,
|
||||||
|
onKeypress,
|
||||||
|
showCursor = true,
|
||||||
|
placeholder,
|
||||||
|
prefix,
|
||||||
|
borderColor,
|
||||||
|
isActive = true,
|
||||||
|
renderLine = defaultRenderLine,
|
||||||
|
}) => {
|
||||||
|
// ── Keyboard handling ──
|
||||||
|
|
||||||
|
const handleKey = useCallback(
|
||||||
|
(key: Key) => {
|
||||||
|
// Let the consumer intercept first
|
||||||
|
if (onKeypress?.(key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Standard readline shortcuts ──
|
||||||
|
|
||||||
|
// Submit (Enter, no modifiers)
|
||||||
|
if (keyMatchers[Command.SUBMIT](key)) {
|
||||||
|
if (buffer.text.trim()) {
|
||||||
|
const text = buffer.text;
|
||||||
|
buffer.setText('');
|
||||||
|
onSubmit(text);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Newline (Shift+Enter, Ctrl+Enter, Ctrl+J)
|
||||||
|
if (keyMatchers[Command.NEWLINE](key)) {
|
||||||
|
buffer.newline();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape → clear input
|
||||||
|
if (keyMatchers[Command.ESCAPE](key)) {
|
||||||
|
if (buffer.text.length > 0) {
|
||||||
|
buffer.setText('');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+C → clear input
|
||||||
|
if (keyMatchers[Command.CLEAR_INPUT](key)) {
|
||||||
|
if (buffer.text.length > 0) {
|
||||||
|
buffer.setText('');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+A → home
|
||||||
|
if (keyMatchers[Command.HOME](key)) {
|
||||||
|
buffer.move('home');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+E → end
|
||||||
|
if (keyMatchers[Command.END](key)) {
|
||||||
|
buffer.move('end');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+K → kill to end of line
|
||||||
|
if (keyMatchers[Command.KILL_LINE_RIGHT](key)) {
|
||||||
|
buffer.killLineRight();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+U → kill to start of line
|
||||||
|
if (keyMatchers[Command.KILL_LINE_LEFT](key)) {
|
||||||
|
buffer.killLineLeft();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+W / Alt+Backspace → delete word backward
|
||||||
|
if (keyMatchers[Command.DELETE_WORD_BACKWARD](key)) {
|
||||||
|
buffer.deleteWordLeft();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+X Ctrl+E → open in external editor
|
||||||
|
if (keyMatchers[Command.OPEN_EXTERNAL_EDITOR](key)) {
|
||||||
|
buffer.openInExternalEditor();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backspace
|
||||||
|
if (
|
||||||
|
key.name === 'backspace' ||
|
||||||
|
key.sequence === '\x7f' ||
|
||||||
|
(key.ctrl && key.name === 'h')
|
||||||
|
) {
|
||||||
|
buffer.backspace();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallthrough — delegate to buffer's built-in input handler
|
||||||
|
buffer.handleInput(key);
|
||||||
|
},
|
||||||
|
[buffer, onSubmit, onKeypress],
|
||||||
|
);
|
||||||
|
|
||||||
|
useKeypress(handleKey, { isActive });
|
||||||
|
|
||||||
|
// ── Rendering ──
|
||||||
|
|
||||||
|
const linesToRender = buffer.viewportVisualLines;
|
||||||
|
const [cursorVisualRow, cursorVisualCol] = buffer.visualCursor;
|
||||||
|
const scrollVisualRow = buffer.visualScrollRow;
|
||||||
|
|
||||||
|
const resolvedBorderColor = borderColor ?? theme.border.focused;
|
||||||
|
const resolvedPrefix = prefix ?? (
|
||||||
|
<Text color={theme.text.accent}>{'> '}</Text>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
borderStyle="single"
|
||||||
|
borderTop={true}
|
||||||
|
borderBottom={true}
|
||||||
|
borderLeft={false}
|
||||||
|
borderRight={false}
|
||||||
|
borderColor={resolvedBorderColor}
|
||||||
|
>
|
||||||
|
{resolvedPrefix}
|
||||||
|
<Box flexGrow={1} flexDirection="column">
|
||||||
|
{buffer.text.length === 0 && placeholder ? (
|
||||||
|
showCursor ? (
|
||||||
|
<Text>
|
||||||
|
{chalk.inverse(placeholder.slice(0, 1))}
|
||||||
|
<Text color={theme.text.secondary}>{placeholder.slice(1)}</Text>
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text color={theme.text.secondary}>{placeholder}</Text>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
linesToRender.map((lineText, idx) => {
|
||||||
|
const absoluteVisualIndex = scrollVisualRow + idx;
|
||||||
|
const isOnCursorLine = absoluteVisualIndex === cursorVisualRow;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box key={idx} height={1}>
|
||||||
|
{renderLine({
|
||||||
|
lineText,
|
||||||
|
isOnCursorLine,
|
||||||
|
cursorCol: cursorVisualCol,
|
||||||
|
showCursor,
|
||||||
|
visualLineIndex: idx,
|
||||||
|
absoluteVisualIndex,
|
||||||
|
buffer,
|
||||||
|
scrollVisualRow,
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -115,8 +115,8 @@ export const Composer = () => {
|
||||||
|
|
||||||
{/* Exclusive area: only one component visible at a time */}
|
{/* Exclusive area: only one component visible at a time */}
|
||||||
{/* Hide footer when a confirmation dialog (e.g. ask_user_question) is active */}
|
{/* Hide footer when a confirmation dialog (e.g. ask_user_question) is active */}
|
||||||
{!showSuggestions &&
|
{uiState.isInputActive &&
|
||||||
uiState.streamingState !== StreamingState.WaitingForConfirmation &&
|
!showSuggestions &&
|
||||||
(showShortcuts ? (
|
(showShortcuts ? (
|
||||||
<KeyboardShortcuts />
|
<KeyboardShortcuts />
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,10 @@ import { AuthDialog } from '../auth/AuthDialog.js';
|
||||||
import { EditorSettingsDialog } from './EditorSettingsDialog.js';
|
import { EditorSettingsDialog } from './EditorSettingsDialog.js';
|
||||||
import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js';
|
import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js';
|
||||||
import { ModelDialog } from './ModelDialog.js';
|
import { ModelDialog } from './ModelDialog.js';
|
||||||
|
import { ArenaStartDialog } from './arena/ArenaStartDialog.js';
|
||||||
|
import { ArenaSelectDialog } from './arena/ArenaSelectDialog.js';
|
||||||
|
import { ArenaStopDialog } from './arena/ArenaStopDialog.js';
|
||||||
|
import { ArenaStatusDialog } from './arena/ArenaStatusDialog.js';
|
||||||
import { ApprovalModeDialog } from './ApprovalModeDialog.js';
|
import { ApprovalModeDialog } from './ApprovalModeDialog.js';
|
||||||
import { theme } from '../semantic-colors.js';
|
import { theme } from '../semantic-colors.js';
|
||||||
import { useUIState } from '../contexts/UIStateContext.js';
|
import { useUIState } from '../contexts/UIStateContext.js';
|
||||||
|
|
@ -237,6 +241,49 @@ export const DialogManager = ({
|
||||||
if (uiState.isModelDialogOpen) {
|
if (uiState.isModelDialogOpen) {
|
||||||
return <ModelDialog onClose={uiActions.closeModelDialog} />;
|
return <ModelDialog onClose={uiActions.closeModelDialog} />;
|
||||||
}
|
}
|
||||||
|
if (uiState.activeArenaDialog === 'start') {
|
||||||
|
return (
|
||||||
|
<ArenaStartDialog
|
||||||
|
onClose={() => uiActions.closeArenaDialog()}
|
||||||
|
onConfirm={(models) => uiActions.handleArenaModelsSelected?.(models)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (uiState.activeArenaDialog === 'status') {
|
||||||
|
const arenaManager = config.getArenaManager();
|
||||||
|
if (arenaManager) {
|
||||||
|
return (
|
||||||
|
<ArenaStatusDialog
|
||||||
|
manager={arenaManager}
|
||||||
|
closeArenaDialog={uiActions.closeArenaDialog}
|
||||||
|
width={mainAreaWidth}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (uiState.activeArenaDialog === 'stop') {
|
||||||
|
return (
|
||||||
|
<ArenaStopDialog
|
||||||
|
config={config}
|
||||||
|
addItem={addItem}
|
||||||
|
closeArenaDialog={uiActions.closeArenaDialog}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (uiState.activeArenaDialog === 'select') {
|
||||||
|
const arenaManager = config.getArenaManager();
|
||||||
|
if (arenaManager) {
|
||||||
|
return (
|
||||||
|
<ArenaSelectDialog
|
||||||
|
manager={arenaManager}
|
||||||
|
config={config}
|
||||||
|
addItem={addItem}
|
||||||
|
closeArenaDialog={uiActions.closeArenaDialog}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (uiState.isAuthDialogOpen || uiState.authError) {
|
if (uiState.isAuthDialogOpen || uiState.authError) {
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import {
|
||||||
WarningMessage,
|
WarningMessage,
|
||||||
ErrorMessage,
|
ErrorMessage,
|
||||||
RetryCountdownMessage,
|
RetryCountdownMessage,
|
||||||
|
SuccessMessage,
|
||||||
} from './messages/StatusMessages.js';
|
} from './messages/StatusMessages.js';
|
||||||
import { Box } from 'ink';
|
import { Box } from 'ink';
|
||||||
import { AboutBox } from './AboutBox.js';
|
import { AboutBox } from './AboutBox.js';
|
||||||
|
|
@ -38,6 +39,7 @@ import { getMCPServerStatus } from '@qwen-code/qwen-code-core';
|
||||||
import { SkillsList } from './views/SkillsList.js';
|
import { SkillsList } from './views/SkillsList.js';
|
||||||
import { ToolsList } from './views/ToolsList.js';
|
import { ToolsList } from './views/ToolsList.js';
|
||||||
import { McpStatus } from './views/McpStatus.js';
|
import { McpStatus } from './views/McpStatus.js';
|
||||||
|
import { ArenaAgentCard, ArenaSessionCard } from './arena/ArenaCards.js';
|
||||||
import { InsightProgressMessage } from './messages/InsightProgressMessage.js';
|
import { InsightProgressMessage } from './messages/InsightProgressMessage.js';
|
||||||
|
|
||||||
interface HistoryItemDisplayProps {
|
interface HistoryItemDisplayProps {
|
||||||
|
|
@ -132,6 +134,9 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
|
||||||
{itemForDisplay.type === 'info' && (
|
{itemForDisplay.type === 'info' && (
|
||||||
<InfoMessage text={itemForDisplay.text} />
|
<InfoMessage text={itemForDisplay.text} />
|
||||||
)}
|
)}
|
||||||
|
{itemForDisplay.type === 'success' && (
|
||||||
|
<SuccessMessage text={itemForDisplay.text} />
|
||||||
|
)}
|
||||||
{itemForDisplay.type === 'warning' && (
|
{itemForDisplay.type === 'warning' && (
|
||||||
<WarningMessage text={itemForDisplay.text} />
|
<WarningMessage text={itemForDisplay.text} />
|
||||||
)}
|
)}
|
||||||
|
|
@ -191,6 +196,18 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
|
||||||
{itemForDisplay.type === 'mcp_status' && (
|
{itemForDisplay.type === 'mcp_status' && (
|
||||||
<McpStatus {...itemForDisplay} serverStatus={getMCPServerStatus} />
|
<McpStatus {...itemForDisplay} serverStatus={getMCPServerStatus} />
|
||||||
)}
|
)}
|
||||||
|
{itemForDisplay.type === 'arena_agent_complete' && (
|
||||||
|
<ArenaAgentCard agent={itemForDisplay.agent} width={boxWidth} />
|
||||||
|
)}
|
||||||
|
{itemForDisplay.type === 'arena_session_complete' && (
|
||||||
|
<ArenaSessionCard
|
||||||
|
sessionStatus={itemForDisplay.sessionStatus}
|
||||||
|
task={itemForDisplay.task}
|
||||||
|
totalDurationMs={itemForDisplay.totalDurationMs}
|
||||||
|
agents={itemForDisplay.agents}
|
||||||
|
width={boxWidth}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{itemForDisplay.type === 'insight_progress' && (
|
{itemForDisplay.type === 'insight_progress' && (
|
||||||
<InsightProgressMessage progress={itemForDisplay.progress} />
|
<InsightProgressMessage progress={itemForDisplay.progress} />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1957,6 +1957,25 @@ describe('InputPrompt', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('command search (Ctrl+R when not in shell)', () => {
|
describe('command search (Ctrl+R when not in shell)', () => {
|
||||||
|
it('passes newest-first user history to command search', async () => {
|
||||||
|
props.shellModeActive = false;
|
||||||
|
props.userMessages = ['oldest', 'middle', 'newest'];
|
||||||
|
|
||||||
|
const { unmount } = renderWithProviders(<InputPrompt {...props} />);
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
const commandSearchCall =
|
||||||
|
mockedUseReverseSearchCompletion.mock.calls.find(
|
||||||
|
([, history]) =>
|
||||||
|
Array.isArray(history) &&
|
||||||
|
history.length === 3 &&
|
||||||
|
history.includes('newest'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(commandSearchCall?.[1]).toEqual(['newest', 'middle', 'oldest']);
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
it('enters command search on Ctrl+R and shows suggestions', async () => {
|
it('enters command search on Ctrl+R and shows suggestions', async () => {
|
||||||
props.shellModeActive = false;
|
props.shellModeActive = false;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import { useCallback, useEffect, useState, useRef } from 'react';
|
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js';
|
import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js';
|
||||||
import { theme } from '../semantic-colors.js';
|
import { theme } from '../semantic-colors.js';
|
||||||
|
|
@ -18,7 +18,6 @@ import { useShellHistory } from '../hooks/useShellHistory.js';
|
||||||
import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js';
|
import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js';
|
||||||
import { useCommandCompletion } from '../hooks/useCommandCompletion.js';
|
import { useCommandCompletion } from '../hooks/useCommandCompletion.js';
|
||||||
import type { Key } from '../hooks/useKeypress.js';
|
import type { Key } from '../hooks/useKeypress.js';
|
||||||
import { useKeypress } from '../hooks/useKeypress.js';
|
|
||||||
import { keyMatchers, Command } from '../keyMatchers.js';
|
import { keyMatchers, Command } from '../keyMatchers.js';
|
||||||
import type { CommandContext, SlashCommand } from '../commands/types.js';
|
import type { CommandContext, SlashCommand } from '../commands/types.js';
|
||||||
import type { Config } from '@qwen-code/qwen-code-core';
|
import type { Config } from '@qwen-code/qwen-code-core';
|
||||||
|
|
@ -43,7 +42,13 @@ import { useShellFocusState } from '../contexts/ShellFocusContext.js';
|
||||||
import { useUIState } from '../contexts/UIStateContext.js';
|
import { useUIState } from '../contexts/UIStateContext.js';
|
||||||
import { useUIActions } from '../contexts/UIActionsContext.js';
|
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||||
import { useKeypressContext } from '../contexts/KeypressContext.js';
|
import { useKeypressContext } from '../contexts/KeypressContext.js';
|
||||||
|
import {
|
||||||
|
useAgentViewState,
|
||||||
|
useAgentViewActions,
|
||||||
|
} from '../contexts/AgentViewContext.js';
|
||||||
import { FEEDBACK_DIALOG_KEYS } from '../FeedbackDialog.js';
|
import { FEEDBACK_DIALOG_KEYS } from '../FeedbackDialog.js';
|
||||||
|
import { BaseTextInput } from './BaseTextInput.js';
|
||||||
|
import type { RenderLineOptions } from './BaseTextInput.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents an attachment (e.g., pasted image) displayed above the input prompt
|
* Represents an attachment (e.g., pasted image) displayed above the input prompt
|
||||||
|
|
@ -78,30 +83,8 @@ export interface InputPromptProps {
|
||||||
isEmbeddedShellFocused?: boolean;
|
isEmbeddedShellFocused?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The input content, input container, and input suggestions list may have different widths
|
// Re-export from shared utils for backwards compatibility
|
||||||
export const calculatePromptWidths = (terminalWidth: number) => {
|
export { calculatePromptWidths } from '../utils/layoutUtils.js';
|
||||||
const widthFraction = 0.9;
|
|
||||||
const FRAME_PADDING_AND_BORDER = 4; // Border (2) + padding (2)
|
|
||||||
const PROMPT_PREFIX_WIDTH = 2; // '> ' or '! '
|
|
||||||
const MIN_CONTENT_WIDTH = 2;
|
|
||||||
|
|
||||||
const innerContentWidth =
|
|
||||||
Math.floor(terminalWidth * widthFraction) -
|
|
||||||
FRAME_PADDING_AND_BORDER -
|
|
||||||
PROMPT_PREFIX_WIDTH;
|
|
||||||
|
|
||||||
const inputWidth = Math.max(MIN_CONTENT_WIDTH, innerContentWidth);
|
|
||||||
const FRAME_OVERHEAD = FRAME_PADDING_AND_BORDER + PROMPT_PREFIX_WIDTH;
|
|
||||||
const containerWidth = inputWidth + FRAME_OVERHEAD;
|
|
||||||
const suggestionsWidth = Math.max(20, Math.floor(terminalWidth * 1.0));
|
|
||||||
|
|
||||||
return {
|
|
||||||
inputWidth,
|
|
||||||
containerWidth,
|
|
||||||
suggestionsWidth,
|
|
||||||
frameOverhead: FRAME_OVERHEAD,
|
|
||||||
} as const;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Large paste placeholder thresholds
|
// Large paste placeholder thresholds
|
||||||
const LARGE_PASTE_CHAR_THRESHOLD = 1000;
|
const LARGE_PASTE_CHAR_THRESHOLD = 1000;
|
||||||
|
|
@ -132,6 +115,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
const uiState = useUIState();
|
const uiState = useUIState();
|
||||||
const uiActions = useUIActions();
|
const uiActions = useUIActions();
|
||||||
const { pasteWorkaround } = useKeypressContext();
|
const { pasteWorkaround } = useKeypressContext();
|
||||||
|
const { agents, agentTabBarFocused } = useAgentViewState();
|
||||||
|
const { setAgentTabBarFocused } = useAgentViewActions();
|
||||||
|
const hasAgents = agents.size > 0;
|
||||||
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
|
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
|
||||||
const [escPressCount, setEscPressCount] = useState(0);
|
const [escPressCount, setEscPressCount] = useState(0);
|
||||||
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
|
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
|
||||||
|
|
@ -213,9 +199,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
reverseSearchActive,
|
reverseSearchActive,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const commandSearchHistory = useMemo(
|
||||||
|
() => [...userMessages].reverse(),
|
||||||
|
[userMessages],
|
||||||
|
);
|
||||||
|
|
||||||
const commandSearchCompletion = useReverseSearchCompletion(
|
const commandSearchCompletion = useReverseSearchCompletion(
|
||||||
buffer,
|
buffer,
|
||||||
userMessages,
|
commandSearchHistory,
|
||||||
commandSearchActive,
|
commandSearchActive,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -225,7 +216,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
const resetCommandSearchCompletionState =
|
const resetCommandSearchCompletionState =
|
||||||
commandSearchCompletion.resetCompletionState;
|
commandSearchCompletion.resetCompletionState;
|
||||||
|
|
||||||
const showCursor = focus && isShellFocused && !isEmbeddedShellFocused;
|
const showCursor =
|
||||||
|
focus && isShellFocused && !isEmbeddedShellFocused && !agentTabBarFocused;
|
||||||
|
|
||||||
const resetEscapeState = useCallback(() => {
|
const resetEscapeState = useCallback(() => {
|
||||||
if (escapeTimerRef.current) {
|
if (escapeTimerRef.current) {
|
||||||
|
|
@ -351,6 +343,17 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
onChange: customSetTextAndResetCompletionSignal,
|
onChange: customSetTextAndResetCompletionSignal,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// When an arena session starts (agents appear), reset history position so
|
||||||
|
// that pressing down-arrow immediately focuses the agent tab bar instead
|
||||||
|
// of cycling through input history.
|
||||||
|
const prevHasAgentsRef = useRef(hasAgents);
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasAgents && !prevHasAgentsRef.current) {
|
||||||
|
inputHistory.resetHistoryNav();
|
||||||
|
}
|
||||||
|
prevHasAgentsRef.current = hasAgents;
|
||||||
|
}, [hasAgents, inputHistory]);
|
||||||
|
|
||||||
// Effect to reset completion if history navigation just occurred and set the text
|
// Effect to reset completion if history navigation just occurred and set the text
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (justNavigatedHistory) {
|
if (justNavigatedHistory) {
|
||||||
|
|
@ -411,13 +414,30 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleInput = useCallback(
|
const handleInput = useCallback(
|
||||||
(key: Key) => {
|
(key: Key): boolean => {
|
||||||
|
// When the tab bar has focus, block all non-printable keys so arrow
|
||||||
|
// keys and shortcuts don't interfere. Printable characters fall
|
||||||
|
// through to BaseTextInput's default handler so the first keystroke
|
||||||
|
// appears in the input immediately (the tab bar handler releases
|
||||||
|
// focus on the same event).
|
||||||
|
if (agentTabBarFocused) {
|
||||||
|
if (
|
||||||
|
key.sequence &&
|
||||||
|
key.sequence.length === 1 &&
|
||||||
|
!key.ctrl &&
|
||||||
|
!key.meta
|
||||||
|
) {
|
||||||
|
return false; // let BaseTextInput type the character
|
||||||
|
}
|
||||||
|
return true; // consume non-printable keys
|
||||||
|
}
|
||||||
|
|
||||||
// TODO(jacobr): this special case is likely not needed anymore.
|
// TODO(jacobr): this special case is likely not needed anymore.
|
||||||
// We should probably stop supporting paste if the InputPrompt is not
|
// We should probably stop supporting paste if the InputPrompt is not
|
||||||
// focused.
|
// focused.
|
||||||
/// We want to handle paste even when not focused to support drag and drop.
|
/// We want to handle paste even when not focused to support drag and drop.
|
||||||
if (!focus && !key.paste) {
|
if (!focus && !key.paste) {
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key.paste) {
|
if (key.paste) {
|
||||||
|
|
@ -459,18 +479,18 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
// Normal paste handling for small content
|
// Normal paste handling for small content
|
||||||
buffer.handleInput(key);
|
buffer.handleInput(key);
|
||||||
}
|
}
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (vimHandleInput && vimHandleInput(key)) {
|
if (vimHandleInput && vimHandleInput(key)) {
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle feedback dialog keyboard interactions when dialog is open
|
// Handle feedback dialog keyboard interactions when dialog is open
|
||||||
if (uiState.isFeedbackDialogOpen) {
|
if (uiState.isFeedbackDialogOpen) {
|
||||||
// If it's one of the feedback option keys (1-4), let FeedbackDialog handle it
|
// If it's one of the feedback option keys (1-4), let FeedbackDialog handle it
|
||||||
if ((FEEDBACK_DIALOG_KEYS as readonly string[]).includes(key.name)) {
|
if ((FEEDBACK_DIALOG_KEYS as readonly string[]).includes(key.name)) {
|
||||||
return;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
// For any other key, close feedback dialog temporarily and continue with normal processing
|
// For any other key, close feedback dialog temporarily and continue with normal processing
|
||||||
uiActions.temporaryCloseFeedbackDialog();
|
uiActions.temporaryCloseFeedbackDialog();
|
||||||
|
|
@ -496,7 +516,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
}
|
}
|
||||||
setShellModeActive(!shellModeActive);
|
setShellModeActive(!shellModeActive);
|
||||||
buffer.setText(''); // Clear the '!' from input
|
buffer.setText(''); // Clear the '!' from input
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toggle keyboard shortcuts display with "?" when buffer is empty
|
// Toggle keyboard shortcuts display with "?" when buffer is empty
|
||||||
|
|
@ -507,7 +527,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
onToggleShortcuts
|
onToggleShortcuts
|
||||||
) {
|
) {
|
||||||
onToggleShortcuts();
|
onToggleShortcuts();
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide shortcuts on any other key press
|
// Hide shortcuts on any other key press
|
||||||
|
|
@ -537,33 +557,33 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
setReverseSearchActive,
|
setReverseSearchActive,
|
||||||
reverseSearchCompletion.resetCompletionState,
|
reverseSearchCompletion.resetCompletionState,
|
||||||
);
|
);
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
if (commandSearchActive) {
|
if (commandSearchActive) {
|
||||||
cancelSearch(
|
cancelSearch(
|
||||||
setCommandSearchActive,
|
setCommandSearchActive,
|
||||||
commandSearchCompletion.resetCompletionState,
|
commandSearchCompletion.resetCompletionState,
|
||||||
);
|
);
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shellModeActive) {
|
if (shellModeActive) {
|
||||||
setShellModeActive(false);
|
setShellModeActive(false);
|
||||||
resetEscapeState();
|
resetEscapeState();
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (completion.showSuggestions) {
|
if (completion.showSuggestions) {
|
||||||
completion.resetCompletionState();
|
completion.resetCompletionState();
|
||||||
setExpandedSuggestionIndex(-1);
|
setExpandedSuggestionIndex(-1);
|
||||||
resetEscapeState();
|
resetEscapeState();
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle double ESC for clearing input
|
// Handle double ESC for clearing input
|
||||||
if (escPressCount === 0) {
|
if (escPressCount === 0) {
|
||||||
if (buffer.text === '') {
|
if (buffer.text === '') {
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
setEscPressCount(1);
|
setEscPressCount(1);
|
||||||
setShowEscapePrompt(true);
|
setShowEscapePrompt(true);
|
||||||
|
|
@ -579,7 +599,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
resetCompletionState();
|
resetCompletionState();
|
||||||
resetEscapeState();
|
resetEscapeState();
|
||||||
}
|
}
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ctrl+Y: Retry the last failed request.
|
// Ctrl+Y: Retry the last failed request.
|
||||||
|
|
@ -589,19 +609,19 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
// If no failed request exists, a message will be shown to the user.
|
// If no failed request exists, a message will be shown to the user.
|
||||||
if (keyMatchers[Command.RETRY_LAST](key)) {
|
if (keyMatchers[Command.RETRY_LAST](key)) {
|
||||||
uiActions.handleRetryLastPrompt();
|
uiActions.handleRetryLastPrompt();
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shellModeActive && keyMatchers[Command.REVERSE_SEARCH](key)) {
|
if (shellModeActive && keyMatchers[Command.REVERSE_SEARCH](key)) {
|
||||||
setReverseSearchActive(true);
|
setReverseSearchActive(true);
|
||||||
setTextBeforeReverseSearch(buffer.text);
|
setTextBeforeReverseSearch(buffer.text);
|
||||||
setCursorPosition(buffer.cursor);
|
setCursorPosition(buffer.cursor);
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (keyMatchers[Command.CLEAR_SCREEN](key)) {
|
if (keyMatchers[Command.CLEAR_SCREEN](key)) {
|
||||||
onClearScreen();
|
onClearScreen();
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reverseSearchActive || commandSearchActive) {
|
if (reverseSearchActive || commandSearchActive) {
|
||||||
|
|
@ -626,29 +646,29 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
if (showSuggestions) {
|
if (showSuggestions) {
|
||||||
if (keyMatchers[Command.NAVIGATION_UP](key)) {
|
if (keyMatchers[Command.NAVIGATION_UP](key)) {
|
||||||
navigateUp();
|
navigateUp();
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
|
if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
|
||||||
navigateDown();
|
navigateDown();
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
if (keyMatchers[Command.COLLAPSE_SUGGESTION](key)) {
|
if (keyMatchers[Command.COLLAPSE_SUGGESTION](key)) {
|
||||||
if (suggestions[activeSuggestionIndex].value.length >= MAX_WIDTH) {
|
if (suggestions[activeSuggestionIndex].value.length >= MAX_WIDTH) {
|
||||||
setExpandedSuggestionIndex(-1);
|
setExpandedSuggestionIndex(-1);
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (keyMatchers[Command.EXPAND_SUGGESTION](key)) {
|
if (keyMatchers[Command.EXPAND_SUGGESTION](key)) {
|
||||||
if (suggestions[activeSuggestionIndex].value.length >= MAX_WIDTH) {
|
if (suggestions[activeSuggestionIndex].value.length >= MAX_WIDTH) {
|
||||||
setExpandedSuggestionIndex(activeSuggestionIndex);
|
setExpandedSuggestionIndex(activeSuggestionIndex);
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (keyMatchers[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH](key)) {
|
if (keyMatchers[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH](key)) {
|
||||||
sc.handleAutocomplete(activeSuggestionIndex);
|
sc.handleAutocomplete(activeSuggestionIndex);
|
||||||
resetState();
|
resetState();
|
||||||
setActive(false);
|
setActive(false);
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -660,7 +680,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
handleSubmitAndClear(textToSubmit);
|
handleSubmitAndClear(textToSubmit);
|
||||||
resetState();
|
resetState();
|
||||||
setActive(false);
|
setActive(false);
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent up/down from falling through to regular history navigation
|
// Prevent up/down from falling through to regular history navigation
|
||||||
|
|
@ -668,14 +688,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
keyMatchers[Command.NAVIGATION_UP](key) ||
|
keyMatchers[Command.NAVIGATION_UP](key) ||
|
||||||
keyMatchers[Command.NAVIGATION_DOWN](key)
|
keyMatchers[Command.NAVIGATION_DOWN](key)
|
||||||
) {
|
) {
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the command is a perfect match, pressing enter should execute it.
|
// If the command is a perfect match, pressing enter should execute it.
|
||||||
if (completion.isPerfectMatch && keyMatchers[Command.RETURN](key)) {
|
if (completion.isPerfectMatch && keyMatchers[Command.RETURN](key)) {
|
||||||
handleSubmitAndClear(buffer.text);
|
handleSubmitAndClear(buffer.text);
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (completion.showSuggestions) {
|
if (completion.showSuggestions) {
|
||||||
|
|
@ -683,12 +703,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
if (keyMatchers[Command.COMPLETION_UP](key)) {
|
if (keyMatchers[Command.COMPLETION_UP](key)) {
|
||||||
completion.navigateUp();
|
completion.navigateUp();
|
||||||
setExpandedSuggestionIndex(-1); // Reset expansion when navigating
|
setExpandedSuggestionIndex(-1); // Reset expansion when navigating
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
if (keyMatchers[Command.COMPLETION_DOWN](key)) {
|
if (keyMatchers[Command.COMPLETION_DOWN](key)) {
|
||||||
completion.navigateDown();
|
completion.navigateDown();
|
||||||
setExpandedSuggestionIndex(-1); // Reset expansion when navigating
|
setExpandedSuggestionIndex(-1); // Reset expansion when navigating
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -703,7 +723,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
setExpandedSuggestionIndex(-1); // Reset expansion after selection
|
setExpandedSuggestionIndex(-1); // Reset expansion after selection
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -711,28 +731,28 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
if (isAttachmentMode && attachments.length > 0) {
|
if (isAttachmentMode && attachments.length > 0) {
|
||||||
if (key.name === 'left') {
|
if (key.name === 'left') {
|
||||||
setSelectedAttachmentIndex((i) => Math.max(0, i - 1));
|
setSelectedAttachmentIndex((i) => Math.max(0, i - 1));
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
if (key.name === 'right') {
|
if (key.name === 'right') {
|
||||||
setSelectedAttachmentIndex((i) =>
|
setSelectedAttachmentIndex((i) =>
|
||||||
Math.min(attachments.length - 1, i + 1),
|
Math.min(attachments.length - 1, i + 1),
|
||||||
);
|
);
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
|
if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
|
||||||
// Exit attachment mode and return to input
|
// Exit attachment mode and return to input
|
||||||
setIsAttachmentMode(false);
|
setIsAttachmentMode(false);
|
||||||
setSelectedAttachmentIndex(-1);
|
setSelectedAttachmentIndex(-1);
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
if (key.name === 'backspace' || key.name === 'delete') {
|
if (key.name === 'backspace' || key.name === 'delete') {
|
||||||
handleAttachmentDelete(selectedAttachmentIndex);
|
handleAttachmentDelete(selectedAttachmentIndex);
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
if (key.name === 'return' || key.name === 'escape') {
|
if (key.name === 'return' || key.name === 'escape') {
|
||||||
setIsAttachmentMode(false);
|
setIsAttachmentMode(false);
|
||||||
setSelectedAttachmentIndex(-1);
|
setSelectedAttachmentIndex(-1);
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
// For other keys, exit attachment mode and let input handle them
|
// For other keys, exit attachment mode and let input handle them
|
||||||
setIsAttachmentMode(false);
|
setIsAttachmentMode(false);
|
||||||
|
|
@ -753,7 +773,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
) {
|
) {
|
||||||
setIsAttachmentMode(true);
|
setIsAttachmentMode(true);
|
||||||
setSelectedAttachmentIndex(attachments.length - 1);
|
setSelectedAttachmentIndex(attachments.length - 1);
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!shellModeActive) {
|
if (!shellModeActive) {
|
||||||
|
|
@ -761,16 +781,16 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
setCommandSearchActive(true);
|
setCommandSearchActive(true);
|
||||||
setTextBeforeReverseSearch(buffer.text);
|
setTextBeforeReverseSearch(buffer.text);
|
||||||
setCursorPosition(buffer.cursor);
|
setCursorPosition(buffer.cursor);
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (keyMatchers[Command.HISTORY_UP](key)) {
|
if (keyMatchers[Command.HISTORY_UP](key)) {
|
||||||
inputHistory.navigateUp();
|
inputHistory.navigateUp();
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
if (keyMatchers[Command.HISTORY_DOWN](key)) {
|
if (keyMatchers[Command.HISTORY_DOWN](key)) {
|
||||||
inputHistory.navigateDown();
|
inputHistory.navigateDown();
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
// Handle arrow-up/down for history on single-line or at edges
|
// Handle arrow-up/down for history on single-line or at edges
|
||||||
if (
|
if (
|
||||||
|
|
@ -779,27 +799,33 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
(buffer.visualCursor[0] === 0 && buffer.visualScrollRow === 0))
|
(buffer.visualCursor[0] === 0 && buffer.visualScrollRow === 0))
|
||||||
) {
|
) {
|
||||||
inputHistory.navigateUp();
|
inputHistory.navigateUp();
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
keyMatchers[Command.NAVIGATION_DOWN](key) &&
|
keyMatchers[Command.NAVIGATION_DOWN](key) &&
|
||||||
(buffer.allVisualLines.length === 1 ||
|
(buffer.allVisualLines.length === 1 ||
|
||||||
buffer.visualCursor[0] === buffer.allVisualLines.length - 1)
|
buffer.visualCursor[0] === buffer.allVisualLines.length - 1)
|
||||||
) {
|
) {
|
||||||
inputHistory.navigateDown();
|
if (inputHistory.navigateDown()) {
|
||||||
return;
|
return true;
|
||||||
|
}
|
||||||
|
if (hasAgents) {
|
||||||
|
setAgentTabBarFocused(true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Shell History Navigation
|
// Shell History Navigation
|
||||||
if (keyMatchers[Command.NAVIGATION_UP](key)) {
|
if (keyMatchers[Command.NAVIGATION_UP](key)) {
|
||||||
const prevCommand = shellHistory.getPreviousCommand();
|
const prevCommand = shellHistory.getPreviousCommand();
|
||||||
if (prevCommand !== null) buffer.setText(prevCommand);
|
if (prevCommand !== null) buffer.setText(prevCommand);
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
|
if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
|
||||||
const nextCommand = shellHistory.getNextCommand();
|
const nextCommand = shellHistory.getNextCommand();
|
||||||
if (nextCommand !== null) buffer.setText(nextCommand);
|
if (nextCommand !== null) buffer.setText(nextCommand);
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -810,7 +836,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
// paste markers may not work reliably and Enter key events can leak from pasted text.
|
// paste markers may not work reliably and Enter key events can leak from pasted text.
|
||||||
if (pasteWorkaround && recentPasteTime !== null) {
|
if (pasteWorkaround && recentPasteTime !== null) {
|
||||||
// Paste occurred recently, ignore this submit to prevent auto-execution
|
// Paste occurred recently, ignore this submit to prevent auto-execution
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [row, col] = buffer.cursor;
|
const [row, col] = buffer.cursor;
|
||||||
|
|
@ -823,65 +849,21 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
handleSubmitAndClear(buffer.text);
|
handleSubmitAndClear(buffer.text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return true;
|
||||||
}
|
|
||||||
|
|
||||||
// Newline insertion
|
|
||||||
if (keyMatchers[Command.NEWLINE](key)) {
|
|
||||||
buffer.newline();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl+A (Home) / Ctrl+E (End)
|
|
||||||
if (keyMatchers[Command.HOME](key)) {
|
|
||||||
buffer.move('home');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (keyMatchers[Command.END](key)) {
|
|
||||||
buffer.move('end');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Ctrl+C (Clear input)
|
|
||||||
if (keyMatchers[Command.CLEAR_INPUT](key)) {
|
|
||||||
if (buffer.text.length > 0) {
|
|
||||||
buffer.setText('');
|
|
||||||
resetCompletionState();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kill line commands
|
|
||||||
if (keyMatchers[Command.KILL_LINE_RIGHT](key)) {
|
|
||||||
buffer.killLineRight();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (keyMatchers[Command.KILL_LINE_LEFT](key)) {
|
|
||||||
buffer.killLineLeft();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (keyMatchers[Command.DELETE_WORD_BACKWARD](key)) {
|
|
||||||
buffer.deleteWordLeft();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// External editor
|
|
||||||
if (keyMatchers[Command.OPEN_EXTERNAL_EDITOR](key)) {
|
|
||||||
buffer.openInExternalEditor();
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ctrl+V for clipboard image paste
|
// Ctrl+V for clipboard image paste
|
||||||
if (keyMatchers[Command.PASTE_CLIPBOARD_IMAGE](key)) {
|
if (keyMatchers[Command.PASTE_CLIPBOARD_IMAGE](key)) {
|
||||||
handleClipboardImage();
|
handleClipboardImage();
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle backspace with placeholder-aware deletion
|
// Handle backspace with placeholder-aware deletion
|
||||||
if (
|
if (
|
||||||
key.name === 'backspace' ||
|
pendingPastes.size > 0 &&
|
||||||
|
(key.name === 'backspace' ||
|
||||||
key.sequence === '\x7f' ||
|
key.sequence === '\x7f' ||
|
||||||
(key.ctrl && key.name === 'h')
|
(key.ctrl && key.name === 'h'))
|
||||||
) {
|
) {
|
||||||
const text = buffer.text;
|
const text = buffer.text;
|
||||||
const [row, col] = buffer.cursor;
|
const [row, col] = buffer.cursor;
|
||||||
|
|
@ -894,7 +876,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
offset += col;
|
offset += col;
|
||||||
|
|
||||||
// Check if we're at the end of any placeholder
|
// Check if we're at the end of any placeholder
|
||||||
let placeholderDeleted = false;
|
|
||||||
for (const placeholder of pendingPastes.keys()) {
|
for (const placeholder of pendingPastes.keys()) {
|
||||||
const placeholderStart = offset - placeholder.length;
|
const placeholderStart = offset - placeholder.length;
|
||||||
if (
|
if (
|
||||||
|
|
@ -913,20 +894,22 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
freePlaceholderId(parsed.charCount, parsed.id);
|
freePlaceholderId(parsed.charCount, parsed.id);
|
||||||
}
|
}
|
||||||
placeholderDeleted = true;
|
return true;
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// No placeholder matched — fall through to BaseTextInput's default backspace
|
||||||
|
}
|
||||||
|
|
||||||
if (!placeholderDeleted) {
|
// Ctrl+C with completion active — also reset completion state
|
||||||
// Normal backspace behavior
|
if (keyMatchers[Command.CLEAR_INPUT](key)) {
|
||||||
buffer.backspace();
|
if (buffer.text.length > 0) {
|
||||||
|
resetCompletionState();
|
||||||
}
|
}
|
||||||
return;
|
// Fall through to BaseTextInput's default CLEAR_INPUT handler
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to the text buffer's default input handling for all other keys
|
// All remaining keys (readline shortcuts, text input) handled by BaseTextInput
|
||||||
buffer.handleInput(key);
|
return false;
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
focus,
|
focus,
|
||||||
|
|
@ -964,15 +947,89 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
pendingPastes,
|
pendingPastes,
|
||||||
parsePlaceholder,
|
parsePlaceholder,
|
||||||
freePlaceholderId,
|
freePlaceholderId,
|
||||||
|
agentTabBarFocused,
|
||||||
|
hasAgents,
|
||||||
|
setAgentTabBarFocused,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
useKeypress(handleInput, { isActive: !isEmbeddedShellFocused });
|
const renderLineWithHighlighting = useCallback(
|
||||||
|
(opts: RenderLineOptions): React.ReactNode => {
|
||||||
|
const {
|
||||||
|
lineText,
|
||||||
|
isOnCursorLine,
|
||||||
|
cursorCol: cursorVisualColAbsolute,
|
||||||
|
showCursor: showCursorOpt,
|
||||||
|
absoluteVisualIndex,
|
||||||
|
buffer: buf,
|
||||||
|
} = opts;
|
||||||
|
const mapEntry = buf.visualToLogicalMap[absoluteVisualIndex];
|
||||||
|
const [logicalLineIdx, logicalStartCol] = mapEntry;
|
||||||
|
const logicalLine = buf.lines[logicalLineIdx] || '';
|
||||||
|
const tokens = parseInputForHighlighting(logicalLine, logicalLineIdx);
|
||||||
|
|
||||||
const linesToRender = buffer.viewportVisualLines;
|
const visualStart = logicalStartCol;
|
||||||
const [cursorVisualRowAbsolute, cursorVisualColAbsolute] =
|
const visualEnd = logicalStartCol + cpLen(lineText);
|
||||||
buffer.visualCursor;
|
const segments = buildSegmentsForVisualSlice(
|
||||||
const scrollVisualRow = buffer.visualScrollRow;
|
tokens,
|
||||||
|
visualStart,
|
||||||
|
visualEnd,
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderedLine: React.ReactNode[] = [];
|
||||||
|
let charCount = 0;
|
||||||
|
segments.forEach((seg, segIdx) => {
|
||||||
|
const segLen = cpLen(seg.text);
|
||||||
|
let display = seg.text;
|
||||||
|
|
||||||
|
if (isOnCursorLine) {
|
||||||
|
const segStart = charCount;
|
||||||
|
const segEnd = segStart + segLen;
|
||||||
|
if (
|
||||||
|
cursorVisualColAbsolute >= segStart &&
|
||||||
|
cursorVisualColAbsolute < segEnd
|
||||||
|
) {
|
||||||
|
const charToHighlight = cpSlice(
|
||||||
|
seg.text,
|
||||||
|
cursorVisualColAbsolute - segStart,
|
||||||
|
cursorVisualColAbsolute - segStart + 1,
|
||||||
|
);
|
||||||
|
const highlighted = showCursorOpt
|
||||||
|
? chalk.inverse(charToHighlight)
|
||||||
|
: charToHighlight;
|
||||||
|
display =
|
||||||
|
cpSlice(seg.text, 0, cursorVisualColAbsolute - segStart) +
|
||||||
|
highlighted +
|
||||||
|
cpSlice(seg.text, cursorVisualColAbsolute - segStart + 1);
|
||||||
|
}
|
||||||
|
charCount = segEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
const color =
|
||||||
|
seg.type === 'command' || seg.type === 'file'
|
||||||
|
? theme.text.accent
|
||||||
|
: theme.text.primary;
|
||||||
|
|
||||||
|
renderedLine.push(
|
||||||
|
<Text key={`token-${segIdx}`} color={color}>
|
||||||
|
{display}
|
||||||
|
</Text>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isOnCursorLine && cursorVisualColAbsolute === cpLen(lineText)) {
|
||||||
|
// Add zero-width space after cursor to prevent Ink from trimming trailing whitespace
|
||||||
|
renderedLine.push(
|
||||||
|
<Text key={`cursor-end-${cursorVisualColAbsolute}`}>
|
||||||
|
{showCursorOpt ? chalk.inverse(' ') + '\u200B' : ' \u200B'}
|
||||||
|
</Text>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Text>{renderedLine}</Text>;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const getActiveCompletion = () => {
|
const getActiveCompletion = () => {
|
||||||
if (commandSearchActive) return commandSearchCompletion;
|
if (commandSearchActive) return commandSearchCompletion;
|
||||||
|
|
@ -1009,10 +1066,33 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
const borderColor =
|
const borderColor =
|
||||||
isShellFocused && !isEmbeddedShellFocused
|
isShellFocused && !isEmbeddedShellFocused && !agentTabBarFocused
|
||||||
? (statusColor ?? theme.border.focused)
|
? (statusColor ?? theme.border.focused)
|
||||||
: theme.border.default;
|
: theme.border.default;
|
||||||
|
|
||||||
|
const prefixNode = (
|
||||||
|
<Text
|
||||||
|
color={statusColor ?? theme.text.accent}
|
||||||
|
aria-label={statusText || undefined}
|
||||||
|
>
|
||||||
|
{shellModeActive ? (
|
||||||
|
reverseSearchActive ? (
|
||||||
|
<Text color={theme.text.link} aria-label={SCREEN_READER_USER_PREFIX}>
|
||||||
|
(r:){' '}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
'!'
|
||||||
|
)
|
||||||
|
) : commandSearchActive ? (
|
||||||
|
<Text color={theme.text.accent}>(r:) </Text>
|
||||||
|
) : showYoloStyling ? (
|
||||||
|
'*'
|
||||||
|
) : (
|
||||||
|
'>'
|
||||||
|
)}{' '}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{attachments.length > 0 && (
|
{attachments.length > 0 && (
|
||||||
|
|
@ -1032,142 +1112,17 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
<Box
|
<BaseTextInput
|
||||||
borderStyle="single"
|
buffer={buffer}
|
||||||
borderTop={true}
|
onSubmit={handleSubmitAndClear}
|
||||||
borderBottom={true}
|
onKeypress={handleInput}
|
||||||
borderLeft={false}
|
showCursor={showCursor}
|
||||||
borderRight={false}
|
placeholder={placeholder}
|
||||||
|
prefix={prefixNode}
|
||||||
borderColor={borderColor}
|
borderColor={borderColor}
|
||||||
>
|
isActive={!isEmbeddedShellFocused}
|
||||||
<Text
|
renderLine={renderLineWithHighlighting}
|
||||||
color={statusColor ?? theme.text.accent}
|
/>
|
||||||
aria-label={statusText || undefined}
|
|
||||||
>
|
|
||||||
{shellModeActive ? (
|
|
||||||
reverseSearchActive ? (
|
|
||||||
<Text
|
|
||||||
color={theme.text.link}
|
|
||||||
aria-label={SCREEN_READER_USER_PREFIX}
|
|
||||||
>
|
|
||||||
(r:){' '}
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
'!'
|
|
||||||
)
|
|
||||||
) : commandSearchActive ? (
|
|
||||||
<Text color={theme.text.accent}>(r:) </Text>
|
|
||||||
) : showYoloStyling ? (
|
|
||||||
'*'
|
|
||||||
) : (
|
|
||||||
'>'
|
|
||||||
)}{' '}
|
|
||||||
</Text>
|
|
||||||
<Box flexGrow={1} flexDirection="column">
|
|
||||||
{buffer.text.length === 0 && placeholder ? (
|
|
||||||
showCursor ? (
|
|
||||||
<Text>
|
|
||||||
{chalk.inverse(placeholder.slice(0, 1))}
|
|
||||||
<Text color={theme.text.secondary}>{placeholder.slice(1)}</Text>
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<Text color={theme.text.secondary}>{placeholder}</Text>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
linesToRender.map((lineText, visualIdxInRenderedSet) => {
|
|
||||||
const absoluteVisualIdx =
|
|
||||||
scrollVisualRow + visualIdxInRenderedSet;
|
|
||||||
const mapEntry = buffer.visualToLogicalMap[absoluteVisualIdx];
|
|
||||||
const cursorVisualRow = cursorVisualRowAbsolute - scrollVisualRow;
|
|
||||||
const isOnCursorLine =
|
|
||||||
focus && visualIdxInRenderedSet === cursorVisualRow;
|
|
||||||
|
|
||||||
const renderedLine: React.ReactNode[] = [];
|
|
||||||
|
|
||||||
const [logicalLineIdx, logicalStartCol] = mapEntry;
|
|
||||||
const logicalLine = buffer.lines[logicalLineIdx] || '';
|
|
||||||
const tokens = parseInputForHighlighting(
|
|
||||||
logicalLine,
|
|
||||||
logicalLineIdx,
|
|
||||||
);
|
|
||||||
|
|
||||||
const visualStart = logicalStartCol;
|
|
||||||
const visualEnd = logicalStartCol + cpLen(lineText);
|
|
||||||
const segments = buildSegmentsForVisualSlice(
|
|
||||||
tokens,
|
|
||||||
visualStart,
|
|
||||||
visualEnd,
|
|
||||||
);
|
|
||||||
|
|
||||||
let charCount = 0;
|
|
||||||
segments.forEach((seg, segIdx) => {
|
|
||||||
const segLen = cpLen(seg.text);
|
|
||||||
let display = seg.text;
|
|
||||||
|
|
||||||
if (isOnCursorLine) {
|
|
||||||
const relativeVisualColForHighlight = cursorVisualColAbsolute;
|
|
||||||
const segStart = charCount;
|
|
||||||
const segEnd = segStart + segLen;
|
|
||||||
if (
|
|
||||||
relativeVisualColForHighlight >= segStart &&
|
|
||||||
relativeVisualColForHighlight < segEnd
|
|
||||||
) {
|
|
||||||
const charToHighlight = cpSlice(
|
|
||||||
seg.text,
|
|
||||||
relativeVisualColForHighlight - segStart,
|
|
||||||
relativeVisualColForHighlight - segStart + 1,
|
|
||||||
);
|
|
||||||
const highlighted = showCursor
|
|
||||||
? chalk.inverse(charToHighlight)
|
|
||||||
: charToHighlight;
|
|
||||||
display =
|
|
||||||
cpSlice(
|
|
||||||
seg.text,
|
|
||||||
0,
|
|
||||||
relativeVisualColForHighlight - segStart,
|
|
||||||
) +
|
|
||||||
highlighted +
|
|
||||||
cpSlice(
|
|
||||||
seg.text,
|
|
||||||
relativeVisualColForHighlight - segStart + 1,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
charCount = segEnd;
|
|
||||||
}
|
|
||||||
|
|
||||||
const color =
|
|
||||||
seg.type === 'command' || seg.type === 'file'
|
|
||||||
? theme.text.accent
|
|
||||||
: theme.text.primary;
|
|
||||||
|
|
||||||
renderedLine.push(
|
|
||||||
<Text key={`token-${segIdx}`} color={color}>
|
|
||||||
{display}
|
|
||||||
</Text>,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (
|
|
||||||
isOnCursorLine &&
|
|
||||||
cursorVisualColAbsolute === cpLen(lineText)
|
|
||||||
) {
|
|
||||||
// Add zero-width space after cursor to prevent Ink from trimming trailing whitespace
|
|
||||||
renderedLine.push(
|
|
||||||
<Text key={`cursor-end-${cursorVisualColAbsolute}`}>
|
|
||||||
{showCursor ? chalk.inverse(' ') + '\u200B' : ' \u200B'}
|
|
||||||
</Text>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box key={`line-${visualIdxInRenderedSet}`} height={1}>
|
|
||||||
<Text>{renderedLine}</Text>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
{shouldShowSuggestions && (
|
{shouldShowSuggestions && (
|
||||||
<Box marginLeft={2} marginRight={2}>
|
<Box marginLeft={2} marginRight={2}>
|
||||||
<SuggestionsDisplay
|
<SuggestionsDisplay
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box paddingLeft={0} flexDirection="column">
|
<Box paddingLeft={2} flexDirection="column">
|
||||||
{/* Main loading line */}
|
{/* Main loading line */}
|
||||||
<Box
|
<Box
|
||||||
width="100%"
|
width="100%"
|
||||||
|
|
|
||||||
272
packages/cli/src/ui/components/agent-view/AgentChatView.tsx
Normal file
272
packages/cli/src/ui/components/agent-view/AgentChatView.tsx
Normal file
|
|
@ -0,0 +1,272 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @fileoverview AgentChatView — displays a single in-process agent's conversation.
|
||||||
|
*
|
||||||
|
* Renders the agent's message history using HistoryItemDisplay — the same
|
||||||
|
* component used by the main agent view. AgentMessage[] is converted to
|
||||||
|
* HistoryItem[] by agentMessagesToHistoryItems() so all 27 HistoryItem types
|
||||||
|
* are available without duplicating rendering logic.
|
||||||
|
*
|
||||||
|
* Layout:
|
||||||
|
* - Static area: finalized messages (efficient Ink <Static>)
|
||||||
|
* - Live area: tool groups still executing / awaiting confirmation
|
||||||
|
* - Status line: spinner while the agent is running
|
||||||
|
*
|
||||||
|
* Model text output is shown only after each round completes (no live
|
||||||
|
* streaming), which avoids per-chunk re-renders and keeps the display simple.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Box, Text, Static } from 'ink';
|
||||||
|
import { useMemo, useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
AgentStatus,
|
||||||
|
AgentEventType,
|
||||||
|
getGitBranch,
|
||||||
|
type AgentStatusChangeEvent,
|
||||||
|
} from '@qwen-code/qwen-code-core';
|
||||||
|
import {
|
||||||
|
useAgentViewState,
|
||||||
|
useAgentViewActions,
|
||||||
|
} from '../../contexts/AgentViewContext.js';
|
||||||
|
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||||
|
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||||
|
import { HistoryItemDisplay } from '../HistoryItemDisplay.js';
|
||||||
|
import { ToolCallStatus } from '../../types.js';
|
||||||
|
import { theme } from '../../semantic-colors.js';
|
||||||
|
import { GeminiRespondingSpinner } from '../GeminiRespondingSpinner.js';
|
||||||
|
import { useKeypress } from '../../hooks/useKeypress.js';
|
||||||
|
import { agentMessagesToHistoryItems } from './agentHistoryAdapter.js';
|
||||||
|
import { AgentHeader } from './AgentHeader.js';
|
||||||
|
|
||||||
|
// ─── Main Component ─────────────────────────────────────────
|
||||||
|
|
||||||
|
interface AgentChatViewProps {
|
||||||
|
agentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AgentChatView = ({ agentId }: AgentChatViewProps) => {
|
||||||
|
const { agents } = useAgentViewState();
|
||||||
|
const { setAgentShellFocused } = useAgentViewActions();
|
||||||
|
const uiState = useUIState();
|
||||||
|
const { historyRemountKey, availableTerminalHeight, constrainHeight } =
|
||||||
|
uiState;
|
||||||
|
const { columns: terminalWidth } = useTerminalSize();
|
||||||
|
const agent = agents.get(agentId);
|
||||||
|
const contentWidth = terminalWidth - 4;
|
||||||
|
|
||||||
|
// Force re-render on message updates and status changes.
|
||||||
|
// STREAM_TEXT is deliberately excluded — model text is shown only after
|
||||||
|
// each round completes (via committed messages), avoiding per-chunk re-renders.
|
||||||
|
const [, setRenderTick] = useState(0);
|
||||||
|
const tickRef = useRef(0);
|
||||||
|
const forceRender = useCallback(() => {
|
||||||
|
tickRef.current += 1;
|
||||||
|
setRenderTick(tickRef.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!agent) return;
|
||||||
|
|
||||||
|
const emitter = agent.interactiveAgent.getEventEmitter();
|
||||||
|
if (!emitter) return;
|
||||||
|
|
||||||
|
const onStatusChange = (_event: AgentStatusChangeEvent) => forceRender();
|
||||||
|
const onToolCall = () => forceRender();
|
||||||
|
const onToolResult = () => forceRender();
|
||||||
|
const onRoundEnd = () => forceRender();
|
||||||
|
const onApproval = () => forceRender();
|
||||||
|
const onOutputUpdate = () => forceRender();
|
||||||
|
|
||||||
|
emitter.on(AgentEventType.STATUS_CHANGE, onStatusChange);
|
||||||
|
emitter.on(AgentEventType.TOOL_CALL, onToolCall);
|
||||||
|
emitter.on(AgentEventType.TOOL_RESULT, onToolResult);
|
||||||
|
emitter.on(AgentEventType.ROUND_END, onRoundEnd);
|
||||||
|
emitter.on(AgentEventType.TOOL_WAITING_APPROVAL, onApproval);
|
||||||
|
emitter.on(AgentEventType.TOOL_OUTPUT_UPDATE, onOutputUpdate);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
emitter.off(AgentEventType.STATUS_CHANGE, onStatusChange);
|
||||||
|
emitter.off(AgentEventType.TOOL_CALL, onToolCall);
|
||||||
|
emitter.off(AgentEventType.TOOL_RESULT, onToolResult);
|
||||||
|
emitter.off(AgentEventType.ROUND_END, onRoundEnd);
|
||||||
|
emitter.off(AgentEventType.TOOL_WAITING_APPROVAL, onApproval);
|
||||||
|
emitter.off(AgentEventType.TOOL_OUTPUT_UPDATE, onOutputUpdate);
|
||||||
|
};
|
||||||
|
}, [agent, forceRender]);
|
||||||
|
|
||||||
|
const interactiveAgent = agent?.interactiveAgent;
|
||||||
|
const messages = interactiveAgent?.getMessages() ?? [];
|
||||||
|
const pendingApprovals = interactiveAgent?.getPendingApprovals();
|
||||||
|
const liveOutputs = interactiveAgent?.getLiveOutputs();
|
||||||
|
const shellPids = interactiveAgent?.getShellPids();
|
||||||
|
const status = interactiveAgent?.getStatus();
|
||||||
|
const isRunning =
|
||||||
|
status === AgentStatus.RUNNING || status === AgentStatus.INITIALIZING;
|
||||||
|
|
||||||
|
// Derive the active PTY PID: first shell PID among currently-executing tools.
|
||||||
|
// Resets naturally to undefined when the tool finishes (shellPids cleared).
|
||||||
|
const activePtyId =
|
||||||
|
shellPids && shellPids.size > 0
|
||||||
|
? shellPids.values().next().value
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Track whether the user has toggled input focus into the embedded shell.
|
||||||
|
// Mirrors the main agent's embeddedShellFocused in AppContainer.
|
||||||
|
const [embeddedShellFocused, setEmbeddedShellFocusedLocal] = useState(false);
|
||||||
|
|
||||||
|
// Sync to AgentViewContext so AgentTabBar can suppress arrow-key navigation
|
||||||
|
// when an agent's embedded shell is focused.
|
||||||
|
useEffect(() => {
|
||||||
|
setAgentShellFocused(embeddedShellFocused);
|
||||||
|
return () => setAgentShellFocused(false);
|
||||||
|
}, [embeddedShellFocused, setAgentShellFocused]);
|
||||||
|
|
||||||
|
// Reset focus when the shell exits (activePtyId disappears).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activePtyId) setEmbeddedShellFocusedLocal(false);
|
||||||
|
}, [activePtyId]);
|
||||||
|
|
||||||
|
// Ctrl+F: toggle shell input focus when a PTY is active.
|
||||||
|
useKeypress(
|
||||||
|
(key) => {
|
||||||
|
if (key.ctrl && key.name === 'f') {
|
||||||
|
if (activePtyId || embeddedShellFocused) {
|
||||||
|
setEmbeddedShellFocusedLocal((prev) => !prev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ isActive: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Convert AgentMessage[] → HistoryItem[] via adapter.
|
||||||
|
// tickRef.current in deps ensures we rebuild when events fire even if
|
||||||
|
// messages.length and pendingApprovals.size haven't changed (e.g. a
|
||||||
|
// tool result updates an existing entry in place).
|
||||||
|
const allItems = useMemo(
|
||||||
|
() =>
|
||||||
|
agentMessagesToHistoryItems(
|
||||||
|
messages,
|
||||||
|
pendingApprovals ?? new Map(),
|
||||||
|
liveOutputs,
|
||||||
|
shellPids,
|
||||||
|
),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[
|
||||||
|
agentId,
|
||||||
|
messages.length,
|
||||||
|
pendingApprovals?.size,
|
||||||
|
liveOutputs?.size,
|
||||||
|
shellPids?.size,
|
||||||
|
tickRef.current,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Split into committed (Static) and pending (live area).
|
||||||
|
// Any tool_group with an Executing or Confirming tool — plus everything
|
||||||
|
// after it — stays in the live area so confirmation dialogs remain
|
||||||
|
// interactive (Ink's <Static> cannot receive input).
|
||||||
|
const splitIndex = useMemo(() => {
|
||||||
|
for (let idx = allItems.length - 1; idx >= 0; idx--) {
|
||||||
|
const item = allItems[idx]!;
|
||||||
|
if (
|
||||||
|
item.type === 'tool_group' &&
|
||||||
|
item.tools.some(
|
||||||
|
(t) =>
|
||||||
|
t.status === ToolCallStatus.Executing ||
|
||||||
|
t.status === ToolCallStatus.Confirming,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return idx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return allItems.length; // all committed
|
||||||
|
}, [allItems]);
|
||||||
|
|
||||||
|
const committedItems = allItems.slice(0, splitIndex);
|
||||||
|
const pendingItems = allItems.slice(splitIndex);
|
||||||
|
|
||||||
|
const core = interactiveAgent?.getCore();
|
||||||
|
const agentWorkingDir = core?.runtimeContext.getTargetDir() ?? '';
|
||||||
|
// Cache the branch — it won't change during the agent's lifetime and
|
||||||
|
// getGitBranch uses synchronous execSync which blocks the render loop.
|
||||||
|
const agentGitBranch = useMemo(
|
||||||
|
() => (agentWorkingDir ? getGitBranch(agentWorkingDir) : ''),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[agentId],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!agent || !interactiveAgent || !core) {
|
||||||
|
return (
|
||||||
|
<Box marginX={2}>
|
||||||
|
<Text color={theme.status.error}>
|
||||||
|
Agent "{agentId}" not found.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentModelId = core.modelConfig.model ?? '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{/* Committed message history.
|
||||||
|
key includes historyRemountKey: when refreshStatic() clears the
|
||||||
|
terminal it bumps the key, forcing Static to remount and re-emit
|
||||||
|
all items on the cleared screen. */}
|
||||||
|
<Static
|
||||||
|
key={`agent-${agentId}-${historyRemountKey}`}
|
||||||
|
items={[
|
||||||
|
<AgentHeader
|
||||||
|
key="agent-header"
|
||||||
|
modelId={agentModelId}
|
||||||
|
modelName={agent.modelName}
|
||||||
|
workingDirectory={agentWorkingDir}
|
||||||
|
gitBranch={agentGitBranch}
|
||||||
|
/>,
|
||||||
|
...committedItems.map((item) => (
|
||||||
|
<HistoryItemDisplay
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
isPending={false}
|
||||||
|
terminalWidth={terminalWidth}
|
||||||
|
mainAreaWidth={contentWidth}
|
||||||
|
/>
|
||||||
|
)),
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{(item) => item}
|
||||||
|
</Static>
|
||||||
|
|
||||||
|
{/* Live area — tool groups awaiting confirmation or still executing.
|
||||||
|
Must remain outside Static so confirmation dialogs are interactive.
|
||||||
|
Pass PTY state so ShellInputPrompt is reachable via Ctrl+F. */}
|
||||||
|
{pendingItems.map((item) => (
|
||||||
|
<HistoryItemDisplay
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
isPending={true}
|
||||||
|
terminalWidth={terminalWidth}
|
||||||
|
mainAreaWidth={contentWidth}
|
||||||
|
availableTerminalHeight={
|
||||||
|
constrainHeight ? availableTerminalHeight : undefined
|
||||||
|
}
|
||||||
|
isFocused={true}
|
||||||
|
activeShellPtyId={activePtyId ?? null}
|
||||||
|
embeddedShellFocused={embeddedShellFocused}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Spinner */}
|
||||||
|
{isRunning && (
|
||||||
|
<Box marginX={2} marginTop={1}>
|
||||||
|
<GeminiRespondingSpinner />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
308
packages/cli/src/ui/components/agent-view/AgentComposer.tsx
Normal file
308
packages/cli/src/ui/components/agent-view/AgentComposer.tsx
Normal file
|
|
@ -0,0 +1,308 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @fileoverview AgentComposer — footer area for in-process agent tabs.
|
||||||
|
*
|
||||||
|
* Replaces the main Composer when an agent tab is active so that:
|
||||||
|
* - The loading indicator reflects the agent's status (not the main agent)
|
||||||
|
* - The input prompt sends messages to the agent (via enqueueMessage)
|
||||||
|
* - Keyboard events are scoped — no conflict with the main InputPrompt
|
||||||
|
*
|
||||||
|
* Wraps its content in a local StreamingContext.Provider so reusable
|
||||||
|
* components like LoadingIndicator and GeminiRespondingSpinner read the
|
||||||
|
* agent's derived streaming state instead of the main agent's.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Box, Text, useStdin } from 'ink';
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
AgentStatus,
|
||||||
|
isTerminalStatus,
|
||||||
|
ApprovalMode,
|
||||||
|
APPROVAL_MODES,
|
||||||
|
} from '@qwen-code/qwen-code-core';
|
||||||
|
import {
|
||||||
|
useAgentViewState,
|
||||||
|
useAgentViewActions,
|
||||||
|
} from '../../contexts/AgentViewContext.js';
|
||||||
|
import { useConfig } from '../../contexts/ConfigContext.js';
|
||||||
|
import { StreamingContext } from '../../contexts/StreamingContext.js';
|
||||||
|
import { StreamingState } from '../../types.js';
|
||||||
|
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||||
|
import { useAgentStreamingState } from '../../hooks/useAgentStreamingState.js';
|
||||||
|
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
|
||||||
|
import { useTextBuffer } from '../shared/text-buffer.js';
|
||||||
|
import { calculatePromptWidths } from '../../utils/layoutUtils.js';
|
||||||
|
import { BaseTextInput } from '../BaseTextInput.js';
|
||||||
|
import { LoadingIndicator } from '../LoadingIndicator.js';
|
||||||
|
import { QueuedMessageDisplay } from '../QueuedMessageDisplay.js';
|
||||||
|
import { AgentFooter } from './AgentFooter.js';
|
||||||
|
import { keyMatchers, Command } from '../../keyMatchers.js';
|
||||||
|
import { theme } from '../../semantic-colors.js';
|
||||||
|
import { t } from '../../../i18n/index.js';
|
||||||
|
|
||||||
|
// ─── Types ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface AgentComposerProps {
|
||||||
|
agentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Component ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const AgentComposer: React.FC<AgentComposerProps> = ({ agentId }) => {
|
||||||
|
const { agents, agentTabBarFocused, agentShellFocused, agentApprovalModes } =
|
||||||
|
useAgentViewState();
|
||||||
|
const {
|
||||||
|
setAgentInputBufferText,
|
||||||
|
setAgentTabBarFocused,
|
||||||
|
setAgentApprovalMode,
|
||||||
|
} = useAgentViewActions();
|
||||||
|
const agent = agents.get(agentId);
|
||||||
|
const interactiveAgent = agent?.interactiveAgent;
|
||||||
|
|
||||||
|
const config = useConfig();
|
||||||
|
const { columns: terminalWidth } = useTerminalSize();
|
||||||
|
const { inputWidth } = calculatePromptWidths(terminalWidth);
|
||||||
|
const { stdin, setRawMode } = useStdin();
|
||||||
|
|
||||||
|
const {
|
||||||
|
status,
|
||||||
|
streamingState,
|
||||||
|
isInputActive,
|
||||||
|
elapsedTime,
|
||||||
|
lastPromptTokenCount,
|
||||||
|
} = useAgentStreamingState(interactiveAgent);
|
||||||
|
|
||||||
|
// ── Escape to cancel the active agent round ──
|
||||||
|
|
||||||
|
useKeypress(
|
||||||
|
(key) => {
|
||||||
|
if (
|
||||||
|
key.name === 'escape' &&
|
||||||
|
streamingState === StreamingState.Responding
|
||||||
|
) {
|
||||||
|
interactiveAgent?.cancelCurrentRound();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
isActive:
|
||||||
|
streamingState === StreamingState.Responding && !agentShellFocused,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Shift+Tab to cycle this agent's approval mode ──
|
||||||
|
|
||||||
|
const agentApprovalMode =
|
||||||
|
agentApprovalModes.get(agentId) ?? ApprovalMode.DEFAULT;
|
||||||
|
|
||||||
|
useKeypress(
|
||||||
|
(key) => {
|
||||||
|
const isShiftTab = key.shift && key.name === 'tab';
|
||||||
|
const isWindowsTab =
|
||||||
|
process.platform === 'win32' &&
|
||||||
|
key.name === 'tab' &&
|
||||||
|
!key.ctrl &&
|
||||||
|
!key.meta;
|
||||||
|
if (isShiftTab || isWindowsTab) {
|
||||||
|
const currentIndex = APPROVAL_MODES.indexOf(agentApprovalMode);
|
||||||
|
const nextIndex =
|
||||||
|
currentIndex === -1 ? 0 : (currentIndex + 1) % APPROVAL_MODES.length;
|
||||||
|
setAgentApprovalMode(agentId, APPROVAL_MODES[nextIndex]!);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ isActive: !agentShellFocused },
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Input buffer (independent from main agent) ──
|
||||||
|
|
||||||
|
const isValidPath = useCallback((): boolean => false, []);
|
||||||
|
|
||||||
|
const buffer = useTextBuffer({
|
||||||
|
initialText: '',
|
||||||
|
viewport: { height: 3, width: inputWidth },
|
||||||
|
stdin,
|
||||||
|
setRawMode,
|
||||||
|
isValidPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync agent buffer text to context so AgentTabBar can guard tab switching
|
||||||
|
useEffect(() => {
|
||||||
|
setAgentInputBufferText(buffer.text);
|
||||||
|
return () => setAgentInputBufferText('');
|
||||||
|
}, [buffer.text, setAgentInputBufferText]);
|
||||||
|
|
||||||
|
// When agent input is not active (agent running, completed, etc.),
|
||||||
|
// auto-focus the tab bar so arrow keys switch tabs directly.
|
||||||
|
// We also depend on streamingState so that transitions like
|
||||||
|
// WaitingForConfirmation → Responding re-trigger the effect — the
|
||||||
|
// approval keypress releases tab-bar focus (printable char handler),
|
||||||
|
// but isInputActive stays false throughout, so without this extra
|
||||||
|
// dependency the focus would never be restored.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isInputActive) {
|
||||||
|
setAgentTabBarFocused(true);
|
||||||
|
}
|
||||||
|
}, [isInputActive, streamingState, setAgentTabBarFocused]);
|
||||||
|
|
||||||
|
// ── Focus management between input and tab bar ──
|
||||||
|
|
||||||
|
const handleKeypress = useCallback(
|
||||||
|
(key: Key): boolean => {
|
||||||
|
// When tab bar has focus, block all non-printable keys so they don't
|
||||||
|
// act on the hidden buffer. Printable characters fall through to
|
||||||
|
// BaseTextInput naturally; the tab bar handler releases focus on the
|
||||||
|
// same event so the keystroke appears in the input immediately.
|
||||||
|
if (agentTabBarFocused) {
|
||||||
|
if (
|
||||||
|
key.sequence &&
|
||||||
|
key.sequence.length === 1 &&
|
||||||
|
!key.ctrl &&
|
||||||
|
!key.meta
|
||||||
|
) {
|
||||||
|
return false; // let BaseTextInput type the character
|
||||||
|
}
|
||||||
|
return true; // consume non-printable keys
|
||||||
|
}
|
||||||
|
|
||||||
|
// Down arrow at the bottom edge (or empty buffer) → focus the tab bar
|
||||||
|
if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
|
||||||
|
if (
|
||||||
|
buffer.text === '' ||
|
||||||
|
buffer.allVisualLines.length === 1 ||
|
||||||
|
buffer.visualCursor[0] === buffer.allVisualLines.length - 1
|
||||||
|
) {
|
||||||
|
setAgentTabBarFocused(true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
[buffer, agentTabBarFocused, setAgentTabBarFocused],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Message queue (accumulate while streaming, flush as one prompt on idle) ──
|
||||||
|
|
||||||
|
const [messageQueue, setMessageQueue] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// When agent becomes idle (and not terminal), flush queued messages.
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
streamingState === StreamingState.Idle &&
|
||||||
|
messageQueue.length > 0 &&
|
||||||
|
status !== undefined &&
|
||||||
|
!isTerminalStatus(status)
|
||||||
|
) {
|
||||||
|
const combined = messageQueue.join('\n');
|
||||||
|
setMessageQueue([]);
|
||||||
|
interactiveAgent?.enqueueMessage(combined);
|
||||||
|
}
|
||||||
|
}, [streamingState, messageQueue, interactiveAgent, status]);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
(text: string) => {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed || !interactiveAgent) return;
|
||||||
|
if (streamingState === StreamingState.Idle) {
|
||||||
|
interactiveAgent.enqueueMessage(trimmed);
|
||||||
|
} else {
|
||||||
|
setMessageQueue((prev) => [...prev, trimmed]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[interactiveAgent, streamingState],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Render ──
|
||||||
|
|
||||||
|
const statusLabel = useMemo(() => {
|
||||||
|
switch (status) {
|
||||||
|
case AgentStatus.COMPLETED:
|
||||||
|
return { text: t('Completed'), color: theme.status.success };
|
||||||
|
case AgentStatus.FAILED:
|
||||||
|
return {
|
||||||
|
text: t('Failed: {{error}}', {
|
||||||
|
error:
|
||||||
|
interactiveAgent?.getError() ??
|
||||||
|
interactiveAgent?.getLastRoundError() ??
|
||||||
|
'unknown',
|
||||||
|
}),
|
||||||
|
color: theme.status.error,
|
||||||
|
};
|
||||||
|
case AgentStatus.CANCELLED:
|
||||||
|
return { text: t('Cancelled'), color: theme.text.secondary };
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [status, interactiveAgent]);
|
||||||
|
|
||||||
|
// ── Approval-mode styling (mirrors main InputPrompt) ──
|
||||||
|
|
||||||
|
const isYolo = agentApprovalMode === ApprovalMode.YOLO;
|
||||||
|
const isAutoAccept = agentApprovalMode !== ApprovalMode.DEFAULT;
|
||||||
|
|
||||||
|
const statusColor = isYolo
|
||||||
|
? theme.status.errorDim
|
||||||
|
: isAutoAccept
|
||||||
|
? theme.status.warningDim
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const inputBorderColor =
|
||||||
|
!isInputActive || agentTabBarFocused
|
||||||
|
? theme.border.default
|
||||||
|
: (statusColor ?? theme.border.focused);
|
||||||
|
|
||||||
|
const prefixNode = (
|
||||||
|
<Text color={statusColor ?? theme.text.accent}>{isYolo ? '*' : '>'} </Text>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StreamingContext.Provider value={streamingState}>
|
||||||
|
<Box flexDirection="column" marginTop={1}>
|
||||||
|
{/* Loading indicator — mirrors main Composer but reads agent's
|
||||||
|
streaming state via the overridden StreamingContext. */}
|
||||||
|
<LoadingIndicator
|
||||||
|
currentLoadingPhrase={
|
||||||
|
streamingState === StreamingState.Responding
|
||||||
|
? t('Thinking…')
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
elapsedTime={elapsedTime}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Terminal status for completed/failed agents */}
|
||||||
|
{statusLabel && (
|
||||||
|
<Box marginLeft={2}>
|
||||||
|
<Text color={statusLabel.color}>{statusLabel.text}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<QueuedMessageDisplay messageQueue={messageQueue} />
|
||||||
|
|
||||||
|
{/* Input prompt — always visible, like the main Composer */}
|
||||||
|
<BaseTextInput
|
||||||
|
buffer={buffer}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onKeypress={handleKeypress}
|
||||||
|
showCursor={isInputActive && !agentTabBarFocused}
|
||||||
|
placeholder={' ' + t('Send a message to this agent')}
|
||||||
|
prefix={prefixNode}
|
||||||
|
borderColor={inputBorderColor}
|
||||||
|
isActive={isInputActive && !agentShellFocused}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Footer: approval mode + context usage */}
|
||||||
|
<AgentFooter
|
||||||
|
approvalMode={agentApprovalMode}
|
||||||
|
promptTokenCount={lastPromptTokenCount}
|
||||||
|
contextWindowSize={
|
||||||
|
config.getContentGeneratorConfig()?.contextWindowSize
|
||||||
|
}
|
||||||
|
terminalWidth={terminalWidth}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</StreamingContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
66
packages/cli/src/ui/components/agent-view/AgentFooter.tsx
Normal file
66
packages/cli/src/ui/components/agent-view/AgentFooter.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @fileoverview Lightweight footer for agent tabs showing approval mode
|
||||||
|
* and context usage. Mirrors the main Footer layout but without
|
||||||
|
* main-agent-specific concerns (vim mode, shell mode, exit prompts, etc.).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type React from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import { ApprovalMode } from '@qwen-code/qwen-code-core';
|
||||||
|
import { AutoAcceptIndicator } from '../AutoAcceptIndicator.js';
|
||||||
|
import { ContextUsageDisplay } from '../ContextUsageDisplay.js';
|
||||||
|
import { theme } from '../../semantic-colors.js';
|
||||||
|
|
||||||
|
interface AgentFooterProps {
|
||||||
|
approvalMode: ApprovalMode | undefined;
|
||||||
|
promptTokenCount: number;
|
||||||
|
contextWindowSize: number | undefined;
|
||||||
|
terminalWidth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AgentFooter: React.FC<AgentFooterProps> = ({
|
||||||
|
approvalMode,
|
||||||
|
promptTokenCount,
|
||||||
|
contextWindowSize,
|
||||||
|
terminalWidth,
|
||||||
|
}) => {
|
||||||
|
const showApproval =
|
||||||
|
approvalMode !== undefined && approvalMode !== ApprovalMode.DEFAULT;
|
||||||
|
const showContext = promptTokenCount > 0 && contextWindowSize !== undefined;
|
||||||
|
|
||||||
|
if (!showApproval && !showContext) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
justifyContent="space-between"
|
||||||
|
width="100%"
|
||||||
|
flexDirection="row"
|
||||||
|
alignItems="center"
|
||||||
|
>
|
||||||
|
<Box marginLeft={2}>
|
||||||
|
{showApproval ? (
|
||||||
|
<AutoAcceptIndicator approvalMode={approvalMode} />
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
<Box marginRight={2}>
|
||||||
|
{showContext && (
|
||||||
|
<Text color={theme.text.accent}>
|
||||||
|
<ContextUsageDisplay
|
||||||
|
promptTokenCount={promptTokenCount}
|
||||||
|
terminalWidth={terminalWidth}
|
||||||
|
contextWindowSize={contextWindowSize!}
|
||||||
|
/>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
64
packages/cli/src/ui/components/agent-view/AgentHeader.tsx
Normal file
64
packages/cli/src/ui/components/agent-view/AgentHeader.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @fileoverview Compact header for agent tabs, visually distinct from the
|
||||||
|
* main view's boxed logo header. Shows model, working directory, and git
|
||||||
|
* branch in a bordered info panel.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type React from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import { shortenPath, tildeifyPath } from '@qwen-code/qwen-code-core';
|
||||||
|
import { theme } from '../../semantic-colors.js';
|
||||||
|
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||||
|
|
||||||
|
interface AgentHeaderProps {
|
||||||
|
modelId: string;
|
||||||
|
modelName?: string;
|
||||||
|
workingDirectory: string;
|
||||||
|
gitBranch?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AgentHeader: React.FC<AgentHeaderProps> = ({
|
||||||
|
modelId,
|
||||||
|
modelName,
|
||||||
|
workingDirectory,
|
||||||
|
gitBranch,
|
||||||
|
}) => {
|
||||||
|
const { columns: terminalWidth } = useTerminalSize();
|
||||||
|
const maxPathLen = Math.max(20, terminalWidth - 12);
|
||||||
|
const displayPath = shortenPath(tildeifyPath(workingDirectory), maxPathLen);
|
||||||
|
|
||||||
|
const modelText =
|
||||||
|
modelName && modelName !== modelId ? `${modelId} (${modelName})` : modelId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
flexDirection="column"
|
||||||
|
marginX={2}
|
||||||
|
marginTop={1}
|
||||||
|
borderStyle="round"
|
||||||
|
borderColor={theme.border.default}
|
||||||
|
paddingX={1}
|
||||||
|
>
|
||||||
|
<Text>
|
||||||
|
<Text color={theme.text.secondary}>{'Model: '}</Text>
|
||||||
|
<Text color={theme.text.primary}>{modelText}</Text>
|
||||||
|
</Text>
|
||||||
|
<Text>
|
||||||
|
<Text color={theme.text.secondary}>{'Path: '}</Text>
|
||||||
|
<Text color={theme.text.primary}>{displayPath}</Text>
|
||||||
|
</Text>
|
||||||
|
{gitBranch && (
|
||||||
|
<Text>
|
||||||
|
<Text color={theme.text.secondary}>{'Branch: '}</Text>
|
||||||
|
<Text color={theme.text.primary}>{gitBranch}</Text>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
167
packages/cli/src/ui/components/agent-view/AgentTabBar.tsx
Normal file
167
packages/cli/src/ui/components/agent-view/AgentTabBar.tsx
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @fileoverview AgentTabBar — horizontal tab strip for in-process agent views.
|
||||||
|
*
|
||||||
|
* Rendered at the top of the terminal whenever in-process agents are registered.
|
||||||
|
*
|
||||||
|
* On the main tab, Left/Right switch tabs when the input buffer is empty.
|
||||||
|
* On agent tabs, the tab bar uses an exclusive-focus model:
|
||||||
|
* - Down arrow at the input's bottom edge focuses the tab bar
|
||||||
|
* - Left/Right switch tabs only when the tab bar is focused
|
||||||
|
* - Up arrow or typing returns focus to the input
|
||||||
|
*
|
||||||
|
* Tab indicators: running, idle/completed, failed, cancelled
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { AgentStatus, AgentEventType } from '@qwen-code/qwen-code-core';
|
||||||
|
import {
|
||||||
|
useAgentViewState,
|
||||||
|
useAgentViewActions,
|
||||||
|
type RegisteredAgent,
|
||||||
|
} from '../../contexts/AgentViewContext.js';
|
||||||
|
import { useKeypress } from '../../hooks/useKeypress.js';
|
||||||
|
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||||
|
import { theme } from '../../semantic-colors.js';
|
||||||
|
|
||||||
|
// ─── Status Indicators ──────────────────────────────────────
|
||||||
|
|
||||||
|
function statusIndicator(agent: RegisteredAgent): {
|
||||||
|
symbol: string;
|
||||||
|
color: string;
|
||||||
|
} {
|
||||||
|
const status = agent.interactiveAgent.getStatus();
|
||||||
|
switch (status) {
|
||||||
|
case AgentStatus.RUNNING:
|
||||||
|
case AgentStatus.INITIALIZING:
|
||||||
|
return { symbol: '\u25CF', color: theme.status.warning }; // ● running
|
||||||
|
case AgentStatus.IDLE:
|
||||||
|
return { symbol: '\u25CF', color: theme.status.success }; // ● idle (ready)
|
||||||
|
case AgentStatus.COMPLETED:
|
||||||
|
return { symbol: '\u2713', color: theme.status.success }; // ✓ completed
|
||||||
|
case AgentStatus.FAILED:
|
||||||
|
return { symbol: '\u2717', color: theme.status.error }; // ✗ failed
|
||||||
|
case AgentStatus.CANCELLED:
|
||||||
|
return { symbol: '\u25CB', color: theme.text.secondary }; // ○ cancelled
|
||||||
|
default:
|
||||||
|
return { symbol: '\u25CB', color: theme.text.secondary }; // ○ fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Component ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const AgentTabBar: React.FC = () => {
|
||||||
|
const { activeView, agents, agentShellFocused, agentTabBarFocused } =
|
||||||
|
useAgentViewState();
|
||||||
|
const { switchToNext, switchToPrevious, setAgentTabBarFocused } =
|
||||||
|
useAgentViewActions();
|
||||||
|
const { embeddedShellFocused } = useUIState();
|
||||||
|
|
||||||
|
useKeypress(
|
||||||
|
(key) => {
|
||||||
|
if (embeddedShellFocused || agentShellFocused) return;
|
||||||
|
if (!agentTabBarFocused) return;
|
||||||
|
|
||||||
|
if (key.name === 'left') {
|
||||||
|
switchToPrevious();
|
||||||
|
} else if (key.name === 'right') {
|
||||||
|
switchToNext();
|
||||||
|
} else if (key.name === 'up') {
|
||||||
|
setAgentTabBarFocused(false);
|
||||||
|
} else if (
|
||||||
|
key.sequence &&
|
||||||
|
key.sequence.length === 1 &&
|
||||||
|
!key.ctrl &&
|
||||||
|
!key.meta
|
||||||
|
) {
|
||||||
|
// Printable character → return focus to input (key falls through
|
||||||
|
// to BaseTextInput's useKeypress and gets typed normally)
|
||||||
|
setAgentTabBarFocused(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ isActive: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Subscribe to STATUS_CHANGE events from all agents so the tab bar
|
||||||
|
// re-renders when an agent's status transitions (e.g. RUNNING → COMPLETED).
|
||||||
|
// Without this, status indicators would be stale until the next unrelated render.
|
||||||
|
const [, setTick] = useState(0);
|
||||||
|
const forceRender = useCallback(() => setTick((t) => t + 1), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cleanups: Array<() => void> = [];
|
||||||
|
for (const [, agent] of agents) {
|
||||||
|
const emitter = agent.interactiveAgent.getEventEmitter();
|
||||||
|
if (emitter) {
|
||||||
|
emitter.on(AgentEventType.STATUS_CHANGE, forceRender);
|
||||||
|
cleanups.push(() =>
|
||||||
|
emitter.off(AgentEventType.STATUS_CHANGE, forceRender),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return () => cleanups.forEach((fn) => fn());
|
||||||
|
}, [agents, forceRender]);
|
||||||
|
|
||||||
|
const isFocused = agentTabBarFocused;
|
||||||
|
|
||||||
|
// Navigation hint varies by context
|
||||||
|
const hint = isFocused ? '\u2190/\u2192 switch \u2191 input' : '\u2193 tabs';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="row" paddingX={1}>
|
||||||
|
{/* Main tab */}
|
||||||
|
<Box marginRight={1}>
|
||||||
|
<Text
|
||||||
|
bold={activeView === 'main'}
|
||||||
|
dimColor={!isFocused}
|
||||||
|
backgroundColor={
|
||||||
|
activeView === 'main' ? theme.border.default : undefined
|
||||||
|
}
|
||||||
|
color={
|
||||||
|
activeView === 'main' ? theme.text.primary : theme.text.secondary
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{' Main '}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Separator */}
|
||||||
|
<Text dimColor={!isFocused} color={theme.border.default}>
|
||||||
|
{'\u2502'}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Agent tabs */}
|
||||||
|
{[...agents.entries()].map(([agentId, agent]) => {
|
||||||
|
const isActive = activeView === agentId;
|
||||||
|
const { symbol, color: indicatorColor } = statusIndicator(agent);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box key={agentId} marginLeft={1}>
|
||||||
|
<Text
|
||||||
|
bold={isActive}
|
||||||
|
dimColor={!isFocused}
|
||||||
|
backgroundColor={isActive ? theme.border.default : undefined}
|
||||||
|
color={isActive ? undefined : agent.color || theme.text.secondary}
|
||||||
|
>
|
||||||
|
{` ${agent.modelId} `}
|
||||||
|
</Text>
|
||||||
|
<Text dimColor={!isFocused} color={indicatorColor}>
|
||||||
|
{` ${symbol}`}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Navigation hint */}
|
||||||
|
<Box marginLeft={2}>
|
||||||
|
<Text color={theme.text.secondary}>{hint}</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,510 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { agentMessagesToHistoryItems } from './agentHistoryAdapter.js';
|
||||||
|
import type {
|
||||||
|
AgentMessage,
|
||||||
|
ToolCallConfirmationDetails,
|
||||||
|
} from '@qwen-code/qwen-code-core';
|
||||||
|
import { ToolCallStatus } from '../../types.js';
|
||||||
|
|
||||||
|
// ─── Helpers ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function msg(
|
||||||
|
role: AgentMessage['role'],
|
||||||
|
content: string,
|
||||||
|
extra?: Partial<AgentMessage>,
|
||||||
|
): AgentMessage {
|
||||||
|
return { role, content, timestamp: 0, ...extra };
|
||||||
|
}
|
||||||
|
|
||||||
|
const noApprovals = new Map<string, ToolCallConfirmationDetails>();
|
||||||
|
|
||||||
|
function toolCallMsg(
|
||||||
|
callId: string,
|
||||||
|
toolName: string,
|
||||||
|
opts?: { description?: string; renderOutputAsMarkdown?: boolean },
|
||||||
|
): AgentMessage {
|
||||||
|
return msg('tool_call', `Tool call: ${toolName}`, {
|
||||||
|
metadata: {
|
||||||
|
callId,
|
||||||
|
toolName,
|
||||||
|
description: opts?.description ?? '',
|
||||||
|
renderOutputAsMarkdown: opts?.renderOutputAsMarkdown,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolResultMsg(
|
||||||
|
callId: string,
|
||||||
|
toolName: string,
|
||||||
|
opts?: {
|
||||||
|
success?: boolean;
|
||||||
|
resultDisplay?: string;
|
||||||
|
outputFile?: string;
|
||||||
|
},
|
||||||
|
): AgentMessage {
|
||||||
|
return msg('tool_result', `Tool ${toolName}`, {
|
||||||
|
metadata: {
|
||||||
|
callId,
|
||||||
|
toolName,
|
||||||
|
success: opts?.success ?? true,
|
||||||
|
resultDisplay: opts?.resultDisplay,
|
||||||
|
outputFile: opts?.outputFile,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Role mapping ────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('agentMessagesToHistoryItems — role mapping', () => {
|
||||||
|
it('maps user message', () => {
|
||||||
|
const items = agentMessagesToHistoryItems(
|
||||||
|
[msg('user', 'hello')],
|
||||||
|
noApprovals,
|
||||||
|
);
|
||||||
|
expect(items).toHaveLength(1);
|
||||||
|
expect(items[0]).toMatchObject({ type: 'user', text: 'hello' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps plain assistant message', () => {
|
||||||
|
const items = agentMessagesToHistoryItems(
|
||||||
|
[msg('assistant', 'response')],
|
||||||
|
noApprovals,
|
||||||
|
);
|
||||||
|
expect(items[0]).toMatchObject({ type: 'gemini', text: 'response' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps thought assistant message', () => {
|
||||||
|
const items = agentMessagesToHistoryItems(
|
||||||
|
[msg('assistant', 'thinking...', { thought: true })],
|
||||||
|
noApprovals,
|
||||||
|
);
|
||||||
|
expect(items[0]).toMatchObject({
|
||||||
|
type: 'gemini_thought',
|
||||||
|
text: 'thinking...',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps assistant message with error metadata', () => {
|
||||||
|
const items = agentMessagesToHistoryItems(
|
||||||
|
[msg('assistant', 'oops', { metadata: { error: true } })],
|
||||||
|
noApprovals,
|
||||||
|
);
|
||||||
|
expect(items[0]).toMatchObject({ type: 'error', text: 'oops' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps info message with no level → type info', () => {
|
||||||
|
const items = agentMessagesToHistoryItems(
|
||||||
|
[msg('info', 'note')],
|
||||||
|
noApprovals,
|
||||||
|
);
|
||||||
|
expect(items[0]).toMatchObject({ type: 'info', text: 'note' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['warning', 'warning'],
|
||||||
|
['success', 'success'],
|
||||||
|
['error', 'error'],
|
||||||
|
] as const)('maps info message with level=%s', (level, expectedType) => {
|
||||||
|
const items = agentMessagesToHistoryItems(
|
||||||
|
[msg('info', 'text', { metadata: { level } })],
|
||||||
|
noApprovals,
|
||||||
|
);
|
||||||
|
expect(items[0]).toMatchObject({ type: expectedType });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps unknown info level → type info', () => {
|
||||||
|
const items = agentMessagesToHistoryItems(
|
||||||
|
[msg('info', 'x', { metadata: { level: 'verbose' } })],
|
||||||
|
noApprovals,
|
||||||
|
);
|
||||||
|
expect(items[0]).toMatchObject({ type: 'info' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips unknown roles without crashing', () => {
|
||||||
|
const items = agentMessagesToHistoryItems(
|
||||||
|
[
|
||||||
|
msg('user', 'before'),
|
||||||
|
// force an unknown role
|
||||||
|
{ role: 'unknown' as AgentMessage['role'], content: 'x', timestamp: 0 },
|
||||||
|
msg('user', 'after'),
|
||||||
|
],
|
||||||
|
noApprovals,
|
||||||
|
);
|
||||||
|
expect(items).toHaveLength(2);
|
||||||
|
expect(items[0]).toMatchObject({ type: 'user', text: 'before' });
|
||||||
|
expect(items[1]).toMatchObject({ type: 'user', text: 'after' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Tool grouping ───────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('agentMessagesToHistoryItems — tool grouping', () => {
|
||||||
|
it('merges a tool_call + tool_result pair into one tool_group', () => {
|
||||||
|
const items = agentMessagesToHistoryItems(
|
||||||
|
[toolCallMsg('c1', 'read_file'), toolResultMsg('c1', 'read_file')],
|
||||||
|
noApprovals,
|
||||||
|
);
|
||||||
|
expect(items).toHaveLength(1);
|
||||||
|
expect(items[0]!.type).toBe('tool_group');
|
||||||
|
const group = items[0] as Extract<
|
||||||
|
(typeof items)[0],
|
||||||
|
{ type: 'tool_group' }
|
||||||
|
>;
|
||||||
|
expect(group.tools).toHaveLength(1);
|
||||||
|
expect(group.tools[0]!.name).toBe('read_file');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges multiple parallel tool calls into one tool_group', () => {
|
||||||
|
const items = agentMessagesToHistoryItems(
|
||||||
|
[
|
||||||
|
toolCallMsg('c1', 'read_file'),
|
||||||
|
toolCallMsg('c2', 'write_file'),
|
||||||
|
toolResultMsg('c1', 'read_file'),
|
||||||
|
toolResultMsg('c2', 'write_file'),
|
||||||
|
],
|
||||||
|
noApprovals,
|
||||||
|
);
|
||||||
|
expect(items).toHaveLength(1);
|
||||||
|
const group = items[0] as Extract<
|
||||||
|
(typeof items)[0],
|
||||||
|
{ type: 'tool_group' }
|
||||||
|
>;
|
||||||
|
expect(group.tools).toHaveLength(2);
|
||||||
|
expect(group.tools[0]!.name).toBe('read_file');
|
||||||
|
expect(group.tools[1]!.name).toBe('write_file');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves tool call order by first appearance', () => {
|
||||||
|
const items = agentMessagesToHistoryItems(
|
||||||
|
[
|
||||||
|
toolCallMsg('c2', 'second'),
|
||||||
|
toolCallMsg('c1', 'first'),
|
||||||
|
toolResultMsg('c1', 'first'),
|
||||||
|
toolResultMsg('c2', 'second'),
|
||||||
|
],
|
||||||
|
noApprovals,
|
||||||
|
);
|
||||||
|
const group = items[0] as Extract<
|
||||||
|
(typeof items)[0],
|
||||||
|
{ type: 'tool_group' }
|
||||||
|
>;
|
||||||
|
expect(group.tools[0]!.name).toBe('second');
|
||||||
|
expect(group.tools[1]!.name).toBe('first');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('breaks tool groups at non-tool messages', () => {
|
||||||
|
const items = agentMessagesToHistoryItems(
|
||||||
|
[
|
||||||
|
toolCallMsg('c1', 'tool_a'),
|
||||||
|
toolResultMsg('c1', 'tool_a'),
|
||||||
|
msg('assistant', 'between'),
|
||||||
|
toolCallMsg('c2', 'tool_b'),
|
||||||
|
toolResultMsg('c2', 'tool_b'),
|
||||||
|
],
|
||||||
|
noApprovals,
|
||||||
|
);
|
||||||
|
expect(items).toHaveLength(3);
|
||||||
|
expect(items[0]!.type).toBe('tool_group');
|
||||||
|
expect(items[1]!.type).toBe('gemini');
|
||||||
|
expect(items[2]!.type).toBe('tool_group');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles tool_result arriving without a prior tool_call gracefully', () => {
|
||||||
|
const items = agentMessagesToHistoryItems(
|
||||||
|
[
|
||||||
|
toolResultMsg('c1', 'orphan', {
|
||||||
|
success: true,
|
||||||
|
resultDisplay: 'output',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
noApprovals,
|
||||||
|
);
|
||||||
|
expect(items).toHaveLength(1);
|
||||||
|
const group = items[0] as Extract<
|
||||||
|
(typeof items)[0],
|
||||||
|
{ type: 'tool_group' }
|
||||||
|
>;
|
||||||
|
expect(group.tools[0]!.callId).toBe('c1');
|
||||||
|
expect(group.tools[0]!.status).toBe(ToolCallStatus.Success);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Tool status ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('agentMessagesToHistoryItems — tool status', () => {
|
||||||
|
it('Executing: tool_call with no result yet', () => {
|
||||||
|
const items = agentMessagesToHistoryItems(
|
||||||
|
[toolCallMsg('c1', 'shell')],
|
||||||
|
noApprovals,
|
||||||
|
);
|
||||||
|
const group = items[0] as Extract<
|
||||||
|
(typeof items)[0],
|
||||||
|
{ type: 'tool_group' }
|
||||||
|
>;
|
||||||
|
expect(group.tools[0]!.status).toBe(ToolCallStatus.Executing);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Success: tool_result with success=true', () => {
|
||||||
|
const items = agentMessagesToHistoryItems(
|
||||||
|
[
|
||||||
|
toolCallMsg('c1', 'read'),
|
||||||
|
toolResultMsg('c1', 'read', { success: true }),
|
||||||
|
],
|
||||||
|
noApprovals,
|
||||||
|
);
|
||||||
|
const group = items[0] as Extract<
|
||||||
|
(typeof items)[0],
|
||||||
|
{ type: 'tool_group' }
|
||||||
|
>;
|
||||||
|
expect(group.tools[0]!.status).toBe(ToolCallStatus.Success);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Error: tool_result with success=false', () => {
|
||||||
|
const items = agentMessagesToHistoryItems(
|
||||||
|
[
|
||||||
|
toolCallMsg('c1', 'write'),
|
||||||
|
toolResultMsg('c1', 'write', { success: false }),
|
||||||
|
],
|
||||||
|
noApprovals,
|
||||||
|
);
|
||||||
|
const group = items[0] as Extract<
|
||||||
|
(typeof items)[0],
|
||||||
|
{ type: 'tool_group' }
|
||||||
|
>;
|
||||||
|
expect(group.tools[0]!.status).toBe(ToolCallStatus.Error);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Confirming: tool_call present in pendingApprovals', () => {
|
||||||
|
const fakeApproval = {} as ToolCallConfirmationDetails;
|
||||||
|
const approvals = new Map([['c1', fakeApproval]]);
|
||||||
|
const items = agentMessagesToHistoryItems(
|
||||||
|
[toolCallMsg('c1', 'shell')],
|
||||||
|
approvals,
|
||||||
|
);
|
||||||
|
const group = items[0] as Extract<
|
||||||
|
(typeof items)[0],
|
||||||
|
{ type: 'tool_group' }
|
||||||
|
>;
|
||||||
|
expect(group.tools[0]!.status).toBe(ToolCallStatus.Confirming);
|
||||||
|
expect(group.tools[0]!.confirmationDetails).toBe(fakeApproval);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Confirming takes priority over Executing', () => {
|
||||||
|
// pending approval AND no result yet → Confirming, not Executing
|
||||||
|
const approvals = new Map([['c1', {} as ToolCallConfirmationDetails]]);
|
||||||
|
const items = agentMessagesToHistoryItems(
|
||||||
|
[toolCallMsg('c1', 'shell')],
|
||||||
|
approvals,
|
||||||
|
);
|
||||||
|
const group = items[0] as Extract<
|
||||||
|
(typeof items)[0],
|
||||||
|
{ type: 'tool_group' }
|
||||||
|
>;
|
||||||
|
expect(group.tools[0]!.status).toBe(ToolCallStatus.Confirming);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Tool metadata ───────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('agentMessagesToHistoryItems — tool metadata', () => {
|
||||||
|
it('forwards resultDisplay from tool_result', () => {
|
||||||
|
const items = agentMessagesToHistoryItems(
|
||||||
|
[
|
||||||
|
toolCallMsg('c1', 'read'),
|
||||||
|
toolResultMsg('c1', 'read', {
|
||||||
|
success: true,
|
||||||
|
resultDisplay: 'file contents',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
noApprovals,
|
||||||
|
);
|
||||||
|
const group = items[0] as Extract<
|
||||||
|
(typeof items)[0],
|
||||||
|
{ type: 'tool_group' }
|
||||||
|
>;
|
||||||
|
expect(group.tools[0]!.resultDisplay).toBe('file contents');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards renderOutputAsMarkdown from tool_call', () => {
|
||||||
|
const items = agentMessagesToHistoryItems(
|
||||||
|
[
|
||||||
|
toolCallMsg('c1', 'web_fetch', { renderOutputAsMarkdown: true }),
|
||||||
|
toolResultMsg('c1', 'web_fetch', { success: true }),
|
||||||
|
],
|
||||||
|
noApprovals,
|
||||||
|
);
|
||||||
|
const group = items[0] as Extract<
|
||||||
|
(typeof items)[0],
|
||||||
|
{ type: 'tool_group' }
|
||||||
|
>;
|
||||||
|
expect(group.tools[0]!.renderOutputAsMarkdown).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards description from tool_call', () => {
|
||||||
|
const items = agentMessagesToHistoryItems(
|
||||||
|
[toolCallMsg('c1', 'read', { description: 'reading src/index.ts' })],
|
||||||
|
noApprovals,
|
||||||
|
);
|
||||||
|
const group = items[0] as Extract<
|
||||||
|
(typeof items)[0],
|
||||||
|
{ type: 'tool_group' }
|
||||||
|
>;
|
||||||
|
expect(group.tools[0]!.description).toBe('reading src/index.ts');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── liveOutputs overlay ─────────────────────────────────────
|
||||||
|
|
||||||
|
describe('agentMessagesToHistoryItems — liveOutputs', () => {
|
||||||
|
it('uses liveOutput as resultDisplay for Executing tools', () => {
|
||||||
|
const liveOutputs = new Map([['c1', 'live stdout so far']]);
|
||||||
|
const items = agentMessagesToHistoryItems(
|
||||||
|
[toolCallMsg('c1', 'shell')],
|
||||||
|
noApprovals,
|
||||||
|
liveOutputs,
|
||||||
|
);
|
||||||
|
const group = items[0] as Extract<
|
||||||
|
(typeof items)[0],
|
||||||
|
{ type: 'tool_group' }
|
||||||
|
>;
|
||||||
|
expect(group.tools[0]!.resultDisplay).toBe('live stdout so far');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores liveOutput for completed tools', () => {
|
||||||
|
const liveOutputs = new Map([['c1', 'stale live output']]);
|
||||||
|
const items = agentMessagesToHistoryItems(
|
||||||
|
[
|
||||||
|
toolCallMsg('c1', 'shell'),
|
||||||
|
toolResultMsg('c1', 'shell', {
|
||||||
|
success: true,
|
||||||
|
resultDisplay: 'final output',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
noApprovals,
|
||||||
|
liveOutputs,
|
||||||
|
);
|
||||||
|
const group = items[0] as Extract<
|
||||||
|
(typeof items)[0],
|
||||||
|
{ type: 'tool_group' }
|
||||||
|
>;
|
||||||
|
expect(group.tools[0]!.resultDisplay).toBe('final output');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to entry resultDisplay when no liveOutput for callId', () => {
|
||||||
|
const liveOutputs = new Map([['other-id', 'unrelated']]);
|
||||||
|
const items = agentMessagesToHistoryItems(
|
||||||
|
[toolCallMsg('c1', 'shell')],
|
||||||
|
noApprovals,
|
||||||
|
liveOutputs,
|
||||||
|
);
|
||||||
|
const group = items[0] as Extract<
|
||||||
|
(typeof items)[0],
|
||||||
|
{ type: 'tool_group' }
|
||||||
|
>;
|
||||||
|
expect(group.tools[0]!.resultDisplay).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── shellPids overlay ───────────────────────────────────────
|
||||||
|
|
||||||
|
describe('agentMessagesToHistoryItems — shellPids', () => {
|
||||||
|
it('sets ptyId for Executing tools with a known PID', () => {
|
||||||
|
const shellPids = new Map([['c1', 12345]]);
|
||||||
|
const items = agentMessagesToHistoryItems(
|
||||||
|
[toolCallMsg('c1', 'shell')],
|
||||||
|
noApprovals,
|
||||||
|
undefined,
|
||||||
|
shellPids,
|
||||||
|
);
|
||||||
|
const group = items[0] as Extract<
|
||||||
|
(typeof items)[0],
|
||||||
|
{ type: 'tool_group' }
|
||||||
|
>;
|
||||||
|
expect(group.tools[0]!.ptyId).toBe(12345);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not set ptyId for completed tools', () => {
|
||||||
|
const shellPids = new Map([['c1', 12345]]);
|
||||||
|
const items = agentMessagesToHistoryItems(
|
||||||
|
[
|
||||||
|
toolCallMsg('c1', 'shell'),
|
||||||
|
toolResultMsg('c1', 'shell', { success: true }),
|
||||||
|
],
|
||||||
|
noApprovals,
|
||||||
|
undefined,
|
||||||
|
shellPids,
|
||||||
|
);
|
||||||
|
const group = items[0] as Extract<
|
||||||
|
(typeof items)[0],
|
||||||
|
{ type: 'tool_group' }
|
||||||
|
>;
|
||||||
|
expect(group.tools[0]!.ptyId).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not set ptyId when shellPids is not provided', () => {
|
||||||
|
const items = agentMessagesToHistoryItems(
|
||||||
|
[toolCallMsg('c1', 'shell')],
|
||||||
|
noApprovals,
|
||||||
|
);
|
||||||
|
const group = items[0] as Extract<
|
||||||
|
(typeof items)[0],
|
||||||
|
{ type: 'tool_group' }
|
||||||
|
>;
|
||||||
|
expect(group.tools[0]!.ptyId).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── ID stability ────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('agentMessagesToHistoryItems — ID stability', () => {
|
||||||
|
it('assigns monotonically increasing IDs', () => {
|
||||||
|
const items = agentMessagesToHistoryItems(
|
||||||
|
[
|
||||||
|
msg('user', 'u1'),
|
||||||
|
msg('assistant', 'a1'),
|
||||||
|
msg('info', 'i1'),
|
||||||
|
toolCallMsg('c1', 'tool'),
|
||||||
|
toolResultMsg('c1', 'tool'),
|
||||||
|
],
|
||||||
|
noApprovals,
|
||||||
|
);
|
||||||
|
const ids = items.map((i) => i.id);
|
||||||
|
expect(ids).toEqual([0, 1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tool_group consumes one ID regardless of how many calls it contains', () => {
|
||||||
|
const items = agentMessagesToHistoryItems(
|
||||||
|
[
|
||||||
|
msg('user', 'go'),
|
||||||
|
toolCallMsg('c1', 'tool_a'),
|
||||||
|
toolCallMsg('c2', 'tool_b'),
|
||||||
|
toolResultMsg('c1', 'tool_a'),
|
||||||
|
toolResultMsg('c2', 'tool_b'),
|
||||||
|
msg('assistant', 'done'),
|
||||||
|
],
|
||||||
|
noApprovals,
|
||||||
|
);
|
||||||
|
// user=0, tool_group=1, assistant=2
|
||||||
|
expect(items.map((i) => i.id)).toEqual([0, 1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('IDs from a prefix of messages are stable when more messages are appended', () => {
|
||||||
|
const base: AgentMessage[] = [msg('user', 'u'), msg('assistant', 'a')];
|
||||||
|
|
||||||
|
const before = agentMessagesToHistoryItems(base, noApprovals);
|
||||||
|
const after = agentMessagesToHistoryItems(
|
||||||
|
[...base, msg('info', 'i')],
|
||||||
|
noApprovals,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(after[0]!.id).toBe(before[0]!.id);
|
||||||
|
expect(after[1]!.id).toBe(before[1]!.id);
|
||||||
|
expect(after[2]!.id).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
194
packages/cli/src/ui/components/agent-view/agentHistoryAdapter.ts
Normal file
194
packages/cli/src/ui/components/agent-view/agentHistoryAdapter.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @fileoverview agentHistoryAdapter — converts AgentMessage[] to HistoryItem[].
|
||||||
|
*
|
||||||
|
* This adapter bridges the sub-agent data model (AgentMessage[] from
|
||||||
|
* AgentInteractive) to the shared rendering model (HistoryItem[] consumed by
|
||||||
|
* HistoryItemDisplay). It lives in the CLI package so that packages/core types
|
||||||
|
* are never coupled to CLI rendering types.
|
||||||
|
*
|
||||||
|
* ID stability: AgentMessage[] is append-only, so the resulting HistoryItem[]
|
||||||
|
* only ever grows. Index-based IDs are therefore stable — Ink's <Static>
|
||||||
|
* requires items never shift or be removed, which this guarantees.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AgentMessage,
|
||||||
|
ToolCallConfirmationDetails,
|
||||||
|
ToolResultDisplay,
|
||||||
|
} from '@qwen-code/qwen-code-core';
|
||||||
|
import type { HistoryItem, IndividualToolCallDisplay } from '../../types.js';
|
||||||
|
import { ToolCallStatus } from '../../types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert AgentMessage[] + pendingApprovals into HistoryItem[].
|
||||||
|
*
|
||||||
|
* Consecutive tool_call / tool_result messages are merged into a single
|
||||||
|
* tool_group HistoryItem. pendingApprovals overlays confirmation state so
|
||||||
|
* ToolGroupMessage can render confirmation dialogs.
|
||||||
|
*
|
||||||
|
* liveOutputs (optional) provides real-time display data for executing tools.
|
||||||
|
* shellPids (optional) provides PTY PIDs for interactive shell tools so
|
||||||
|
* HistoryItemDisplay can render ShellInputPrompt on the active shell.
|
||||||
|
*/
|
||||||
|
export function agentMessagesToHistoryItems(
|
||||||
|
messages: readonly AgentMessage[],
|
||||||
|
pendingApprovals: ReadonlyMap<string, ToolCallConfirmationDetails>,
|
||||||
|
liveOutputs?: ReadonlyMap<string, ToolResultDisplay>,
|
||||||
|
shellPids?: ReadonlyMap<string, number>,
|
||||||
|
): HistoryItem[] {
|
||||||
|
const items: HistoryItem[] = [];
|
||||||
|
let nextId = 0;
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
while (i < messages.length) {
|
||||||
|
const msg = messages[i]!;
|
||||||
|
|
||||||
|
// ── user ──────────────────────────────────────────────────
|
||||||
|
if (msg.role === 'user') {
|
||||||
|
items.push({ type: 'user', text: msg.content, id: nextId++ });
|
||||||
|
i++;
|
||||||
|
|
||||||
|
// ── assistant ─────────────────────────────────────────────
|
||||||
|
} else if (msg.role === 'assistant') {
|
||||||
|
if (msg.metadata?.['error']) {
|
||||||
|
items.push({ type: 'error', text: msg.content, id: nextId++ });
|
||||||
|
} else if (msg.thought) {
|
||||||
|
items.push({ type: 'gemini_thought', text: msg.content, id: nextId++ });
|
||||||
|
} else {
|
||||||
|
items.push({ type: 'gemini', text: msg.content, id: nextId++ });
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
|
||||||
|
// ── info / warning / success / error ──────────────────────
|
||||||
|
} else if (msg.role === 'info') {
|
||||||
|
const level = msg.metadata?.['level'] as string | undefined;
|
||||||
|
const type =
|
||||||
|
level === 'warning' || level === 'success' || level === 'error'
|
||||||
|
? level
|
||||||
|
: 'info';
|
||||||
|
items.push({ type, text: msg.content, id: nextId++ });
|
||||||
|
i++;
|
||||||
|
|
||||||
|
// ── tool_call / tool_result → tool_group ──────────────────
|
||||||
|
} else if (msg.role === 'tool_call' || msg.role === 'tool_result') {
|
||||||
|
const groupId = nextId++;
|
||||||
|
|
||||||
|
const callMap = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
callId: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
resultDisplay: ToolResultDisplay | string | undefined;
|
||||||
|
outputFile: string | undefined;
|
||||||
|
renderOutputAsMarkdown: boolean | undefined;
|
||||||
|
success: boolean | undefined;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
const callOrder: string[] = [];
|
||||||
|
|
||||||
|
while (
|
||||||
|
i < messages.length &&
|
||||||
|
(messages[i]!.role === 'tool_call' ||
|
||||||
|
messages[i]!.role === 'tool_result')
|
||||||
|
) {
|
||||||
|
const m = messages[i]!;
|
||||||
|
const callId = (m.metadata?.['callId'] as string) ?? `unknown-${i}`;
|
||||||
|
|
||||||
|
if (m.role === 'tool_call') {
|
||||||
|
if (!callMap.has(callId)) callOrder.push(callId);
|
||||||
|
callMap.set(callId, {
|
||||||
|
callId,
|
||||||
|
name: (m.metadata?.['toolName'] as string) ?? 'unknown',
|
||||||
|
description: (m.metadata?.['description'] as string) ?? '',
|
||||||
|
resultDisplay: undefined,
|
||||||
|
outputFile: undefined,
|
||||||
|
renderOutputAsMarkdown: m.metadata?.['renderOutputAsMarkdown'] as
|
||||||
|
| boolean
|
||||||
|
| undefined,
|
||||||
|
success: undefined,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// tool_result — attach to existing call entry
|
||||||
|
const entry = callMap.get(callId);
|
||||||
|
const resultDisplay = m.metadata?.['resultDisplay'] as
|
||||||
|
| ToolResultDisplay
|
||||||
|
| string
|
||||||
|
| undefined;
|
||||||
|
const outputFile = m.metadata?.['outputFile'] as string | undefined;
|
||||||
|
const success = m.metadata?.['success'] as boolean;
|
||||||
|
|
||||||
|
if (entry) {
|
||||||
|
entry.success = success;
|
||||||
|
entry.resultDisplay = resultDisplay;
|
||||||
|
entry.outputFile = outputFile;
|
||||||
|
} else {
|
||||||
|
// Result arrived without a prior tool_call message (shouldn't
|
||||||
|
// normally happen, but handle gracefully)
|
||||||
|
callOrder.push(callId);
|
||||||
|
callMap.set(callId, {
|
||||||
|
callId,
|
||||||
|
name: (m.metadata?.['toolName'] as string) ?? 'unknown',
|
||||||
|
description: '',
|
||||||
|
resultDisplay,
|
||||||
|
outputFile,
|
||||||
|
renderOutputAsMarkdown: undefined,
|
||||||
|
success,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tools: IndividualToolCallDisplay[] = callOrder.map((callId) => {
|
||||||
|
const entry = callMap.get(callId)!;
|
||||||
|
const approval = pendingApprovals.get(callId);
|
||||||
|
|
||||||
|
let status: ToolCallStatus;
|
||||||
|
if (approval) {
|
||||||
|
status = ToolCallStatus.Confirming;
|
||||||
|
} else if (entry.success === undefined) {
|
||||||
|
status = ToolCallStatus.Executing;
|
||||||
|
} else if (entry.success) {
|
||||||
|
status = ToolCallStatus.Success;
|
||||||
|
} else {
|
||||||
|
status = ToolCallStatus.Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For executing tools, use live output if available (Gap 4)
|
||||||
|
const resultDisplay =
|
||||||
|
status === ToolCallStatus.Executing && liveOutputs?.has(callId)
|
||||||
|
? liveOutputs.get(callId)
|
||||||
|
: entry.resultDisplay;
|
||||||
|
|
||||||
|
return {
|
||||||
|
callId: entry.callId,
|
||||||
|
name: entry.name,
|
||||||
|
description: entry.description,
|
||||||
|
resultDisplay,
|
||||||
|
outputFile: entry.outputFile,
|
||||||
|
renderOutputAsMarkdown: entry.renderOutputAsMarkdown,
|
||||||
|
status,
|
||||||
|
confirmationDetails: approval,
|
||||||
|
ptyId:
|
||||||
|
status === ToolCallStatus.Executing
|
||||||
|
? shellPids?.get(callId)
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
items.push({ type: 'tool_group', tools, id: groupId });
|
||||||
|
} else {
|
||||||
|
// Skip unknown roles
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
12
packages/cli/src/ui/components/agent-view/index.ts
Normal file
12
packages/cli/src/ui/components/agent-view/index.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { AgentTabBar } from './AgentTabBar.js';
|
||||||
|
export { AgentChatView } from './AgentChatView.js';
|
||||||
|
export { AgentHeader } from './AgentHeader.js';
|
||||||
|
export { AgentComposer } from './AgentComposer.js';
|
||||||
|
export { AgentFooter } from './AgentFooter.js';
|
||||||
|
export { agentMessagesToHistoryItems } from './agentHistoryAdapter.js';
|
||||||
290
packages/cli/src/ui/components/arena/ArenaCards.tsx
Normal file
290
packages/cli/src/ui/components/arena/ArenaCards.tsx
Normal file
|
|
@ -0,0 +1,290 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type React from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import { theme } from '../../semantic-colors.js';
|
||||||
|
import { formatDuration } from '../../utils/formatters.js';
|
||||||
|
import { getArenaStatusLabel } from '../../utils/displayUtils.js';
|
||||||
|
import type { ArenaAgentCardData } from '../../types.js';
|
||||||
|
|
||||||
|
// ─── Helpers ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// ─── Agent Complete Card ────────────────────────────────────
|
||||||
|
|
||||||
|
interface ArenaAgentCardProps {
|
||||||
|
agent: ArenaAgentCardData;
|
||||||
|
width?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ArenaAgentCard: React.FC<ArenaAgentCardProps> = ({
|
||||||
|
agent,
|
||||||
|
width,
|
||||||
|
}) => {
|
||||||
|
const { icon, text, color } = getArenaStatusLabel(agent.status);
|
||||||
|
const duration = formatDuration(agent.durationMs);
|
||||||
|
const tokens = agent.totalTokens.toLocaleString();
|
||||||
|
const inTokens = agent.inputTokens.toLocaleString();
|
||||||
|
const outTokens = agent.outputTokens.toLocaleString();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" width={width}>
|
||||||
|
{/* Line 1: Status icon + text + label + duration */}
|
||||||
|
<Box>
|
||||||
|
<Text color={color}>
|
||||||
|
{icon} {agent.label} · {text} · {duration}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Line 2: Tokens */}
|
||||||
|
<Box marginLeft={2}>
|
||||||
|
<Text color={theme.text.secondary}>
|
||||||
|
Tokens: {tokens} (in {inTokens}, out {outTokens})
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Line 3: Tool Calls with colored success/error counts */}
|
||||||
|
<Box marginLeft={2}>
|
||||||
|
<Text color={theme.text.secondary}>
|
||||||
|
Tool Calls: {agent.toolCalls}
|
||||||
|
{agent.failedToolCalls > 0 && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
(
|
||||||
|
<Text color={theme.status.success}>
|
||||||
|
✓ {agent.successfulToolCalls}
|
||||||
|
</Text>
|
||||||
|
<Text color={theme.text.secondary}> </Text>
|
||||||
|
<Text color={theme.status.error}>✕ {agent.failedToolCalls}</Text>)
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Error line (if terminated with error) */}
|
||||||
|
{agent.error && (
|
||||||
|
<Box marginLeft={2}>
|
||||||
|
<Text color={theme.status.error}>{agent.error}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Session Complete Card ──────────────────────────────────
|
||||||
|
|
||||||
|
interface ArenaSessionCardProps {
|
||||||
|
sessionStatus: string;
|
||||||
|
task: string;
|
||||||
|
totalDurationMs: number;
|
||||||
|
agents: ArenaAgentCardData[];
|
||||||
|
width?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pad or truncate a string to a fixed visual width.
|
||||||
|
*/
|
||||||
|
function pad(
|
||||||
|
str: string,
|
||||||
|
len: number,
|
||||||
|
align: 'left' | 'right' = 'left',
|
||||||
|
): string {
|
||||||
|
if (str.length >= len) return str.slice(0, len);
|
||||||
|
const padding = ' '.repeat(len - str.length);
|
||||||
|
return align === 'right' ? padding + str : str + padding;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncate a string to a maximum length, adding ellipsis if truncated.
|
||||||
|
*/
|
||||||
|
function truncate(str: string, maxLen: number): string {
|
||||||
|
if (str.length <= maxLen) return str;
|
||||||
|
return str.slice(0, maxLen - 1) + '…';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate diff stats from a unified diff string.
|
||||||
|
* Returns the stats string and individual counts for colored rendering.
|
||||||
|
*/
|
||||||
|
function getDiffStats(diff: string | undefined): {
|
||||||
|
text: string;
|
||||||
|
additions: number;
|
||||||
|
deletions: number;
|
||||||
|
} {
|
||||||
|
if (!diff) return { text: '', additions: 0, deletions: 0 };
|
||||||
|
const lines = diff.split('\n');
|
||||||
|
let additions = 0;
|
||||||
|
let deletions = 0;
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('+') && !line.startsWith('+++')) {
|
||||||
|
additions++;
|
||||||
|
} else if (line.startsWith('-') && !line.startsWith('---')) {
|
||||||
|
deletions++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { text: `+${additions}/-${deletions}`, additions, deletions };
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_MODEL_NAME_LENGTH = 35;
|
||||||
|
|
||||||
|
export const ArenaSessionCard: React.FC<ArenaSessionCardProps> = ({
|
||||||
|
sessionStatus,
|
||||||
|
task,
|
||||||
|
agents,
|
||||||
|
width,
|
||||||
|
}) => {
|
||||||
|
// Truncate task for display
|
||||||
|
const maxTaskLen = 60;
|
||||||
|
const displayTask =
|
||||||
|
task.length > maxTaskLen ? task.slice(0, maxTaskLen - 1) + '…' : task;
|
||||||
|
|
||||||
|
// Column widths for the agent table (unified with Arena Results)
|
||||||
|
const colStatus = 14;
|
||||||
|
const colTime = 8;
|
||||||
|
const colTokens = 10;
|
||||||
|
const colChanges = 10;
|
||||||
|
|
||||||
|
const titleLabel =
|
||||||
|
sessionStatus === 'idle'
|
||||||
|
? 'Agents Status · Idle'
|
||||||
|
: sessionStatus === 'completed'
|
||||||
|
? 'Arena Complete'
|
||||||
|
: sessionStatus === 'cancelled'
|
||||||
|
? 'Arena Cancelled'
|
||||||
|
: 'Arena Failed';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
borderStyle="round"
|
||||||
|
borderColor={theme.border.default}
|
||||||
|
flexDirection="column"
|
||||||
|
paddingX={2}
|
||||||
|
paddingY={1}
|
||||||
|
width={width}
|
||||||
|
>
|
||||||
|
{/* Title - neutral color (not green) */}
|
||||||
|
<Box>
|
||||||
|
<Text bold color={theme.text.primary}>
|
||||||
|
{titleLabel}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box height={1} />
|
||||||
|
|
||||||
|
{/* Task */}
|
||||||
|
<Box>
|
||||||
|
<Text>
|
||||||
|
<Text color={theme.text.secondary}>Task: </Text>
|
||||||
|
<Text color={theme.text.primary}>"{displayTask}"</Text>
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box height={1} />
|
||||||
|
|
||||||
|
{/* Table header - unified columns: Agent, Status, Time, Tokens, Changes */}
|
||||||
|
<Box>
|
||||||
|
<Box flexGrow={1}>
|
||||||
|
<Text bold color={theme.text.secondary}>
|
||||||
|
Agent
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box width={colStatus} justifyContent="flex-end">
|
||||||
|
<Text bold color={theme.text.secondary}>
|
||||||
|
Status
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box width={colTime} justifyContent="flex-end">
|
||||||
|
<Text bold color={theme.text.secondary}>
|
||||||
|
Time
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box width={colTokens} justifyContent="flex-end">
|
||||||
|
<Text bold color={theme.text.secondary}>
|
||||||
|
Tokens
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box width={colChanges} justifyContent="flex-end">
|
||||||
|
<Text bold color={theme.text.secondary}>
|
||||||
|
Changes
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Table separator */}
|
||||||
|
<Box>
|
||||||
|
<Text color={theme.border.default}>
|
||||||
|
{'─'.repeat((width ?? 60) - 8)}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Agent rows */}
|
||||||
|
{agents.map((agent) => {
|
||||||
|
const { text: statusText, color } = getArenaStatusLabel(agent.status);
|
||||||
|
const diffStats = getDiffStats(agent.diff);
|
||||||
|
return (
|
||||||
|
<Box key={agent.label}>
|
||||||
|
<Box flexGrow={1}>
|
||||||
|
<Text color={theme.text.primary}>
|
||||||
|
{truncate(agent.label, MAX_MODEL_NAME_LENGTH)}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box width={colStatus} justifyContent="flex-end">
|
||||||
|
<Text color={color}>{statusText}</Text>
|
||||||
|
</Box>
|
||||||
|
<Box width={colTime} justifyContent="flex-end">
|
||||||
|
<Text color={theme.text.primary}>
|
||||||
|
{pad(formatDuration(agent.durationMs), colTime - 1, 'right')}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box width={colTokens} justifyContent="flex-end">
|
||||||
|
<Text color={theme.text.primary}>
|
||||||
|
{pad(
|
||||||
|
agent.totalTokens.toLocaleString(),
|
||||||
|
colTokens - 1,
|
||||||
|
'right',
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box width={colChanges} justifyContent="flex-end">
|
||||||
|
{diffStats.additions > 0 || diffStats.deletions > 0 ? (
|
||||||
|
<Text>
|
||||||
|
<Text color={theme.status.success}>
|
||||||
|
+{diffStats.additions}
|
||||||
|
</Text>
|
||||||
|
<Text color={theme.text.secondary}>/</Text>
|
||||||
|
<Text color={theme.status.error}>-{diffStats.deletions}</Text>
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text color={theme.text.secondary}>-</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<Box height={1} />
|
||||||
|
|
||||||
|
{/* Hint */}
|
||||||
|
{sessionStatus === 'idle' && (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color={theme.text.secondary}>
|
||||||
|
Switch to an agent tab to continue, or{' '}
|
||||||
|
<Text color={theme.text.accent}>/arena select</Text> to pick a
|
||||||
|
winner.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{sessionStatus === 'completed' && (
|
||||||
|
<Box>
|
||||||
|
<Text color={theme.text.secondary}>
|
||||||
|
Run <Text color={theme.text.accent}>/arena select</Text> to pick a
|
||||||
|
winner.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
260
packages/cli/src/ui/components/arena/ArenaSelectDialog.tsx
Normal file
260
packages/cli/src/ui/components/arena/ArenaSelectDialog.tsx
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type React from 'react';
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import {
|
||||||
|
type ArenaManager,
|
||||||
|
isSuccessStatus,
|
||||||
|
type Config,
|
||||||
|
} from '@qwen-code/qwen-code-core';
|
||||||
|
import { theme } from '../../semantic-colors.js';
|
||||||
|
import { useKeypress } from '../../hooks/useKeypress.js';
|
||||||
|
import { MessageType, type HistoryItemWithoutId } from '../../types.js';
|
||||||
|
import type { UseHistoryManagerReturn } from '../../hooks/useHistoryManager.js';
|
||||||
|
import { formatDuration } from '../../utils/formatters.js';
|
||||||
|
import { getArenaStatusLabel } from '../../utils/displayUtils.js';
|
||||||
|
import { DescriptiveRadioButtonSelect } from '../shared/DescriptiveRadioButtonSelect.js';
|
||||||
|
import type { DescriptiveRadioSelectItem } from '../shared/DescriptiveRadioButtonSelect.js';
|
||||||
|
|
||||||
|
interface ArenaSelectDialogProps {
|
||||||
|
manager: ArenaManager;
|
||||||
|
config: Config;
|
||||||
|
addItem: UseHistoryManagerReturn['addItem'];
|
||||||
|
closeArenaDialog: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ArenaSelectDialog({
|
||||||
|
manager,
|
||||||
|
config,
|
||||||
|
addItem,
|
||||||
|
closeArenaDialog,
|
||||||
|
}: ArenaSelectDialogProps): React.JSX.Element {
|
||||||
|
const pushMessage = useCallback(
|
||||||
|
(result: { messageType: 'info' | 'error'; content: string }) => {
|
||||||
|
const item: HistoryItemWithoutId = {
|
||||||
|
type:
|
||||||
|
result.messageType === 'info' ? MessageType.INFO : MessageType.ERROR,
|
||||||
|
text: result.content,
|
||||||
|
};
|
||||||
|
addItem(item, Date.now());
|
||||||
|
|
||||||
|
try {
|
||||||
|
const chatRecorder = config.getChatRecordingService();
|
||||||
|
chatRecorder?.recordSlashCommand({
|
||||||
|
phase: 'result',
|
||||||
|
rawCommand: '/arena select',
|
||||||
|
outputHistoryItems: [{ ...item } as Record<string, unknown>],
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Best-effort recording
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[addItem, config],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onSelect = useCallback(
|
||||||
|
async (agentId: string) => {
|
||||||
|
closeArenaDialog();
|
||||||
|
const mgr = config.getArenaManager();
|
||||||
|
if (!mgr) {
|
||||||
|
pushMessage({
|
||||||
|
messageType: 'error',
|
||||||
|
content: 'No arena session found. Start one with /arena start.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const agent =
|
||||||
|
mgr.getAgentState(agentId) ??
|
||||||
|
mgr.getAgentStates().find((item) => item.agentId === agentId);
|
||||||
|
const label = agent?.model.modelId || agentId;
|
||||||
|
|
||||||
|
pushMessage({
|
||||||
|
messageType: 'info',
|
||||||
|
content: `Applying changes from ${label}…`,
|
||||||
|
});
|
||||||
|
const result = await mgr.applyAgentResult(agentId);
|
||||||
|
if (!result.success) {
|
||||||
|
pushMessage({
|
||||||
|
messageType: 'error',
|
||||||
|
content: `Failed to apply changes from ${label}: ${result.error}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await config.cleanupArenaRuntime(true);
|
||||||
|
} catch (err) {
|
||||||
|
pushMessage({
|
||||||
|
messageType: 'error',
|
||||||
|
content: `Warning: failed to clean up arena resources: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
pushMessage({
|
||||||
|
messageType: 'info',
|
||||||
|
content: `Applied changes from ${label} to workspace. Arena session complete.`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[closeArenaDialog, config, pushMessage],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onDiscard = useCallback(async () => {
|
||||||
|
closeArenaDialog();
|
||||||
|
const mgr = config.getArenaManager();
|
||||||
|
if (!mgr) {
|
||||||
|
pushMessage({
|
||||||
|
messageType: 'error',
|
||||||
|
content: 'No arena session found. Start one with /arena start.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
pushMessage({
|
||||||
|
messageType: 'info',
|
||||||
|
content: 'Discarding Arena results and cleaning up…',
|
||||||
|
});
|
||||||
|
await config.cleanupArenaRuntime(true);
|
||||||
|
pushMessage({
|
||||||
|
messageType: 'info',
|
||||||
|
content: 'Arena results discarded. All worktrees cleaned up.',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
pushMessage({
|
||||||
|
messageType: 'error',
|
||||||
|
content: `Failed to clean up arena worktrees: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [closeArenaDialog, config, pushMessage]);
|
||||||
|
|
||||||
|
const result = manager.getResult();
|
||||||
|
const agents = manager.getAgentStates();
|
||||||
|
|
||||||
|
const items: Array<DescriptiveRadioSelectItem<string>> = useMemo(
|
||||||
|
() =>
|
||||||
|
agents.map((agent) => {
|
||||||
|
const label = agent.model.modelId;
|
||||||
|
const statusInfo = getArenaStatusLabel(agent.status);
|
||||||
|
const duration = formatDuration(agent.stats.durationMs);
|
||||||
|
const tokens = agent.stats.totalTokens.toLocaleString();
|
||||||
|
|
||||||
|
// Build diff summary from cached result if available
|
||||||
|
let diffAdditions = 0;
|
||||||
|
let diffDeletions = 0;
|
||||||
|
if (isSuccessStatus(agent.status) && result) {
|
||||||
|
const agentResult = result.agents.find(
|
||||||
|
(a) => a.agentId === agent.agentId,
|
||||||
|
);
|
||||||
|
if (agentResult?.diff) {
|
||||||
|
const lines = agentResult.diff.split('\n');
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('+') && !line.startsWith('+++')) {
|
||||||
|
diffAdditions++;
|
||||||
|
} else if (line.startsWith('-') && !line.startsWith('---')) {
|
||||||
|
diffDeletions++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title: full model name (not truncated)
|
||||||
|
const title = <Text>{label}</Text>;
|
||||||
|
|
||||||
|
// Description: status, time, tokens, changes (unified with Arena Complete columns)
|
||||||
|
const description = (
|
||||||
|
<Text>
|
||||||
|
<Text color={statusInfo.color}>{statusInfo.text}</Text>
|
||||||
|
<Text color={theme.text.secondary}> · </Text>
|
||||||
|
<Text color={theme.text.secondary}>{duration}</Text>
|
||||||
|
<Text color={theme.text.secondary}> · </Text>
|
||||||
|
<Text color={theme.text.secondary}>{tokens} tokens</Text>
|
||||||
|
{(diffAdditions > 0 || diffDeletions > 0) && (
|
||||||
|
<>
|
||||||
|
<Text color={theme.text.secondary}> · </Text>
|
||||||
|
<Text color={theme.status.success}>+{diffAdditions}</Text>
|
||||||
|
<Text color={theme.text.secondary}>/</Text>
|
||||||
|
<Text color={theme.status.error}>-{diffDeletions}</Text>
|
||||||
|
<Text color={theme.text.secondary}> lines</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: agent.agentId,
|
||||||
|
value: agent.agentId,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
disabled: !isSuccessStatus(agent.status),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
[agents, result],
|
||||||
|
);
|
||||||
|
|
||||||
|
useKeypress(
|
||||||
|
(key) => {
|
||||||
|
if (key.name === 'escape') {
|
||||||
|
closeArenaDialog();
|
||||||
|
}
|
||||||
|
if (key.name === 'd' && !key.ctrl && !key.meta) {
|
||||||
|
onDiscard();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ isActive: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const task = result?.task || '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
borderStyle="round"
|
||||||
|
borderColor={theme.border.default}
|
||||||
|
flexDirection="column"
|
||||||
|
padding={1}
|
||||||
|
width="100%"
|
||||||
|
>
|
||||||
|
{/* Neutral title color (not green) */}
|
||||||
|
<Text bold color={theme.text.primary}>
|
||||||
|
Arena Results
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
<Text>
|
||||||
|
<Text color={theme.text.secondary}>Task: </Text>
|
||||||
|
<Text
|
||||||
|
color={theme.text.primary}
|
||||||
|
>{`"${task.length > 60 ? task.slice(0, 59) + '…' : task}"`}</Text>
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={theme.text.secondary}>
|
||||||
|
Select a winner to apply changes:
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
<DescriptiveRadioButtonSelect
|
||||||
|
items={items}
|
||||||
|
initialIndex={items.findIndex((item) => !item.disabled)}
|
||||||
|
onSelect={(agentId: string) => {
|
||||||
|
onSelect(agentId);
|
||||||
|
}}
|
||||||
|
isFocused={true}
|
||||||
|
showNumbers={false}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={theme.text.secondary}>
|
||||||
|
Enter to select, d to discard all, Esc to cancel
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
161
packages/cli/src/ui/components/arena/ArenaStartDialog.tsx
Normal file
161
packages/cli/src/ui/components/arena/ArenaStartDialog.tsx
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type React from 'react';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import Link from 'ink-link';
|
||||||
|
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||||
|
import { useConfig } from '../../contexts/ConfigContext.js';
|
||||||
|
import { theme } from '../../semantic-colors.js';
|
||||||
|
import { useKeypress } from '../../hooks/useKeypress.js';
|
||||||
|
import { MultiSelect } from '../shared/MultiSelect.js';
|
||||||
|
import { t } from '../../../i18n/index.js';
|
||||||
|
|
||||||
|
interface ArenaStartDialogProps {
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: (selectedModels: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODEL_PROVIDERS_DOCUMENTATION_URL =
|
||||||
|
'https://qwenlm.github.io/qwen-code-docs/en/users/configuration/settings/#modelproviders';
|
||||||
|
|
||||||
|
export function ArenaStartDialog({
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}: ArenaStartDialogProps): React.JSX.Element {
|
||||||
|
const config = useConfig();
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const modelItems = useMemo(() => {
|
||||||
|
const allModels = config.getAllConfiguredModels();
|
||||||
|
const selectableModels = allModels.filter((model) => !model.isRuntimeModel);
|
||||||
|
|
||||||
|
return selectableModels.map((model) => {
|
||||||
|
const token = `${model.authType}:${model.id}`;
|
||||||
|
const isQwenOauth = model.authType === AuthType.QWEN_OAUTH;
|
||||||
|
return {
|
||||||
|
key: token,
|
||||||
|
value: token,
|
||||||
|
label: `[${model.authType}] ${model.label}`,
|
||||||
|
disabled: isQwenOauth,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [config]);
|
||||||
|
const hasDisabledQwenOauth = modelItems.some((item) => item.disabled);
|
||||||
|
const selectableModelCount = modelItems.filter(
|
||||||
|
(item) => !item.disabled,
|
||||||
|
).length;
|
||||||
|
const needsMoreModels = selectableModelCount < 2;
|
||||||
|
const shouldShowMoreModelsHint =
|
||||||
|
selectableModelCount >= 2 && selectableModelCount < 3;
|
||||||
|
|
||||||
|
useKeypress(
|
||||||
|
(key) => {
|
||||||
|
if (key.name === 'escape') {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ isActive: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleConfirm = (values: string[]) => {
|
||||||
|
if (values.length < 2) {
|
||||||
|
setErrorMessage(
|
||||||
|
t('Please select at least 2 models to start an Arena session.'),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrorMessage(null);
|
||||||
|
onConfirm(values);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
borderStyle="round"
|
||||||
|
borderColor={theme.border.default}
|
||||||
|
flexDirection="column"
|
||||||
|
padding={1}
|
||||||
|
width="100%"
|
||||||
|
>
|
||||||
|
<Text bold>{t('Select Models')}</Text>
|
||||||
|
|
||||||
|
{modelItems.length === 0 ? (
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
<Text color={theme.status.warning}>
|
||||||
|
{t('No models available. Please configure models first.')}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<MultiSelect
|
||||||
|
items={modelItems}
|
||||||
|
initialIndex={0}
|
||||||
|
onConfirm={handleConfirm}
|
||||||
|
showNumbers
|
||||||
|
showScrollArrows
|
||||||
|
maxItemsToShow={10}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{errorMessage && (
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={theme.status.error}>{errorMessage}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(hasDisabledQwenOauth || needsMoreModels) && (
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
{hasDisabledQwenOauth && (
|
||||||
|
<Text color={theme.status.warning}>
|
||||||
|
{t('Note: qwen-oauth models are not supported in Arena.')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{needsMoreModels && (
|
||||||
|
<>
|
||||||
|
<Text color={theme.status.warning}>
|
||||||
|
{t('Arena requires at least 2 models. To add more:')}
|
||||||
|
</Text>
|
||||||
|
<Text color={theme.status.warning}>
|
||||||
|
{t(
|
||||||
|
' - Run /auth to set up a Coding Plan (includes multiple models)',
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<Text color={theme.status.warning}>
|
||||||
|
{t(' - Or configure modelProviders in settings.json')}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{shouldShowMoreModelsHint && (
|
||||||
|
<>
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={theme.text.secondary}>
|
||||||
|
{t('Configure more models with the modelProviders guide:')}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box marginTop={0}>
|
||||||
|
<Link url={MODEL_PROVIDERS_DOCUMENTATION_URL} fallback={false}>
|
||||||
|
<Text color={theme.text.secondary} underline>
|
||||||
|
{MODEL_PROVIDERS_DOCUMENTATION_URL}
|
||||||
|
</Text>
|
||||||
|
</Link>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
<Text color={theme.text.secondary}>
|
||||||
|
{t('Space to toggle, Enter to confirm, Esc to cancel')}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
288
packages/cli/src/ui/components/arena/ArenaStatusDialog.tsx
Normal file
288
packages/cli/src/ui/components/arena/ArenaStatusDialog.tsx
Normal file
|
|
@ -0,0 +1,288 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type React from 'react';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import {
|
||||||
|
type ArenaManager,
|
||||||
|
type ArenaAgentState,
|
||||||
|
type InProcessBackend,
|
||||||
|
type AgentStatsSummary,
|
||||||
|
isSettledStatus,
|
||||||
|
ArenaSessionStatus,
|
||||||
|
DISPLAY_MODE,
|
||||||
|
} from '@qwen-code/qwen-code-core';
|
||||||
|
import { theme } from '../../semantic-colors.js';
|
||||||
|
import { useKeypress } from '../../hooks/useKeypress.js';
|
||||||
|
import { formatDuration } from '../../utils/formatters.js';
|
||||||
|
import { getArenaStatusLabel } from '../../utils/displayUtils.js';
|
||||||
|
|
||||||
|
const STATUS_REFRESH_INTERVAL_MS = 2000;
|
||||||
|
const IN_PROCESS_REFRESH_INTERVAL_MS = 1000;
|
||||||
|
|
||||||
|
interface ArenaStatusDialogProps {
|
||||||
|
manager: ArenaManager;
|
||||||
|
closeArenaDialog: () => void;
|
||||||
|
width?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(str: string, maxLen: number): string {
|
||||||
|
if (str.length <= maxLen) return str;
|
||||||
|
return str.slice(0, maxLen - 1) + '…';
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad(
|
||||||
|
str: string,
|
||||||
|
len: number,
|
||||||
|
align: 'left' | 'right' = 'left',
|
||||||
|
): string {
|
||||||
|
if (str.length >= len) return str.slice(0, len);
|
||||||
|
const padding = ' '.repeat(len - str.length);
|
||||||
|
return align === 'right' ? padding + str : str + padding;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getElapsedMs(agent: ArenaAgentState): number {
|
||||||
|
if (isSettledStatus(agent.status)) {
|
||||||
|
return agent.stats.durationMs;
|
||||||
|
}
|
||||||
|
return Date.now() - agent.startedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSessionStatusLabel(status: ArenaSessionStatus): {
|
||||||
|
text: string;
|
||||||
|
color: string;
|
||||||
|
} {
|
||||||
|
switch (status) {
|
||||||
|
case ArenaSessionStatus.RUNNING:
|
||||||
|
return { text: 'Running', color: theme.status.success };
|
||||||
|
case ArenaSessionStatus.INITIALIZING:
|
||||||
|
return { text: 'Initializing', color: theme.status.warning };
|
||||||
|
case ArenaSessionStatus.IDLE:
|
||||||
|
return { text: 'Idle', color: theme.status.success };
|
||||||
|
case ArenaSessionStatus.COMPLETED:
|
||||||
|
return { text: 'Completed', color: theme.status.success };
|
||||||
|
case ArenaSessionStatus.CANCELLED:
|
||||||
|
return { text: 'Cancelled', color: theme.status.warning };
|
||||||
|
case ArenaSessionStatus.FAILED:
|
||||||
|
return { text: 'Failed', color: theme.status.error };
|
||||||
|
default:
|
||||||
|
return { text: String(status), color: theme.text.secondary };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_MODEL_NAME_LENGTH = 35;
|
||||||
|
|
||||||
|
export function ArenaStatusDialog({
|
||||||
|
manager,
|
||||||
|
closeArenaDialog,
|
||||||
|
width,
|
||||||
|
}: ArenaStatusDialogProps): React.JSX.Element {
|
||||||
|
const [tick, setTick] = useState(0);
|
||||||
|
|
||||||
|
// Detect in-process backend for live stats reading
|
||||||
|
const backend = manager.getBackend();
|
||||||
|
const isInProcess = backend?.type === DISPLAY_MODE.IN_PROCESS;
|
||||||
|
const inProcessBackend = isInProcess ? (backend as InProcessBackend) : null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = isInProcess
|
||||||
|
? IN_PROCESS_REFRESH_INTERVAL_MS
|
||||||
|
: STATUS_REFRESH_INTERVAL_MS;
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setTick((prev) => prev + 1);
|
||||||
|
}, interval);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [isInProcess]);
|
||||||
|
|
||||||
|
// Force re-read on every tick
|
||||||
|
void tick;
|
||||||
|
|
||||||
|
const sessionStatus = manager.getSessionStatus();
|
||||||
|
const sessionLabel = getSessionStatusLabel(sessionStatus);
|
||||||
|
const agents = manager.getAgentStates();
|
||||||
|
const task = manager.getTask() ?? '';
|
||||||
|
|
||||||
|
// For in-process mode, read live stats directly from AgentInteractive
|
||||||
|
const liveStats = useMemo(() => {
|
||||||
|
if (!inProcessBackend) return null;
|
||||||
|
const statsMap = new Map<string, AgentStatsSummary>();
|
||||||
|
for (const agent of agents) {
|
||||||
|
const interactive = inProcessBackend.getAgent(agent.agentId);
|
||||||
|
if (interactive) {
|
||||||
|
statsMap.set(agent.agentId, interactive.getStats());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return statsMap;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [inProcessBackend, agents, tick]);
|
||||||
|
|
||||||
|
const maxTaskLen = 60;
|
||||||
|
const displayTask =
|
||||||
|
task.length > maxTaskLen ? task.slice(0, maxTaskLen - 1) + '…' : task;
|
||||||
|
|
||||||
|
const colStatus = 14;
|
||||||
|
const colTime = 8;
|
||||||
|
const colTokens = 10;
|
||||||
|
const colRounds = 8;
|
||||||
|
const colTools = 8;
|
||||||
|
|
||||||
|
useKeypress(
|
||||||
|
(key) => {
|
||||||
|
if (key.name === 'escape' || key.name === 'q' || key.name === 'return') {
|
||||||
|
closeArenaDialog();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ isActive: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Inner content width: total width minus border (2) and paddingX (2*2)
|
||||||
|
const innerWidth = (width ?? 80) - 6;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
borderStyle="round"
|
||||||
|
borderColor={theme.border.default}
|
||||||
|
flexDirection="column"
|
||||||
|
paddingX={2}
|
||||||
|
paddingY={1}
|
||||||
|
width="100%"
|
||||||
|
>
|
||||||
|
{/* Title */}
|
||||||
|
<Box>
|
||||||
|
<Text bold color={theme.text.primary}>
|
||||||
|
Arena Status
|
||||||
|
</Text>
|
||||||
|
<Text color={theme.text.secondary}> · </Text>
|
||||||
|
<Text color={sessionLabel.color}>{sessionLabel.text}</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box height={1} />
|
||||||
|
|
||||||
|
{/* Task */}
|
||||||
|
<Box>
|
||||||
|
<Text>
|
||||||
|
<Text color={theme.text.secondary}>Task: </Text>
|
||||||
|
<Text color={theme.text.primary}>"{displayTask}"</Text>
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box height={1} />
|
||||||
|
|
||||||
|
{/* Table header */}
|
||||||
|
<Box>
|
||||||
|
<Box flexGrow={1}>
|
||||||
|
<Text bold color={theme.text.secondary}>
|
||||||
|
Agent
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box width={colStatus} justifyContent="flex-end">
|
||||||
|
<Text bold color={theme.text.secondary}>
|
||||||
|
Status
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box width={colTime} justifyContent="flex-end">
|
||||||
|
<Text bold color={theme.text.secondary}>
|
||||||
|
Time
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box width={colTokens} justifyContent="flex-end">
|
||||||
|
<Text bold color={theme.text.secondary}>
|
||||||
|
Tokens
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box width={colRounds} justifyContent="flex-end">
|
||||||
|
<Text bold color={theme.text.secondary}>
|
||||||
|
Rounds
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box width={colTools} justifyContent="flex-end">
|
||||||
|
<Text bold color={theme.text.secondary}>
|
||||||
|
Tools
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Separator */}
|
||||||
|
<Box>
|
||||||
|
<Text color={theme.border.default}>{'─'.repeat(innerWidth)}</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Agent rows */}
|
||||||
|
{agents.map((agent) => {
|
||||||
|
const label = agent.model.modelId;
|
||||||
|
const { text: statusText, color } = getArenaStatusLabel(agent.status);
|
||||||
|
const elapsed = getElapsedMs(agent);
|
||||||
|
|
||||||
|
// Use live stats from AgentInteractive when in-process, otherwise
|
||||||
|
// fall back to the cached ArenaAgentState.stats (file-polled).
|
||||||
|
const live = liveStats?.get(agent.agentId);
|
||||||
|
const totalTokens = live?.totalTokens ?? agent.stats.totalTokens;
|
||||||
|
const rounds = live?.rounds ?? agent.stats.rounds;
|
||||||
|
const toolCalls = live?.totalToolCalls ?? agent.stats.toolCalls;
|
||||||
|
const successfulToolCalls =
|
||||||
|
live?.successfulToolCalls ?? agent.stats.successfulToolCalls;
|
||||||
|
const failedToolCalls =
|
||||||
|
live?.failedToolCalls ?? agent.stats.failedToolCalls;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box key={agent.agentId} flexDirection="column">
|
||||||
|
<Box>
|
||||||
|
<Box flexGrow={1}>
|
||||||
|
<Text color={theme.text.primary}>
|
||||||
|
{truncate(label, MAX_MODEL_NAME_LENGTH)}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box width={colStatus} justifyContent="flex-end">
|
||||||
|
<Text color={color}>{statusText}</Text>
|
||||||
|
</Box>
|
||||||
|
<Box width={colTime} justifyContent="flex-end">
|
||||||
|
<Text color={theme.text.primary}>
|
||||||
|
{pad(formatDuration(elapsed), colTime - 1, 'right')}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box width={colTokens} justifyContent="flex-end">
|
||||||
|
<Text color={theme.text.primary}>
|
||||||
|
{pad(totalTokens.toLocaleString(), colTokens - 1, 'right')}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box width={colRounds} justifyContent="flex-end">
|
||||||
|
<Text color={theme.text.primary}>
|
||||||
|
{pad(String(rounds), colRounds - 1, 'right')}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box width={colTools} justifyContent="flex-end">
|
||||||
|
{failedToolCalls > 0 ? (
|
||||||
|
<Text>
|
||||||
|
<Text color={theme.status.success}>
|
||||||
|
{successfulToolCalls}
|
||||||
|
</Text>
|
||||||
|
<Text color={theme.text.secondary}>/</Text>
|
||||||
|
<Text color={theme.status.error}>{failedToolCalls}</Text>
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
color={
|
||||||
|
toolCalls > 0 ? theme.status.success : theme.text.primary
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{pad(String(toolCalls), colTools - 1, 'right')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{agents.length === 0 && (
|
||||||
|
<Box>
|
||||||
|
<Text color={theme.text.secondary}>No agents registered yet.</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
213
packages/cli/src/ui/components/arena/ArenaStopDialog.tsx
Normal file
213
packages/cli/src/ui/components/arena/ArenaStopDialog.tsx
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type React from 'react';
|
||||||
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import {
|
||||||
|
ArenaSessionStatus,
|
||||||
|
createDebugLogger,
|
||||||
|
type Config,
|
||||||
|
} from '@qwen-code/qwen-code-core';
|
||||||
|
import { theme } from '../../semantic-colors.js';
|
||||||
|
import { useKeypress } from '../../hooks/useKeypress.js';
|
||||||
|
import { MessageType, type HistoryItemWithoutId } from '../../types.js';
|
||||||
|
import type { UseHistoryManagerReturn } from '../../hooks/useHistoryManager.js';
|
||||||
|
import { DescriptiveRadioButtonSelect } from '../shared/DescriptiveRadioButtonSelect.js';
|
||||||
|
import type { DescriptiveRadioSelectItem } from '../shared/DescriptiveRadioButtonSelect.js';
|
||||||
|
|
||||||
|
const debugLogger = createDebugLogger('ARENA_STOP_DIALOG');
|
||||||
|
|
||||||
|
type StopAction = 'cleanup' | 'preserve';
|
||||||
|
|
||||||
|
interface ArenaStopDialogProps {
|
||||||
|
config: Config;
|
||||||
|
addItem: UseHistoryManagerReturn['addItem'];
|
||||||
|
closeArenaDialog: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ArenaStopDialog({
|
||||||
|
config,
|
||||||
|
addItem,
|
||||||
|
closeArenaDialog,
|
||||||
|
}: ArenaStopDialogProps): React.JSX.Element {
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
|
||||||
|
const pushMessage = useCallback(
|
||||||
|
(result: { messageType: 'info' | 'error'; content: string }) => {
|
||||||
|
const item: HistoryItemWithoutId = {
|
||||||
|
type:
|
||||||
|
result.messageType === 'info' ? MessageType.INFO : MessageType.ERROR,
|
||||||
|
text: result.content,
|
||||||
|
};
|
||||||
|
addItem(item, Date.now());
|
||||||
|
|
||||||
|
try {
|
||||||
|
const chatRecorder = config.getChatRecordingService();
|
||||||
|
chatRecorder?.recordSlashCommand({
|
||||||
|
phase: 'result',
|
||||||
|
rawCommand: '/arena stop',
|
||||||
|
outputHistoryItems: [{ ...item } as Record<string, unknown>],
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Best-effort recording
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[addItem, config],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onStop = useCallback(
|
||||||
|
async (action: StopAction) => {
|
||||||
|
if (isProcessing) return;
|
||||||
|
setIsProcessing(true);
|
||||||
|
closeArenaDialog();
|
||||||
|
|
||||||
|
const mgr = config.getArenaManager();
|
||||||
|
if (!mgr) {
|
||||||
|
pushMessage({
|
||||||
|
messageType: 'error',
|
||||||
|
content: 'No running Arena session found.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sessionStatus = mgr.getSessionStatus();
|
||||||
|
if (
|
||||||
|
sessionStatus === ArenaSessionStatus.RUNNING ||
|
||||||
|
sessionStatus === ArenaSessionStatus.INITIALIZING
|
||||||
|
) {
|
||||||
|
pushMessage({
|
||||||
|
messageType: 'info',
|
||||||
|
content: 'Stopping Arena agents…',
|
||||||
|
});
|
||||||
|
await mgr.cancel();
|
||||||
|
}
|
||||||
|
await mgr.waitForSettled();
|
||||||
|
pushMessage({
|
||||||
|
messageType: 'info',
|
||||||
|
content: 'Cleaning up Arena resources…',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (action === 'preserve') {
|
||||||
|
await mgr.cleanupRuntime();
|
||||||
|
} else {
|
||||||
|
await mgr.cleanup();
|
||||||
|
}
|
||||||
|
config.setArenaManager(null);
|
||||||
|
|
||||||
|
if (action === 'preserve') {
|
||||||
|
pushMessage({
|
||||||
|
messageType: 'info',
|
||||||
|
content:
|
||||||
|
'Arena session stopped. Worktrees and session files were preserved. ' +
|
||||||
|
'Use /arena select --discard to manually clean up later.',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
pushMessage({
|
||||||
|
messageType: 'info',
|
||||||
|
content:
|
||||||
|
'Arena session stopped. All Arena resources (including Git worktrees) were cleaned up.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
debugLogger.error('Failed to stop Arena session:', error);
|
||||||
|
pushMessage({
|
||||||
|
messageType: 'error',
|
||||||
|
content: `Failed to stop Arena session: ${message}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isProcessing, closeArenaDialog, config, pushMessage],
|
||||||
|
);
|
||||||
|
|
||||||
|
const configPreserve =
|
||||||
|
config.getAgentsSettings().arena?.preserveArtifacts ?? false;
|
||||||
|
|
||||||
|
const items: Array<DescriptiveRadioSelectItem<StopAction>> = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
key: 'cleanup',
|
||||||
|
value: 'cleanup' as StopAction,
|
||||||
|
title: <Text>Stop and clean up</Text>,
|
||||||
|
description: (
|
||||||
|
<Text color={theme.text.secondary}>
|
||||||
|
Remove all worktrees and session files
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'preserve',
|
||||||
|
value: 'preserve' as StopAction,
|
||||||
|
title: <Text>Stop and preserve artifacts</Text>,
|
||||||
|
description: (
|
||||||
|
<Text color={theme.text.secondary}>
|
||||||
|
Keep worktrees and session files for later inspection
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const defaultIndex = configPreserve ? 1 : 0;
|
||||||
|
|
||||||
|
useKeypress(
|
||||||
|
(key) => {
|
||||||
|
if (key.name === 'escape') {
|
||||||
|
closeArenaDialog();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ isActive: !isProcessing },
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
borderStyle="round"
|
||||||
|
borderColor={theme.border.default}
|
||||||
|
flexDirection="column"
|
||||||
|
padding={1}
|
||||||
|
width="100%"
|
||||||
|
>
|
||||||
|
<Text bold color={theme.text.primary}>
|
||||||
|
Stop Arena Session
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={theme.text.secondary}>
|
||||||
|
Choose what to do with Arena artifacts:
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
<DescriptiveRadioButtonSelect
|
||||||
|
items={items}
|
||||||
|
initialIndex={defaultIndex}
|
||||||
|
onSelect={(action: StopAction) => {
|
||||||
|
onStop(action);
|
||||||
|
}}
|
||||||
|
isFocused={!isProcessing}
|
||||||
|
showNumbers={false}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{configPreserve && (
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={theme.text.secondary} dimColor>
|
||||||
|
Default: preserve (agents.arena.preserveArtifacts is enabled)
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={theme.text.secondary}>
|
||||||
|
Enter to confirm, Esc to cancel
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -75,7 +75,7 @@ export const SuccessMessage: React.FC<StatusTextProps> = ({ text }) => (
|
||||||
export const WarningMessage: React.FC<StatusTextProps> = ({ text }) => (
|
export const WarningMessage: React.FC<StatusTextProps> = ({ text }) => (
|
||||||
<StatusMessage
|
<StatusMessage
|
||||||
text={text}
|
text={text}
|
||||||
prefix="⚠"
|
prefix="△"
|
||||||
prefixColor={theme.status.warning}
|
prefixColor={theme.status.warning}
|
||||||
textColor={theme.status.warning}
|
textColor={theme.status.warning}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,11 @@ export function DescriptiveRadioButtonSelect<T>({
|
||||||
renderItem={(item, { titleColor }) => (
|
renderItem={(item, { titleColor }) => (
|
||||||
<Box flexDirection="column" key={item.key}>
|
<Box flexDirection="column" key={item.key}>
|
||||||
<Text color={titleColor}>{item.title}</Text>
|
<Text color={titleColor}>{item.title}</Text>
|
||||||
|
{typeof item.description === 'string' ? (
|
||||||
<Text color={theme.text.secondary}>{item.description}</Text>
|
<Text color={theme.text.secondary}>{item.description}</Text>
|
||||||
|
) : (
|
||||||
|
item.description
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
193
packages/cli/src/ui/components/shared/MultiSelect.tsx
Normal file
193
packages/cli/src/ui/components/shared/MultiSelect.tsx
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type React from 'react';
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import { theme } from '../../semantic-colors.js';
|
||||||
|
import { useSelectionList } from '../../hooks/useSelectionList.js';
|
||||||
|
import { useKeypress } from '../../hooks/useKeypress.js';
|
||||||
|
import type { SelectionListItem } from '../../hooks/useSelectionList.js';
|
||||||
|
|
||||||
|
export interface MultiSelectItem<T> extends SelectionListItem<T> {
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MultiSelectProps<T> {
|
||||||
|
items: Array<MultiSelectItem<T>>;
|
||||||
|
initialIndex?: number;
|
||||||
|
initialSelectedKeys?: string[];
|
||||||
|
onConfirm: (selectedValues: T[]) => void;
|
||||||
|
onChange?: (selectedValues: T[]) => void;
|
||||||
|
onHighlight?: (value: T) => void;
|
||||||
|
isFocused?: boolean;
|
||||||
|
showNumbers?: boolean;
|
||||||
|
showScrollArrows?: boolean;
|
||||||
|
maxItemsToShow?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_SELECTED_KEYS: string[] = [];
|
||||||
|
|
||||||
|
function getSelectedValues<T>(
|
||||||
|
items: Array<MultiSelectItem<T>>,
|
||||||
|
selectedKeys: Set<string>,
|
||||||
|
): T[] {
|
||||||
|
return items
|
||||||
|
.filter((item) => selectedKeys.has(item.key))
|
||||||
|
.map((item) => item.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MultiSelect<T>({
|
||||||
|
items,
|
||||||
|
initialIndex = 0,
|
||||||
|
initialSelectedKeys = EMPTY_SELECTED_KEYS,
|
||||||
|
onConfirm,
|
||||||
|
onChange,
|
||||||
|
onHighlight,
|
||||||
|
isFocused = true,
|
||||||
|
showNumbers = true,
|
||||||
|
showScrollArrows = false,
|
||||||
|
maxItemsToShow = 10,
|
||||||
|
}: MultiSelectProps<T>): React.JSX.Element {
|
||||||
|
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(
|
||||||
|
() => new Set(initialSelectedKeys),
|
||||||
|
);
|
||||||
|
const [scrollOffset, setScrollOffset] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedKeys((prev) => {
|
||||||
|
const next = new Set(initialSelectedKeys);
|
||||||
|
if (
|
||||||
|
prev.size === next.size &&
|
||||||
|
Array.from(next).every((key) => prev.has(key))
|
||||||
|
) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [initialSelectedKeys]);
|
||||||
|
|
||||||
|
const { activeIndex } = useSelectionList({
|
||||||
|
items,
|
||||||
|
initialIndex,
|
||||||
|
isFocused,
|
||||||
|
// Disable numeric quick-select in useSelectionList — in a multi-select
|
||||||
|
// context, onSelect triggers onConfirm (submit), so numeric keys would
|
||||||
|
// accidentally submit the dialog instead of toggling checkboxes.
|
||||||
|
// Numbers are still rendered visually via the showNumbers prop below.
|
||||||
|
showNumbers: false,
|
||||||
|
onHighlight,
|
||||||
|
onSelect: () => {
|
||||||
|
onConfirm(getSelectedValues(items, selectedKeys));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleSelectionAtIndex = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
const item = items[index];
|
||||||
|
if (!item || item.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedKeys((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(item.key)) {
|
||||||
|
next.delete(item.key);
|
||||||
|
} else {
|
||||||
|
next.add(item.key);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[items],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onChange?.(getSelectedValues(items, selectedKeys));
|
||||||
|
}, [items, selectedKeys, onChange]);
|
||||||
|
|
||||||
|
useKeypress(
|
||||||
|
(key) => {
|
||||||
|
if (key.name === 'space' || key.sequence === ' ') {
|
||||||
|
toggleSelectionAtIndex(activeIndex);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ isActive: isFocused },
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const newScrollOffset = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(activeIndex - maxItemsToShow + 1, items.length - maxItemsToShow),
|
||||||
|
);
|
||||||
|
if (activeIndex < scrollOffset) {
|
||||||
|
setScrollOffset(activeIndex);
|
||||||
|
} else if (activeIndex >= scrollOffset + maxItemsToShow) {
|
||||||
|
setScrollOffset(newScrollOffset);
|
||||||
|
}
|
||||||
|
}, [activeIndex, items.length, scrollOffset, maxItemsToShow]);
|
||||||
|
|
||||||
|
const visibleItems = useMemo(
|
||||||
|
() => items.slice(scrollOffset, scrollOffset + maxItemsToShow),
|
||||||
|
[items, scrollOffset, maxItemsToShow],
|
||||||
|
);
|
||||||
|
const numberColumnWidth = String(items.length).length;
|
||||||
|
const hasMoreAbove = scrollOffset > 0;
|
||||||
|
const hasMoreBelow = scrollOffset + maxItemsToShow < items.length;
|
||||||
|
const moreAboveCount = scrollOffset;
|
||||||
|
const moreBelowCount = Math.max(
|
||||||
|
0,
|
||||||
|
items.length - (scrollOffset + maxItemsToShow),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{showScrollArrows && hasMoreAbove && (
|
||||||
|
<Text color={theme.text.secondary}>↑ {moreAboveCount} more above</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{visibleItems.map((item, index) => {
|
||||||
|
const itemIndex = scrollOffset + index;
|
||||||
|
const isActive = activeIndex === itemIndex;
|
||||||
|
const isChecked = selectedKeys.has(item.key);
|
||||||
|
|
||||||
|
const itemNumberText = `${String(itemIndex + 1).padStart(
|
||||||
|
numberColumnWidth,
|
||||||
|
)}.`;
|
||||||
|
const checkboxText = item.disabled ? '[x]' : isChecked ? '[✓]' : '[ ]';
|
||||||
|
|
||||||
|
let textColor = theme.text.primary;
|
||||||
|
if (item.disabled) {
|
||||||
|
textColor = theme.text.secondary;
|
||||||
|
} else if (isActive) {
|
||||||
|
textColor = theme.status.success;
|
||||||
|
} else if (isChecked) {
|
||||||
|
textColor = theme.text.accent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box key={item.key} alignItems="flex-start">
|
||||||
|
<Box minWidth={4} flexShrink={0}>
|
||||||
|
<Text color={textColor}>{checkboxText}</Text>
|
||||||
|
</Box>
|
||||||
|
{showNumbers && (
|
||||||
|
<Box marginRight={1} minWidth={itemNumberText.length}>
|
||||||
|
<Text color={textColor}>{itemNumberText}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Box flexGrow={1}>
|
||||||
|
<Text color={textColor}>{item.label}</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{showScrollArrows && hasMoreBelow && (
|
||||||
|
<Text color={theme.text.secondary}>↓ {moreBelowCount} more below</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1907,8 +1907,8 @@ export function useTextBuffer({
|
||||||
else if (key.ctrl && key.name === 'b') move('left');
|
else if (key.ctrl && key.name === 'b') move('left');
|
||||||
else if (key.name === 'right' && !key.meta && !key.ctrl) move('right');
|
else if (key.name === 'right' && !key.meta && !key.ctrl) move('right');
|
||||||
else if (key.ctrl && key.name === 'f') move('right');
|
else if (key.ctrl && key.name === 'f') move('right');
|
||||||
else if (key.name === 'up') move('up');
|
else if (key.name === 'up' && !key.shift) move('up');
|
||||||
else if (key.name === 'down') move('down');
|
else if (key.name === 'down' && !key.shift) move('down');
|
||||||
else if ((key.ctrl || key.meta) && key.name === 'left') move('wordLeft');
|
else if ((key.ctrl || key.meta) && key.name === 'left') move('wordLeft');
|
||||||
else if (key.meta && key.name === 'b') move('wordLeft');
|
else if (key.meta && key.name === 'b') move('wordLeft');
|
||||||
else if ((key.ctrl || key.meta) && key.name === 'right')
|
else if ((key.ctrl || key.meta) && key.name === 'right')
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import React, { useMemo } from 'react';
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import type {
|
import type {
|
||||||
TaskResultDisplay,
|
TaskResultDisplay,
|
||||||
SubagentStatsSummary,
|
AgentStatsSummary,
|
||||||
Config,
|
Config,
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
import { theme } from '../../../semantic-colors.js';
|
import { theme } from '../../../semantic-colors.js';
|
||||||
|
|
@ -467,7 +467,7 @@ const ExecutionSummaryDetails: React.FC<{
|
||||||
* Tool usage statistics component
|
* Tool usage statistics component
|
||||||
*/
|
*/
|
||||||
const ToolUsageStats: React.FC<{
|
const ToolUsageStats: React.FC<{
|
||||||
executionSummary?: SubagentStatsSummary;
|
executionSummary?: AgentStatsSummary;
|
||||||
}> = ({ executionSummary }) => {
|
}> = ({ executionSummary }) => {
|
||||||
if (!executionSummary) {
|
if (!executionSummary) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
308
packages/cli/src/ui/contexts/AgentViewContext.tsx
Normal file
308
packages/cli/src/ui/contexts/AgentViewContext.tsx
Normal file
|
|
@ -0,0 +1,308 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @fileoverview AgentViewContext — React context for in-process agent view switching.
|
||||||
|
*
|
||||||
|
* Tracks which view is active (main or an agent tab) and the set of registered
|
||||||
|
* AgentInteractive instances. Consumed by AgentTabBar, AgentChatView, and
|
||||||
|
* DefaultAppLayout to implement tab-based agent navigation.
|
||||||
|
*
|
||||||
|
* Kept separate from UIStateContext to avoid bloating the main state with
|
||||||
|
* in-process-only concerns and to make the feature self-contained.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import {
|
||||||
|
type AgentInteractive,
|
||||||
|
type ApprovalMode,
|
||||||
|
type Config,
|
||||||
|
} from '@qwen-code/qwen-code-core';
|
||||||
|
import { useArenaInProcess } from '../hooks/useArenaInProcess.js';
|
||||||
|
|
||||||
|
// ─── Types ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface RegisteredAgent {
|
||||||
|
interactiveAgent: AgentInteractive;
|
||||||
|
/** Model identifier shown in tabs and paths (e.g. "glm-5"). */
|
||||||
|
modelId: string;
|
||||||
|
/** Human-friendly model name (e.g. "GLM 5"). */
|
||||||
|
modelName?: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentViewState {
|
||||||
|
/** 'main' or an agentId */
|
||||||
|
activeView: string;
|
||||||
|
/** Registered in-process agents keyed by agentId */
|
||||||
|
agents: ReadonlyMap<string, RegisteredAgent>;
|
||||||
|
/** Whether any agent tab's embedded shell currently has input focus. */
|
||||||
|
agentShellFocused: boolean;
|
||||||
|
/** Current text in the active agent tab's input buffer (empty when on main). */
|
||||||
|
agentInputBufferText: string;
|
||||||
|
/** Whether the tab bar has keyboard focus (vs the agent input). */
|
||||||
|
agentTabBarFocused: boolean;
|
||||||
|
/** Per-agent approval modes (keyed by agentId). */
|
||||||
|
agentApprovalModes: ReadonlyMap<string, ApprovalMode>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentViewActions {
|
||||||
|
switchToMain(): void;
|
||||||
|
switchToAgent(agentId: string): void;
|
||||||
|
switchToNext(): void;
|
||||||
|
switchToPrevious(): void;
|
||||||
|
registerAgent(
|
||||||
|
agentId: string,
|
||||||
|
interactiveAgent: AgentInteractive,
|
||||||
|
modelId: string,
|
||||||
|
color: string,
|
||||||
|
modelName?: string,
|
||||||
|
): void;
|
||||||
|
unregisterAgent(agentId: string): void;
|
||||||
|
unregisterAll(): void;
|
||||||
|
setAgentShellFocused(focused: boolean): void;
|
||||||
|
setAgentInputBufferText(text: string): void;
|
||||||
|
setAgentTabBarFocused(focused: boolean): void;
|
||||||
|
setAgentApprovalMode(agentId: string, mode: ApprovalMode): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Context ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const AgentViewStateContext = createContext<AgentViewState | null>(null);
|
||||||
|
const AgentViewActionsContext = createContext<AgentViewActions | null>(null);
|
||||||
|
|
||||||
|
// ─── Defaults (used when no provider is mounted) ────────────
|
||||||
|
|
||||||
|
const DEFAULT_STATE: AgentViewState = {
|
||||||
|
activeView: 'main',
|
||||||
|
agents: new Map(),
|
||||||
|
agentShellFocused: false,
|
||||||
|
agentInputBufferText: '',
|
||||||
|
agentTabBarFocused: false,
|
||||||
|
agentApprovalModes: new Map(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const noop = () => {};
|
||||||
|
|
||||||
|
const DEFAULT_ACTIONS: AgentViewActions = {
|
||||||
|
switchToMain: noop,
|
||||||
|
switchToAgent: noop,
|
||||||
|
switchToNext: noop,
|
||||||
|
switchToPrevious: noop,
|
||||||
|
registerAgent: noop,
|
||||||
|
unregisterAgent: noop,
|
||||||
|
unregisterAll: noop,
|
||||||
|
setAgentShellFocused: noop,
|
||||||
|
setAgentInputBufferText: noop,
|
||||||
|
setAgentTabBarFocused: noop,
|
||||||
|
setAgentApprovalMode: noop,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Hook: useAgentViewState ────────────────────────────────
|
||||||
|
|
||||||
|
export function useAgentViewState(): AgentViewState {
|
||||||
|
return useContext(AgentViewStateContext) ?? DEFAULT_STATE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Hook: useAgentViewActions ──────────────────────────────
|
||||||
|
|
||||||
|
export function useAgentViewActions(): AgentViewActions {
|
||||||
|
return useContext(AgentViewActionsContext) ?? DEFAULT_ACTIONS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Provider ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface AgentViewProviderProps {
|
||||||
|
config?: Config;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentViewProvider({
|
||||||
|
config,
|
||||||
|
children,
|
||||||
|
}: AgentViewProviderProps) {
|
||||||
|
const [activeView, setActiveView] = useState<string>('main');
|
||||||
|
const [agents, setAgents] = useState<Map<string, RegisteredAgent>>(
|
||||||
|
() => new Map(),
|
||||||
|
);
|
||||||
|
const [agentShellFocused, setAgentShellFocused] = useState(false);
|
||||||
|
const [agentInputBufferText, setAgentInputBufferText] = useState('');
|
||||||
|
const [agentTabBarFocused, setAgentTabBarFocused] = useState(false);
|
||||||
|
const [agentApprovalModes, setAgentApprovalModes] = useState<
|
||||||
|
Map<string, ApprovalMode>
|
||||||
|
>(() => new Map());
|
||||||
|
|
||||||
|
// ── Navigation ──
|
||||||
|
|
||||||
|
const switchToMain = useCallback(() => {
|
||||||
|
setActiveView('main');
|
||||||
|
setAgentTabBarFocused(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const switchToAgent = useCallback(
|
||||||
|
(agentId: string) => {
|
||||||
|
if (agents.has(agentId)) {
|
||||||
|
setActiveView(agentId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[agents],
|
||||||
|
);
|
||||||
|
|
||||||
|
const switchToNext = useCallback(() => {
|
||||||
|
const ids = ['main', ...agents.keys()];
|
||||||
|
const currentIndex = ids.indexOf(activeView);
|
||||||
|
const nextIndex = (currentIndex + 1) % ids.length;
|
||||||
|
setActiveView(ids[nextIndex]!);
|
||||||
|
}, [agents, activeView]);
|
||||||
|
|
||||||
|
const switchToPrevious = useCallback(() => {
|
||||||
|
const ids = ['main', ...agents.keys()];
|
||||||
|
const currentIndex = ids.indexOf(activeView);
|
||||||
|
const prevIndex = (currentIndex - 1 + ids.length) % ids.length;
|
||||||
|
setActiveView(ids[prevIndex]!);
|
||||||
|
}, [agents, activeView]);
|
||||||
|
|
||||||
|
// ── Registration ──
|
||||||
|
|
||||||
|
const registerAgent = useCallback(
|
||||||
|
(
|
||||||
|
agentId: string,
|
||||||
|
interactiveAgent: AgentInteractive,
|
||||||
|
modelId: string,
|
||||||
|
color: string,
|
||||||
|
modelName?: string,
|
||||||
|
) => {
|
||||||
|
setAgents((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(agentId, {
|
||||||
|
interactiveAgent,
|
||||||
|
modelId,
|
||||||
|
color,
|
||||||
|
modelName,
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
// Seed approval mode from the agent's own config
|
||||||
|
const mode = interactiveAgent.getCore().runtimeContext.getApprovalMode();
|
||||||
|
setAgentApprovalModes((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(agentId, mode);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const unregisterAgent = useCallback((agentId: string) => {
|
||||||
|
setAgents((prev) => {
|
||||||
|
if (!prev.has(agentId)) return prev;
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.delete(agentId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setAgentApprovalModes((prev) => {
|
||||||
|
if (!prev.has(agentId)) return prev;
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.delete(agentId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setActiveView((current) => (current === agentId ? 'main' : current));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const unregisterAll = useCallback(() => {
|
||||||
|
setAgents(new Map());
|
||||||
|
setAgentApprovalModes(new Map());
|
||||||
|
setActiveView('main');
|
||||||
|
setAgentTabBarFocused(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setAgentApprovalMode = useCallback(
|
||||||
|
(agentId: string, mode: ApprovalMode) => {
|
||||||
|
// Update the agent's runtime config so tool scheduling picks it up
|
||||||
|
const agent = agents.get(agentId);
|
||||||
|
if (agent) {
|
||||||
|
agent.interactiveAgent.getCore().runtimeContext.setApprovalMode(mode);
|
||||||
|
}
|
||||||
|
// Update UI state
|
||||||
|
setAgentApprovalModes((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(agentId, mode);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[agents],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Memoized values ──
|
||||||
|
|
||||||
|
const state: AgentViewState = useMemo(
|
||||||
|
() => ({
|
||||||
|
activeView,
|
||||||
|
agents,
|
||||||
|
agentShellFocused,
|
||||||
|
agentInputBufferText,
|
||||||
|
agentTabBarFocused,
|
||||||
|
agentApprovalModes,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
activeView,
|
||||||
|
agents,
|
||||||
|
agentShellFocused,
|
||||||
|
agentInputBufferText,
|
||||||
|
agentTabBarFocused,
|
||||||
|
agentApprovalModes,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const actions: AgentViewActions = useMemo(
|
||||||
|
() => ({
|
||||||
|
switchToMain,
|
||||||
|
switchToAgent,
|
||||||
|
switchToNext,
|
||||||
|
switchToPrevious,
|
||||||
|
registerAgent,
|
||||||
|
unregisterAgent,
|
||||||
|
unregisterAll,
|
||||||
|
setAgentShellFocused,
|
||||||
|
setAgentInputBufferText,
|
||||||
|
setAgentTabBarFocused,
|
||||||
|
setAgentApprovalMode,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
switchToMain,
|
||||||
|
switchToAgent,
|
||||||
|
switchToNext,
|
||||||
|
switchToPrevious,
|
||||||
|
registerAgent,
|
||||||
|
unregisterAgent,
|
||||||
|
unregisterAll,
|
||||||
|
setAgentShellFocused,
|
||||||
|
setAgentInputBufferText,
|
||||||
|
setAgentTabBarFocused,
|
||||||
|
setAgentApprovalMode,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Arena in-process bridge ──
|
||||||
|
// Bridge arena manager events to agent registration. The hook is kept
|
||||||
|
// in its own file for separation of concerns; it's called here so the
|
||||||
|
// provider is the single owner of agent tab lifecycle.
|
||||||
|
useArenaInProcess(config ?? null, actions);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AgentViewStateContext.Provider value={state}>
|
||||||
|
<AgentViewActionsContext.Provider value={actions}>
|
||||||
|
{children}
|
||||||
|
</AgentViewActionsContext.Provider>
|
||||||
|
</AgentViewStateContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1367,6 +1367,75 @@ describe('KeypressContext - Kitty Protocol', () => {
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('drops unsupported Kitty CSI-u keys without blocking later input', () => {
|
||||||
|
const keyHandler = vi.fn();
|
||||||
|
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||||
|
act(() => result.current.subscribe(keyHandler));
|
||||||
|
|
||||||
|
act(() => stdin.sendKittySequence(`\x1b[57358u`)); // CAPS_LOCK
|
||||||
|
act(() =>
|
||||||
|
stdin.pressKey({
|
||||||
|
name: 'a',
|
||||||
|
ctrl: false,
|
||||||
|
meta: false,
|
||||||
|
shift: false,
|
||||||
|
paste: false,
|
||||||
|
sequence: 'a',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(keyHandler).toHaveBeenCalledTimes(1);
|
||||||
|
expect(keyHandler).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: 'a',
|
||||||
|
sequence: 'a',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recovers plain text that arrives in the same chunk after an unsupported CSI-u key', () => {
|
||||||
|
const keyHandler = vi.fn();
|
||||||
|
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||||
|
act(() => result.current.subscribe(keyHandler));
|
||||||
|
|
||||||
|
act(() =>
|
||||||
|
stdin.pressKey({
|
||||||
|
name: '',
|
||||||
|
ctrl: false,
|
||||||
|
meta: false,
|
||||||
|
shift: false,
|
||||||
|
paste: false,
|
||||||
|
sequence: '\x1b[57358ua',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(keyHandler).toHaveBeenCalledTimes(1);
|
||||||
|
expect(keyHandler).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: 'a',
|
||||||
|
sequence: 'a',
|
||||||
|
kittyProtocol: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops unsupported CSI-u variants with event metadata and keeps parsing', () => {
|
||||||
|
const keyHandler = vi.fn();
|
||||||
|
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||||
|
act(() => result.current.subscribe(keyHandler));
|
||||||
|
|
||||||
|
act(() => stdin.sendKittySequence(`\x1b[57358;1:1u\x1b[100u`));
|
||||||
|
|
||||||
|
expect(keyHandler).toHaveBeenCalledTimes(1);
|
||||||
|
expect(keyHandler).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: 'd',
|
||||||
|
sequence: 'd',
|
||||||
|
kittyProtocol: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Kitty keypad private-use keys', () => {
|
describe('Kitty keypad private-use keys', () => {
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,25 @@ export function KeypressProvider({
|
||||||
let rawDataBuffer = Buffer.alloc(0);
|
let rawDataBuffer = Buffer.alloc(0);
|
||||||
let rawFlushTimeout: NodeJS.Timeout | null = null;
|
let rawFlushTimeout: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
const createPrintableKey = (char: string): Key => {
|
||||||
|
const printableName =
|
||||||
|
char === ' '
|
||||||
|
? 'space'
|
||||||
|
: /^[A-Za-z]$/.test(char)
|
||||||
|
? char.toLowerCase()
|
||||||
|
: char;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: printableName,
|
||||||
|
ctrl: false,
|
||||||
|
meta: false,
|
||||||
|
shift: false,
|
||||||
|
paste: false,
|
||||||
|
sequence: char,
|
||||||
|
kittyProtocol: true,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// Parse a single complete kitty sequence from the start (prefix) of the
|
// Parse a single complete kitty sequence from the start (prefix) of the
|
||||||
// buffer and return both the Key and the number of characters consumed.
|
// buffer and return both the Key and the number of characters consumed.
|
||||||
// This lets us "peel off" one complete event when multiple sequences arrive
|
// This lets us "peel off" one complete event when multiple sequences arrive
|
||||||
|
|
@ -415,22 +434,11 @@ export function KeypressProvider({
|
||||||
keyCode <= 0x10ffff &&
|
keyCode <= 0x10ffff &&
|
||||||
!(keyCode >= 0xe000 && keyCode <= 0xf8ff)
|
!(keyCode >= 0xe000 && keyCode <= 0xf8ff)
|
||||||
) {
|
) {
|
||||||
const char = String.fromCodePoint(keyCode);
|
|
||||||
const printableName =
|
|
||||||
char === ' '
|
|
||||||
? 'space'
|
|
||||||
: /^[A-Za-z]$/.test(char)
|
|
||||||
? char.toLowerCase()
|
|
||||||
: char;
|
|
||||||
return {
|
return {
|
||||||
key: {
|
key: {
|
||||||
name: printableName,
|
...createPrintableKey(String.fromCodePoint(keyCode)),
|
||||||
ctrl: false,
|
|
||||||
meta: alt,
|
meta: alt,
|
||||||
shift,
|
shift,
|
||||||
paste: false,
|
|
||||||
sequence: char,
|
|
||||||
kittyProtocol: true,
|
|
||||||
},
|
},
|
||||||
length: m[0].length,
|
length: m[0].length,
|
||||||
};
|
};
|
||||||
|
|
@ -490,6 +498,42 @@ export function KeypressProvider({
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getCompleteCsiSequenceLength = (buffer: string): number | null => {
|
||||||
|
if (!buffer.startsWith(`${ESC}[`)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 2; i < buffer.length; i++) {
|
||||||
|
const code = buffer.charCodeAt(i);
|
||||||
|
if (code >= 0x40 && code <= 0x7e) {
|
||||||
|
return i + 1;
|
||||||
|
}
|
||||||
|
if (code < 0x20 || code > 0x3f) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parsePlainTextPrefix = (
|
||||||
|
buffer: string,
|
||||||
|
): { key: Key; length: number } | null => {
|
||||||
|
if (!buffer || buffer.startsWith(ESC)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [char] = Array.from(buffer);
|
||||||
|
if (!char) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: createPrintableKey(char),
|
||||||
|
length: char.length,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const broadcast = (key: Key) => {
|
const broadcast = (key: Key) => {
|
||||||
for (const handler of subscribers) {
|
for (const handler of subscribers) {
|
||||||
handler(key);
|
handler(key);
|
||||||
|
|
@ -653,24 +697,10 @@ export function KeypressProvider({
|
||||||
// start of the buffer. This handles batched inputs cleanly. If the
|
// start of the buffer. This handles batched inputs cleanly. If the
|
||||||
// prefix is incomplete or invalid, skip to the next CSI introducer
|
// prefix is incomplete or invalid, skip to the next CSI introducer
|
||||||
// (ESC[) so that a following valid sequence can still be parsed.
|
// (ESC[) so that a following valid sequence can still be parsed.
|
||||||
let parsedAny = false;
|
let bufferedInputHandled = false;
|
||||||
while (kittySequenceBuffer) {
|
while (kittySequenceBuffer) {
|
||||||
const parsed = parseKittyPrefix(kittySequenceBuffer);
|
const parsed = parseKittyPrefix(kittySequenceBuffer);
|
||||||
if (!parsed) {
|
if (parsed) {
|
||||||
// Look for the next potential CSI start beyond index 0
|
|
||||||
const nextStart = kittySequenceBuffer.indexOf(`${ESC}[`, 1);
|
|
||||||
if (nextStart > 0) {
|
|
||||||
if (debugKeystrokeLogging) {
|
|
||||||
debugLogger.debug(
|
|
||||||
'[DEBUG] Skipping incomplete/invalid CSI prefix:',
|
|
||||||
kittySequenceBuffer.slice(0, nextStart),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
kittySequenceBuffer = kittySequenceBuffer.slice(nextStart);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (debugKeystrokeLogging) {
|
if (debugKeystrokeLogging) {
|
||||||
const parsedSequence = kittySequenceBuffer.slice(
|
const parsedSequence = kittySequenceBuffer.slice(
|
||||||
0,
|
0,
|
||||||
|
|
@ -691,9 +721,58 @@ export function KeypressProvider({
|
||||||
// Consume the parsed prefix and broadcast it.
|
// Consume the parsed prefix and broadcast it.
|
||||||
kittySequenceBuffer = kittySequenceBuffer.slice(parsed.length);
|
kittySequenceBuffer = kittySequenceBuffer.slice(parsed.length);
|
||||||
broadcast(parsed.key);
|
broadcast(parsed.key);
|
||||||
parsedAny = true;
|
bufferedInputHandled = true;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
if (parsedAny) return;
|
|
||||||
|
const completeUnsupportedCsiLength =
|
||||||
|
getCompleteCsiSequenceLength(kittySequenceBuffer);
|
||||||
|
if (completeUnsupportedCsiLength) {
|
||||||
|
if (debugKeystrokeLogging) {
|
||||||
|
debugLogger.debug(
|
||||||
|
'[DEBUG] Dropping unsupported complete CSI sequence:',
|
||||||
|
kittySequenceBuffer.slice(0, completeUnsupportedCsiLength),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
kittySequenceBuffer = kittySequenceBuffer.slice(
|
||||||
|
completeUnsupportedCsiLength,
|
||||||
|
);
|
||||||
|
bufferedInputHandled = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const plainTextPrefix = parsePlainTextPrefix(kittySequenceBuffer);
|
||||||
|
if (plainTextPrefix) {
|
||||||
|
if (debugKeystrokeLogging) {
|
||||||
|
debugLogger.debug(
|
||||||
|
'[DEBUG] Recovered plain text after kitty sequence:',
|
||||||
|
plainTextPrefix.key.sequence,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
kittySequenceBuffer = kittySequenceBuffer.slice(
|
||||||
|
plainTextPrefix.length,
|
||||||
|
);
|
||||||
|
broadcast(plainTextPrefix.key);
|
||||||
|
bufferedInputHandled = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look for the next potential CSI start beyond index 0
|
||||||
|
const nextStart = kittySequenceBuffer.indexOf(`${ESC}[`, 1);
|
||||||
|
if (nextStart > 0) {
|
||||||
|
if (debugKeystrokeLogging) {
|
||||||
|
debugLogger.debug(
|
||||||
|
'[DEBUG] Skipping incomplete/invalid CSI prefix:',
|
||||||
|
kittySequenceBuffer.slice(0, nextStart),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
kittySequenceBuffer = kittySequenceBuffer.slice(nextStart);
|
||||||
|
bufferedInputHandled = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (bufferedInputHandled) return;
|
||||||
|
|
||||||
if (config?.getDebugMode() || debugKeystrokeLogging) {
|
if (config?.getDebugMode() || debugKeystrokeLogging) {
|
||||||
const codes = Array.from(kittySequenceBuffer).map((ch) =>
|
const codes = Array.from(kittySequenceBuffer).map((ch) =>
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
import { type SettingScope } from '../../config/settings.js';
|
import { type SettingScope } from '../../config/settings.js';
|
||||||
import { type CodingPlanRegion } from '../../constants/codingPlan.js';
|
import { type CodingPlanRegion } from '../../constants/codingPlan.js';
|
||||||
import type { AuthState } from '../types.js';
|
import type { AuthState } from '../types.js';
|
||||||
|
import { type ArenaDialogType } from '../hooks/useArenaCommand.js';
|
||||||
// OpenAICredentials type (previously imported from OpenAIKeyPrompt)
|
// OpenAICredentials type (previously imported from OpenAIKeyPrompt)
|
||||||
export interface OpenAICredentials {
|
export interface OpenAICredentials {
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
|
|
@ -54,6 +55,9 @@ export interface UIActions {
|
||||||
exitEditorDialog: () => void;
|
exitEditorDialog: () => void;
|
||||||
closeSettingsDialog: () => void;
|
closeSettingsDialog: () => void;
|
||||||
closeModelDialog: () => void;
|
closeModelDialog: () => void;
|
||||||
|
openArenaDialog: (type: Exclude<ArenaDialogType, null>) => void;
|
||||||
|
closeArenaDialog: () => void;
|
||||||
|
handleArenaModelsSelected?: (models: string[]) => void;
|
||||||
dismissCodingPlanUpdate: () => void;
|
dismissCodingPlanUpdate: () => void;
|
||||||
closePermissionsDialog: () => void;
|
closePermissionsDialog: () => void;
|
||||||
setShellModeActive: (value: boolean) => void;
|
setShellModeActive: (value: boolean) => void;
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ import type { UpdateObject } from '../utils/updateCheck.js';
|
||||||
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
|
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
|
||||||
import { type RestartReason } from '../hooks/useIdeTrustListener.js';
|
import { type RestartReason } from '../hooks/useIdeTrustListener.js';
|
||||||
import { type CodingPlanUpdateRequest } from '../hooks/useCodingPlanUpdates.js';
|
import { type CodingPlanUpdateRequest } from '../hooks/useCodingPlanUpdates.js';
|
||||||
|
import { type ArenaDialogType } from '../hooks/useArenaCommand.js';
|
||||||
|
|
||||||
export interface UIState {
|
export interface UIState {
|
||||||
history: HistoryItem[];
|
history: HistoryItem[];
|
||||||
|
|
@ -52,6 +53,7 @@ export interface UIState {
|
||||||
quittingMessages: HistoryItem[] | null;
|
quittingMessages: HistoryItem[] | null;
|
||||||
isSettingsDialogOpen: boolean;
|
isSettingsDialogOpen: boolean;
|
||||||
isModelDialogOpen: boolean;
|
isModelDialogOpen: boolean;
|
||||||
|
activeArenaDialog: ArenaDialogType;
|
||||||
isPermissionsDialogOpen: boolean;
|
isPermissionsDialogOpen: boolean;
|
||||||
isApprovalModeDialogOpen: boolean;
|
isApprovalModeDialogOpen: boolean;
|
||||||
isResumeDialogOpen: boolean;
|
isResumeDialogOpen: boolean;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
import { useCallback, useMemo, useEffect, useRef, useState } from 'react';
|
import { useCallback, useMemo, useEffect, useRef, useState } from 'react';
|
||||||
import { type PartListUnion } from '@google/genai';
|
import { type PartListUnion } from '@google/genai';
|
||||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||||
|
import type { ArenaDialogType } from './useArenaCommand.js';
|
||||||
import {
|
import {
|
||||||
type Logger,
|
type Logger,
|
||||||
type Config,
|
type Config,
|
||||||
|
|
@ -66,6 +67,7 @@ const SLASH_COMMANDS_SKIP_RECORDING = new Set([
|
||||||
|
|
||||||
interface SlashCommandProcessorActions {
|
interface SlashCommandProcessorActions {
|
||||||
openAuthDialog: () => void;
|
openAuthDialog: () => void;
|
||||||
|
openArenaDialog?: (type: Exclude<ArenaDialogType, null>) => void;
|
||||||
openThemeDialog: () => void;
|
openThemeDialog: () => void;
|
||||||
openEditorDialog: () => void;
|
openEditorDialog: () => void;
|
||||||
openSettingsDialog: () => void;
|
openSettingsDialog: () => void;
|
||||||
|
|
@ -456,6 +458,18 @@ export const useSlashCommandProcessor = (
|
||||||
return { type: 'handled' };
|
return { type: 'handled' };
|
||||||
case 'dialog':
|
case 'dialog':
|
||||||
switch (result.dialog) {
|
switch (result.dialog) {
|
||||||
|
case 'arena_start':
|
||||||
|
actions.openArenaDialog?.('start');
|
||||||
|
return { type: 'handled' };
|
||||||
|
case 'arena_select':
|
||||||
|
actions.openArenaDialog?.('select');
|
||||||
|
return { type: 'handled' };
|
||||||
|
case 'arena_stop':
|
||||||
|
actions.openArenaDialog?.('stop');
|
||||||
|
return { type: 'handled' };
|
||||||
|
case 'arena_status':
|
||||||
|
actions.openArenaDialog?.('status');
|
||||||
|
return { type: 'handled' };
|
||||||
case 'auth':
|
case 'auth':
|
||||||
actions.openAuthDialog();
|
actions.openAuthDialog();
|
||||||
return { type: 'handled' };
|
return { type: 'handled' };
|
||||||
|
|
|
||||||
166
packages/cli/src/ui/hooks/useAgentStreamingState.ts
Normal file
166
packages/cli/src/ui/hooks/useAgentStreamingState.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @fileoverview Hook that subscribes to an AgentInteractive's events and
|
||||||
|
* derives streaming state, elapsed time, input-active flag, and status.
|
||||||
|
*
|
||||||
|
* Extracts the common reactivity + derived-state pattern shared by
|
||||||
|
* AgentComposer and AgentChatView so each component only deals with
|
||||||
|
* layout and interaction.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
AgentStatus,
|
||||||
|
AgentEventType,
|
||||||
|
isTerminalStatus,
|
||||||
|
type AgentInteractive,
|
||||||
|
type AgentEventEmitter,
|
||||||
|
} from '@qwen-code/qwen-code-core';
|
||||||
|
import { StreamingState } from '../types.js';
|
||||||
|
import { useTimer } from './useTimer.js';
|
||||||
|
|
||||||
|
// ─── Types ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface AgentStreamingInfo {
|
||||||
|
/** The agent's current lifecycle status. */
|
||||||
|
status: AgentStatus | undefined;
|
||||||
|
/** Derived streaming state for StreamingContext / LoadingIndicator. */
|
||||||
|
streamingState: StreamingState;
|
||||||
|
/** Whether the agent can accept user input right now. */
|
||||||
|
isInputActive: boolean;
|
||||||
|
/** Seconds elapsed while in Responding state (resets each cycle). */
|
||||||
|
elapsedTime: number;
|
||||||
|
/** Prompt token count from the most recent round (for context usage). */
|
||||||
|
lastPromptTokenCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Hook ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to an AgentInteractive's events and derive UI streaming state.
|
||||||
|
*
|
||||||
|
* @param interactiveAgent - The agent instance, or undefined if not yet registered.
|
||||||
|
* @param events - Which event types trigger a re-render. Defaults to
|
||||||
|
* STATUS_CHANGE, TOOL_WAITING_APPROVAL, and TOOL_RESULT — sufficient for
|
||||||
|
* composer / footer use. Callers like AgentChatView can pass a broader set
|
||||||
|
* (e.g. include TOOL_CALL, ROUND_END, TOOL_OUTPUT_UPDATE) for richer updates.
|
||||||
|
*/
|
||||||
|
export function useAgentStreamingState(
|
||||||
|
interactiveAgent: AgentInteractive | undefined,
|
||||||
|
events?: ReadonlyArray<(typeof AgentEventType)[keyof typeof AgentEventType]>,
|
||||||
|
): AgentStreamingInfo {
|
||||||
|
// ── Force-render on agent events ──
|
||||||
|
|
||||||
|
const [, setTick] = useState(0);
|
||||||
|
const tickRef = useRef(0);
|
||||||
|
const forceRender = useCallback(() => {
|
||||||
|
tickRef.current += 1;
|
||||||
|
setTick(tickRef.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Track last prompt token count from USAGE_METADATA events ──
|
||||||
|
|
||||||
|
const [lastPromptTokenCount, setLastPromptTokenCount] = useState(
|
||||||
|
() => interactiveAgent?.getLastPromptTokenCount() ?? 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const subscribedEvents = events ?? DEFAULT_EVENTS;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!interactiveAgent) return;
|
||||||
|
const emitter: AgentEventEmitter | undefined =
|
||||||
|
interactiveAgent.getEventEmitter();
|
||||||
|
if (!emitter) return;
|
||||||
|
|
||||||
|
const handler = () => forceRender();
|
||||||
|
for (const evt of subscribedEvents) {
|
||||||
|
emitter.on(evt, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dedicated listener for usage metadata — updates React state directly
|
||||||
|
// so the token count is available immediately (even if no other event
|
||||||
|
// triggers a re-render). Prefers totalTokenCount (prompt + output)
|
||||||
|
// because output becomes history for the next round, matching
|
||||||
|
// geminiChat.ts.
|
||||||
|
const usageHandler = (event: {
|
||||||
|
usage?: { totalTokenCount?: number; promptTokenCount?: number };
|
||||||
|
}) => {
|
||||||
|
const count =
|
||||||
|
event?.usage?.totalTokenCount ?? event?.usage?.promptTokenCount;
|
||||||
|
if (typeof count === 'number' && count > 0) {
|
||||||
|
setLastPromptTokenCount(count);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
emitter.on(AgentEventType.USAGE_METADATA, usageHandler);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
for (const evt of subscribedEvents) {
|
||||||
|
emitter.off(evt, handler);
|
||||||
|
}
|
||||||
|
emitter.off(AgentEventType.USAGE_METADATA, usageHandler);
|
||||||
|
};
|
||||||
|
}, [interactiveAgent, forceRender, subscribedEvents]);
|
||||||
|
|
||||||
|
// ── Derived state ──
|
||||||
|
|
||||||
|
const status = interactiveAgent?.getStatus();
|
||||||
|
const pendingApprovals = interactiveAgent?.getPendingApprovals();
|
||||||
|
const hasPendingApprovals =
|
||||||
|
pendingApprovals !== undefined && pendingApprovals.size > 0;
|
||||||
|
|
||||||
|
const streamingState = useMemo(() => {
|
||||||
|
if (hasPendingApprovals) {
|
||||||
|
return StreamingState.WaitingForConfirmation;
|
||||||
|
}
|
||||||
|
if (status === AgentStatus.RUNNING || status === AgentStatus.INITIALIZING) {
|
||||||
|
return StreamingState.Responding;
|
||||||
|
}
|
||||||
|
return StreamingState.Idle;
|
||||||
|
}, [status, hasPendingApprovals]);
|
||||||
|
|
||||||
|
const isInputActive =
|
||||||
|
(streamingState === StreamingState.Idle ||
|
||||||
|
streamingState === StreamingState.Responding) &&
|
||||||
|
status !== undefined &&
|
||||||
|
!isTerminalStatus(status);
|
||||||
|
|
||||||
|
// ── Timer (resets each time we enter Responding) ──
|
||||||
|
|
||||||
|
const [timerResetKey, setTimerResetKey] = useState(0);
|
||||||
|
const prevStreamingRef = useRef(streamingState);
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
streamingState === StreamingState.Responding &&
|
||||||
|
prevStreamingRef.current !== StreamingState.Responding
|
||||||
|
) {
|
||||||
|
setTimerResetKey((k) => k + 1);
|
||||||
|
}
|
||||||
|
prevStreamingRef.current = streamingState;
|
||||||
|
}, [streamingState]);
|
||||||
|
|
||||||
|
const elapsedTime = useTimer(
|
||||||
|
streamingState === StreamingState.Responding,
|
||||||
|
timerResetKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
streamingState,
|
||||||
|
isInputActive,
|
||||||
|
elapsedTime,
|
||||||
|
lastPromptTokenCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Defaults ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
const DEFAULT_EVENTS = [
|
||||||
|
AgentEventType.STATUS_CHANGE,
|
||||||
|
AgentEventType.TOOL_WAITING_APPROVAL,
|
||||||
|
AgentEventType.TOOL_RESULT,
|
||||||
|
] as const;
|
||||||
37
packages/cli/src/ui/hooks/useArenaCommand.ts
Normal file
37
packages/cli/src/ui/hooks/useArenaCommand.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
export type ArenaDialogType = 'start' | 'select' | 'stop' | 'status' | null;
|
||||||
|
|
||||||
|
interface UseArenaCommandReturn {
|
||||||
|
activeArenaDialog: ArenaDialogType;
|
||||||
|
openArenaDialog: (type: Exclude<ArenaDialogType, null>) => void;
|
||||||
|
closeArenaDialog: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useArenaCommand(): UseArenaCommandReturn {
|
||||||
|
const [activeArenaDialog, setActiveArenaDialog] =
|
||||||
|
useState<ArenaDialogType>(null);
|
||||||
|
|
||||||
|
const openArenaDialog = useCallback(
|
||||||
|
(type: Exclude<ArenaDialogType, null>) => {
|
||||||
|
setActiveArenaDialog(type);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const closeArenaDialog = useCallback(() => {
|
||||||
|
setActiveArenaDialog(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeArenaDialog,
|
||||||
|
openArenaDialog,
|
||||||
|
closeArenaDialog,
|
||||||
|
};
|
||||||
|
}
|
||||||
177
packages/cli/src/ui/hooks/useArenaInProcess.ts
Normal file
177
packages/cli/src/ui/hooks/useArenaInProcess.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @fileoverview useArenaInProcess — bridges ArenaManager in-process events
|
||||||
|
* to AgentViewContext agent registration.
|
||||||
|
*
|
||||||
|
* Subscribes to `config.onArenaManagerChange()` to react immediately when
|
||||||
|
* the arena manager is set or cleared. Event listeners are attached to the
|
||||||
|
* manager's emitter as soon as it appears — the backend is resolved lazily
|
||||||
|
* inside the AGENT_START handler, which only fires after the backend is
|
||||||
|
* initialized.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
ArenaEventType,
|
||||||
|
ArenaSessionStatus,
|
||||||
|
DISPLAY_MODE,
|
||||||
|
type ArenaAgentStartEvent,
|
||||||
|
type ArenaManager,
|
||||||
|
type ArenaSessionCompleteEvent,
|
||||||
|
type Config,
|
||||||
|
type InProcessBackend,
|
||||||
|
} from '@qwen-code/qwen-code-core';
|
||||||
|
import type { AgentViewActions } from '../contexts/AgentViewContext.js';
|
||||||
|
import { theme } from '../semantic-colors.js';
|
||||||
|
|
||||||
|
const AGENT_COLORS = [
|
||||||
|
theme.text.accent,
|
||||||
|
theme.text.link,
|
||||||
|
theme.status.success,
|
||||||
|
theme.status.warning,
|
||||||
|
theme.text.code,
|
||||||
|
theme.status.error,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bridge arena in-process events to agent tab registration/unregistration.
|
||||||
|
*
|
||||||
|
* Called by AgentViewProvider — accepts config and actions directly so the
|
||||||
|
* hook has no dependency on AgentViewContext (avoiding a circular import).
|
||||||
|
*/
|
||||||
|
export function useArenaInProcess(
|
||||||
|
config: Config | null,
|
||||||
|
actions: AgentViewActions,
|
||||||
|
): void {
|
||||||
|
const actionsRef = useRef(actions);
|
||||||
|
actionsRef.current = actions;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!config) return;
|
||||||
|
|
||||||
|
let detachArenaListeners: (() => void) | null = null;
|
||||||
|
const retryTimeouts = new Set<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
/** Remove agent tabs, cancel pending retries, and detach arena events. */
|
||||||
|
const detachSession = () => {
|
||||||
|
actionsRef.current.unregisterAll();
|
||||||
|
for (const t of retryTimeouts) clearTimeout(t);
|
||||||
|
retryTimeouts.clear();
|
||||||
|
detachArenaListeners?.();
|
||||||
|
detachArenaListeners = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Attach to an arena manager's event emitter. The backend is resolved
|
||||||
|
* lazily — we only need it when registering agents, not at subscribe
|
||||||
|
* time. This avoids the race where setArenaManager fires before
|
||||||
|
* manager.start() initializes the backend. */
|
||||||
|
const attachSession = (manager: ArenaManager) => {
|
||||||
|
const emitter = manager.getEventEmitter();
|
||||||
|
let colorIndex = 0;
|
||||||
|
|
||||||
|
const nextColor = () => AGENT_COLORS[colorIndex++ % AGENT_COLORS.length]!;
|
||||||
|
|
||||||
|
/** Resolve the InProcessBackend, or null if not applicable. */
|
||||||
|
const getInProcessBackend = (): InProcessBackend | null => {
|
||||||
|
const backend = manager.getBackend();
|
||||||
|
if (!backend || backend.type !== DISPLAY_MODE.IN_PROCESS) return null;
|
||||||
|
return backend as InProcessBackend;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Register agents that already started (events may have fired before
|
||||||
|
// the callback was attached).
|
||||||
|
const inProcessBackend = getInProcessBackend();
|
||||||
|
if (inProcessBackend) {
|
||||||
|
for (const agentState of manager.getAgentStates()) {
|
||||||
|
const interactive = inProcessBackend.getAgent(agentState.agentId);
|
||||||
|
if (interactive) {
|
||||||
|
actionsRef.current.registerAgent(
|
||||||
|
agentState.agentId,
|
||||||
|
interactive,
|
||||||
|
agentState.model.modelId,
|
||||||
|
nextColor(),
|
||||||
|
agentState.model.displayName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AGENT_START fires *before* backend.spawnAgent() creates the
|
||||||
|
// AgentInteractive, so getAgent() may return undefined. Retry briefly.
|
||||||
|
const MAX_RETRIES = 20;
|
||||||
|
const RETRY_MS = 50;
|
||||||
|
|
||||||
|
const onAgentStart = (event: ArenaAgentStartEvent) => {
|
||||||
|
const tryRegister = (retriesLeft: number) => {
|
||||||
|
const backend = getInProcessBackend();
|
||||||
|
if (!backend) return; // not an in-process session
|
||||||
|
|
||||||
|
const interactive = backend.getAgent(event.agentId);
|
||||||
|
if (interactive) {
|
||||||
|
actionsRef.current.registerAgent(
|
||||||
|
event.agentId,
|
||||||
|
interactive,
|
||||||
|
event.model.modelId,
|
||||||
|
nextColor(),
|
||||||
|
event.model.displayName,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (retriesLeft > 0) {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
retryTimeouts.delete(timeout);
|
||||||
|
tryRegister(retriesLeft - 1);
|
||||||
|
}, RETRY_MS);
|
||||||
|
retryTimeouts.add(timeout);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tryRegister(MAX_RETRIES);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSessionComplete = (event: ArenaSessionCompleteEvent) => {
|
||||||
|
// IDLE means agents finished but the session is still alive for
|
||||||
|
// follow-up interaction — keep the tab bar.
|
||||||
|
if (event.result.status === ArenaSessionStatus.IDLE) return;
|
||||||
|
detachSession();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSessionError = () => detachSession();
|
||||||
|
|
||||||
|
emitter.on(ArenaEventType.AGENT_START, onAgentStart);
|
||||||
|
emitter.on(ArenaEventType.SESSION_COMPLETE, onSessionComplete);
|
||||||
|
emitter.on(ArenaEventType.SESSION_ERROR, onSessionError);
|
||||||
|
|
||||||
|
detachArenaListeners = () => {
|
||||||
|
emitter.off(ArenaEventType.AGENT_START, onAgentStart);
|
||||||
|
emitter.off(ArenaEventType.SESSION_COMPLETE, onSessionComplete);
|
||||||
|
emitter.off(ArenaEventType.SESSION_ERROR, onSessionError);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleManagerChange = (manager: ArenaManager | null) => {
|
||||||
|
detachSession();
|
||||||
|
if (manager) {
|
||||||
|
attachSession(manager);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Subscribe to future changes.
|
||||||
|
config.onArenaManagerChange(handleManagerChange);
|
||||||
|
|
||||||
|
// Handle the case where a manager already exists when we mount.
|
||||||
|
const current = config.getArenaManager();
|
||||||
|
if (current) {
|
||||||
|
attachSession(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
config.onArenaManagerChange(null);
|
||||||
|
detachSession();
|
||||||
|
};
|
||||||
|
}, [config]);
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,8 @@ export interface UseAutoAcceptIndicatorArgs {
|
||||||
addItem?: (item: HistoryItemWithoutId, timestamp: number) => void;
|
addItem?: (item: HistoryItemWithoutId, timestamp: number) => void;
|
||||||
onApprovalModeChange?: (mode: ApprovalMode) => void;
|
onApprovalModeChange?: (mode: ApprovalMode) => void;
|
||||||
shouldBlockTab?: () => boolean;
|
shouldBlockTab?: () => boolean;
|
||||||
|
/** When true, the keyboard handler is disabled (e.g. agent tab is active). */
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAutoAcceptIndicator({
|
export function useAutoAcceptIndicator({
|
||||||
|
|
@ -26,6 +28,7 @@ export function useAutoAcceptIndicator({
|
||||||
addItem,
|
addItem,
|
||||||
onApprovalModeChange,
|
onApprovalModeChange,
|
||||||
shouldBlockTab,
|
shouldBlockTab,
|
||||||
|
disabled,
|
||||||
}: UseAutoAcceptIndicatorArgs): ApprovalMode {
|
}: UseAutoAcceptIndicatorArgs): ApprovalMode {
|
||||||
const currentConfigValue = config.getApprovalMode();
|
const currentConfigValue = config.getApprovalMode();
|
||||||
const [showAutoAcceptIndicator, setShowAutoAcceptIndicator] =
|
const [showAutoAcceptIndicator, setShowAutoAcceptIndicator] =
|
||||||
|
|
@ -78,7 +81,7 @@ export function useAutoAcceptIndicator({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ isActive: true },
|
{ isActive: !disabled },
|
||||||
);
|
);
|
||||||
|
|
||||||
return showAutoAcceptIndicator;
|
return showAutoAcceptIndicator;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { SettingScope } from '../../config/settings.js';
|
import { SettingScope } from '../../config/settings.js';
|
||||||
import type { AuthType, ApprovalMode } from '@qwen-code/qwen-code-core';
|
import type { AuthType, ApprovalMode } from '@qwen-code/qwen-code-core';
|
||||||
|
import type { ArenaDialogType } from './useArenaCommand.js';
|
||||||
// OpenAICredentials type (previously imported from OpenAIKeyPrompt)
|
// OpenAICredentials type (previously imported from OpenAIKeyPrompt)
|
||||||
interface OpenAICredentials {
|
interface OpenAICredentials {
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
|
|
@ -42,6 +43,10 @@ export interface DialogCloseOptions {
|
||||||
isSettingsDialogOpen: boolean;
|
isSettingsDialogOpen: boolean;
|
||||||
closeSettingsDialog: () => void;
|
closeSettingsDialog: () => void;
|
||||||
|
|
||||||
|
// Arena dialogs
|
||||||
|
activeArenaDialog: ArenaDialogType;
|
||||||
|
closeArenaDialog: () => void;
|
||||||
|
|
||||||
// Folder trust dialog
|
// Folder trust dialog
|
||||||
isFolderTrustDialogOpen: boolean;
|
isFolderTrustDialogOpen: boolean;
|
||||||
|
|
||||||
|
|
@ -83,6 +88,11 @@ export function useDialogClose(options: DialogCloseOptions) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.activeArenaDialog !== null) {
|
||||||
|
options.closeArenaDialog();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (options.isFolderTrustDialogOpen) {
|
if (options.isFolderTrustDialogOpen) {
|
||||||
// FolderTrustDialog doesn't expose close function, but ESC would prevent exit
|
// FolderTrustDialog doesn't expose close function, but ESC would prevent exit
|
||||||
// We follow the same pattern - prevent exit behavior
|
// We follow the same pattern - prevent exit behavior
|
||||||
|
|
|
||||||
|
|
@ -203,6 +203,7 @@ describe('useGeminiStream', () => {
|
||||||
.fn()
|
.fn()
|
||||||
.mockReturnValue(contentGeneratorConfig),
|
.mockReturnValue(contentGeneratorConfig),
|
||||||
getMaxSessionTurns: vi.fn(() => 50),
|
getMaxSessionTurns: vi.fn(() => 50),
|
||||||
|
getArenaAgentClient: vi.fn(() => null),
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
mockOnDebugMessage = vi.fn();
|
mockOnDebugMessage = vi.fn();
|
||||||
mockHandleSlashCommand = vi.fn().mockResolvedValue(false);
|
mockHandleSlashCommand = vi.fn().mockResolvedValue(false);
|
||||||
|
|
|
||||||
|
|
@ -430,6 +430,12 @@ export const useGeminiStream = (
|
||||||
isSubmittingQueryRef.current = false;
|
isSubmittingQueryRef.current = false;
|
||||||
abortControllerRef.current?.abort();
|
abortControllerRef.current?.abort();
|
||||||
|
|
||||||
|
// Report cancellation to arena status reporter (if in arena mode).
|
||||||
|
// This is needed because cancellation during tool execution won't
|
||||||
|
// flow through sendMessageStream where the inline reportCancelled()
|
||||||
|
// lives — tools get cancelled and handleCompletedTools returns early.
|
||||||
|
config.getArenaAgentClient()?.reportCancelled();
|
||||||
|
|
||||||
// Log API cancellation
|
// Log API cancellation
|
||||||
const prompt_id = config.getSessionId() + '########' + getPromptCount();
|
const prompt_id = config.getSessionId() + '########' + getPromptCount();
|
||||||
const cancellationEvent = new ApiCancelEvent(
|
const cancellationEvent = new ApiCancelEvent(
|
||||||
|
|
@ -1433,6 +1439,9 @@ export const useGeminiStream = (
|
||||||
role: 'user',
|
role: 'user',
|
||||||
parts: combinedParts,
|
parts: combinedParts,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Report cancellation to arena (safety net — cancelOngoingRequest
|
||||||
|
config.getArenaAgentClient()?.reportCancelled();
|
||||||
}
|
}
|
||||||
|
|
||||||
const callIdsToMarkAsSubmitted = geminiTools.map(
|
const callIdsToMarkAsSubmitted = geminiTools.map(
|
||||||
|
|
@ -1469,6 +1478,7 @@ export const useGeminiStream = (
|
||||||
geminiClient,
|
geminiClient,
|
||||||
performMemoryRefresh,
|
performMemoryRefresh,
|
||||||
modelSwitchedFromQuotaError,
|
modelSwitchedFromQuotaError,
|
||||||
|
config,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ export interface UseInputHistoryReturn {
|
||||||
handleSubmit: (value: string) => void;
|
handleSubmit: (value: string) => void;
|
||||||
navigateUp: () => boolean;
|
navigateUp: () => boolean;
|
||||||
navigateDown: () => boolean;
|
navigateDown: () => boolean;
|
||||||
|
resetHistoryNav: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useInputHistory({
|
export function useInputHistory({
|
||||||
|
|
@ -107,5 +108,6 @@ export function useInputHistory({
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
navigateUp,
|
navigateUp,
|
||||||
navigateDown,
|
navigateDown,
|
||||||
|
resetHistoryNav,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { renderHook, act } from '@testing-library/react';
|
import { renderHook, act } from '@testing-library/react';
|
||||||
import {
|
import {
|
||||||
useSelectionList,
|
useSelectionList,
|
||||||
|
|
@ -915,6 +916,37 @@ describe('useSelectionList', () => {
|
||||||
|
|
||||||
expect(result.current.activeIndex).toBe(2);
|
expect(result.current.activeIndex).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should handle equivalent items regenerated on each render', () => {
|
||||||
|
const { result } = renderHook(() => {
|
||||||
|
const [tick, setTick] = useState(0);
|
||||||
|
const regeneratedItems = [
|
||||||
|
{ value: 'A', key: 'A' },
|
||||||
|
{ value: 'B', disabled: true, key: 'B' },
|
||||||
|
{ value: 'C', key: 'C' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const selection = useSelectionList({
|
||||||
|
items: regeneratedItems,
|
||||||
|
onSelect: mockOnSelect,
|
||||||
|
initialIndex: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tick === 0) {
|
||||||
|
setTick(1);
|
||||||
|
}
|
||||||
|
}, [tick]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tick,
|
||||||
|
activeIndex: selection.activeIndex,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.tick).toBe(1);
|
||||||
|
expect(result.current.activeIndex).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Manual Control', () => {
|
describe('Manual Control', () => {
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,27 @@ const computeInitialIndex = <T>(
|
||||||
return targetIndex;
|
return targetIndex;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const areItemsStructurallyEqual = <T>(
|
||||||
|
a: Array<SelectionListItem<T>>,
|
||||||
|
b: Array<SelectionListItem<T>>,
|
||||||
|
): boolean => {
|
||||||
|
if (a === b) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a.length !== b.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
if (a[i]?.key !== b[i]?.key || a[i]?.disabled !== b[i]?.disabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
function selectionListReducer<T>(
|
function selectionListReducer<T>(
|
||||||
state: SelectionListState<T>,
|
state: SelectionListState<T>,
|
||||||
action: SelectionListAction<T>,
|
action: SelectionListAction<T>,
|
||||||
|
|
@ -176,22 +197,30 @@ function selectionListReducer<T>(
|
||||||
|
|
||||||
case 'INITIALIZE': {
|
case 'INITIALIZE': {
|
||||||
const { initialIndex, items } = action.payload;
|
const { initialIndex, items } = action.payload;
|
||||||
|
const initialIndexChanged = initialIndex !== state.initialIndex;
|
||||||
const activeKey =
|
const activeKey =
|
||||||
initialIndex === state.initialIndex &&
|
!initialIndexChanged && state.activeIndex !== state.initialIndex
|
||||||
state.activeIndex !== state.initialIndex
|
|
||||||
? state.items[state.activeIndex]?.key
|
? state.items[state.activeIndex]?.key
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const targetIndex = computeInitialIndex(initialIndex, items, activeKey);
|
||||||
|
const itemsStructurallyEqual = areItemsStructurallyEqual(
|
||||||
|
items,
|
||||||
|
state.items,
|
||||||
|
);
|
||||||
|
|
||||||
if (items === state.items && initialIndex === state.initialIndex) {
|
if (
|
||||||
|
!initialIndexChanged &&
|
||||||
|
targetIndex === state.activeIndex &&
|
||||||
|
itemsStructurallyEqual
|
||||||
|
) {
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetIndex = computeInitialIndex(initialIndex, items, activeKey);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
items,
|
items: itemsStructurallyEqual ? state.items : items,
|
||||||
activeIndex: targetIndex,
|
activeIndex: targetIndex,
|
||||||
|
initialIndex,
|
||||||
pendingHighlight: false,
|
pendingHighlight: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,25 +5,62 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
import { Box } from 'ink';
|
import { Box } from 'ink';
|
||||||
import { MainContent } from '../components/MainContent.js';
|
import { MainContent } from '../components/MainContent.js';
|
||||||
import { DialogManager } from '../components/DialogManager.js';
|
import { DialogManager } from '../components/DialogManager.js';
|
||||||
import { Composer } from '../components/Composer.js';
|
import { Composer } from '../components/Composer.js';
|
||||||
import { ExitWarning } from '../components/ExitWarning.js';
|
import { ExitWarning } from '../components/ExitWarning.js';
|
||||||
|
import { AgentTabBar } from '../components/agent-view/AgentTabBar.js';
|
||||||
|
import { AgentChatView } from '../components/agent-view/AgentChatView.js';
|
||||||
|
import { AgentComposer } from '../components/agent-view/AgentComposer.js';
|
||||||
import { useUIState } from '../contexts/UIStateContext.js';
|
import { useUIState } from '../contexts/UIStateContext.js';
|
||||||
|
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||||
|
import { useAgentViewState } from '../contexts/AgentViewContext.js';
|
||||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||||
|
|
||||||
export const DefaultAppLayout: React.FC = () => {
|
export const DefaultAppLayout: React.FC = () => {
|
||||||
const uiState = useUIState();
|
const uiState = useUIState();
|
||||||
|
const { refreshStatic } = useUIActions();
|
||||||
|
const { activeView, agents } = useAgentViewState();
|
||||||
const { columns: terminalWidth } = useTerminalSize();
|
const { columns: terminalWidth } = useTerminalSize();
|
||||||
|
const hasAgents = agents.size > 0;
|
||||||
|
const isAgentTab = activeView !== 'main' && agents.has(activeView);
|
||||||
|
|
||||||
|
// Clear terminal on view switch so previous view's <Static> output
|
||||||
|
// is removed. refreshStatic clears the terminal and bumps the
|
||||||
|
// historyRemountKey so MainContent's <Static> re-renders all items
|
||||||
|
// when switching back.
|
||||||
|
const prevViewRef = useRef(activeView);
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevViewRef.current !== activeView) {
|
||||||
|
prevViewRef.current = activeView;
|
||||||
|
refreshStatic();
|
||||||
|
}
|
||||||
|
}, [activeView, refreshStatic]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" width={terminalWidth}>
|
<Box flexDirection="column" width={terminalWidth}>
|
||||||
|
{isAgentTab ? (
|
||||||
|
<>
|
||||||
|
{/* Agent view: chat history + agent-specific composer */}
|
||||||
|
<AgentChatView agentId={activeView} />
|
||||||
|
<Box flexDirection="column" ref={uiState.mainControlsRef}>
|
||||||
|
<AgentComposer key={activeView} agentId={activeView} />
|
||||||
|
<ExitWarning />
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Main view: conversation history + main composer / dialogs */}
|
||||||
<MainContent />
|
<MainContent />
|
||||||
|
|
||||||
<Box flexDirection="column" ref={uiState.mainControlsRef}>
|
<Box flexDirection="column" ref={uiState.mainControlsRef}>
|
||||||
{uiState.dialogsVisible ? (
|
{uiState.dialogsVisible ? (
|
||||||
<Box marginX={2} flexDirection="column" width={uiState.mainAreaWidth}>
|
<Box
|
||||||
|
marginX={2}
|
||||||
|
flexDirection="column"
|
||||||
|
width={uiState.mainAreaWidth}
|
||||||
|
>
|
||||||
<DialogManager
|
<DialogManager
|
||||||
terminalWidth={uiState.terminalWidth}
|
terminalWidth={uiState.terminalWidth}
|
||||||
addItem={uiState.historyManager.addItem}
|
addItem={uiState.historyManager.addItem}
|
||||||
|
|
@ -32,9 +69,13 @@ export const DefaultAppLayout: React.FC = () => {
|
||||||
) : (
|
) : (
|
||||||
<Composer />
|
<Composer />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ExitWarning />
|
<ExitWarning />
|
||||||
</Box>
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tab bar: visible whenever in-process agents exist and input is active */}
|
||||||
|
{hasAgents && !uiState.dialogsVisible && <AgentTabBar />}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import type {
|
||||||
ToolCallConfirmationDetails,
|
ToolCallConfirmationDetails,
|
||||||
ToolConfirmationOutcome,
|
ToolConfirmationOutcome,
|
||||||
ToolResultDisplay,
|
ToolResultDisplay,
|
||||||
|
AgentStatus,
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
import type { PartListUnion } from '@google/genai';
|
import type { PartListUnion } from '@google/genai';
|
||||||
import { type ReactNode } from 'react';
|
import { type ReactNode } from 'react';
|
||||||
|
|
@ -128,6 +129,11 @@ export type HistoryItemWarning = HistoryItemBase & {
|
||||||
text: string;
|
text: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type HistoryItemSuccess = HistoryItemBase & {
|
||||||
|
type: 'success';
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type HistoryItemRetryCountdown = HistoryItemBase & {
|
export type HistoryItemRetryCountdown = HistoryItemBase & {
|
||||||
type: 'retry_countdown';
|
type: 'retry_countdown';
|
||||||
text: string;
|
text: string;
|
||||||
|
|
@ -256,6 +262,40 @@ export type HistoryItemMcpStatus = HistoryItemBase & {
|
||||||
showTips: boolean;
|
showTips: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arena agent completion card data.
|
||||||
|
*/
|
||||||
|
export interface ArenaAgentCardData {
|
||||||
|
label: string;
|
||||||
|
status: AgentStatus;
|
||||||
|
durationMs: number;
|
||||||
|
totalTokens: number;
|
||||||
|
inputTokens: number;
|
||||||
|
outputTokens: number;
|
||||||
|
toolCalls: number;
|
||||||
|
successfulToolCalls: number;
|
||||||
|
failedToolCalls: number;
|
||||||
|
rounds: number;
|
||||||
|
error?: string;
|
||||||
|
diff?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HistoryItemArenaAgentComplete = HistoryItemBase & {
|
||||||
|
type: 'arena_agent_complete';
|
||||||
|
agent: ArenaAgentCardData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HistoryItemArenaSessionComplete = HistoryItemBase & {
|
||||||
|
type: 'arena_session_complete';
|
||||||
|
sessionStatus: string;
|
||||||
|
task: string;
|
||||||
|
totalDurationMs: number;
|
||||||
|
agents: ArenaAgentCardData[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insight progress message.
|
||||||
|
*/
|
||||||
export type HistoryItemInsightProgress = HistoryItemBase & {
|
export type HistoryItemInsightProgress = HistoryItemBase & {
|
||||||
type: 'insight_progress';
|
type: 'insight_progress';
|
||||||
progress: InsightProgressProps;
|
progress: InsightProgressProps;
|
||||||
|
|
@ -275,6 +315,7 @@ export type HistoryItemWithoutId =
|
||||||
| HistoryItemInfo
|
| HistoryItemInfo
|
||||||
| HistoryItemError
|
| HistoryItemError
|
||||||
| HistoryItemWarning
|
| HistoryItemWarning
|
||||||
|
| HistoryItemSuccess
|
||||||
| HistoryItemRetryCountdown
|
| HistoryItemRetryCountdown
|
||||||
| HistoryItemAbout
|
| HistoryItemAbout
|
||||||
| HistoryItemHelp
|
| HistoryItemHelp
|
||||||
|
|
@ -290,6 +331,8 @@ export type HistoryItemWithoutId =
|
||||||
| HistoryItemToolsList
|
| HistoryItemToolsList
|
||||||
| HistoryItemSkillsList
|
| HistoryItemSkillsList
|
||||||
| HistoryItemMcpStatus
|
| HistoryItemMcpStatus
|
||||||
|
| HistoryItemArenaAgentComplete
|
||||||
|
| HistoryItemArenaSessionComplete
|
||||||
| HistoryItemInsightProgress;
|
| HistoryItemInsightProgress;
|
||||||
|
|
||||||
export type HistoryItem = HistoryItemWithoutId & { id: number };
|
export type HistoryItem = HistoryItemWithoutId & { id: number };
|
||||||
|
|
@ -297,6 +340,7 @@ export type HistoryItem = HistoryItemWithoutId & { id: number };
|
||||||
// Message types used by internal command feedback (subset of HistoryItem types)
|
// Message types used by internal command feedback (subset of HistoryItem types)
|
||||||
export enum MessageType {
|
export enum MessageType {
|
||||||
INFO = 'info',
|
INFO = 'info',
|
||||||
|
SUCCESS = 'success',
|
||||||
ERROR = 'error',
|
ERROR = 'error',
|
||||||
WARNING = 'warning',
|
WARNING = 'warning',
|
||||||
USER = 'user',
|
USER = 'user',
|
||||||
|
|
@ -313,6 +357,8 @@ export enum MessageType {
|
||||||
TOOLS_LIST = 'tools_list',
|
TOOLS_LIST = 'tools_list',
|
||||||
SKILLS_LIST = 'skills_list',
|
SKILLS_LIST = 'skills_list',
|
||||||
MCP_STATUS = 'mcp_status',
|
MCP_STATUS = 'mcp_status',
|
||||||
|
ARENA_AGENT_COMPLETE = 'arena_agent_complete',
|
||||||
|
ARENA_SESSION_COMPLETE = 'arena_session_complete',
|
||||||
INSIGHT_PROGRESS = 'insight_progress',
|
INSIGHT_PROGRESS = 'insight_progress',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,7 @@ const RenderInlineInternal: React.FC<RenderInlineProps> = ({
|
||||||
const codeMatch = fullMatch.match(/^(`+)(.+?)\1$/s);
|
const codeMatch = fullMatch.match(/^(`+)(.+?)\1$/s);
|
||||||
if (codeMatch && codeMatch[2]) {
|
if (codeMatch && codeMatch[2]) {
|
||||||
renderedNode = (
|
renderedNode = (
|
||||||
<Text key={key} color={theme.text.accent}>
|
<Text key={key} color={theme.text.code}>
|
||||||
{codeMatch[2]}
|
{codeMatch[2]}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,34 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { theme } from '../semantic-colors.js';
|
import { theme } from '../semantic-colors.js';
|
||||||
|
import { AgentStatus } from '@qwen-code/qwen-code-core';
|
||||||
|
|
||||||
|
// --- Status Labels ---
|
||||||
|
|
||||||
|
export interface StatusLabel {
|
||||||
|
icon: string;
|
||||||
|
text: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getArenaStatusLabel(status: AgentStatus): StatusLabel {
|
||||||
|
switch (status) {
|
||||||
|
case AgentStatus.IDLE:
|
||||||
|
return { icon: '✓', text: 'Idle', color: theme.status.success };
|
||||||
|
case AgentStatus.COMPLETED:
|
||||||
|
return { icon: '✓', text: 'Done', color: theme.status.success };
|
||||||
|
case AgentStatus.CANCELLED:
|
||||||
|
return { icon: '⊘', text: 'Cancelled', color: theme.status.warning };
|
||||||
|
case AgentStatus.FAILED:
|
||||||
|
return { icon: '✗', text: 'Failed', color: theme.status.error };
|
||||||
|
case AgentStatus.RUNNING:
|
||||||
|
return { icon: '○', text: 'Running', color: theme.text.secondary };
|
||||||
|
case AgentStatus.INITIALIZING:
|
||||||
|
return { icon: '○', text: 'Initializing', color: theme.text.secondary };
|
||||||
|
default:
|
||||||
|
return { icon: '○', text: status, color: theme.text.secondary };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Thresholds ---
|
// --- Thresholds ---
|
||||||
export const TOOL_SUCCESS_RATE_HIGH = 95;
|
export const TOOL_SUCCESS_RATE_HIGH = 95;
|
||||||
|
|
|
||||||
40
packages/cli/src/ui/utils/layoutUtils.ts
Normal file
40
packages/cli/src/ui/utils/layoutUtils.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @fileoverview Shared layout calculation utilities for the terminal UI.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the widths for the input prompt area based on terminal width.
|
||||||
|
*
|
||||||
|
* Returns the content width (for the text buffer), the total container width
|
||||||
|
* (including border + padding + prefix), the suggestions dropdown width,
|
||||||
|
* and the frame overhead constant.
|
||||||
|
*/
|
||||||
|
export const calculatePromptWidths = (terminalWidth: number) => {
|
||||||
|
const widthFraction = 0.9;
|
||||||
|
const FRAME_PADDING_AND_BORDER = 4; // Border (2) + padding (2)
|
||||||
|
const PROMPT_PREFIX_WIDTH = 2; // '> ' or '! '
|
||||||
|
const MIN_CONTENT_WIDTH = 2;
|
||||||
|
|
||||||
|
const innerContentWidth =
|
||||||
|
Math.floor(terminalWidth * widthFraction) -
|
||||||
|
FRAME_PADDING_AND_BORDER -
|
||||||
|
PROMPT_PREFIX_WIDTH;
|
||||||
|
|
||||||
|
const inputWidth = Math.max(MIN_CONTENT_WIDTH, innerContentWidth);
|
||||||
|
const FRAME_OVERHEAD = FRAME_PADDING_AND_BORDER + PROMPT_PREFIX_WIDTH;
|
||||||
|
const containerWidth = inputWidth + FRAME_OVERHEAD;
|
||||||
|
const suggestionsWidth = Math.max(20, Math.floor(terminalWidth * 1.0));
|
||||||
|
|
||||||
|
return {
|
||||||
|
inputWidth,
|
||||||
|
containerWidth,
|
||||||
|
suggestionsWidth,
|
||||||
|
frameOverhead: FRAME_OVERHEAD,
|
||||||
|
} as const;
|
||||||
|
};
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@qwen-code/qwen-code-core",
|
"name": "@qwen-code/qwen-code-core",
|
||||||
"version": "0.12.5",
|
"version": "0.13.0",
|
||||||
"description": "Qwen Code Core",
|
"description": "Qwen Code Core",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|
|
||||||
568
packages/core/src/agents/arena/ArenaAgentClient.test.ts
Normal file
568
packages/core/src/agents/arena/ArenaAgentClient.test.ts
Normal file
|
|
@ -0,0 +1,568 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import * as fs from 'node:fs/promises';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import * as os from 'node:os';
|
||||||
|
import { ArenaAgentClient } from './ArenaAgentClient.js';
|
||||||
|
import { safeAgentId } from './types.js';
|
||||||
|
import type { ArenaControlSignal } from './types.js';
|
||||||
|
import { uiTelemetryService } from '../../telemetry/uiTelemetry.js';
|
||||||
|
import type { SessionMetrics } from '../../telemetry/uiTelemetry.js';
|
||||||
|
import { ToolCallDecision } from '../../telemetry/tool-call-decision.js';
|
||||||
|
|
||||||
|
const createMockMetrics = (
|
||||||
|
overrides: Partial<{
|
||||||
|
totalRequests: number;
|
||||||
|
totalTokens: number;
|
||||||
|
promptTokens: number;
|
||||||
|
candidatesTokens: number;
|
||||||
|
totalLatencyMs: number;
|
||||||
|
totalCalls: number;
|
||||||
|
totalSuccess: number;
|
||||||
|
totalFail: number;
|
||||||
|
}> = {},
|
||||||
|
): SessionMetrics => ({
|
||||||
|
models: {
|
||||||
|
'test-model': {
|
||||||
|
api: {
|
||||||
|
totalRequests: overrides.totalRequests ?? 0,
|
||||||
|
totalErrors: 0,
|
||||||
|
totalLatencyMs: overrides.totalLatencyMs ?? 0,
|
||||||
|
},
|
||||||
|
tokens: {
|
||||||
|
prompt: overrides.promptTokens ?? 0,
|
||||||
|
candidates: overrides.candidatesTokens ?? 0,
|
||||||
|
total: overrides.totalTokens ?? 0,
|
||||||
|
cached: 0,
|
||||||
|
thoughts: 0,
|
||||||
|
tool: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tools: {
|
||||||
|
totalCalls: overrides.totalCalls ?? 0,
|
||||||
|
totalSuccess: overrides.totalSuccess ?? 0,
|
||||||
|
totalFail: overrides.totalFail ?? 0,
|
||||||
|
totalDurationMs: 0,
|
||||||
|
totalDecisions: {
|
||||||
|
[ToolCallDecision.ACCEPT]: 0,
|
||||||
|
[ToolCallDecision.REJECT]: 0,
|
||||||
|
[ToolCallDecision.MODIFY]: 0,
|
||||||
|
[ToolCallDecision.AUTO_ACCEPT]: 0,
|
||||||
|
},
|
||||||
|
byName: {},
|
||||||
|
},
|
||||||
|
files: {
|
||||||
|
totalLinesAdded: 0,
|
||||||
|
totalLinesRemoved: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ArenaAgentClient', () => {
|
||||||
|
let tempDir: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'arena-reporter-test-'));
|
||||||
|
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
|
||||||
|
createMockMetrics(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
try {
|
||||||
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create() factory', () => {
|
||||||
|
it('should return null when ARENA_AGENT_ID is not set', () => {
|
||||||
|
const original = process.env['ARENA_AGENT_ID'];
|
||||||
|
const originalSession = process.env['ARENA_SESSION_ID'];
|
||||||
|
const originalDir = process.env['ARENA_SESSION_DIR'];
|
||||||
|
delete process.env['ARENA_AGENT_ID'];
|
||||||
|
delete process.env['ARENA_SESSION_ID'];
|
||||||
|
delete process.env['ARENA_SESSION_DIR'];
|
||||||
|
|
||||||
|
const reporter = ArenaAgentClient.create();
|
||||||
|
expect(reporter).toBeNull();
|
||||||
|
|
||||||
|
// Restore
|
||||||
|
if (original !== undefined) {
|
||||||
|
process.env['ARENA_AGENT_ID'] = original;
|
||||||
|
}
|
||||||
|
if (originalSession !== undefined) {
|
||||||
|
process.env['ARENA_SESSION_ID'] = originalSession;
|
||||||
|
}
|
||||||
|
if (originalDir !== undefined) {
|
||||||
|
process.env['ARENA_SESSION_DIR'] = originalDir;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when ARENA_SESSION_ID is not set', () => {
|
||||||
|
const originalAgent = process.env['ARENA_AGENT_ID'];
|
||||||
|
const originalSession = process.env['ARENA_SESSION_ID'];
|
||||||
|
const originalDir = process.env['ARENA_SESSION_DIR'];
|
||||||
|
|
||||||
|
process.env['ARENA_AGENT_ID'] = 'test-agent';
|
||||||
|
delete process.env['ARENA_SESSION_ID'];
|
||||||
|
process.env['ARENA_SESSION_DIR'] = tempDir;
|
||||||
|
|
||||||
|
const reporter = ArenaAgentClient.create();
|
||||||
|
expect(reporter).toBeNull();
|
||||||
|
|
||||||
|
// Restore
|
||||||
|
if (originalAgent !== undefined) {
|
||||||
|
process.env['ARENA_AGENT_ID'] = originalAgent;
|
||||||
|
} else {
|
||||||
|
delete process.env['ARENA_AGENT_ID'];
|
||||||
|
}
|
||||||
|
if (originalSession !== undefined) {
|
||||||
|
process.env['ARENA_SESSION_ID'] = originalSession;
|
||||||
|
}
|
||||||
|
if (originalDir !== undefined) {
|
||||||
|
process.env['ARENA_SESSION_DIR'] = originalDir;
|
||||||
|
} else {
|
||||||
|
delete process.env['ARENA_SESSION_DIR'];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when ARENA_SESSION_DIR is not set', () => {
|
||||||
|
const originalAgent = process.env['ARENA_AGENT_ID'];
|
||||||
|
const originalSession = process.env['ARENA_SESSION_ID'];
|
||||||
|
const originalDir = process.env['ARENA_SESSION_DIR'];
|
||||||
|
|
||||||
|
process.env['ARENA_AGENT_ID'] = 'test-agent';
|
||||||
|
process.env['ARENA_SESSION_ID'] = 'test-session';
|
||||||
|
delete process.env['ARENA_SESSION_DIR'];
|
||||||
|
|
||||||
|
const reporter = ArenaAgentClient.create();
|
||||||
|
expect(reporter).toBeNull();
|
||||||
|
|
||||||
|
// Restore
|
||||||
|
if (originalAgent !== undefined) {
|
||||||
|
process.env['ARENA_AGENT_ID'] = originalAgent;
|
||||||
|
} else {
|
||||||
|
delete process.env['ARENA_AGENT_ID'];
|
||||||
|
}
|
||||||
|
if (originalSession !== undefined) {
|
||||||
|
process.env['ARENA_SESSION_ID'] = originalSession;
|
||||||
|
} else {
|
||||||
|
delete process.env['ARENA_SESSION_ID'];
|
||||||
|
}
|
||||||
|
if (originalDir !== undefined) {
|
||||||
|
process.env['ARENA_SESSION_DIR'] = originalDir;
|
||||||
|
} else {
|
||||||
|
delete process.env['ARENA_SESSION_DIR'];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return an instance when all env vars are set', () => {
|
||||||
|
const originalAgent = process.env['ARENA_AGENT_ID'];
|
||||||
|
const originalSession = process.env['ARENA_SESSION_ID'];
|
||||||
|
const originalDir = process.env['ARENA_SESSION_DIR'];
|
||||||
|
|
||||||
|
process.env['ARENA_AGENT_ID'] = 'test-agent';
|
||||||
|
process.env['ARENA_SESSION_ID'] = 'test-session';
|
||||||
|
process.env['ARENA_SESSION_DIR'] = tempDir;
|
||||||
|
|
||||||
|
const reporter = ArenaAgentClient.create();
|
||||||
|
expect(reporter).toBeInstanceOf(ArenaAgentClient);
|
||||||
|
|
||||||
|
// Restore
|
||||||
|
if (originalAgent !== undefined) {
|
||||||
|
process.env['ARENA_AGENT_ID'] = originalAgent;
|
||||||
|
} else {
|
||||||
|
delete process.env['ARENA_AGENT_ID'];
|
||||||
|
}
|
||||||
|
if (originalSession !== undefined) {
|
||||||
|
process.env['ARENA_SESSION_ID'] = originalSession;
|
||||||
|
} else {
|
||||||
|
delete process.env['ARENA_SESSION_ID'];
|
||||||
|
}
|
||||||
|
if (originalDir !== undefined) {
|
||||||
|
process.env['ARENA_SESSION_DIR'] = originalDir;
|
||||||
|
} else {
|
||||||
|
delete process.env['ARENA_SESSION_DIR'];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('init()', () => {
|
||||||
|
it('should create the agents/ and control/ directories', async () => {
|
||||||
|
const reporter = new ArenaAgentClient('agent-1', tempDir);
|
||||||
|
await reporter.init();
|
||||||
|
|
||||||
|
const agentsDir = path.join(tempDir, 'agents');
|
||||||
|
const controlDir = path.join(tempDir, 'control');
|
||||||
|
const agentsStat = await fs.stat(agentsDir);
|
||||||
|
const controlStat = await fs.stat(controlDir);
|
||||||
|
expect(agentsStat.isDirectory()).toBe(true);
|
||||||
|
expect(controlStat.isDirectory()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be idempotent', async () => {
|
||||||
|
const reporter = new ArenaAgentClient('agent-1', tempDir);
|
||||||
|
await reporter.init();
|
||||||
|
await reporter.init(); // Should not throw
|
||||||
|
|
||||||
|
const agentsDir = path.join(tempDir, 'agents');
|
||||||
|
const stat = await fs.stat(agentsDir);
|
||||||
|
expect(stat.isDirectory()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateStatus()', () => {
|
||||||
|
it('should write per-agent status file with stats from telemetry', async () => {
|
||||||
|
const agentId = 'model-a';
|
||||||
|
const reporter = new ArenaAgentClient(agentId, tempDir);
|
||||||
|
await reporter.init();
|
||||||
|
|
||||||
|
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
|
||||||
|
createMockMetrics({
|
||||||
|
totalRequests: 3,
|
||||||
|
totalTokens: 1500,
|
||||||
|
promptTokens: 1000,
|
||||||
|
candidatesTokens: 500,
|
||||||
|
totalCalls: 7,
|
||||||
|
totalSuccess: 6,
|
||||||
|
totalFail: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await reporter.updateStatus('Editing files');
|
||||||
|
|
||||||
|
const statusPath = path.join(
|
||||||
|
tempDir,
|
||||||
|
'agents',
|
||||||
|
`${safeAgentId(agentId)}.json`,
|
||||||
|
);
|
||||||
|
const content = JSON.parse(await fs.readFile(statusPath, 'utf-8'));
|
||||||
|
|
||||||
|
expect(content.agentId).toBe(agentId);
|
||||||
|
expect(content.status).toBe('running');
|
||||||
|
expect(content.rounds).toBe(3);
|
||||||
|
expect(content.currentActivity).toBe('Editing files');
|
||||||
|
expect(content.stats.totalTokens).toBe(1500);
|
||||||
|
expect(content.stats.inputTokens).toBe(1000);
|
||||||
|
expect(content.stats.outputTokens).toBe(500);
|
||||||
|
expect(content.stats.toolCalls).toBe(7);
|
||||||
|
expect(content.stats.successfulToolCalls).toBe(6);
|
||||||
|
expect(content.stats.failedToolCalls).toBe(1);
|
||||||
|
expect(content.finalSummary).toBeNull();
|
||||||
|
expect(content.error).toBeNull();
|
||||||
|
expect(content.updatedAt).toBeTypeOf('number');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should perform atomic write (no partial reads)', async () => {
|
||||||
|
const agentId = 'model-a';
|
||||||
|
const reporter = new ArenaAgentClient(agentId, tempDir);
|
||||||
|
await reporter.init();
|
||||||
|
|
||||||
|
// Write status multiple times rapidly
|
||||||
|
const promises = [];
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
promises.push(reporter.updateStatus());
|
||||||
|
}
|
||||||
|
await Promise.all(promises);
|
||||||
|
|
||||||
|
// The file should be valid JSON (no corruption from concurrent writes)
|
||||||
|
const statusPath = path.join(
|
||||||
|
tempDir,
|
||||||
|
'agents',
|
||||||
|
`${safeAgentId(agentId)}.json`,
|
||||||
|
);
|
||||||
|
const content = JSON.parse(await fs.readFile(statusPath, 'utf-8'));
|
||||||
|
expect(content.agentId).toBe(agentId);
|
||||||
|
expect(content.status).toBe('running');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reflect latest telemetry on each call', async () => {
|
||||||
|
const agentId = 'model-a';
|
||||||
|
const reporter = new ArenaAgentClient(agentId, tempDir);
|
||||||
|
await reporter.init();
|
||||||
|
|
||||||
|
// First update
|
||||||
|
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
|
||||||
|
createMockMetrics({
|
||||||
|
totalRequests: 1,
|
||||||
|
totalTokens: 100,
|
||||||
|
totalCalls: 5,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await reporter.updateStatus();
|
||||||
|
|
||||||
|
// Second update with updated telemetry
|
||||||
|
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
|
||||||
|
createMockMetrics({
|
||||||
|
totalRequests: 2,
|
||||||
|
totalTokens: 200,
|
||||||
|
totalCalls: 8,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await reporter.updateStatus();
|
||||||
|
|
||||||
|
const statusPath = path.join(
|
||||||
|
tempDir,
|
||||||
|
'agents',
|
||||||
|
`${safeAgentId(agentId)}.json`,
|
||||||
|
);
|
||||||
|
const content = JSON.parse(await fs.readFile(statusPath, 'utf-8'));
|
||||||
|
|
||||||
|
expect(content.rounds).toBe(2);
|
||||||
|
expect(content.stats.totalTokens).toBe(200);
|
||||||
|
expect(content.stats.toolCalls).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should auto-initialize if not yet initialized', async () => {
|
||||||
|
const agentId = 'model-a';
|
||||||
|
const reporter = new ArenaAgentClient(agentId, tempDir);
|
||||||
|
// Skip init() call
|
||||||
|
|
||||||
|
await reporter.updateStatus();
|
||||||
|
|
||||||
|
const statusPath = path.join(
|
||||||
|
tempDir,
|
||||||
|
'agents',
|
||||||
|
`${safeAgentId(agentId)}.json`,
|
||||||
|
);
|
||||||
|
const content = JSON.parse(await fs.readFile(statusPath, 'utf-8'));
|
||||||
|
expect(content.agentId).toBe(agentId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('checkControlSignal()', () => {
|
||||||
|
it('should return null when no control file exists', async () => {
|
||||||
|
const agentId = 'model-a';
|
||||||
|
const reporter = new ArenaAgentClient(agentId, tempDir);
|
||||||
|
await reporter.init();
|
||||||
|
|
||||||
|
const signal = await reporter.checkControlSignal();
|
||||||
|
expect(signal).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should read and delete control file', async () => {
|
||||||
|
const agentId = 'model-a';
|
||||||
|
const reporter = new ArenaAgentClient(agentId, tempDir);
|
||||||
|
await reporter.init();
|
||||||
|
|
||||||
|
// Write a control signal
|
||||||
|
const controlSignal: ArenaControlSignal = {
|
||||||
|
type: 'shutdown',
|
||||||
|
reason: 'User cancelled',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
const controlPath = path.join(
|
||||||
|
tempDir,
|
||||||
|
'control',
|
||||||
|
`${safeAgentId(agentId)}.json`,
|
||||||
|
);
|
||||||
|
await fs.writeFile(controlPath, JSON.stringify(controlSignal), 'utf-8');
|
||||||
|
|
||||||
|
// Read it
|
||||||
|
const signal = await reporter.checkControlSignal();
|
||||||
|
expect(signal).not.toBeNull();
|
||||||
|
expect(signal!.type).toBe('shutdown');
|
||||||
|
expect(signal!.reason).toBe('User cancelled');
|
||||||
|
|
||||||
|
// File should be deleted (consumed)
|
||||||
|
await expect(fs.access(controlPath)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null on subsequent reads (consume-once)', async () => {
|
||||||
|
const agentId = 'model-a';
|
||||||
|
const reporter = new ArenaAgentClient(agentId, tempDir);
|
||||||
|
await reporter.init();
|
||||||
|
|
||||||
|
// Write a control signal
|
||||||
|
const controlSignal: ArenaControlSignal = {
|
||||||
|
type: 'cancel',
|
||||||
|
reason: 'Timeout',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
const controlPath = path.join(
|
||||||
|
tempDir,
|
||||||
|
'control',
|
||||||
|
`${safeAgentId(agentId)}.json`,
|
||||||
|
);
|
||||||
|
await fs.writeFile(controlPath, JSON.stringify(controlSignal), 'utf-8');
|
||||||
|
|
||||||
|
// First read should return the signal
|
||||||
|
const first = await reporter.checkControlSignal();
|
||||||
|
expect(first).not.toBeNull();
|
||||||
|
|
||||||
|
// Second read should return null
|
||||||
|
const second = await reporter.checkControlSignal();
|
||||||
|
expect(second).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reportCompleted()', () => {
|
||||||
|
it('should write status with completed state and optional summary', async () => {
|
||||||
|
const agentId = 'model-a';
|
||||||
|
const reporter = new ArenaAgentClient(agentId, tempDir);
|
||||||
|
await reporter.init();
|
||||||
|
|
||||||
|
await reporter.reportCompleted('Successfully implemented feature X');
|
||||||
|
|
||||||
|
const statusPath = path.join(
|
||||||
|
tempDir,
|
||||||
|
'agents',
|
||||||
|
`${safeAgentId(agentId)}.json`,
|
||||||
|
);
|
||||||
|
const content = JSON.parse(await fs.readFile(statusPath, 'utf-8'));
|
||||||
|
|
||||||
|
expect(content.status).toBe('completed');
|
||||||
|
expect(content.finalSummary).toBe('Successfully implemented feature X');
|
||||||
|
expect(content.error).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should write status with idle state and no summary', async () => {
|
||||||
|
const agentId = 'model-a';
|
||||||
|
const reporter = new ArenaAgentClient(agentId, tempDir);
|
||||||
|
await reporter.init();
|
||||||
|
|
||||||
|
await reporter.reportCompleted();
|
||||||
|
|
||||||
|
const statusPath = path.join(
|
||||||
|
tempDir,
|
||||||
|
'agents',
|
||||||
|
`${safeAgentId(agentId)}.json`,
|
||||||
|
);
|
||||||
|
const content = JSON.parse(await fs.readFile(statusPath, 'utf-8'));
|
||||||
|
|
||||||
|
expect(content.status).toBe('completed');
|
||||||
|
expect(content.finalSummary).toBeNull();
|
||||||
|
expect(content.error).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stats aggregation and wall-clock durationMs', () => {
|
||||||
|
it('should aggregate multi-model stats and use wall-clock durationMs', async () => {
|
||||||
|
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue({
|
||||||
|
models: {
|
||||||
|
'model-a': {
|
||||||
|
api: {
|
||||||
|
totalRequests: 3,
|
||||||
|
totalErrors: 0,
|
||||||
|
totalLatencyMs: 1000,
|
||||||
|
},
|
||||||
|
tokens: {
|
||||||
|
prompt: 100,
|
||||||
|
candidates: 50,
|
||||||
|
total: 150,
|
||||||
|
cached: 0,
|
||||||
|
thoughts: 0,
|
||||||
|
tool: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'model-b': {
|
||||||
|
api: {
|
||||||
|
totalRequests: 2,
|
||||||
|
totalErrors: 1,
|
||||||
|
totalLatencyMs: 500,
|
||||||
|
},
|
||||||
|
tokens: {
|
||||||
|
prompt: 200,
|
||||||
|
candidates: 100,
|
||||||
|
total: 300,
|
||||||
|
cached: 0,
|
||||||
|
thoughts: 0,
|
||||||
|
tool: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tools: {
|
||||||
|
totalCalls: 10,
|
||||||
|
totalSuccess: 8,
|
||||||
|
totalFail: 2,
|
||||||
|
totalDurationMs: 2000,
|
||||||
|
totalDecisions: {
|
||||||
|
[ToolCallDecision.ACCEPT]: 0,
|
||||||
|
[ToolCallDecision.REJECT]: 0,
|
||||||
|
[ToolCallDecision.MODIFY]: 0,
|
||||||
|
[ToolCallDecision.AUTO_ACCEPT]: 0,
|
||||||
|
},
|
||||||
|
byName: {},
|
||||||
|
},
|
||||||
|
files: { totalLinesAdded: 0, totalLinesRemoved: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const reporter = new ArenaAgentClient('model-a', tempDir);
|
||||||
|
await reporter.init();
|
||||||
|
await reporter.updateStatus();
|
||||||
|
|
||||||
|
const statusPath = path.join(
|
||||||
|
tempDir,
|
||||||
|
'agents',
|
||||||
|
`${safeAgentId('model-a')}.json`,
|
||||||
|
);
|
||||||
|
const content = JSON.parse(await fs.readFile(statusPath, 'utf-8'));
|
||||||
|
|
||||||
|
expect(content.stats.rounds).toBe(5);
|
||||||
|
expect(content.stats.totalTokens).toBe(450);
|
||||||
|
expect(content.stats.inputTokens).toBe(300);
|
||||||
|
expect(content.stats.outputTokens).toBe(150);
|
||||||
|
expect(content.stats.toolCalls).toBe(10);
|
||||||
|
expect(content.stats.successfulToolCalls).toBe(8);
|
||||||
|
expect(content.stats.failedToolCalls).toBe(2);
|
||||||
|
// durationMs should be wall-clock time, not API latency sum (1500)
|
||||||
|
expect(content.stats.durationMs).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(content.stats.durationMs).toBeLessThan(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return zeros when no models exist', async () => {
|
||||||
|
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
|
||||||
|
createMockMetrics(),
|
||||||
|
);
|
||||||
|
// Override with empty models
|
||||||
|
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue({
|
||||||
|
...createMockMetrics(),
|
||||||
|
models: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const reporter = new ArenaAgentClient('model-a', tempDir);
|
||||||
|
await reporter.init();
|
||||||
|
await reporter.updateStatus();
|
||||||
|
|
||||||
|
const statusPath = path.join(
|
||||||
|
tempDir,
|
||||||
|
'agents',
|
||||||
|
`${safeAgentId('model-a')}.json`,
|
||||||
|
);
|
||||||
|
const content = JSON.parse(await fs.readFile(statusPath, 'utf-8'));
|
||||||
|
|
||||||
|
expect(content.stats.rounds).toBe(0);
|
||||||
|
expect(content.stats.totalTokens).toBe(0);
|
||||||
|
expect(content.stats.inputTokens).toBe(0);
|
||||||
|
expect(content.stats.outputTokens).toBe(0);
|
||||||
|
// durationMs is wall-clock, so still non-negative even with no models
|
||||||
|
expect(content.stats.durationMs).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('safeAgentId()', () => {
|
||||||
|
it('should pass through typical model IDs unchanged', () => {
|
||||||
|
expect(safeAgentId('qwen-coder-plus')).toBe('qwen-coder-plus');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle IDs without unsafe characters', () => {
|
||||||
|
expect(safeAgentId('simple-id')).toBe('simple-id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should replace slashes with double dashes', () => {
|
||||||
|
expect(safeAgentId('org/model-name')).toBe('org--model-name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple unsafe characters', () => {
|
||||||
|
expect(safeAgentId('a/b\\c:d')).toBe('a--b--c--d');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
241
packages/core/src/agents/arena/ArenaAgentClient.ts
Normal file
241
packages/core/src/agents/arena/ArenaAgentClient.ts
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from 'node:fs/promises';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import { createDebugLogger } from '../../utils/debugLogger.js';
|
||||||
|
import { isNodeError } from '../../utils/errors.js';
|
||||||
|
import { atomicWriteJSON } from '../../utils/atomicFileWrite.js';
|
||||||
|
import { uiTelemetryService } from '../../telemetry/uiTelemetry.js';
|
||||||
|
import type {
|
||||||
|
ArenaAgentStats,
|
||||||
|
ArenaControlSignal,
|
||||||
|
ArenaStatusFile,
|
||||||
|
} from './types.js';
|
||||||
|
import { safeAgentId } from './types.js';
|
||||||
|
import { AgentStatus } from '../runtime/agent-types.js';
|
||||||
|
|
||||||
|
const debugLogger = createDebugLogger('ARENA_AGENT_CLIENT');
|
||||||
|
|
||||||
|
const AGENTS_SUBDIR = 'agents';
|
||||||
|
const CONTROL_SUBDIR = 'control';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ArenaAgentClient is used by child agent processes to communicate
|
||||||
|
* their status back to the main ArenaManager process via file-based IPC.
|
||||||
|
*
|
||||||
|
* Status files are written to a centralized arena session directory:
|
||||||
|
* `<arenaSessionDir>/agents/<safeAgentId>.json`
|
||||||
|
*
|
||||||
|
* Control signals are read from:
|
||||||
|
* `<arenaSessionDir>/control/<safeAgentId>.json`
|
||||||
|
*
|
||||||
|
* It self-activates based on the ARENA_AGENT_ID environment variable.
|
||||||
|
* When running outside an Arena session, `ArenaAgentClient.create()`
|
||||||
|
* returns null.
|
||||||
|
*/
|
||||||
|
export class ArenaAgentClient {
|
||||||
|
private readonly agentsDir: string;
|
||||||
|
private readonly controlDir: string;
|
||||||
|
private readonly statusFilePath: string;
|
||||||
|
private readonly controlFilePath: string;
|
||||||
|
private readonly startTimeMs: number;
|
||||||
|
private initialized = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Static factory - returns an instance if ARENA_AGENT_ID, ARENA_SESSION_ID,
|
||||||
|
* and ARENA_SESSION_DIR env vars are present, null otherwise.
|
||||||
|
*/
|
||||||
|
static create(): ArenaAgentClient | null {
|
||||||
|
const agentId = process.env['ARENA_AGENT_ID'];
|
||||||
|
const sessionId = process.env['ARENA_SESSION_ID'];
|
||||||
|
const sessionDir = process.env['ARENA_SESSION_DIR'];
|
||||||
|
|
||||||
|
if (!agentId || !sessionId || !sessionDir) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ArenaAgentClient(agentId, sessionDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly agentId: string,
|
||||||
|
arenaSessionDir: string,
|
||||||
|
) {
|
||||||
|
const safe = safeAgentId(agentId);
|
||||||
|
this.agentsDir = path.join(arenaSessionDir, AGENTS_SUBDIR);
|
||||||
|
this.controlDir = path.join(arenaSessionDir, CONTROL_SUBDIR);
|
||||||
|
this.statusFilePath = path.join(this.agentsDir, `${safe}.json`);
|
||||||
|
this.controlFilePath = path.join(this.controlDir, `${safe}.json`);
|
||||||
|
this.startTimeMs = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the agents/ and control/ directories under the arena session
|
||||||
|
* dir. Called automatically on first use if not invoked explicitly.
|
||||||
|
*/
|
||||||
|
async init(): Promise<void> {
|
||||||
|
await fs.mkdir(this.agentsDir, { recursive: true });
|
||||||
|
await fs.mkdir(this.controlDir, { recursive: true });
|
||||||
|
this.initialized = true;
|
||||||
|
debugLogger.info(
|
||||||
|
`ArenaAgentClient initialized for agent ${this.agentId} at ${this.agentsDir}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write current status to the per-agent status file using atomic write
|
||||||
|
* (write to temp file then rename).
|
||||||
|
*
|
||||||
|
* Stats are derived automatically from uiTelemetryService which is the
|
||||||
|
* canonical source for token counts, tool calls, and API request counts.
|
||||||
|
*/
|
||||||
|
async updateStatus(currentActivity?: string): Promise<void> {
|
||||||
|
await this.ensureInitialized();
|
||||||
|
|
||||||
|
const stats = this.getStatsFromTelemetry();
|
||||||
|
|
||||||
|
const statusFile: ArenaStatusFile = {
|
||||||
|
agentId: this.agentId,
|
||||||
|
status: AgentStatus.RUNNING,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
rounds: stats.rounds,
|
||||||
|
currentActivity,
|
||||||
|
stats,
|
||||||
|
finalSummary: null,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
await atomicWriteJSON(this.statusFilePath, statusFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read and delete control.json (consume-once pattern).
|
||||||
|
* Returns null if no control signal is pending.
|
||||||
|
*/
|
||||||
|
async checkControlSignal(): Promise<ArenaControlSignal | null> {
|
||||||
|
await this.ensureInitialized();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(this.controlFilePath, 'utf-8');
|
||||||
|
// Parse before deleting so a corrupted file isn't silently consumed
|
||||||
|
const signal = JSON.parse(content) as ArenaControlSignal;
|
||||||
|
await fs.unlink(this.controlFilePath);
|
||||||
|
return signal;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
// File doesn't exist = no signal pending
|
||||||
|
if (isNodeError(error) && error.code === 'ENOENT') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Re-throw permission errors so they surface immediately
|
||||||
|
if (isNodeError(error) && error.code === 'EACCES') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
debugLogger.error('Error reading control signal:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report that the agent has completed the current task successfully.
|
||||||
|
* This is the primary signal to the main process that the agent is done working.
|
||||||
|
*/
|
||||||
|
async reportCompleted(finalSummary?: string): Promise<void> {
|
||||||
|
await this.ensureInitialized();
|
||||||
|
|
||||||
|
const stats = this.getStatsFromTelemetry();
|
||||||
|
|
||||||
|
const statusFile: ArenaStatusFile = {
|
||||||
|
agentId: this.agentId,
|
||||||
|
status: AgentStatus.COMPLETED,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
rounds: stats.rounds,
|
||||||
|
stats,
|
||||||
|
finalSummary: finalSummary ?? null,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
await atomicWriteJSON(this.statusFilePath, statusFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report that the agent hit an error (API/auth/rate-limit, loop, etc.).
|
||||||
|
*/
|
||||||
|
async reportError(errorMessage: string): Promise<void> {
|
||||||
|
await this.ensureInitialized();
|
||||||
|
|
||||||
|
const stats = this.getStatsFromTelemetry();
|
||||||
|
|
||||||
|
const statusFile: ArenaStatusFile = {
|
||||||
|
agentId: this.agentId,
|
||||||
|
status: AgentStatus.FAILED,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
rounds: stats.rounds,
|
||||||
|
stats,
|
||||||
|
finalSummary: null,
|
||||||
|
error: errorMessage,
|
||||||
|
};
|
||||||
|
|
||||||
|
await atomicWriteJSON(this.statusFilePath, statusFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report that the agent's current request was cancelled by the user.
|
||||||
|
*/
|
||||||
|
async reportCancelled(): Promise<void> {
|
||||||
|
await this.ensureInitialized();
|
||||||
|
|
||||||
|
const stats = this.getStatsFromTelemetry();
|
||||||
|
|
||||||
|
const statusFile: ArenaStatusFile = {
|
||||||
|
agentId: this.agentId,
|
||||||
|
status: AgentStatus.CANCELLED,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
rounds: stats.rounds,
|
||||||
|
stats,
|
||||||
|
finalSummary: null,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
await atomicWriteJSON(this.statusFilePath, statusFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build ArenaAgentStats from uiTelemetryService metrics
|
||||||
|
*/
|
||||||
|
private getStatsFromTelemetry(): ArenaAgentStats {
|
||||||
|
const metrics = uiTelemetryService.getMetrics();
|
||||||
|
|
||||||
|
let rounds = 0;
|
||||||
|
let totalTokens = 0;
|
||||||
|
let inputTokens = 0;
|
||||||
|
let outputTokens = 0;
|
||||||
|
|
||||||
|
for (const model of Object.values(metrics.models)) {
|
||||||
|
rounds += model.api.totalRequests;
|
||||||
|
totalTokens += model.tokens.total;
|
||||||
|
inputTokens += model.tokens.prompt;
|
||||||
|
outputTokens += model.tokens.candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rounds,
|
||||||
|
totalTokens,
|
||||||
|
inputTokens,
|
||||||
|
outputTokens,
|
||||||
|
durationMs: Date.now() - this.startTimeMs,
|
||||||
|
toolCalls: metrics.tools.totalCalls,
|
||||||
|
successfulToolCalls: metrics.tools.totalSuccess,
|
||||||
|
failedToolCalls: metrics.tools.totalFail,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureInitialized(): Promise<void> {
|
||||||
|
if (!this.initialized) {
|
||||||
|
await this.init();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
505
packages/core/src/agents/arena/ArenaManager.test.ts
Normal file
505
packages/core/src/agents/arena/ArenaManager.test.ts
Normal file
|
|
@ -0,0 +1,505 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import * as fs from 'node:fs/promises';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import * as os from 'node:os';
|
||||||
|
import { ArenaManager } from './ArenaManager.js';
|
||||||
|
import { ArenaEventType } from './arena-events.js';
|
||||||
|
import { ArenaSessionStatus, ARENA_MAX_AGENTS } from './types.js';
|
||||||
|
|
||||||
|
const hoistedMockSetupWorktrees = vi.hoisted(() => vi.fn());
|
||||||
|
const hoistedMockCleanupSession = vi.hoisted(() => vi.fn());
|
||||||
|
const hoistedMockGetWorktreeDiff = vi.hoisted(() => vi.fn());
|
||||||
|
const hoistedMockApplyWorktreeChanges = vi.hoisted(() => vi.fn());
|
||||||
|
const hoistedMockDetectBackend = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock('../index.js', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('../index.js')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
detectBackend: hoistedMockDetectBackend,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock GitWorktreeService to avoid real git operations.
|
||||||
|
// The class mock includes static methods used by ArenaManager.
|
||||||
|
vi.mock('../../services/gitWorktreeService.js', () => {
|
||||||
|
const MockClass = vi.fn().mockImplementation(() => ({
|
||||||
|
checkGitAvailable: vi.fn().mockResolvedValue({ available: true }),
|
||||||
|
isGitRepository: vi.fn().mockResolvedValue(true),
|
||||||
|
setupWorktrees: hoistedMockSetupWorktrees,
|
||||||
|
cleanupSession: hoistedMockCleanupSession,
|
||||||
|
getWorktreeDiff: hoistedMockGetWorktreeDiff,
|
||||||
|
applyWorktreeChanges: hoistedMockApplyWorktreeChanges,
|
||||||
|
}));
|
||||||
|
// Static methods called by ArenaManager
|
||||||
|
(MockClass as unknown as Record<string, unknown>)['getBaseDir'] = () =>
|
||||||
|
path.join(os.tmpdir(), 'arena-mock');
|
||||||
|
(MockClass as unknown as Record<string, unknown>)['getSessionDir'] = (
|
||||||
|
sessionId: string,
|
||||||
|
) => path.join(os.tmpdir(), 'arena-mock', sessionId);
|
||||||
|
(MockClass as unknown as Record<string, unknown>)['getWorktreesDir'] = (
|
||||||
|
sessionId: string,
|
||||||
|
) => path.join(os.tmpdir(), 'arena-mock', sessionId, 'worktrees');
|
||||||
|
return { GitWorktreeService: MockClass };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock the Config class
|
||||||
|
const createMockConfig = (
|
||||||
|
workingDir: string,
|
||||||
|
arenaSettings: Record<string, unknown> = {},
|
||||||
|
) => ({
|
||||||
|
getWorkingDir: () => workingDir,
|
||||||
|
getModel: () => 'test-model',
|
||||||
|
getSessionId: () => 'test-session',
|
||||||
|
getUserMemory: () => '',
|
||||||
|
getToolRegistry: () => ({
|
||||||
|
getFunctionDeclarations: () => [],
|
||||||
|
getFunctionDeclarationsFiltered: () => [],
|
||||||
|
getTool: () => undefined,
|
||||||
|
}),
|
||||||
|
getAgentsSettings: () => ({ arena: arenaSettings }),
|
||||||
|
getUsageStatisticsEnabled: () => false,
|
||||||
|
getTelemetryEnabled: () => false,
|
||||||
|
getTelemetryLogPromptsEnabled: () => false,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ArenaManager', () => {
|
||||||
|
let tempDir: string;
|
||||||
|
let mockConfig: ReturnType<typeof createMockConfig>;
|
||||||
|
let mockBackend: ReturnType<typeof createMockBackend>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Create a temp directory - no need for git repo since we mock GitWorktreeService
|
||||||
|
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'arena-test-'));
|
||||||
|
// Use tempDir as worktreeBaseDir to avoid slow filesystem access in deriveWorktreeDirName
|
||||||
|
mockConfig = createMockConfig(tempDir, { worktreeBaseDir: tempDir });
|
||||||
|
|
||||||
|
mockBackend = createMockBackend();
|
||||||
|
hoistedMockDetectBackend.mockResolvedValue({ backend: mockBackend });
|
||||||
|
|
||||||
|
hoistedMockSetupWorktrees.mockImplementation(
|
||||||
|
async ({
|
||||||
|
sessionId,
|
||||||
|
sourceRepoPath,
|
||||||
|
worktreeNames,
|
||||||
|
}: {
|
||||||
|
sessionId: string;
|
||||||
|
sourceRepoPath: string;
|
||||||
|
worktreeNames: string[];
|
||||||
|
}) => {
|
||||||
|
const worktrees = worktreeNames.map((name) => ({
|
||||||
|
id: `${sessionId}/${name}`,
|
||||||
|
name,
|
||||||
|
path: path.join(sourceRepoPath, `.arena-${sessionId}`, name),
|
||||||
|
branch: `arena/${sessionId}/${name}`,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
sessionId,
|
||||||
|
worktrees,
|
||||||
|
worktreesByName: Object.fromEntries(
|
||||||
|
worktrees.map((worktree) => [worktree.name, worktree]),
|
||||||
|
),
|
||||||
|
errors: [],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
hoistedMockCleanupSession.mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
removedWorktrees: [],
|
||||||
|
removedBranches: [],
|
||||||
|
errors: [],
|
||||||
|
});
|
||||||
|
hoistedMockGetWorktreeDiff.mockResolvedValue('');
|
||||||
|
hoistedMockApplyWorktreeChanges.mockResolvedValue({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
try {
|
||||||
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('constructor', () => {
|
||||||
|
it('should create an ArenaManager instance', () => {
|
||||||
|
const manager = new ArenaManager(mockConfig as never);
|
||||||
|
expect(manager).toBeDefined();
|
||||||
|
expect(manager.getSessionId()).toBeUndefined();
|
||||||
|
expect(manager.getSessionStatus()).toBe(ArenaSessionStatus.INITIALIZING);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not have a backend before start', () => {
|
||||||
|
const manager = new ArenaManager(mockConfig as never);
|
||||||
|
expect(manager.getBackend()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('start validation', () => {
|
||||||
|
it('should reject start with less than 2 models', async () => {
|
||||||
|
const manager = new ArenaManager(mockConfig as never);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
manager.start({
|
||||||
|
models: [{ modelId: 'model-1', authType: 'openai' }],
|
||||||
|
task: 'Test task',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('Arena requires at least 2 models');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject start with more than max models', async () => {
|
||||||
|
const manager = new ArenaManager(mockConfig as never);
|
||||||
|
|
||||||
|
const models = Array.from({ length: ARENA_MAX_AGENTS + 1 }, (_, i) => ({
|
||||||
|
modelId: `model-${i}`,
|
||||||
|
authType: 'openai',
|
||||||
|
}));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
manager.start({
|
||||||
|
models,
|
||||||
|
task: 'Test task',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(
|
||||||
|
`Arena supports a maximum of ${ARENA_MAX_AGENTS} models`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject start with empty task', async () => {
|
||||||
|
const manager = new ArenaManager(mockConfig as never);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
manager.start({
|
||||||
|
models: [
|
||||||
|
{ modelId: 'model-1', authType: 'openai' },
|
||||||
|
{ modelId: 'model-2', authType: 'openai' },
|
||||||
|
],
|
||||||
|
task: '',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('Arena requires a task/prompt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject start with duplicate model IDs', async () => {
|
||||||
|
const manager = new ArenaManager(mockConfig as never);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
manager.start({
|
||||||
|
models: [
|
||||||
|
{ modelId: 'model-1', authType: 'openai' },
|
||||||
|
{ modelId: 'model-1', authType: 'openai' },
|
||||||
|
],
|
||||||
|
task: 'Test task',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('Arena models must have unique identifiers');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('event emitter', () => {
|
||||||
|
it('should return the event emitter', () => {
|
||||||
|
const manager = new ArenaManager(mockConfig as never);
|
||||||
|
const emitter = manager.getEventEmitter();
|
||||||
|
expect(emitter).toBeDefined();
|
||||||
|
expect(typeof emitter.on).toBe('function');
|
||||||
|
expect(typeof emitter.off).toBe('function');
|
||||||
|
expect(typeof emitter.emit).toBe('function');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PTY interaction methods', () => {
|
||||||
|
it('should expose PTY interaction methods', () => {
|
||||||
|
const manager = new ArenaManager(mockConfig as never);
|
||||||
|
expect(typeof manager.switchToAgent).toBe('function');
|
||||||
|
expect(typeof manager.switchToNextAgent).toBe('function');
|
||||||
|
expect(typeof manager.switchToPreviousAgent).toBe('function');
|
||||||
|
expect(typeof manager.getActiveAgentId).toBe('function');
|
||||||
|
expect(typeof manager.getActiveSnapshot).toBe('function');
|
||||||
|
expect(typeof manager.getAgentSnapshot).toBe('function');
|
||||||
|
expect(typeof manager.forwardInput).toBe('function');
|
||||||
|
expect(typeof manager.resizeAgents).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for active agent ID when no session', () => {
|
||||||
|
const manager = new ArenaManager(mockConfig as never);
|
||||||
|
expect(manager.getActiveAgentId()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for active snapshot when no session', () => {
|
||||||
|
const manager = new ArenaManager(mockConfig as never);
|
||||||
|
expect(manager.getActiveSnapshot()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cancel', () => {
|
||||||
|
it('should handle cancel when no session is active', async () => {
|
||||||
|
const manager = new ArenaManager(mockConfig as never);
|
||||||
|
await expect(manager.cancel()).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cleanup', () => {
|
||||||
|
it('should handle cleanup when no session is active', async () => {
|
||||||
|
const manager = new ArenaManager(mockConfig as never);
|
||||||
|
await expect(manager.cleanup()).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAgentStates', () => {
|
||||||
|
it('should return empty array when no agents', () => {
|
||||||
|
const manager = new ArenaManager(mockConfig as never);
|
||||||
|
expect(manager.getAgentStates()).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAgentState', () => {
|
||||||
|
it('should return undefined for non-existent agent', () => {
|
||||||
|
const manager = new ArenaManager(mockConfig as never);
|
||||||
|
expect(manager.getAgentState('non-existent')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyAgentResult', () => {
|
||||||
|
it('should return error for non-existent agent', async () => {
|
||||||
|
const manager = new ArenaManager(mockConfig as never);
|
||||||
|
const result = await manager.applyAgentResult('non-existent');
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('not found');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAgentDiff', () => {
|
||||||
|
it('should return error message for non-existent agent', async () => {
|
||||||
|
const manager = new ArenaManager(mockConfig as never);
|
||||||
|
const diff = await manager.getAgentDiff('non-existent');
|
||||||
|
expect(diff).toContain('not found');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('backend initialization', () => {
|
||||||
|
it('should emit SESSION_UPDATE with type warning when backend detection returns warning', async () => {
|
||||||
|
const manager = new ArenaManager(mockConfig as never);
|
||||||
|
const updates: Array<{
|
||||||
|
type: string;
|
||||||
|
message: string;
|
||||||
|
sessionId: string;
|
||||||
|
}> = [];
|
||||||
|
manager.getEventEmitter().on(ArenaEventType.SESSION_UPDATE, (event) => {
|
||||||
|
updates.push({
|
||||||
|
type: event.type,
|
||||||
|
message: event.message,
|
||||||
|
sessionId: event.sessionId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
hoistedMockDetectBackend.mockResolvedValueOnce({
|
||||||
|
backend: mockBackend,
|
||||||
|
warning: 'fallback to tmux backend',
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.start(createValidStartOptions());
|
||||||
|
|
||||||
|
expect(hoistedMockDetectBackend).toHaveBeenCalledWith(
|
||||||
|
undefined,
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
const warningUpdate = updates.find((u) => u.type === 'warning');
|
||||||
|
expect(warningUpdate).toBeDefined();
|
||||||
|
expect(warningUpdate?.message).toContain('fallback to tmux backend');
|
||||||
|
expect(warningUpdate?.sessionId).toBe('test-session');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit SESSION_ERROR and mark FAILED when backend init fails', async () => {
|
||||||
|
const manager = new ArenaManager(mockConfig as never);
|
||||||
|
const sessionErrors: string[] = [];
|
||||||
|
manager.getEventEmitter().on(ArenaEventType.SESSION_ERROR, (event) => {
|
||||||
|
sessionErrors.push(event.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
mockBackend.init.mockRejectedValueOnce(new Error('init failed'));
|
||||||
|
|
||||||
|
await expect(manager.start(createValidStartOptions())).rejects.toThrow(
|
||||||
|
'init failed',
|
||||||
|
);
|
||||||
|
expect(manager.getSessionStatus()).toBe(ArenaSessionStatus.FAILED);
|
||||||
|
expect(sessionErrors).toEqual(['init failed']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('chat history forwarding', () => {
|
||||||
|
it('should pass chatHistory to backend spawnAgent calls', async () => {
|
||||||
|
const manager = new ArenaManager(mockConfig as never);
|
||||||
|
const chatHistory = [
|
||||||
|
{ role: 'user' as const, parts: [{ text: 'prior question' }] },
|
||||||
|
{ role: 'model' as const, parts: [{ text: 'prior answer' }] },
|
||||||
|
];
|
||||||
|
|
||||||
|
await manager.start({
|
||||||
|
...createValidStartOptions(),
|
||||||
|
chatHistory,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Both agents should have been spawned with chatHistory in
|
||||||
|
// the inProcess config.
|
||||||
|
expect(mockBackend.spawnAgent).toHaveBeenCalledTimes(2);
|
||||||
|
for (const call of mockBackend.spawnAgent.mock.calls) {
|
||||||
|
const spawnConfig = call[0] as {
|
||||||
|
inProcess?: { chatHistory?: unknown };
|
||||||
|
};
|
||||||
|
expect(spawnConfig.inProcess?.chatHistory).toEqual(chatHistory);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass undefined chatHistory when not provided', async () => {
|
||||||
|
const manager = new ArenaManager(mockConfig as never);
|
||||||
|
|
||||||
|
await manager.start(createValidStartOptions());
|
||||||
|
|
||||||
|
expect(mockBackend.spawnAgent).toHaveBeenCalledTimes(2);
|
||||||
|
for (const call of mockBackend.spawnAgent.mock.calls) {
|
||||||
|
const spawnConfig = call[0] as {
|
||||||
|
inProcess?: { chatHistory?: unknown };
|
||||||
|
};
|
||||||
|
expect(spawnConfig.inProcess?.chatHistory).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('active session lifecycle', () => {
|
||||||
|
it('cancel should stop backend and move session to CANCELLED', async () => {
|
||||||
|
const manager = new ArenaManager(mockConfig as never);
|
||||||
|
|
||||||
|
// Disable auto-exit so agents stay running until we cancel.
|
||||||
|
mockBackend.setAutoExit(false);
|
||||||
|
|
||||||
|
const startPromise = manager.start({
|
||||||
|
...createValidStartOptions(),
|
||||||
|
timeoutSeconds: 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait until the backend has spawned all agents.
|
||||||
|
// (Agents are spawned sequentially; cancelling between spawns would
|
||||||
|
// cause spawnAgentPty to overwrite the CANCELLED status back to RUNNING.)
|
||||||
|
await waitForCondition(
|
||||||
|
() => mockBackend.spawnAgent.mock.calls.length >= 2,
|
||||||
|
);
|
||||||
|
|
||||||
|
await manager.cancel();
|
||||||
|
expect(mockBackend.stopAll).toHaveBeenCalledTimes(1);
|
||||||
|
expect(manager.getSessionStatus()).toBe(ArenaSessionStatus.CANCELLED);
|
||||||
|
|
||||||
|
await startPromise;
|
||||||
|
expect(manager.getSessionStatus()).toBe(ArenaSessionStatus.CANCELLED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cleanup should release backend and worktree resources after start', async () => {
|
||||||
|
const manager = new ArenaManager(mockConfig as never);
|
||||||
|
|
||||||
|
// auto-exit is on by default, so agents terminate quickly.
|
||||||
|
await manager.start(createValidStartOptions());
|
||||||
|
|
||||||
|
await manager.cleanup();
|
||||||
|
|
||||||
|
expect(mockBackend.cleanup).toHaveBeenCalledTimes(1);
|
||||||
|
// cleanupSession is called with worktreeDirName (short ID), not the full sessionId.
|
||||||
|
// For 'test-session', the short ID is 'testsess' (first 8 chars with dashes removed).
|
||||||
|
expect(hoistedMockCleanupSession).toHaveBeenCalledWith('testsess');
|
||||||
|
expect(manager.getBackend()).toBeNull();
|
||||||
|
expect(manager.getSessionId()).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ARENA_MAX_AGENTS', () => {
|
||||||
|
it('should be 5', () => {
|
||||||
|
expect(ARENA_MAX_AGENTS).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function createMockBackend() {
|
||||||
|
type ExitCb = (
|
||||||
|
agentId: string,
|
||||||
|
exitCode: number | null,
|
||||||
|
signal: number | null,
|
||||||
|
) => void;
|
||||||
|
let onAgentExit: ExitCb | null = null;
|
||||||
|
let autoExit = true;
|
||||||
|
|
||||||
|
const backend = {
|
||||||
|
type: 'tmux' as const,
|
||||||
|
init: vi.fn().mockResolvedValue(undefined),
|
||||||
|
spawnAgent: vi.fn(async (config: { agentId: string }) => {
|
||||||
|
// By default, simulate immediate agent termination so tests
|
||||||
|
// don't hang in waitForAllAgentsSettled.
|
||||||
|
if (autoExit) {
|
||||||
|
setTimeout(() => onAgentExit?.(config.agentId, 0, null), 5);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
stopAgent: vi.fn(),
|
||||||
|
stopAll: vi.fn(),
|
||||||
|
cleanup: vi.fn().mockResolvedValue(undefined),
|
||||||
|
setOnAgentExit: vi.fn((cb: ExitCb) => {
|
||||||
|
onAgentExit = cb;
|
||||||
|
}),
|
||||||
|
waitForAll: vi.fn().mockResolvedValue(true),
|
||||||
|
switchTo: vi.fn(),
|
||||||
|
switchToNext: vi.fn(),
|
||||||
|
switchToPrevious: vi.fn(),
|
||||||
|
getActiveAgentId: vi.fn().mockReturnValue(null),
|
||||||
|
getActiveSnapshot: vi.fn().mockReturnValue(null),
|
||||||
|
getAgentSnapshot: vi.fn().mockReturnValue(null),
|
||||||
|
getAgentScrollbackLength: vi.fn().mockReturnValue(0),
|
||||||
|
forwardInput: vi.fn().mockReturnValue(false),
|
||||||
|
writeToAgent: vi.fn().mockReturnValue(false),
|
||||||
|
resizeAll: vi.fn(),
|
||||||
|
getAttachHint: vi.fn().mockReturnValue(null),
|
||||||
|
/** Disable automatic agent exit for tests that need to control timing. */
|
||||||
|
setAutoExit(value: boolean) {
|
||||||
|
autoExit = value;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return backend;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createValidStartOptions() {
|
||||||
|
return {
|
||||||
|
models: [
|
||||||
|
{ modelId: 'model-1', authType: 'openai' },
|
||||||
|
{ modelId: 'model-2', authType: 'openai' },
|
||||||
|
],
|
||||||
|
task: 'Implement feature X',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForMicrotask(): Promise<void> {
|
||||||
|
// Use setImmediate (or setTimeout fallback) to yield to the event loop
|
||||||
|
// and allow other async operations (like the start() method) to progress.
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
if (typeof setImmediate === 'function') {
|
||||||
|
setImmediate(resolve);
|
||||||
|
} else {
|
||||||
|
setTimeout(resolve, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForCondition(
|
||||||
|
predicate: () => boolean,
|
||||||
|
timeoutMs = 1000,
|
||||||
|
): Promise<void> {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
while (!predicate()) {
|
||||||
|
if (Date.now() - startedAt > timeoutMs) {
|
||||||
|
throw new Error('Timed out while waiting for condition');
|
||||||
|
}
|
||||||
|
await waitForMicrotask();
|
||||||
|
}
|
||||||
|
}
|
||||||
1648
packages/core/src/agents/arena/ArenaManager.ts
Normal file
1648
packages/core/src/agents/arena/ArenaManager.ts
Normal file
File diff suppressed because it is too large
Load diff
184
packages/core/src/agents/arena/arena-events.ts
Normal file
184
packages/core/src/agents/arena/arena-events.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
import type {
|
||||||
|
ArenaModelConfig,
|
||||||
|
ArenaAgentResult,
|
||||||
|
ArenaSessionResult,
|
||||||
|
} from './types.js';
|
||||||
|
import type { AgentStatus } from '../runtime/agent-types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arena event types.
|
||||||
|
*/
|
||||||
|
export enum ArenaEventType {
|
||||||
|
/** Arena session started */
|
||||||
|
SESSION_START = 'session_start',
|
||||||
|
/** Informational or warning update during session lifecycle */
|
||||||
|
SESSION_UPDATE = 'session_update',
|
||||||
|
/** Arena session completed */
|
||||||
|
SESSION_COMPLETE = 'session_complete',
|
||||||
|
/** Arena session failed */
|
||||||
|
SESSION_ERROR = 'session_error',
|
||||||
|
/** Agent started */
|
||||||
|
AGENT_START = 'agent_start',
|
||||||
|
/** Agent status changed */
|
||||||
|
AGENT_STATUS_CHANGE = 'agent_status_change',
|
||||||
|
/** Agent completed */
|
||||||
|
AGENT_COMPLETE = 'agent_complete',
|
||||||
|
/** Agent error */
|
||||||
|
AGENT_ERROR = 'agent_error',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ArenaEvent =
|
||||||
|
| 'session_start'
|
||||||
|
| 'session_update'
|
||||||
|
| 'session_complete'
|
||||||
|
| 'session_error'
|
||||||
|
| 'agent_start'
|
||||||
|
| 'agent_status_change'
|
||||||
|
| 'agent_complete'
|
||||||
|
| 'agent_error';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event payload for session start.
|
||||||
|
*/
|
||||||
|
export interface ArenaSessionStartEvent {
|
||||||
|
sessionId: string;
|
||||||
|
task: string;
|
||||||
|
models: ArenaModelConfig[];
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event payload for session complete.
|
||||||
|
*/
|
||||||
|
export interface ArenaSessionCompleteEvent {
|
||||||
|
sessionId: string;
|
||||||
|
result: ArenaSessionResult;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event payload for session error.
|
||||||
|
*/
|
||||||
|
export interface ArenaSessionErrorEvent {
|
||||||
|
sessionId: string;
|
||||||
|
error: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event payload for agent start.
|
||||||
|
*/
|
||||||
|
export interface ArenaAgentStartEvent {
|
||||||
|
sessionId: string;
|
||||||
|
agentId: string;
|
||||||
|
model: ArenaModelConfig;
|
||||||
|
worktreePath: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event payload for agent error.
|
||||||
|
*/
|
||||||
|
export interface ArenaAgentErrorEvent {
|
||||||
|
sessionId: string;
|
||||||
|
agentId: string;
|
||||||
|
error: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event payload for agent complete.
|
||||||
|
*/
|
||||||
|
export interface ArenaAgentCompleteEvent {
|
||||||
|
sessionId: string;
|
||||||
|
agentId: string;
|
||||||
|
result: ArenaAgentResult;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event payload for agent status change.
|
||||||
|
*/
|
||||||
|
export interface ArenaAgentStatusChangeEvent {
|
||||||
|
sessionId: string;
|
||||||
|
agentId: string;
|
||||||
|
previousStatus: AgentStatus;
|
||||||
|
newStatus: AgentStatus;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event payload for session update (informational or warning).
|
||||||
|
*/
|
||||||
|
export type ArenaSessionUpdateType = 'info' | 'warning' | 'success';
|
||||||
|
|
||||||
|
export interface ArenaSessionUpdateEvent {
|
||||||
|
sessionId: string;
|
||||||
|
type: ArenaSessionUpdateType;
|
||||||
|
message: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type map for arena events.
|
||||||
|
*/
|
||||||
|
export interface ArenaEventMap {
|
||||||
|
[ArenaEventType.SESSION_START]: ArenaSessionStartEvent;
|
||||||
|
[ArenaEventType.SESSION_UPDATE]: ArenaSessionUpdateEvent;
|
||||||
|
[ArenaEventType.SESSION_COMPLETE]: ArenaSessionCompleteEvent;
|
||||||
|
[ArenaEventType.SESSION_ERROR]: ArenaSessionErrorEvent;
|
||||||
|
[ArenaEventType.AGENT_START]: ArenaAgentStartEvent;
|
||||||
|
[ArenaEventType.AGENT_STATUS_CHANGE]: ArenaAgentStatusChangeEvent;
|
||||||
|
[ArenaEventType.AGENT_COMPLETE]: ArenaAgentCompleteEvent;
|
||||||
|
[ArenaEventType.AGENT_ERROR]: ArenaAgentErrorEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event emitter for Arena events.
|
||||||
|
*/
|
||||||
|
export class ArenaEventEmitter {
|
||||||
|
private ee = new EventEmitter();
|
||||||
|
|
||||||
|
on<E extends keyof ArenaEventMap>(
|
||||||
|
event: E,
|
||||||
|
listener: (payload: ArenaEventMap[E]) => void,
|
||||||
|
): void {
|
||||||
|
this.ee.on(event, listener as (...args: unknown[]) => void);
|
||||||
|
}
|
||||||
|
|
||||||
|
off<E extends keyof ArenaEventMap>(
|
||||||
|
event: E,
|
||||||
|
listener: (payload: ArenaEventMap[E]) => void,
|
||||||
|
): void {
|
||||||
|
this.ee.off(event, listener as (...args: unknown[]) => void);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit<E extends keyof ArenaEventMap>(
|
||||||
|
event: E,
|
||||||
|
payload: ArenaEventMap[E],
|
||||||
|
): void {
|
||||||
|
this.ee.emit(event, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
once<E extends keyof ArenaEventMap>(
|
||||||
|
event: E,
|
||||||
|
listener: (payload: ArenaEventMap[E]) => void,
|
||||||
|
): void {
|
||||||
|
this.ee.once(event, listener as (...args: unknown[]) => void);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeAllListeners(event?: ArenaEvent): void {
|
||||||
|
if (event) {
|
||||||
|
this.ee.removeAllListeners(event);
|
||||||
|
} else {
|
||||||
|
this.ee.removeAllListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
packages/core/src/agents/arena/index.ts
Normal file
14
packages/core/src/agents/arena/index.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Arena-specific exports
|
||||||
|
export * from './types.js';
|
||||||
|
export * from './arena-events.js';
|
||||||
|
export * from './ArenaManager.js';
|
||||||
|
export * from './ArenaAgentClient.js';
|
||||||
|
|
||||||
|
// Re-export shared agent infrastructure for backwards compatibility
|
||||||
|
export * from '../backends/index.js';
|
||||||
280
packages/core/src/agents/arena/types.ts
Normal file
280
packages/core/src/agents/arena/types.ts
Normal file
|
|
@ -0,0 +1,280 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Content } from '@google/genai';
|
||||||
|
import type { WorktreeInfo } from '../../services/gitWorktreeService.js';
|
||||||
|
import type { DisplayMode } from '../backends/types.js';
|
||||||
|
import type { AgentStatus } from '../runtime/agent-types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum number of concurrent agents allowed in an Arena session.
|
||||||
|
*/
|
||||||
|
export const ARENA_MAX_AGENTS = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the status of an Arena session.
|
||||||
|
*/
|
||||||
|
export enum ArenaSessionStatus {
|
||||||
|
/** Session is being set up */
|
||||||
|
INITIALIZING = 'initializing',
|
||||||
|
/** Session is running */
|
||||||
|
RUNNING = 'running',
|
||||||
|
/** All agents finished their current task and are idle (can accept follow-ups) */
|
||||||
|
IDLE = 'idle',
|
||||||
|
/** Session completed for good (winner selected or explicit end) */
|
||||||
|
COMPLETED = 'completed',
|
||||||
|
/** Session was cancelled */
|
||||||
|
CANCELLED = 'cancelled',
|
||||||
|
/** Session failed during initialization */
|
||||||
|
FAILED = 'failed',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for a model participating in the Arena.
|
||||||
|
*/
|
||||||
|
export interface ArenaModelConfig {
|
||||||
|
/** Model identifier (e.g., 'qwen-coder-plus', 'gpt-4') */
|
||||||
|
modelId: string;
|
||||||
|
/** Authentication type for this model */
|
||||||
|
authType: string;
|
||||||
|
/** Display name for UI */
|
||||||
|
displayName?: string;
|
||||||
|
/** Optional API key override */
|
||||||
|
apiKey?: string;
|
||||||
|
/** Optional base URL override */
|
||||||
|
baseUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for an Arena session.
|
||||||
|
*/
|
||||||
|
export interface ArenaConfig {
|
||||||
|
/** Unique identifier for this Arena session */
|
||||||
|
sessionId: string;
|
||||||
|
/** The task/prompt to be executed by all agents */
|
||||||
|
task: string;
|
||||||
|
/** Models participating in the Arena */
|
||||||
|
models: ArenaModelConfig[];
|
||||||
|
/** Maximum number of rounds per agent (default: 50) */
|
||||||
|
maxRoundsPerAgent?: number;
|
||||||
|
/** Total timeout in seconds for the entire Arena session (default: 600) */
|
||||||
|
timeoutSeconds?: number;
|
||||||
|
/** Approval mode inherited from the main process (e.g., 'auto', 'suggest', etc.) */
|
||||||
|
approvalMode?: string;
|
||||||
|
/** Source repository path */
|
||||||
|
sourceRepoPath: string;
|
||||||
|
/** Chat history from the parent session for agent context seeding. */
|
||||||
|
chatHistory?: Content[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statistics for an individual Arena agent.
|
||||||
|
*/
|
||||||
|
export interface ArenaAgentStats {
|
||||||
|
/** Number of completed rounds */
|
||||||
|
rounds: number;
|
||||||
|
/** Total tokens used */
|
||||||
|
totalTokens: number;
|
||||||
|
/** Input tokens used */
|
||||||
|
inputTokens: number;
|
||||||
|
/** Output tokens used */
|
||||||
|
outputTokens: number;
|
||||||
|
/** Total execution time in milliseconds */
|
||||||
|
durationMs: number;
|
||||||
|
/** Number of tool calls made */
|
||||||
|
toolCalls: number;
|
||||||
|
/** Number of successful tool calls */
|
||||||
|
successfulToolCalls: number;
|
||||||
|
/** Number of failed tool calls */
|
||||||
|
failedToolCalls: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result from a single Arena agent.
|
||||||
|
*/
|
||||||
|
export interface ArenaAgentResult {
|
||||||
|
/** Agent identifier */
|
||||||
|
agentId: string;
|
||||||
|
/** Model configuration used */
|
||||||
|
model: ArenaModelConfig;
|
||||||
|
/** Final status */
|
||||||
|
status: AgentStatus;
|
||||||
|
/** Worktree information */
|
||||||
|
worktree: WorktreeInfo;
|
||||||
|
/** Final text output from the agent */
|
||||||
|
finalText?: string;
|
||||||
|
/** Error message if failed */
|
||||||
|
error?: string;
|
||||||
|
/** Execution statistics */
|
||||||
|
stats: ArenaAgentStats;
|
||||||
|
/** Git diff of changes made */
|
||||||
|
diff?: string;
|
||||||
|
/** Files modified by this agent */
|
||||||
|
modifiedFiles?: string[];
|
||||||
|
/** Start timestamp */
|
||||||
|
startedAt: number;
|
||||||
|
/** End timestamp */
|
||||||
|
endedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result from an Arena session.
|
||||||
|
*/
|
||||||
|
export interface ArenaSessionResult {
|
||||||
|
/** Session identifier */
|
||||||
|
sessionId: string;
|
||||||
|
/** Original task */
|
||||||
|
task: string;
|
||||||
|
/** Session status */
|
||||||
|
status: ArenaSessionStatus;
|
||||||
|
/** Results from all agents */
|
||||||
|
agents: ArenaAgentResult[];
|
||||||
|
/** Start timestamp */
|
||||||
|
startedAt: number;
|
||||||
|
/** End timestamp */
|
||||||
|
endedAt?: number;
|
||||||
|
/** Total duration in milliseconds */
|
||||||
|
totalDurationMs?: number;
|
||||||
|
/** Whether the repository was auto-initialized */
|
||||||
|
wasRepoInitialized: boolean;
|
||||||
|
/** Selected winner (agent ID) if user has chosen */
|
||||||
|
selectedWinner?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for starting an Arena session.
|
||||||
|
*/
|
||||||
|
export interface ArenaStartOptions {
|
||||||
|
/** Models to participate (at least 2, max ARENA_MAX_AGENTS) */
|
||||||
|
models: ArenaModelConfig[];
|
||||||
|
/** The task/prompt for all agents */
|
||||||
|
task: string;
|
||||||
|
/** Maximum rounds per agent */
|
||||||
|
maxRoundsPerAgent?: number;
|
||||||
|
/** Timeout in seconds */
|
||||||
|
timeoutSeconds?: number;
|
||||||
|
/** Approval mode to use for agents (inherited from main process) */
|
||||||
|
approvalMode?: string;
|
||||||
|
/** Initial terminal columns for agent PTYs (default: process.stdout.columns or 120) */
|
||||||
|
cols?: number;
|
||||||
|
/** Initial terminal rows for agent PTYs (default: process.stdout.rows or 40) */
|
||||||
|
rows?: number;
|
||||||
|
/** Display mode preference */
|
||||||
|
displayMode?: DisplayMode;
|
||||||
|
/**
|
||||||
|
* Optional chat history from the main session to seed each arena agent
|
||||||
|
* with conversational context. When provided, this history is prepended
|
||||||
|
* to each agent's chat so they understand the prior conversation.
|
||||||
|
*/
|
||||||
|
chatHistory?: Content[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback functions for Arena events.
|
||||||
|
*/
|
||||||
|
export interface ArenaCallbacks {
|
||||||
|
/** Called when an agent starts */
|
||||||
|
onAgentStart?: (agentId: string, model: ArenaModelConfig) => void;
|
||||||
|
/** Called when an agent completes */
|
||||||
|
onAgentComplete?: (result: ArenaAgentResult) => void;
|
||||||
|
/** Called when agent stats are updated */
|
||||||
|
onAgentStatsUpdate?: (
|
||||||
|
agentId: string,
|
||||||
|
stats: Partial<ArenaAgentStats>,
|
||||||
|
) => void;
|
||||||
|
/** Called when the arena session completes */
|
||||||
|
onArenaComplete?: (result: ArenaSessionResult) => void;
|
||||||
|
/** Called on arena error */
|
||||||
|
onArenaError?: (error: Error) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File format for per-agent status (child → main process).
|
||||||
|
* Written atomically by ArenaAgentClient to
|
||||||
|
* `<arenaSessionDir>/agents/<safeAgentId>.json`.
|
||||||
|
*/
|
||||||
|
export interface ArenaStatusFile {
|
||||||
|
agentId: string;
|
||||||
|
status: AgentStatus;
|
||||||
|
updatedAt: number;
|
||||||
|
rounds: number;
|
||||||
|
currentActivity?: string;
|
||||||
|
stats: ArenaAgentStats;
|
||||||
|
finalSummary: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File format for the arena session config file (`config.json`).
|
||||||
|
*
|
||||||
|
* Initially written by GitWorktreeService with static config fields
|
||||||
|
* (arenaSessionId, sourceRepoPath, worktreeNames, baseBranch, createdAt).
|
||||||
|
* Dynamically updated by ArenaManager with agent status data during polling.
|
||||||
|
*/
|
||||||
|
export interface ArenaConfigFile {
|
||||||
|
/** Arena session identifier */
|
||||||
|
arenaSessionId: string;
|
||||||
|
/** Source repository path */
|
||||||
|
sourceRepoPath: string;
|
||||||
|
/** Names of worktrees created */
|
||||||
|
worktreeNames: string[];
|
||||||
|
/** Base branch used for worktrees */
|
||||||
|
baseBranch?: string;
|
||||||
|
/** Timestamp when the session was created */
|
||||||
|
createdAt: number;
|
||||||
|
/** Timestamp of the last status update (set by ArenaManager polling) */
|
||||||
|
updatedAt?: number;
|
||||||
|
/** Per-agent status data, keyed by agentId (set by ArenaManager polling) */
|
||||||
|
agents?: Record<string, ArenaStatusFile>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Control signal format for control.json (main → child process).
|
||||||
|
* Written by ArenaManager, consumed (read + deleted) by ArenaAgentClient.
|
||||||
|
*/
|
||||||
|
export interface ArenaControlSignal {
|
||||||
|
type: 'shutdown' | 'cancel';
|
||||||
|
reason: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert an agentId (e.g. "arena-xxx/qwen-coder-plus") to a filename-safe
|
||||||
|
* string by replacing path-unsafe characters with "--".
|
||||||
|
*/
|
||||||
|
export function safeAgentId(agentId: string): string {
|
||||||
|
return agentId.replace(/[/\\:*?"<>|]/g, '--');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal state for tracking an Arena agent during execution.
|
||||||
|
*/
|
||||||
|
export interface ArenaAgentState {
|
||||||
|
/** Agent identifier */
|
||||||
|
agentId: string;
|
||||||
|
/** Model configuration */
|
||||||
|
model: ArenaModelConfig;
|
||||||
|
/** Current status */
|
||||||
|
status: AgentStatus;
|
||||||
|
/** Worktree information */
|
||||||
|
worktree: WorktreeInfo;
|
||||||
|
/** Abort controller for cancellation */
|
||||||
|
abortController: AbortController;
|
||||||
|
/** Current statistics */
|
||||||
|
stats: ArenaAgentStats;
|
||||||
|
/** Start timestamp */
|
||||||
|
startedAt: number;
|
||||||
|
/** Accumulated text output */
|
||||||
|
accumulatedText: string;
|
||||||
|
/** Promise for the agent execution */
|
||||||
|
executionPromise?: Promise<void>;
|
||||||
|
/** Error if failed */
|
||||||
|
error?: string;
|
||||||
|
/** Unique session ID for this agent (for telemetry correlation) */
|
||||||
|
agentSessionId: string;
|
||||||
|
/** Flush latest counters into `stats` (set by in-process event bridge) */
|
||||||
|
syncStats?: () => void;
|
||||||
|
}
|
||||||
569
packages/core/src/agents/backends/ITermBackend.test.ts
Normal file
569
packages/core/src/agents/backends/ITermBackend.test.ts
Normal file
|
|
@ -0,0 +1,569 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import type { AgentSpawnConfig } from './types.js';
|
||||||
|
|
||||||
|
// ─── Hoisted mocks for iterm-it2 ────────────────────────────────
|
||||||
|
const hoistedVerifyITerm = vi.hoisted(() => vi.fn());
|
||||||
|
const hoistedItermSplitPane = vi.hoisted(() => vi.fn());
|
||||||
|
const hoistedItermRunCommand = vi.hoisted(() => vi.fn());
|
||||||
|
const hoistedItermSendText = vi.hoisted(() => vi.fn());
|
||||||
|
const hoistedItermFocusSession = vi.hoisted(() => vi.fn());
|
||||||
|
const hoistedItermCloseSession = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock('./iterm-it2.js', () => ({
|
||||||
|
verifyITerm: hoistedVerifyITerm,
|
||||||
|
itermSplitPane: hoistedItermSplitPane,
|
||||||
|
itermRunCommand: hoistedItermRunCommand,
|
||||||
|
itermSendText: hoistedItermSendText,
|
||||||
|
itermFocusSession: hoistedItermFocusSession,
|
||||||
|
itermCloseSession: hoistedItermCloseSession,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ─── Hoisted mocks for node:fs/promises ─────────────────────────
|
||||||
|
const hoistedFsMkdir = vi.hoisted(() => vi.fn());
|
||||||
|
const hoistedFsReadFile = vi.hoisted(() => vi.fn());
|
||||||
|
const hoistedFsRm = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock('node:fs/promises', () => ({
|
||||||
|
mkdir: hoistedFsMkdir,
|
||||||
|
readFile: hoistedFsReadFile,
|
||||||
|
rm: hoistedFsRm,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock debug logger
|
||||||
|
vi.mock('../../utils/debugLogger.js', () => ({
|
||||||
|
createDebugLogger: () => ({
|
||||||
|
info: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ITermBackend } from './ITermBackend.js';
|
||||||
|
|
||||||
|
function makeConfig(
|
||||||
|
agentId: string,
|
||||||
|
overrides?: Partial<AgentSpawnConfig>,
|
||||||
|
): AgentSpawnConfig {
|
||||||
|
return {
|
||||||
|
agentId,
|
||||||
|
command: '/usr/bin/node',
|
||||||
|
args: ['agent.js'],
|
||||||
|
cwd: '/tmp/test',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupDefaultMocks(): void {
|
||||||
|
hoistedVerifyITerm.mockResolvedValue(undefined);
|
||||||
|
hoistedItermSplitPane.mockResolvedValue('sess-new-1');
|
||||||
|
hoistedItermRunCommand.mockResolvedValue(undefined);
|
||||||
|
hoistedItermSendText.mockResolvedValue(undefined);
|
||||||
|
hoistedItermFocusSession.mockResolvedValue(undefined);
|
||||||
|
hoistedItermCloseSession.mockResolvedValue(undefined);
|
||||||
|
hoistedFsMkdir.mockResolvedValue(undefined);
|
||||||
|
// Default: marker file doesn't exist yet (agent still running)
|
||||||
|
hoistedFsReadFile.mockRejectedValue(new Error('ENOENT'));
|
||||||
|
hoistedFsRm.mockResolvedValue(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ITermBackend', () => {
|
||||||
|
let backend: ITermBackend;
|
||||||
|
let savedItermSessionId: string | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
savedItermSessionId = process.env['ITERM_SESSION_ID'];
|
||||||
|
delete process.env['ITERM_SESSION_ID'];
|
||||||
|
setupDefaultMocks();
|
||||||
|
backend = new ITermBackend();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await backend.cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.useRealTimers();
|
||||||
|
if (savedItermSessionId !== undefined) {
|
||||||
|
process.env['ITERM_SESSION_ID'] = savedItermSessionId;
|
||||||
|
} else {
|
||||||
|
delete process.env['ITERM_SESSION_ID'];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Initialization ─────────────────────────────────────────
|
||||||
|
|
||||||
|
it('throws if spawnAgent is called before init', async () => {
|
||||||
|
await expect(backend.spawnAgent(makeConfig('a1'))).rejects.toThrow(
|
||||||
|
'not initialized',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('init verifies iTerm availability', async () => {
|
||||||
|
await backend.init();
|
||||||
|
expect(hoistedVerifyITerm).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('init creates exit marker directory', async () => {
|
||||||
|
await backend.init();
|
||||||
|
expect(hoistedFsMkdir).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('agent-iterm-exit-'),
|
||||||
|
{ recursive: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('init is idempotent', async () => {
|
||||||
|
await backend.init();
|
||||||
|
await backend.init();
|
||||||
|
expect(hoistedVerifyITerm).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Spawning ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('spawns first agent using ITERM_SESSION_ID when set', async () => {
|
||||||
|
process.env['ITERM_SESSION_ID'] = 'leader-sess';
|
||||||
|
backend = new ITermBackend();
|
||||||
|
await backend.init();
|
||||||
|
|
||||||
|
await backend.spawnAgent(makeConfig('agent-1'));
|
||||||
|
|
||||||
|
expect(hoistedItermSplitPane).toHaveBeenCalledWith('leader-sess');
|
||||||
|
expect(hoistedItermRunCommand).toHaveBeenCalledWith(
|
||||||
|
'sess-new-1',
|
||||||
|
expect.any(String),
|
||||||
|
);
|
||||||
|
expect(backend.getActiveAgentId()).toBe('agent-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('spawns first agent without ITERM_SESSION_ID', async () => {
|
||||||
|
await backend.init();
|
||||||
|
await backend.spawnAgent(makeConfig('agent-1'));
|
||||||
|
|
||||||
|
expect(hoistedItermSplitPane).toHaveBeenCalledWith(undefined);
|
||||||
|
expect(backend.getActiveAgentId()).toBe('agent-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('spawns subsequent agent from last session', async () => {
|
||||||
|
await backend.init();
|
||||||
|
|
||||||
|
hoistedItermSplitPane.mockResolvedValueOnce('sess-1');
|
||||||
|
await backend.spawnAgent(makeConfig('agent-1'));
|
||||||
|
|
||||||
|
hoistedItermSplitPane.mockResolvedValueOnce('sess-2');
|
||||||
|
await backend.spawnAgent(makeConfig('agent-2'));
|
||||||
|
|
||||||
|
// Second split should use the first agent's session as source
|
||||||
|
expect(hoistedItermSplitPane).toHaveBeenLastCalledWith('sess-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects duplicate agent IDs', async () => {
|
||||||
|
await backend.init();
|
||||||
|
await backend.spawnAgent(makeConfig('dup'));
|
||||||
|
|
||||||
|
await expect(backend.spawnAgent(makeConfig('dup'))).rejects.toThrow(
|
||||||
|
'already exists',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers failed agent and fires exit callback on spawn error', async () => {
|
||||||
|
await backend.init();
|
||||||
|
hoistedItermSplitPane.mockRejectedValueOnce(new Error('split failed'));
|
||||||
|
|
||||||
|
const exitCallback = vi.fn();
|
||||||
|
backend.setOnAgentExit(exitCallback);
|
||||||
|
|
||||||
|
await backend.spawnAgent(makeConfig('fail'));
|
||||||
|
|
||||||
|
expect(exitCallback).toHaveBeenCalledWith('fail', 1, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── buildShellCommand (env key validation) ────────────────
|
||||||
|
|
||||||
|
it('rejects invalid environment variable names', async () => {
|
||||||
|
await backend.init();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
backend.spawnAgent(makeConfig('bad-env', { env: { 'FOO BAR': 'baz' } })),
|
||||||
|
).rejects.toThrow('Invalid environment variable name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects env key starting with a digit', async () => {
|
||||||
|
await backend.init();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
backend.spawnAgent(makeConfig('bad-env', { env: { '1VAR': 'baz' } })),
|
||||||
|
).rejects.toThrow('Invalid environment variable name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts valid environment variable names', async () => {
|
||||||
|
await backend.init();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
backend.spawnAgent(
|
||||||
|
makeConfig('good-env', {
|
||||||
|
env: { MY_VAR_123: 'hello', _PRIVATE: 'world' },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── buildShellCommand (atomic marker write) ──────────────
|
||||||
|
|
||||||
|
it('builds command with atomic exit marker write', async () => {
|
||||||
|
await backend.init();
|
||||||
|
await backend.spawnAgent(makeConfig('a'));
|
||||||
|
|
||||||
|
const cmdArg = hoistedItermRunCommand.mock.calls[0]![1] as string;
|
||||||
|
// Should contain write-then-rename pattern
|
||||||
|
expect(cmdArg).toMatch(/echo \$\? > .+\.tmp.+ && mv .+\.tmp/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds command with cd and quoted args', async () => {
|
||||||
|
await backend.init();
|
||||||
|
await backend.spawnAgent(makeConfig('a'));
|
||||||
|
|
||||||
|
const cmdArg = hoistedItermRunCommand.mock.calls[0]![1] as string;
|
||||||
|
expect(cmdArg).toContain("cd '/tmp/test'");
|
||||||
|
expect(cmdArg).toContain("'/usr/bin/node'");
|
||||||
|
expect(cmdArg).toContain("'agent.js'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes env vars in command when provided', async () => {
|
||||||
|
await backend.init();
|
||||||
|
await backend.spawnAgent(makeConfig('a', { env: { NODE_ENV: 'test' } }));
|
||||||
|
|
||||||
|
const cmdArg = hoistedItermRunCommand.mock.calls[0]![1] as string;
|
||||||
|
expect(cmdArg).toContain("NODE_ENV='test'");
|
||||||
|
expect(cmdArg).toContain('env ');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Navigation ───────────────────────────────────────────
|
||||||
|
|
||||||
|
it('switchTo changes active agent and focuses session', async () => {
|
||||||
|
await backend.init();
|
||||||
|
hoistedItermSplitPane.mockResolvedValueOnce('sess-1');
|
||||||
|
await backend.spawnAgent(makeConfig('a'));
|
||||||
|
|
||||||
|
hoistedItermSplitPane.mockResolvedValueOnce('sess-2');
|
||||||
|
await backend.spawnAgent(makeConfig('b'));
|
||||||
|
|
||||||
|
backend.switchTo('b');
|
||||||
|
expect(backend.getActiveAgentId()).toBe('b');
|
||||||
|
expect(hoistedItermFocusSession).toHaveBeenCalledWith('sess-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switchTo throws for unknown agent', async () => {
|
||||||
|
await backend.init();
|
||||||
|
expect(() => backend.switchTo('ghost')).toThrow('not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switchToNext and switchToPrevious cycle correctly', async () => {
|
||||||
|
await backend.init();
|
||||||
|
|
||||||
|
hoistedItermSplitPane.mockResolvedValueOnce('sess-1');
|
||||||
|
await backend.spawnAgent(makeConfig('a'));
|
||||||
|
|
||||||
|
hoistedItermSplitPane.mockResolvedValueOnce('sess-2');
|
||||||
|
await backend.spawnAgent(makeConfig('b'));
|
||||||
|
|
||||||
|
expect(backend.getActiveAgentId()).toBe('a');
|
||||||
|
backend.switchToNext();
|
||||||
|
expect(backend.getActiveAgentId()).toBe('b');
|
||||||
|
backend.switchToNext();
|
||||||
|
expect(backend.getActiveAgentId()).toBe('a');
|
||||||
|
backend.switchToPrevious();
|
||||||
|
expect(backend.getActiveAgentId()).toBe('b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switchToNext does nothing with a single agent', async () => {
|
||||||
|
await backend.init();
|
||||||
|
await backend.spawnAgent(makeConfig('solo'));
|
||||||
|
backend.switchToNext();
|
||||||
|
expect(backend.getActiveAgentId()).toBe('solo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switchToPrevious does nothing with a single agent', async () => {
|
||||||
|
await backend.init();
|
||||||
|
await backend.spawnAgent(makeConfig('solo'));
|
||||||
|
backend.switchToPrevious();
|
||||||
|
expect(backend.getActiveAgentId()).toBe('solo');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Stop & Cleanup ──────────────────────────────────────
|
||||||
|
|
||||||
|
it('stopAgent closes session and fires exit callback', async () => {
|
||||||
|
await backend.init();
|
||||||
|
hoistedItermSplitPane.mockResolvedValueOnce('sess-1');
|
||||||
|
await backend.spawnAgent(makeConfig('a'));
|
||||||
|
|
||||||
|
const exitCallback = vi.fn();
|
||||||
|
backend.setOnAgentExit(exitCallback);
|
||||||
|
|
||||||
|
backend.stopAgent('a');
|
||||||
|
|
||||||
|
expect(hoistedItermCloseSession).toHaveBeenCalledWith('sess-1');
|
||||||
|
expect(exitCallback).toHaveBeenCalledWith('a', 1, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stopAgent is a no-op for already-stopped agent', async () => {
|
||||||
|
await backend.init();
|
||||||
|
await backend.spawnAgent(makeConfig('a'));
|
||||||
|
backend.stopAgent('a');
|
||||||
|
hoistedItermCloseSession.mockClear();
|
||||||
|
|
||||||
|
backend.stopAgent('a');
|
||||||
|
expect(hoistedItermCloseSession).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stopAgent is a no-op for unknown agent', async () => {
|
||||||
|
await backend.init();
|
||||||
|
backend.stopAgent('ghost');
|
||||||
|
expect(hoistedItermCloseSession).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stopAll closes all sessions and resets activeAgentId', async () => {
|
||||||
|
await backend.init();
|
||||||
|
hoistedItermSplitPane.mockResolvedValueOnce('sess-1');
|
||||||
|
await backend.spawnAgent(makeConfig('a'));
|
||||||
|
|
||||||
|
hoistedItermSplitPane.mockResolvedValueOnce('sess-2');
|
||||||
|
await backend.spawnAgent(makeConfig('b'));
|
||||||
|
|
||||||
|
const exitCallback = vi.fn();
|
||||||
|
backend.setOnAgentExit(exitCallback);
|
||||||
|
|
||||||
|
backend.stopAll();
|
||||||
|
|
||||||
|
expect(hoistedItermCloseSession).toHaveBeenCalledTimes(2);
|
||||||
|
expect(exitCallback).toHaveBeenCalledTimes(2);
|
||||||
|
expect(backend.getActiveAgentId()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cleanup closes sessions and removes exit marker directory', async () => {
|
||||||
|
await backend.init();
|
||||||
|
hoistedItermSplitPane.mockResolvedValueOnce('sess-1');
|
||||||
|
await backend.spawnAgent(makeConfig('a'));
|
||||||
|
|
||||||
|
await backend.cleanup();
|
||||||
|
|
||||||
|
expect(hoistedItermCloseSession).toHaveBeenCalledWith('sess-1');
|
||||||
|
expect(hoistedFsRm).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('agent-iterm-exit-'),
|
||||||
|
{ recursive: true, force: true },
|
||||||
|
);
|
||||||
|
expect(backend.getActiveAgentId()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cleanup tolerates session close errors', async () => {
|
||||||
|
await backend.init();
|
||||||
|
hoistedItermSplitPane.mockResolvedValueOnce('sess-1');
|
||||||
|
await backend.spawnAgent(makeConfig('a'));
|
||||||
|
|
||||||
|
hoistedItermCloseSession.mockRejectedValueOnce(new Error('session gone'));
|
||||||
|
|
||||||
|
// Should not throw
|
||||||
|
await expect(backend.cleanup()).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cleanup tolerates exit marker removal errors', async () => {
|
||||||
|
await backend.init();
|
||||||
|
hoistedFsRm.mockRejectedValueOnce(new Error('ENOENT'));
|
||||||
|
|
||||||
|
// Should not throw
|
||||||
|
await expect(backend.cleanup()).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Exit Detection ─────────────────────────────────────────
|
||||||
|
|
||||||
|
it('marks agent as exited when marker file appears', async () => {
|
||||||
|
await backend.init();
|
||||||
|
await backend.spawnAgent(makeConfig('a'));
|
||||||
|
|
||||||
|
const exitCallback = vi.fn();
|
||||||
|
backend.setOnAgentExit(exitCallback);
|
||||||
|
|
||||||
|
// Simulate marker file appearing with exit code 0
|
||||||
|
hoistedFsReadFile.mockResolvedValue('0\n');
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(600);
|
||||||
|
|
||||||
|
expect(exitCallback).toHaveBeenCalledWith('a', 0, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves non-zero exit codes from marker', async () => {
|
||||||
|
await backend.init();
|
||||||
|
await backend.spawnAgent(makeConfig('a'));
|
||||||
|
|
||||||
|
const exitCallback = vi.fn();
|
||||||
|
backend.setOnAgentExit(exitCallback);
|
||||||
|
|
||||||
|
hoistedFsReadFile.mockResolvedValue('42\n');
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(600);
|
||||||
|
|
||||||
|
expect(exitCallback).toHaveBeenCalledWith('a', 42, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to exit code 1 when marker contains NaN', async () => {
|
||||||
|
await backend.init();
|
||||||
|
await backend.spawnAgent(makeConfig('a'));
|
||||||
|
|
||||||
|
const exitCallback = vi.fn();
|
||||||
|
backend.setOnAgentExit(exitCallback);
|
||||||
|
|
||||||
|
hoistedFsReadFile.mockResolvedValue('garbage\n');
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(600);
|
||||||
|
|
||||||
|
expect(exitCallback).toHaveBeenCalledWith('a', 1, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fire callback twice for the same agent', async () => {
|
||||||
|
await backend.init();
|
||||||
|
await backend.spawnAgent(makeConfig('a'));
|
||||||
|
|
||||||
|
const exitCallback = vi.fn();
|
||||||
|
backend.setOnAgentExit(exitCallback);
|
||||||
|
|
||||||
|
hoistedFsReadFile.mockResolvedValue('0\n');
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(600);
|
||||||
|
await vi.advanceTimersByTimeAsync(600);
|
||||||
|
|
||||||
|
expect(exitCallback).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops polling once all agents have exited', async () => {
|
||||||
|
await backend.init();
|
||||||
|
await backend.spawnAgent(makeConfig('a'));
|
||||||
|
|
||||||
|
hoistedFsReadFile.mockResolvedValue('0\n');
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(600);
|
||||||
|
|
||||||
|
// Reset to track future reads
|
||||||
|
hoistedFsReadFile.mockClear();
|
||||||
|
|
||||||
|
// Advance more — should not poll anymore
|
||||||
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
|
expect(hoistedFsReadFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── waitForAll ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('waitForAll resolves immediately when no agents exist', async () => {
|
||||||
|
await backend.init();
|
||||||
|
const result = await backend.waitForAll();
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('waitForAll resolves when all agents exit', async () => {
|
||||||
|
await backend.init();
|
||||||
|
await backend.spawnAgent(makeConfig('a'));
|
||||||
|
|
||||||
|
hoistedFsReadFile.mockResolvedValue('0\n');
|
||||||
|
|
||||||
|
const waitPromise = backend.waitForAll();
|
||||||
|
await vi.advanceTimersByTimeAsync(600);
|
||||||
|
|
||||||
|
const result = await waitPromise;
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('waitForAll returns false on timeout', async () => {
|
||||||
|
await backend.init();
|
||||||
|
await backend.spawnAgent(makeConfig('a'));
|
||||||
|
|
||||||
|
// Marker never appears (readFile keeps throwing)
|
||||||
|
const waitPromise = backend.waitForAll(1000);
|
||||||
|
await vi.advanceTimersByTimeAsync(1100);
|
||||||
|
|
||||||
|
const result = await waitPromise;
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Input ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('writeToAgent sends text via itermSendText', async () => {
|
||||||
|
await backend.init();
|
||||||
|
hoistedItermSplitPane.mockResolvedValueOnce('sess-1');
|
||||||
|
await backend.spawnAgent(makeConfig('a'));
|
||||||
|
|
||||||
|
const result = backend.writeToAgent('a', 'hello');
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(hoistedItermSendText).toHaveBeenCalledWith('sess-1', 'hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writeToAgent returns false for unknown agent', async () => {
|
||||||
|
await backend.init();
|
||||||
|
expect(backend.writeToAgent('ghost', 'hello')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writeToAgent returns false for stopped agent', async () => {
|
||||||
|
await backend.init();
|
||||||
|
await backend.spawnAgent(makeConfig('a'));
|
||||||
|
backend.stopAgent('a');
|
||||||
|
|
||||||
|
expect(backend.writeToAgent('a', 'hello')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwardInput delegates to active agent', async () => {
|
||||||
|
await backend.init();
|
||||||
|
hoistedItermSplitPane.mockResolvedValueOnce('sess-1');
|
||||||
|
await backend.spawnAgent(makeConfig('a'));
|
||||||
|
|
||||||
|
const result = backend.forwardInput('hello');
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(hoistedItermSendText).toHaveBeenCalledWith('sess-1', 'hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwardInput returns false with no active agent', async () => {
|
||||||
|
await backend.init();
|
||||||
|
expect(backend.forwardInput('hello')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Snapshots ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('getActiveSnapshot returns null', async () => {
|
||||||
|
await backend.init();
|
||||||
|
await backend.spawnAgent(makeConfig('a'));
|
||||||
|
expect(backend.getActiveSnapshot()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getAgentSnapshot returns null', async () => {
|
||||||
|
await backend.init();
|
||||||
|
await backend.spawnAgent(makeConfig('a'));
|
||||||
|
expect(backend.getAgentSnapshot('a')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getAgentScrollbackLength returns 0', async () => {
|
||||||
|
await backend.init();
|
||||||
|
await backend.spawnAgent(makeConfig('a'));
|
||||||
|
expect(backend.getAgentScrollbackLength('a')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── getAttachHint ──────────────────────────────────────────
|
||||||
|
|
||||||
|
it('getAttachHint returns null', async () => {
|
||||||
|
await backend.init();
|
||||||
|
expect(backend.getAttachHint()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── resizeAll ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('resizeAll is a no-op', async () => {
|
||||||
|
await backend.init();
|
||||||
|
// Should not throw
|
||||||
|
backend.resizeAll(80, 24);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── type ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('has type "iterm2"', () => {
|
||||||
|
expect(backend.type).toBe('iterm2');
|
||||||
|
});
|
||||||
|
});
|
||||||
431
packages/core/src/agents/backends/ITermBackend.ts
Normal file
431
packages/core/src/agents/backends/ITermBackend.ts
Normal file
|
|
@ -0,0 +1,431 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @fileoverview ITermBackend implements Backend using the it2 CLI
|
||||||
|
* (iTerm2 Python API).
|
||||||
|
*
|
||||||
|
* Each agent runs in its own iTerm2 split pane. The backend manages pane
|
||||||
|
* creation, exit detection (via exit marker file polling), and cleanup.
|
||||||
|
*
|
||||||
|
* Exit detection uses a file-based marker approach: each agent's command is
|
||||||
|
* wrapped to write its exit code to a temp file on completion, which the backend
|
||||||
|
* polls to detect exits.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from 'node:fs/promises';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import * as os from 'node:os';
|
||||||
|
import { createDebugLogger } from '../../utils/debugLogger.js';
|
||||||
|
import type { AnsiOutput } from '../../utils/terminalSerializer.js';
|
||||||
|
import { DISPLAY_MODE } from './types.js';
|
||||||
|
import type { AgentSpawnConfig, AgentExitCallback, Backend } from './types.js';
|
||||||
|
import {
|
||||||
|
verifyITerm,
|
||||||
|
itermSplitPane,
|
||||||
|
itermRunCommand,
|
||||||
|
itermSendText,
|
||||||
|
itermFocusSession,
|
||||||
|
itermCloseSession,
|
||||||
|
} from './iterm-it2.js';
|
||||||
|
|
||||||
|
const debugLogger = createDebugLogger('ITERM_BACKEND');
|
||||||
|
|
||||||
|
/** Polling interval for exit detection (ms) */
|
||||||
|
const EXIT_POLL_INTERVAL_MS = 500;
|
||||||
|
|
||||||
|
interface ITermAgentSession {
|
||||||
|
agentId: string;
|
||||||
|
sessionId: string;
|
||||||
|
exitMarkerPath: string;
|
||||||
|
status: 'running' | 'exited';
|
||||||
|
exitCode: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ITermBackend implements Backend {
|
||||||
|
readonly type = DISPLAY_MODE.ITERM2;
|
||||||
|
|
||||||
|
/** Directory for exit marker files */
|
||||||
|
private exitMarkerDir: string;
|
||||||
|
/** Session ID of the last agent pane (split source) */
|
||||||
|
private lastSplitSessionId: string | null = null;
|
||||||
|
|
||||||
|
private sessions: Map<string, ITermAgentSession> = new Map();
|
||||||
|
private agentOrder: string[] = [];
|
||||||
|
private activeAgentId: string | null = null;
|
||||||
|
private onExitCallback: AgentExitCallback | null = null;
|
||||||
|
private exitPollTimer: NodeJS.Timeout | null = null;
|
||||||
|
private initialized = false;
|
||||||
|
/** Number of agents currently being spawned asynchronously */
|
||||||
|
private pendingSpawns = 0;
|
||||||
|
/** Queue to serialize spawn operations (prevents split race conditions) */
|
||||||
|
private spawnQueue: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.exitMarkerDir = path.join(
|
||||||
|
os.tmpdir(),
|
||||||
|
`agent-iterm-exit-${Date.now().toString(36)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async init(): Promise<void> {
|
||||||
|
if (this.initialized) return;
|
||||||
|
|
||||||
|
await verifyITerm();
|
||||||
|
|
||||||
|
// Create the exit marker directory
|
||||||
|
await fs.mkdir(this.exitMarkerDir, { recursive: true });
|
||||||
|
|
||||||
|
this.initialized = true;
|
||||||
|
debugLogger.info('ITermBackend initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Agent Lifecycle ────────────────────────────────────────
|
||||||
|
|
||||||
|
async spawnAgent(config: AgentSpawnConfig): Promise<void> {
|
||||||
|
if (!this.initialized) {
|
||||||
|
throw new Error('ITermBackend not initialized. Call init() first.');
|
||||||
|
}
|
||||||
|
if (this.sessions.has(config.agentId)) {
|
||||||
|
throw new Error(`Agent "${config.agentId}" already exists.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const exitMarkerPath = path.join(this.exitMarkerDir, config.agentId);
|
||||||
|
await fs.mkdir(path.dirname(exitMarkerPath), { recursive: true });
|
||||||
|
const cmd = this.buildShellCommand(config, exitMarkerPath);
|
||||||
|
|
||||||
|
this.pendingSpawns++;
|
||||||
|
const spawnPromise = this.spawnQueue.then(() =>
|
||||||
|
this.spawnAgentAsync(config.agentId, cmd, exitMarkerPath),
|
||||||
|
);
|
||||||
|
this.spawnQueue = spawnPromise;
|
||||||
|
await spawnPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async spawnAgentAsync(
|
||||||
|
agentId: string,
|
||||||
|
cmd: string,
|
||||||
|
exitMarkerPath: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
let sessionId: string;
|
||||||
|
|
||||||
|
if (this.sessions.size === 0) {
|
||||||
|
// First agent: split from ITERM_SESSION_ID if present, else active session
|
||||||
|
const leaderSessionId = process.env['ITERM_SESSION_ID'] || undefined;
|
||||||
|
sessionId = await itermSplitPane(leaderSessionId);
|
||||||
|
await itermRunCommand(sessionId, cmd);
|
||||||
|
} else {
|
||||||
|
// Subsequent agents: split from last agent session, else active session
|
||||||
|
sessionId = await itermSplitPane(this.lastSplitSessionId || undefined);
|
||||||
|
await itermRunCommand(sessionId, cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentSession: ITermAgentSession = {
|
||||||
|
agentId,
|
||||||
|
sessionId,
|
||||||
|
exitMarkerPath,
|
||||||
|
status: 'running',
|
||||||
|
exitCode: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.sessions.set(agentId, agentSession);
|
||||||
|
this.agentOrder.push(agentId);
|
||||||
|
this.lastSplitSessionId = sessionId;
|
||||||
|
|
||||||
|
if (this.activeAgentId === null) {
|
||||||
|
this.activeAgentId = agentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.startExitPolling();
|
||||||
|
|
||||||
|
debugLogger.info(`Spawned agent "${agentId}" in session ${sessionId}`);
|
||||||
|
} catch (error) {
|
||||||
|
debugLogger.error(`Failed to spawn agent "${agentId}":`, error);
|
||||||
|
this.sessions.set(agentId, {
|
||||||
|
agentId,
|
||||||
|
sessionId: '',
|
||||||
|
exitMarkerPath,
|
||||||
|
status: 'exited',
|
||||||
|
exitCode: 1,
|
||||||
|
});
|
||||||
|
this.agentOrder.push(agentId);
|
||||||
|
this.onExitCallback?.(agentId, 1, null);
|
||||||
|
} finally {
|
||||||
|
this.pendingSpawns--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stopAgent(agentId: string): void {
|
||||||
|
const session = this.sessions.get(agentId);
|
||||||
|
if (!session || session.status !== 'running') return;
|
||||||
|
itermCloseSession(session.sessionId).catch((e) =>
|
||||||
|
debugLogger.error(`Failed to close session for agent "${agentId}": ${e}`),
|
||||||
|
);
|
||||||
|
session.status = 'exited';
|
||||||
|
session.exitCode = 1;
|
||||||
|
this.onExitCallback?.(agentId, 1, null);
|
||||||
|
debugLogger.info(`Closed iTerm2 session for agent "${agentId}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
stopAll(): void {
|
||||||
|
for (const session of this.sessions.values()) {
|
||||||
|
if (session.status === 'running') {
|
||||||
|
itermCloseSession(session.sessionId).catch((e) =>
|
||||||
|
debugLogger.error(
|
||||||
|
`Failed to close session for agent "${session.agentId}": ${e}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
session.status = 'exited';
|
||||||
|
session.exitCode = 1;
|
||||||
|
this.onExitCallback?.(session.agentId, 1, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.activeAgentId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async cleanup(): Promise<void> {
|
||||||
|
this.stopExitPolling();
|
||||||
|
|
||||||
|
// Close all iTerm2 sessions we created
|
||||||
|
for (const session of this.sessions.values()) {
|
||||||
|
if (!session.sessionId) continue;
|
||||||
|
try {
|
||||||
|
await itermCloseSession(session.sessionId);
|
||||||
|
} catch (error) {
|
||||||
|
debugLogger.error('Session cleanup error (ignored):', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up exit marker files
|
||||||
|
try {
|
||||||
|
await fs.rm(this.exitMarkerDir, {
|
||||||
|
recursive: true,
|
||||||
|
force: true,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
debugLogger.error('Exit marker cleanup error (ignored):', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sessions.clear();
|
||||||
|
this.agentOrder = [];
|
||||||
|
this.activeAgentId = null;
|
||||||
|
this.lastSplitSessionId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOnAgentExit(callback: AgentExitCallback): void {
|
||||||
|
this.onExitCallback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForAll(timeoutMs?: number): Promise<boolean> {
|
||||||
|
if (this.allExited()) return true;
|
||||||
|
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
let timeoutHandle: NodeJS.Timeout | undefined;
|
||||||
|
|
||||||
|
const checkInterval = setInterval(() => {
|
||||||
|
if (this.allExited()) {
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||||
|
resolve(true);
|
||||||
|
}
|
||||||
|
}, EXIT_POLL_INTERVAL_MS);
|
||||||
|
|
||||||
|
if (timeoutMs !== undefined) {
|
||||||
|
timeoutHandle = setTimeout(() => {
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
resolve(false);
|
||||||
|
}, timeoutMs);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Active Agent & Navigation ──────────────────────────────
|
||||||
|
|
||||||
|
switchTo(agentId: string): void {
|
||||||
|
if (!this.sessions.has(agentId)) {
|
||||||
|
throw new Error(`Agent "${agentId}" not found.`);
|
||||||
|
}
|
||||||
|
const session = this.sessions.get(agentId)!;
|
||||||
|
this.activeAgentId = agentId;
|
||||||
|
itermFocusSession(session.sessionId).catch((e) =>
|
||||||
|
debugLogger.error(`Failed to focus session for agent "${agentId}": ${e}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
switchToNext(): void {
|
||||||
|
if (this.agentOrder.length <= 1) return;
|
||||||
|
const currentIndex = this.agentOrder.indexOf(this.activeAgentId ?? '');
|
||||||
|
const nextIndex = (currentIndex + 1) % this.agentOrder.length;
|
||||||
|
this.switchTo(this.agentOrder[nextIndex]!);
|
||||||
|
}
|
||||||
|
|
||||||
|
switchToPrevious(): void {
|
||||||
|
if (this.agentOrder.length <= 1) return;
|
||||||
|
const currentIndex = this.agentOrder.indexOf(this.activeAgentId ?? '');
|
||||||
|
const prevIndex =
|
||||||
|
(currentIndex - 1 + this.agentOrder.length) % this.agentOrder.length;
|
||||||
|
this.switchTo(this.agentOrder[prevIndex]!);
|
||||||
|
}
|
||||||
|
|
||||||
|
getActiveAgentId(): string | null {
|
||||||
|
return this.activeAgentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Screen Capture ─────────────────────────────────────────
|
||||||
|
|
||||||
|
getActiveSnapshot(): AnsiOutput | null {
|
||||||
|
// iTerm2 manages rendering — snapshots not supported
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAgentSnapshot(
|
||||||
|
_agentId: string,
|
||||||
|
_scrollOffset: number = 0,
|
||||||
|
): AnsiOutput | null {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAgentScrollbackLength(_agentId: string): number {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Input ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
forwardInput(data: string): boolean {
|
||||||
|
if (!this.activeAgentId) return false;
|
||||||
|
return this.writeToAgent(this.activeAgentId, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
writeToAgent(agentId: string, data: string): boolean {
|
||||||
|
const session = this.sessions.get(agentId);
|
||||||
|
if (!session || session.status !== 'running') return false;
|
||||||
|
itermSendText(session.sessionId, data).catch((e) =>
|
||||||
|
debugLogger.error(`Failed to send text to agent "${agentId}": ${e}`),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Resize ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
resizeAll(_cols: number, _rows: number): void {
|
||||||
|
// iTerm2 manages pane sizes automatically
|
||||||
|
}
|
||||||
|
|
||||||
|
getAttachHint(): string | null {
|
||||||
|
// iTerm2 panes are visible directly, no attach needed
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Private ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the shell command with exit marker wrapping.
|
||||||
|
*
|
||||||
|
* The command is wrapped so that its exit code is written to a temp file
|
||||||
|
* when it completes. This allows the backend to detect agent exit via
|
||||||
|
* file polling, since iTerm2 `write text` runs commands inside a shell
|
||||||
|
* (the shell stays alive after the command exits).
|
||||||
|
*/
|
||||||
|
private buildShellCommand(
|
||||||
|
config: AgentSpawnConfig,
|
||||||
|
exitMarkerPath: string,
|
||||||
|
): string {
|
||||||
|
const envParts: string[] = [];
|
||||||
|
if (config.env) {
|
||||||
|
for (const [key, value] of Object.entries(config.env)) {
|
||||||
|
if (!VALID_ENV_KEY.test(key)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid environment variable name: "${key}". Names must match /^[A-Za-z_][A-Za-z0-9_]*$/.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
envParts.push(`${key}=${shellQuote(value)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cmdParts = [
|
||||||
|
shellQuote(config.command),
|
||||||
|
...config.args.map(shellQuote),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Build: cd <cwd> && [env K=V] command args; echo $? > <marker>
|
||||||
|
const parts = [`cd ${shellQuote(config.cwd)}`];
|
||||||
|
if (envParts.length > 0) {
|
||||||
|
parts.push(`env ${envParts.join(' ')} ${cmdParts.join(' ')}`);
|
||||||
|
} else {
|
||||||
|
parts.push(cmdParts.join(' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainCmd = parts.join(' && ');
|
||||||
|
// Write exit code to a temp file first, then atomically rename it
|
||||||
|
// to the marker path. This prevents the polling loop from reading
|
||||||
|
// a partially-written file.
|
||||||
|
const tmpMarker = shellQuote(exitMarkerPath + '.tmp');
|
||||||
|
const finalMarker = shellQuote(exitMarkerPath);
|
||||||
|
return `${mainCmd}; echo $? > ${tmpMarker} && mv ${tmpMarker} ${finalMarker}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private allExited(): boolean {
|
||||||
|
if (this.pendingSpawns > 0) return false;
|
||||||
|
if (this.sessions.size === 0) return true;
|
||||||
|
for (const session of this.sessions.values()) {
|
||||||
|
if (session.status === 'running') return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private startExitPolling(): void {
|
||||||
|
if (this.exitPollTimer) return;
|
||||||
|
|
||||||
|
this.exitPollTimer = setInterval(() => {
|
||||||
|
void this.pollExitStatus();
|
||||||
|
}, EXIT_POLL_INTERVAL_MS);
|
||||||
|
this.exitPollTimer.unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopExitPolling(): void {
|
||||||
|
if (this.exitPollTimer) {
|
||||||
|
clearInterval(this.exitPollTimer);
|
||||||
|
this.exitPollTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pollExitStatus(): Promise<void> {
|
||||||
|
for (const agent of this.sessions.values()) {
|
||||||
|
if (agent.status !== 'running') continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(agent.exitMarkerPath, 'utf8');
|
||||||
|
const exitCode = parseInt(content.trim(), 10);
|
||||||
|
agent.status = 'exited';
|
||||||
|
agent.exitCode = isNaN(exitCode) ? 1 : exitCode;
|
||||||
|
|
||||||
|
debugLogger.info(
|
||||||
|
`Agent "${agent.agentId}" exited with code ${agent.exitCode}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.onExitCallback?.(agent.agentId, agent.exitCode, null);
|
||||||
|
} catch {
|
||||||
|
// File doesn't exist yet — command still running
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.allExited()) {
|
||||||
|
this.stopExitPolling();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Regex for valid POSIX environment variable names */
|
||||||
|
const VALID_ENV_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple shell quoting for building command strings.
|
||||||
|
* Wraps value in single quotes, escaping any internal single quotes.
|
||||||
|
*/
|
||||||
|
function shellQuote(value: string): string {
|
||||||
|
return `'${value.replace(/'/g, "'\\''")}'`;
|
||||||
|
}
|
||||||
564
packages/core/src/agents/backends/InProcessBackend.test.ts
Normal file
564
packages/core/src/agents/backends/InProcessBackend.test.ts
Normal file
|
|
@ -0,0 +1,564 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { InProcessBackend } from './InProcessBackend.js';
|
||||||
|
import { DISPLAY_MODE } from './types.js';
|
||||||
|
import type { AgentSpawnConfig } from './types.js';
|
||||||
|
import { AgentCore } from '../runtime/agent-core.js';
|
||||||
|
import { createContentGenerator } from '../../core/contentGenerator.js';
|
||||||
|
|
||||||
|
// Mock createContentGenerator to avoid real API client setup
|
||||||
|
const mockContentGenerator = {
|
||||||
|
generateContentStream: vi.fn(),
|
||||||
|
};
|
||||||
|
vi.mock('../../core/contentGenerator.js', () => ({
|
||||||
|
createContentGenerator: vi.fn().mockResolvedValue({
|
||||||
|
generateContentStream: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock AgentCore and AgentInteractive to avoid real model calls
|
||||||
|
vi.mock('../runtime/agent-core.js', () => ({
|
||||||
|
AgentCore: vi.fn().mockImplementation(() => ({
|
||||||
|
subagentId: 'mock-id',
|
||||||
|
name: 'mock-agent',
|
||||||
|
eventEmitter: {
|
||||||
|
on: vi.fn(),
|
||||||
|
off: vi.fn(),
|
||||||
|
emit: vi.fn(),
|
||||||
|
},
|
||||||
|
stats: {
|
||||||
|
start: vi.fn(),
|
||||||
|
getSummary: vi.fn().mockReturnValue({}),
|
||||||
|
},
|
||||||
|
createChat: vi.fn().mockResolvedValue({}),
|
||||||
|
prepareTools: vi.fn().mockReturnValue([]),
|
||||||
|
runReasoningLoop: vi.fn().mockResolvedValue({
|
||||||
|
text: 'Done',
|
||||||
|
terminateMode: null,
|
||||||
|
turnsUsed: 1,
|
||||||
|
}),
|
||||||
|
getEventEmitter: vi.fn().mockReturnValue({
|
||||||
|
on: vi.fn(),
|
||||||
|
off: vi.fn(),
|
||||||
|
emit: vi.fn(),
|
||||||
|
}),
|
||||||
|
getExecutionSummary: vi.fn().mockReturnValue({}),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function createMockToolRegistry() {
|
||||||
|
return {
|
||||||
|
getFunctionDeclarations: vi.fn().mockReturnValue([]),
|
||||||
|
getAllTools: vi.fn().mockReturnValue([]),
|
||||||
|
getAllToolNames: vi.fn().mockReturnValue([]),
|
||||||
|
registerTool: vi.fn(),
|
||||||
|
copyDiscoveredToolsFrom: vi.fn(),
|
||||||
|
stop: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockConfig() {
|
||||||
|
const registry = createMockToolRegistry();
|
||||||
|
return {
|
||||||
|
getModel: vi.fn().mockReturnValue('test-model'),
|
||||||
|
getToolRegistry: vi.fn().mockReturnValue(registry),
|
||||||
|
getSessionId: vi.fn().mockReturnValue('test-session'),
|
||||||
|
getWorkingDir: vi.fn().mockReturnValue('/tmp'),
|
||||||
|
getTargetDir: vi.fn().mockReturnValue('/tmp'),
|
||||||
|
createToolRegistry: vi.fn().mockResolvedValue(createMockToolRegistry()),
|
||||||
|
getContentGenerator: vi.fn().mockReturnValue(mockContentGenerator),
|
||||||
|
getContentGeneratorConfig: vi.fn().mockReturnValue({
|
||||||
|
model: 'test-model',
|
||||||
|
authType: 'openai',
|
||||||
|
apiKey: 'parent-key',
|
||||||
|
baseUrl: 'https://parent.example.com',
|
||||||
|
}),
|
||||||
|
getAuthType: vi.fn().mockReturnValue('openai'),
|
||||||
|
} as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSpawnConfig(agentId: string): AgentSpawnConfig {
|
||||||
|
return {
|
||||||
|
agentId,
|
||||||
|
command: 'node',
|
||||||
|
args: [],
|
||||||
|
cwd: '/tmp',
|
||||||
|
inProcess: {
|
||||||
|
agentName: `Agent ${agentId}`,
|
||||||
|
initialTask: 'Do something',
|
||||||
|
runtimeConfig: {
|
||||||
|
promptConfig: { systemPrompt: 'You are a helpful assistant.' },
|
||||||
|
modelConfig: { model: 'test-model' },
|
||||||
|
runConfig: { max_turns: 10 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('InProcessBackend', () => {
|
||||||
|
let backend: InProcessBackend;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
backend = new InProcessBackend(createMockConfig());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have IN_PROCESS type', () => {
|
||||||
|
expect(backend.type).toBe(DISPLAY_MODE.IN_PROCESS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should init without error', async () => {
|
||||||
|
await expect(backend.init()).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw when spawning without inProcess config', async () => {
|
||||||
|
const config: AgentSpawnConfig = {
|
||||||
|
agentId: 'test',
|
||||||
|
command: 'node',
|
||||||
|
args: [],
|
||||||
|
cwd: '/tmp',
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(backend.spawnAgent(config)).rejects.toThrow(
|
||||||
|
'InProcessBackend requires inProcess config',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should spawn an agent with inProcess config', async () => {
|
||||||
|
await backend.init();
|
||||||
|
await backend.spawnAgent(createSpawnConfig('agent-1'));
|
||||||
|
|
||||||
|
expect(backend.getActiveAgentId()).toBe('agent-1');
|
||||||
|
expect(backend.getAgent('agent-1')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set first spawned agent as active', async () => {
|
||||||
|
await backend.init();
|
||||||
|
await backend.spawnAgent(createSpawnConfig('agent-1'));
|
||||||
|
await backend.spawnAgent(createSpawnConfig('agent-2'));
|
||||||
|
|
||||||
|
expect(backend.getActiveAgentId()).toBe('agent-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should navigate between agents', async () => {
|
||||||
|
await backend.init();
|
||||||
|
await backend.spawnAgent(createSpawnConfig('agent-1'));
|
||||||
|
await backend.spawnAgent(createSpawnConfig('agent-2'));
|
||||||
|
await backend.spawnAgent(createSpawnConfig('agent-3'));
|
||||||
|
|
||||||
|
expect(backend.getActiveAgentId()).toBe('agent-1');
|
||||||
|
|
||||||
|
backend.switchToNext();
|
||||||
|
expect(backend.getActiveAgentId()).toBe('agent-2');
|
||||||
|
|
||||||
|
backend.switchToNext();
|
||||||
|
expect(backend.getActiveAgentId()).toBe('agent-3');
|
||||||
|
|
||||||
|
// Wraps around
|
||||||
|
backend.switchToNext();
|
||||||
|
expect(backend.getActiveAgentId()).toBe('agent-1');
|
||||||
|
|
||||||
|
backend.switchToPrevious();
|
||||||
|
expect(backend.getActiveAgentId()).toBe('agent-3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should switch to a specific agent', async () => {
|
||||||
|
await backend.init();
|
||||||
|
await backend.spawnAgent(createSpawnConfig('agent-1'));
|
||||||
|
await backend.spawnAgent(createSpawnConfig('agent-2'));
|
||||||
|
|
||||||
|
backend.switchTo('agent-2');
|
||||||
|
expect(backend.getActiveAgentId()).toBe('agent-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should forward input to active agent', async () => {
|
||||||
|
await backend.init();
|
||||||
|
await backend.spawnAgent(createSpawnConfig('agent-1'));
|
||||||
|
|
||||||
|
const result = backend.forwardInput('hello');
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for forwardInput with no active agent', () => {
|
||||||
|
expect(backend.forwardInput('hello')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should write to specific agent', async () => {
|
||||||
|
await backend.init();
|
||||||
|
await backend.spawnAgent(createSpawnConfig('agent-1'));
|
||||||
|
|
||||||
|
expect(backend.writeToAgent('agent-1', 'hello')).toBe(true);
|
||||||
|
expect(backend.writeToAgent('nonexistent', 'hello')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for screen capture methods', async () => {
|
||||||
|
await backend.init();
|
||||||
|
await backend.spawnAgent(createSpawnConfig('agent-1'));
|
||||||
|
|
||||||
|
expect(backend.getActiveSnapshot()).toBeNull();
|
||||||
|
expect(backend.getAgentSnapshot('agent-1')).toBeNull();
|
||||||
|
expect(backend.getAgentScrollbackLength('agent-1')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for attach hint', () => {
|
||||||
|
expect(backend.getAttachHint()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should stop a specific agent', async () => {
|
||||||
|
await backend.init();
|
||||||
|
await backend.spawnAgent(createSpawnConfig('agent-1'));
|
||||||
|
|
||||||
|
const agent = backend.getAgent('agent-1');
|
||||||
|
expect(agent).toBeDefined();
|
||||||
|
|
||||||
|
backend.stopAgent('agent-1');
|
||||||
|
// Agent should eventually reach cancelled state
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should stop all agents', async () => {
|
||||||
|
await backend.init();
|
||||||
|
await backend.spawnAgent(createSpawnConfig('agent-1'));
|
||||||
|
await backend.spawnAgent(createSpawnConfig('agent-2'));
|
||||||
|
|
||||||
|
backend.stopAll();
|
||||||
|
// Both agents should be aborted
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should cleanup all agents', async () => {
|
||||||
|
await backend.init();
|
||||||
|
await backend.spawnAgent(createSpawnConfig('agent-1'));
|
||||||
|
|
||||||
|
await backend.cleanup();
|
||||||
|
|
||||||
|
expect(backend.getActiveAgentId()).toBeNull();
|
||||||
|
expect(backend.getAgent('agent-1')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fire exit callback when agent completes', async () => {
|
||||||
|
await backend.init();
|
||||||
|
|
||||||
|
const exitCallback = vi.fn();
|
||||||
|
backend.setOnAgentExit(exitCallback);
|
||||||
|
|
||||||
|
await backend.spawnAgent(createSpawnConfig('agent-1'));
|
||||||
|
|
||||||
|
// The mock agent stays idle after processing initialTask.
|
||||||
|
// Trigger a graceful shutdown to make it complete.
|
||||||
|
const agent = backend.getAgent('agent-1');
|
||||||
|
expect(agent).toBeDefined();
|
||||||
|
await agent!.shutdown();
|
||||||
|
|
||||||
|
// Wait for the exit callback to fire
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(exitCallback).toHaveBeenCalledWith(
|
||||||
|
'agent-1',
|
||||||
|
expect.any(Number),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass per-agent cwd to AgentCore via config proxy', async () => {
|
||||||
|
const parentConfig = createMockConfig();
|
||||||
|
const backendWithParentCwd = new InProcessBackend(parentConfig);
|
||||||
|
await backendWithParentCwd.init();
|
||||||
|
|
||||||
|
const agentCwd = '/worktree/agent-1';
|
||||||
|
const config = createSpawnConfig('agent-1');
|
||||||
|
config.cwd = agentCwd;
|
||||||
|
|
||||||
|
await backendWithParentCwd.spawnAgent(config);
|
||||||
|
|
||||||
|
const MockAgentCore = AgentCore as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
const lastCall = MockAgentCore.mock.calls.at(-1);
|
||||||
|
expect(lastCall).toBeDefined();
|
||||||
|
|
||||||
|
// Second arg is the runtime context (Config)
|
||||||
|
const agentContext = lastCall![1] as {
|
||||||
|
getWorkingDir: () => string;
|
||||||
|
getTargetDir: () => string;
|
||||||
|
getToolRegistry: () => unknown;
|
||||||
|
};
|
||||||
|
expect(agentContext.getWorkingDir()).toBe(agentCwd);
|
||||||
|
expect(agentContext.getTargetDir()).toBe(agentCwd);
|
||||||
|
expect(agentContext.getToolRegistry()).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should propagate runConfig limits to AgentInteractive', async () => {
|
||||||
|
await backend.init();
|
||||||
|
|
||||||
|
const config = createSpawnConfig('agent-1');
|
||||||
|
config.inProcess!.runtimeConfig.runConfig = {
|
||||||
|
max_turns: 5,
|
||||||
|
max_time_minutes: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
await backend.spawnAgent(config);
|
||||||
|
|
||||||
|
const agent = backend.getAgent('agent-1');
|
||||||
|
expect(agent).toBeDefined();
|
||||||
|
expect(agent!.config.maxTurnsPerMessage).toBe(5);
|
||||||
|
expect(agent!.config.maxTimeMinutesPerMessage).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should default limits to undefined when runConfig omits them', async () => {
|
||||||
|
await backend.init();
|
||||||
|
|
||||||
|
const config = createSpawnConfig('agent-1');
|
||||||
|
config.inProcess!.runtimeConfig.runConfig = {};
|
||||||
|
|
||||||
|
await backend.spawnAgent(config);
|
||||||
|
|
||||||
|
const agent = backend.getAgent('agent-1');
|
||||||
|
expect(agent).toBeDefined();
|
||||||
|
expect(agent!.config.maxTurnsPerMessage).toBeUndefined();
|
||||||
|
expect(agent!.config.maxTimeMinutesPerMessage).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should give each agent its own cwd even when sharing a backend', async () => {
|
||||||
|
await backend.init();
|
||||||
|
|
||||||
|
const config1 = createSpawnConfig('agent-1');
|
||||||
|
config1.cwd = '/worktree/agent-1';
|
||||||
|
const config2 = createSpawnConfig('agent-2');
|
||||||
|
config2.cwd = '/worktree/agent-2';
|
||||||
|
|
||||||
|
await backend.spawnAgent(config1);
|
||||||
|
await backend.spawnAgent(config2);
|
||||||
|
|
||||||
|
const MockAgentCore = AgentCore as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
const calls = MockAgentCore.mock.calls;
|
||||||
|
|
||||||
|
const ctx1 = calls.at(-2)![1] as {
|
||||||
|
getWorkingDir: () => string;
|
||||||
|
getTargetDir: () => string;
|
||||||
|
};
|
||||||
|
const ctx2 = calls.at(-1)![1] as {
|
||||||
|
getWorkingDir: () => string;
|
||||||
|
getTargetDir: () => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(ctx1.getWorkingDir()).toBe('/worktree/agent-1');
|
||||||
|
expect(ctx1.getTargetDir()).toBe('/worktree/agent-1');
|
||||||
|
expect(ctx2.getWorkingDir()).toBe('/worktree/agent-2');
|
||||||
|
expect(ctx2.getTargetDir()).toBe('/worktree/agent-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw when spawning a duplicate agent ID', async () => {
|
||||||
|
await backend.init();
|
||||||
|
await backend.spawnAgent(createSpawnConfig('agent-1'));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
backend.spawnAgent(createSpawnConfig('agent-1')),
|
||||||
|
).rejects.toThrow('Agent "agent-1" already exists.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fire exit callback with code 1 when start() throws', async () => {
|
||||||
|
// Make createChat throw for this test
|
||||||
|
const MockAgentCore = AgentCore as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
MockAgentCore.mockImplementationOnce(() => ({
|
||||||
|
subagentId: 'mock-id',
|
||||||
|
name: 'mock-agent',
|
||||||
|
eventEmitter: {
|
||||||
|
on: vi.fn(),
|
||||||
|
off: vi.fn(),
|
||||||
|
emit: vi.fn(),
|
||||||
|
},
|
||||||
|
stats: {
|
||||||
|
start: vi.fn(),
|
||||||
|
getSummary: vi.fn().mockReturnValue({}),
|
||||||
|
},
|
||||||
|
createChat: vi.fn().mockRejectedValue(new Error('Auth failed')),
|
||||||
|
prepareTools: vi.fn().mockReturnValue([]),
|
||||||
|
getEventEmitter: vi.fn().mockReturnValue({
|
||||||
|
on: vi.fn(),
|
||||||
|
off: vi.fn(),
|
||||||
|
emit: vi.fn(),
|
||||||
|
}),
|
||||||
|
getExecutionSummary: vi.fn().mockReturnValue({}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await backend.init();
|
||||||
|
|
||||||
|
const exitCallback = vi.fn();
|
||||||
|
backend.setOnAgentExit(exitCallback);
|
||||||
|
|
||||||
|
// spawnAgent should NOT throw — it catches the error internally
|
||||||
|
await expect(
|
||||||
|
backend.spawnAgent(createSpawnConfig('agent-fail')),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
// Exit callback should have been fired with exit code 1
|
||||||
|
expect(exitCallback).toHaveBeenCalledWith('agent-fail', 1, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true immediately from waitForAll after cleanup', async () => {
|
||||||
|
await backend.init();
|
||||||
|
await backend.spawnAgent(createSpawnConfig('agent-1'));
|
||||||
|
|
||||||
|
await backend.cleanup();
|
||||||
|
|
||||||
|
// waitForAll should return immediately after cleanup
|
||||||
|
const result = await backend.waitForAll(5000);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('chat history', () => {
|
||||||
|
it('should pass chatHistory to AgentInteractive config', async () => {
|
||||||
|
await backend.init();
|
||||||
|
|
||||||
|
const chatHistory = [
|
||||||
|
{ role: 'user' as const, parts: [{ text: 'prior question' }] },
|
||||||
|
{ role: 'model' as const, parts: [{ text: 'prior answer' }] },
|
||||||
|
];
|
||||||
|
const config = createSpawnConfig('agent-1');
|
||||||
|
config.inProcess!.chatHistory = chatHistory;
|
||||||
|
|
||||||
|
await backend.spawnAgent(config);
|
||||||
|
|
||||||
|
const agent = backend.getAgent('agent-1');
|
||||||
|
expect(agent).toBeDefined();
|
||||||
|
expect(agent!.config.chatHistory).toEqual(chatHistory);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should leave chatHistory undefined when not provided', async () => {
|
||||||
|
await backend.init();
|
||||||
|
await backend.spawnAgent(createSpawnConfig('agent-1'));
|
||||||
|
|
||||||
|
const agent = backend.getAgent('agent-1');
|
||||||
|
expect(agent).toBeDefined();
|
||||||
|
expect(agent!.config.chatHistory).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('auth isolation', () => {
|
||||||
|
it('should create per-agent ContentGenerator when authOverrides is provided', async () => {
|
||||||
|
await backend.init();
|
||||||
|
|
||||||
|
const config = createSpawnConfig('agent-1');
|
||||||
|
config.inProcess!.authOverrides = {
|
||||||
|
authType: 'anthropic',
|
||||||
|
apiKey: 'agent-key-123',
|
||||||
|
baseUrl: 'https://agent.example.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
await backend.spawnAgent(config);
|
||||||
|
|
||||||
|
const mockCreate = createContentGenerator as ReturnType<typeof vi.fn>;
|
||||||
|
expect(mockCreate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
authType: 'anthropic',
|
||||||
|
apiKey: 'agent-key-123',
|
||||||
|
baseUrl: 'https://agent.example.com',
|
||||||
|
model: 'test-model',
|
||||||
|
}),
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should override getContentGenerator on per-agent config', async () => {
|
||||||
|
const agentGenerator = { generateContentStream: vi.fn() };
|
||||||
|
const mockCreate = createContentGenerator as ReturnType<typeof vi.fn>;
|
||||||
|
mockCreate.mockResolvedValueOnce(agentGenerator);
|
||||||
|
|
||||||
|
await backend.init();
|
||||||
|
|
||||||
|
const config = createSpawnConfig('agent-1');
|
||||||
|
config.inProcess!.authOverrides = {
|
||||||
|
authType: 'anthropic',
|
||||||
|
apiKey: 'agent-key',
|
||||||
|
};
|
||||||
|
|
||||||
|
await backend.spawnAgent(config);
|
||||||
|
|
||||||
|
const MockAgentCore = AgentCore as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
const lastCall = MockAgentCore.mock.calls.at(-1);
|
||||||
|
const agentContext = lastCall![1] as {
|
||||||
|
getContentGenerator: () => unknown;
|
||||||
|
getAuthType: () => string | undefined;
|
||||||
|
getModel: () => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(agentContext.getContentGenerator()).toBe(agentGenerator);
|
||||||
|
expect(agentContext.getAuthType()).toBe('anthropic');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not create per-agent ContentGenerator without authOverrides', async () => {
|
||||||
|
const mockCreate = createContentGenerator as ReturnType<typeof vi.fn>;
|
||||||
|
mockCreate.mockClear();
|
||||||
|
|
||||||
|
await backend.init();
|
||||||
|
await backend.spawnAgent(createSpawnConfig('agent-1'));
|
||||||
|
|
||||||
|
expect(mockCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fall back to parent ContentGenerator if per-agent creation fails', async () => {
|
||||||
|
const mockCreate = createContentGenerator as ReturnType<typeof vi.fn>;
|
||||||
|
mockCreate.mockRejectedValueOnce(new Error('Auth failed'));
|
||||||
|
|
||||||
|
await backend.init();
|
||||||
|
|
||||||
|
const config = createSpawnConfig('agent-1');
|
||||||
|
config.inProcess!.authOverrides = {
|
||||||
|
authType: 'anthropic',
|
||||||
|
apiKey: 'bad-key',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Should not throw — falls back gracefully
|
||||||
|
await expect(backend.spawnAgent(config)).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
const MockAgentCore = AgentCore as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
const lastCall = MockAgentCore.mock.calls.at(-1);
|
||||||
|
const agentContext = lastCall![1] as {
|
||||||
|
getContentGenerator: () => unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Falls back to parent's content generator
|
||||||
|
expect(agentContext.getContentGenerator()).toBe(mockContentGenerator);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should give different agents different ContentGenerators', async () => {
|
||||||
|
const gen1 = { generateContentStream: vi.fn() };
|
||||||
|
const gen2 = { generateContentStream: vi.fn() };
|
||||||
|
const mockCreate = createContentGenerator as ReturnType<typeof vi.fn>;
|
||||||
|
mockCreate.mockResolvedValueOnce(gen1).mockResolvedValueOnce(gen2);
|
||||||
|
|
||||||
|
await backend.init();
|
||||||
|
|
||||||
|
const config1 = createSpawnConfig('agent-1');
|
||||||
|
config1.inProcess!.authOverrides = {
|
||||||
|
authType: 'openai',
|
||||||
|
apiKey: 'key-1',
|
||||||
|
baseUrl: 'https://api1.example.com',
|
||||||
|
};
|
||||||
|
const config2 = createSpawnConfig('agent-2');
|
||||||
|
config2.inProcess!.authOverrides = {
|
||||||
|
authType: 'anthropic',
|
||||||
|
apiKey: 'key-2',
|
||||||
|
baseUrl: 'https://api2.example.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
await backend.spawnAgent(config1);
|
||||||
|
await backend.spawnAgent(config2);
|
||||||
|
|
||||||
|
const MockAgentCore = AgentCore as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
const calls = MockAgentCore.mock.calls;
|
||||||
|
|
||||||
|
const ctx1 = calls.at(-2)![1] as {
|
||||||
|
getContentGenerator: () => unknown;
|
||||||
|
};
|
||||||
|
const ctx2 = calls.at(-1)![1] as {
|
||||||
|
getContentGenerator: () => unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(ctx1.getContentGenerator()).toBe(gen1);
|
||||||
|
expect(ctx2.getContentGenerator()).toBe(gen2);
|
||||||
|
expect(ctx1.getContentGenerator()).not.toBe(ctx2.getContentGenerator());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
472
packages/core/src/agents/backends/InProcessBackend.ts
Normal file
472
packages/core/src/agents/backends/InProcessBackend.ts
Normal file
|
|
@ -0,0 +1,472 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @fileoverview InProcessBackend — Backend implementation that runs agents
|
||||||
|
* in the current process using AgentInteractive instead of PTY subprocesses.
|
||||||
|
*
|
||||||
|
* This enables Arena to work without tmux or any external terminal multiplexer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createDebugLogger } from '../../utils/debugLogger.js';
|
||||||
|
import type { Config } from '../../config/config.js';
|
||||||
|
import {
|
||||||
|
type AuthType,
|
||||||
|
type ContentGenerator,
|
||||||
|
type ContentGeneratorConfig,
|
||||||
|
createContentGenerator,
|
||||||
|
} from '../../core/contentGenerator.js';
|
||||||
|
import { AUTH_ENV_MAPPINGS } from '../../models/constants.js';
|
||||||
|
import { AgentStatus, isTerminalStatus } from '../runtime/agent-types.js';
|
||||||
|
import { AgentCore } from '../runtime/agent-core.js';
|
||||||
|
import { AgentEventEmitter } from '../runtime/agent-events.js';
|
||||||
|
import { ContextState } from '../runtime/agent-headless.js';
|
||||||
|
import { AgentInteractive } from '../runtime/agent-interactive.js';
|
||||||
|
import type {
|
||||||
|
Backend,
|
||||||
|
AgentSpawnConfig,
|
||||||
|
AgentExitCallback,
|
||||||
|
InProcessSpawnConfig,
|
||||||
|
} from './types.js';
|
||||||
|
import { DISPLAY_MODE } from './types.js';
|
||||||
|
import type { AnsiOutput } from '../../utils/terminalSerializer.js';
|
||||||
|
import { WorkspaceContext } from '../../utils/workspaceContext.js';
|
||||||
|
import { FileDiscoveryService } from '../../services/fileDiscoveryService.js';
|
||||||
|
import type { ToolRegistry } from '../../tools/tool-registry.js';
|
||||||
|
|
||||||
|
const debugLogger = createDebugLogger('IN_PROCESS_BACKEND');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* InProcessBackend runs agents in the current Node.js process.
|
||||||
|
*
|
||||||
|
* Instead of spawning PTY subprocesses, it creates AgentCore + AgentInteractive
|
||||||
|
* instances that execute in-process. Screen capture returns null (the UI reads
|
||||||
|
* messages directly from AgentInteractive).
|
||||||
|
*/
|
||||||
|
export class InProcessBackend implements Backend {
|
||||||
|
readonly type = DISPLAY_MODE.IN_PROCESS;
|
||||||
|
|
||||||
|
private readonly runtimeContext: Config;
|
||||||
|
private readonly agents = new Map<string, AgentInteractive>();
|
||||||
|
private readonly agentRegistries: ToolRegistry[] = [];
|
||||||
|
private readonly agentOrder: string[] = [];
|
||||||
|
private activeAgentId: string | null = null;
|
||||||
|
private exitCallback: AgentExitCallback | null = null;
|
||||||
|
/** Whether cleanup() has been called */
|
||||||
|
private cleanedUp = false;
|
||||||
|
|
||||||
|
constructor(runtimeContext: Config) {
|
||||||
|
this.runtimeContext = runtimeContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Backend Interface ─────────────────────────────────────
|
||||||
|
|
||||||
|
async init(): Promise<void> {
|
||||||
|
debugLogger.info('InProcessBackend initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
async spawnAgent(config: AgentSpawnConfig): Promise<void> {
|
||||||
|
const inProcessConfig = config.inProcess;
|
||||||
|
if (!inProcessConfig) {
|
||||||
|
throw new Error(
|
||||||
|
`InProcessBackend requires inProcess config for agent ${config.agentId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.agents.has(config.agentId)) {
|
||||||
|
throw new Error(`Agent "${config.agentId}" already exists.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { promptConfig, modelConfig, runConfig, toolConfig } =
|
||||||
|
inProcessConfig.runtimeConfig;
|
||||||
|
|
||||||
|
const eventEmitter = new AgentEventEmitter();
|
||||||
|
|
||||||
|
// Build a per-agent runtime context with isolated working directory,
|
||||||
|
// target directory, workspace context, tool registry, and (optionally)
|
||||||
|
// a dedicated ContentGenerator for per-agent auth isolation.
|
||||||
|
const agentContext = await createPerAgentConfig(
|
||||||
|
this.runtimeContext,
|
||||||
|
config.cwd,
|
||||||
|
inProcessConfig.runtimeConfig.modelConfig.model,
|
||||||
|
inProcessConfig.authOverrides,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.agentRegistries.push(agentContext.getToolRegistry());
|
||||||
|
|
||||||
|
const core = new AgentCore(
|
||||||
|
inProcessConfig.agentName,
|
||||||
|
agentContext,
|
||||||
|
promptConfig,
|
||||||
|
modelConfig,
|
||||||
|
runConfig,
|
||||||
|
toolConfig,
|
||||||
|
eventEmitter,
|
||||||
|
);
|
||||||
|
|
||||||
|
const interactive = new AgentInteractive(
|
||||||
|
{
|
||||||
|
agentId: config.agentId,
|
||||||
|
agentName: inProcessConfig.agentName,
|
||||||
|
initialTask: inProcessConfig.initialTask,
|
||||||
|
maxTurnsPerMessage: runConfig.max_turns,
|
||||||
|
maxTimeMinutesPerMessage: runConfig.max_time_minutes,
|
||||||
|
chatHistory: inProcessConfig.chatHistory,
|
||||||
|
},
|
||||||
|
core,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.agents.set(config.agentId, interactive);
|
||||||
|
this.agentOrder.push(config.agentId);
|
||||||
|
|
||||||
|
// Set first agent as active
|
||||||
|
if (this.activeAgentId === null) {
|
||||||
|
this.activeAgentId = config.agentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const context = new ContextState();
|
||||||
|
await interactive.start(context);
|
||||||
|
|
||||||
|
// Watch for completion and fire exit callback — but only for
|
||||||
|
// truly terminal statuses. IDLE means the agent is still alive
|
||||||
|
// and can accept follow-up messages.
|
||||||
|
void interactive.waitForCompletion().then(() => {
|
||||||
|
const status = interactive.getStatus();
|
||||||
|
if (!isTerminalStatus(status)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const exitCode =
|
||||||
|
status === AgentStatus.COMPLETED
|
||||||
|
? 0
|
||||||
|
: status === AgentStatus.FAILED
|
||||||
|
? 1
|
||||||
|
: null;
|
||||||
|
this.exitCallback?.(config.agentId, exitCode, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
debugLogger.info(`Spawned in-process agent: ${config.agentId}`);
|
||||||
|
} catch (error) {
|
||||||
|
debugLogger.error(
|
||||||
|
`Failed to start in-process agent "${config.agentId}":`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
this.exitCallback?.(config.agentId, 1, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stopAgent(agentId: string): void {
|
||||||
|
const agent = this.agents.get(agentId);
|
||||||
|
if (agent) {
|
||||||
|
agent.abort();
|
||||||
|
debugLogger.info(`Stopped agent: ${agentId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stopAll(): void {
|
||||||
|
for (const agent of this.agents.values()) {
|
||||||
|
agent.abort();
|
||||||
|
}
|
||||||
|
debugLogger.info('Stopped all in-process agents');
|
||||||
|
}
|
||||||
|
|
||||||
|
async cleanup(): Promise<void> {
|
||||||
|
this.cleanedUp = true;
|
||||||
|
|
||||||
|
for (const agent of this.agents.values()) {
|
||||||
|
agent.abort();
|
||||||
|
}
|
||||||
|
// Wait for loops to settle, but cap at 3s so CLI exit isn't blocked
|
||||||
|
// if an agent's reasoning loop doesn't terminate promptly after abort.
|
||||||
|
const CLEANUP_TIMEOUT_MS = 3000;
|
||||||
|
const promises = Array.from(this.agents.values()).map((a) =>
|
||||||
|
a.waitForCompletion().catch(() => {}),
|
||||||
|
);
|
||||||
|
let timerId: ReturnType<typeof setTimeout>;
|
||||||
|
const timeout = new Promise<void>((resolve) => {
|
||||||
|
timerId = setTimeout(resolve, CLEANUP_TIMEOUT_MS);
|
||||||
|
});
|
||||||
|
await Promise.race([Promise.allSettled(promises), timeout]);
|
||||||
|
clearTimeout(timerId!);
|
||||||
|
|
||||||
|
// Stop per-agent tool registries so tools like TaskTool can release
|
||||||
|
// listeners registered on shared managers (e.g. SubagentManager).
|
||||||
|
for (const registry of this.agentRegistries) {
|
||||||
|
await registry.stop().catch(() => {});
|
||||||
|
}
|
||||||
|
this.agentRegistries.length = 0;
|
||||||
|
|
||||||
|
this.agents.clear();
|
||||||
|
this.agentOrder.length = 0;
|
||||||
|
this.activeAgentId = null;
|
||||||
|
debugLogger.info('InProcessBackend cleaned up');
|
||||||
|
}
|
||||||
|
|
||||||
|
setOnAgentExit(callback: AgentExitCallback): void {
|
||||||
|
this.exitCallback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForAll(timeoutMs?: number): Promise<boolean> {
|
||||||
|
if (this.cleanedUp) return true;
|
||||||
|
|
||||||
|
const promises = Array.from(this.agents.values()).map((a) =>
|
||||||
|
a.waitForCompletion(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (timeoutMs === undefined) {
|
||||||
|
await Promise.allSettled(promises);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let timerId: ReturnType<typeof setTimeout>;
|
||||||
|
const timeout = new Promise<'timeout'>((resolve) => {
|
||||||
|
timerId = setTimeout(() => resolve('timeout'), timeoutMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await Promise.race([
|
||||||
|
Promise.allSettled(promises).then(() => 'done' as const),
|
||||||
|
timeout,
|
||||||
|
]);
|
||||||
|
|
||||||
|
clearTimeout(timerId!);
|
||||||
|
return result === 'done';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Navigation ────────────────────────────────────────────
|
||||||
|
|
||||||
|
switchTo(agentId: string): void {
|
||||||
|
if (this.agents.has(agentId)) {
|
||||||
|
this.activeAgentId = agentId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switchToNext(): void {
|
||||||
|
this.activeAgentId = this.navigate(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
switchToPrevious(): void {
|
||||||
|
this.activeAgentId = this.navigate(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
getActiveAgentId(): string | null {
|
||||||
|
return this.activeAgentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Screen Capture (no-op for in-process) ─────────────────
|
||||||
|
|
||||||
|
getActiveSnapshot(): AnsiOutput | null {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAgentSnapshot(
|
||||||
|
_agentId: string,
|
||||||
|
_scrollOffset?: number,
|
||||||
|
): AnsiOutput | null {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAgentScrollbackLength(_agentId: string): number {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Input ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
forwardInput(data: string): boolean {
|
||||||
|
if (!this.activeAgentId) return false;
|
||||||
|
return this.writeToAgent(this.activeAgentId, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
writeToAgent(agentId: string, data: string): boolean {
|
||||||
|
const agent = this.agents.get(agentId);
|
||||||
|
if (!agent) return false;
|
||||||
|
|
||||||
|
agent.enqueueMessage(data);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Resize (no-op) ───────────────────────────────────────
|
||||||
|
|
||||||
|
resizeAll(_cols: number, _rows: number): void {
|
||||||
|
// No terminals to resize in-process
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── External Session ──────────────────────────────────────
|
||||||
|
|
||||||
|
getAttachHint(): string | null {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Extra: Direct Access ──────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an AgentInteractive instance by agent ID.
|
||||||
|
* Used by ArenaManager for direct event subscription.
|
||||||
|
*/
|
||||||
|
getAgent(agentId: string): AgentInteractive | undefined {
|
||||||
|
return this.agents.get(agentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Private ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
private navigate(direction: 1 | -1): string | null {
|
||||||
|
if (this.agentOrder.length === 0) return null;
|
||||||
|
if (!this.activeAgentId) return this.agentOrder[0] ?? null;
|
||||||
|
|
||||||
|
const currentIndex = this.agentOrder.indexOf(this.activeAgentId);
|
||||||
|
if (currentIndex === -1) return this.agentOrder[0] ?? null;
|
||||||
|
|
||||||
|
const nextIndex =
|
||||||
|
(currentIndex + direction + this.agentOrder.length) %
|
||||||
|
this.agentOrder.length;
|
||||||
|
return this.agentOrder[nextIndex] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a per-agent Config that delegates to the shared base Config but
|
||||||
|
* overrides key methods to provide per-agent isolation:
|
||||||
|
*
|
||||||
|
* - `getWorkingDir()` / `getTargetDir()` → agent's worktree cwd
|
||||||
|
* - `getWorkspaceContext()` → WorkspaceContext rooted at agent's cwd
|
||||||
|
* - `getFileService()` → FileDiscoveryService rooted at agent's cwd
|
||||||
|
* (so .qwenignore checks resolve against the agent's worktree)
|
||||||
|
* - `getToolRegistry()` → per-agent tool registry with core tools bound to
|
||||||
|
* the agent Config (so tools resolve paths against the agent's worktree)
|
||||||
|
* - `getContentGenerator()` / `getContentGeneratorConfig()` / `getAuthType()`
|
||||||
|
* → per-agent ContentGenerator when `authOverrides` is provided, enabling
|
||||||
|
* agents to target different model providers in the same Arena session
|
||||||
|
*
|
||||||
|
* Uses prototypal delegation so all other Config methods/properties resolve
|
||||||
|
* against the original instance transparently.
|
||||||
|
*/
|
||||||
|
async function createPerAgentConfig(
|
||||||
|
base: Config,
|
||||||
|
cwd: string,
|
||||||
|
modelId?: string,
|
||||||
|
authOverrides?: InProcessSpawnConfig['authOverrides'],
|
||||||
|
): Promise<Config> {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const override = Object.create(base) as any;
|
||||||
|
|
||||||
|
override.getWorkingDir = () => cwd;
|
||||||
|
override.getTargetDir = () => cwd;
|
||||||
|
override.getProjectRoot = () => cwd;
|
||||||
|
|
||||||
|
const agentWorkspace = new WorkspaceContext(cwd);
|
||||||
|
override.getWorkspaceContext = () => agentWorkspace;
|
||||||
|
|
||||||
|
const agentFileService = new FileDiscoveryService(cwd);
|
||||||
|
override.getFileService = () => agentFileService;
|
||||||
|
|
||||||
|
// Build a per-agent tool registry: core tools are constructed with
|
||||||
|
// the per-agent Config so they resolve paths against cwd. Discovered
|
||||||
|
// (MCP/command) tools are copied from the parent registry as-is.
|
||||||
|
const agentRegistry: ToolRegistry = await override.createToolRegistry(
|
||||||
|
undefined,
|
||||||
|
{ skipDiscovery: true },
|
||||||
|
);
|
||||||
|
agentRegistry.copyDiscoveredToolsFrom(base.getToolRegistry());
|
||||||
|
override.getToolRegistry = () => agentRegistry;
|
||||||
|
|
||||||
|
// Build a per-agent ContentGenerator when auth overrides are provided.
|
||||||
|
// This enables Arena agents to use different providers (OpenAI, Anthropic,
|
||||||
|
// Gemini, etc.) than the parent process.
|
||||||
|
if (authOverrides?.authType) {
|
||||||
|
try {
|
||||||
|
const agentGeneratorConfig = buildAgentContentGeneratorConfig(
|
||||||
|
base,
|
||||||
|
modelId,
|
||||||
|
authOverrides,
|
||||||
|
);
|
||||||
|
const agentGenerator = await createContentGenerator(
|
||||||
|
agentGeneratorConfig,
|
||||||
|
override as Config,
|
||||||
|
);
|
||||||
|
override.getContentGenerator = (): ContentGenerator => agentGenerator;
|
||||||
|
override.getContentGeneratorConfig = (): ContentGeneratorConfig =>
|
||||||
|
agentGeneratorConfig;
|
||||||
|
override.getAuthType = (): AuthType | undefined =>
|
||||||
|
agentGeneratorConfig.authType;
|
||||||
|
override.getModel = (): string => agentGeneratorConfig.model;
|
||||||
|
|
||||||
|
debugLogger.info(
|
||||||
|
`Created per-agent ContentGenerator: authType=${authOverrides.authType}, model=${agentGeneratorConfig.model}`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
debugLogger.error(
|
||||||
|
'Failed to create per-agent ContentGenerator, falling back to parent:',
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return override as Config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a ContentGeneratorConfig for a per-agent ContentGenerator.
|
||||||
|
* Inherits operational settings (timeout, retries, proxy, sampling, etc.)
|
||||||
|
* from the parent's config and overlays the agent-specific auth fields.
|
||||||
|
*
|
||||||
|
* For cross-provider agents the parent's API key / base URL are invalid,
|
||||||
|
* so we resolve credentials from the provider-specific environment
|
||||||
|
* variables (e.g. ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL). This mirrors
|
||||||
|
* what a PTY subprocess does during its own initialization.
|
||||||
|
*/
|
||||||
|
function buildAgentContentGeneratorConfig(
|
||||||
|
base: Config,
|
||||||
|
modelId: string | undefined,
|
||||||
|
authOverrides: NonNullable<InProcessSpawnConfig['authOverrides']>,
|
||||||
|
): ContentGeneratorConfig {
|
||||||
|
const parentConfig = base.getContentGeneratorConfig();
|
||||||
|
const sameProvider = authOverrides.authType === parentConfig.authType;
|
||||||
|
|
||||||
|
const resolvedApiKey = resolveCredentialField(
|
||||||
|
authOverrides.apiKey,
|
||||||
|
sameProvider ? parentConfig.apiKey : undefined,
|
||||||
|
authOverrides.authType,
|
||||||
|
'apiKey',
|
||||||
|
);
|
||||||
|
|
||||||
|
const resolvedBaseUrl = resolveCredentialField(
|
||||||
|
authOverrides.baseUrl,
|
||||||
|
sameProvider ? parentConfig.baseUrl : undefined,
|
||||||
|
authOverrides.authType,
|
||||||
|
'baseUrl',
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...parentConfig,
|
||||||
|
model: modelId ?? parentConfig.model,
|
||||||
|
authType: authOverrides.authType as AuthType,
|
||||||
|
apiKey: resolvedApiKey,
|
||||||
|
baseUrl: resolvedBaseUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a credential field (apiKey or baseUrl) with the following
|
||||||
|
* priority: explicit override → same-provider parent value → env var.
|
||||||
|
*/
|
||||||
|
function resolveCredentialField(
|
||||||
|
explicitValue: string | undefined,
|
||||||
|
inheritedValue: string | undefined,
|
||||||
|
authType: string,
|
||||||
|
field: 'apiKey' | 'baseUrl',
|
||||||
|
): string | undefined {
|
||||||
|
if (explicitValue) return explicitValue;
|
||||||
|
if (inheritedValue) return inheritedValue;
|
||||||
|
|
||||||
|
const envMapping =
|
||||||
|
AUTH_ENV_MAPPINGS[authType as keyof typeof AUTH_ENV_MAPPINGS];
|
||||||
|
if (!envMapping) return undefined;
|
||||||
|
|
||||||
|
for (const envKey of envMapping[field]) {
|
||||||
|
const value = process.env[envKey];
|
||||||
|
if (value) return value;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
482
packages/core/src/agents/backends/TmuxBackend.test.ts
Normal file
482
packages/core/src/agents/backends/TmuxBackend.test.ts
Normal file
|
|
@ -0,0 +1,482 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import type { AgentSpawnConfig } from './types.js';
|
||||||
|
|
||||||
|
// ─── Hoisted mocks for tmux-commands ────────────────────────────
|
||||||
|
const hoistedVerifyTmux = vi.hoisted(() => vi.fn());
|
||||||
|
const hoistedTmuxCurrentPaneId = vi.hoisted(() => vi.fn());
|
||||||
|
const hoistedTmuxCurrentWindowTarget = vi.hoisted(() => vi.fn());
|
||||||
|
const hoistedTmuxHasSession = vi.hoisted(() => vi.fn());
|
||||||
|
const hoistedTmuxHasWindow = vi.hoisted(() => vi.fn());
|
||||||
|
const hoistedTmuxNewSession = vi.hoisted(() => vi.fn());
|
||||||
|
const hoistedTmuxNewWindow = vi.hoisted(() => vi.fn());
|
||||||
|
const hoistedTmuxSplitWindow = vi.hoisted(() => vi.fn());
|
||||||
|
const hoistedTmuxSendKeys = vi.hoisted(() => vi.fn());
|
||||||
|
const hoistedTmuxSelectPane = vi.hoisted(() => vi.fn());
|
||||||
|
const hoistedTmuxSelectPaneTitle = vi.hoisted(() => vi.fn());
|
||||||
|
const hoistedTmuxSelectPaneStyle = vi.hoisted(() => vi.fn());
|
||||||
|
const hoistedTmuxSelectLayout = vi.hoisted(() => vi.fn());
|
||||||
|
const hoistedTmuxListPanes = vi.hoisted(() => vi.fn());
|
||||||
|
const hoistedTmuxSetOption = vi.hoisted(() => vi.fn());
|
||||||
|
const hoistedTmuxRespawnPane = vi.hoisted(() => vi.fn());
|
||||||
|
const hoistedTmuxKillPane = vi.hoisted(() => vi.fn());
|
||||||
|
const hoistedTmuxKillSession = vi.hoisted(() => vi.fn());
|
||||||
|
const hoistedTmuxResizePane = vi.hoisted(() => vi.fn());
|
||||||
|
const hoistedTmuxGetFirstPaneId = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock('./tmux-commands.js', () => ({
|
||||||
|
verifyTmux: hoistedVerifyTmux,
|
||||||
|
tmuxCurrentPaneId: hoistedTmuxCurrentPaneId,
|
||||||
|
tmuxCurrentWindowTarget: hoistedTmuxCurrentWindowTarget,
|
||||||
|
tmuxHasSession: hoistedTmuxHasSession,
|
||||||
|
tmuxHasWindow: hoistedTmuxHasWindow,
|
||||||
|
tmuxNewSession: hoistedTmuxNewSession,
|
||||||
|
tmuxNewWindow: hoistedTmuxNewWindow,
|
||||||
|
tmuxSplitWindow: hoistedTmuxSplitWindow,
|
||||||
|
tmuxSendKeys: hoistedTmuxSendKeys,
|
||||||
|
tmuxSelectPane: hoistedTmuxSelectPane,
|
||||||
|
tmuxSelectPaneTitle: hoistedTmuxSelectPaneTitle,
|
||||||
|
tmuxSelectPaneStyle: hoistedTmuxSelectPaneStyle,
|
||||||
|
tmuxSelectLayout: hoistedTmuxSelectLayout,
|
||||||
|
tmuxListPanes: hoistedTmuxListPanes,
|
||||||
|
tmuxSetOption: hoistedTmuxSetOption,
|
||||||
|
tmuxRespawnPane: hoistedTmuxRespawnPane,
|
||||||
|
tmuxKillPane: hoistedTmuxKillPane,
|
||||||
|
tmuxKillSession: hoistedTmuxKillSession,
|
||||||
|
tmuxResizePane: hoistedTmuxResizePane,
|
||||||
|
tmuxGetFirstPaneId: hoistedTmuxGetFirstPaneId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the debug logger
|
||||||
|
vi.mock('../../utils/debugLogger.js', () => ({
|
||||||
|
createDebugLogger: () => ({
|
||||||
|
info: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { TmuxBackend } from './TmuxBackend.js';
|
||||||
|
|
||||||
|
function makeConfig(
|
||||||
|
agentId: string,
|
||||||
|
overrides?: Partial<AgentSpawnConfig>,
|
||||||
|
): AgentSpawnConfig {
|
||||||
|
return {
|
||||||
|
agentId,
|
||||||
|
command: '/usr/bin/node',
|
||||||
|
args: ['agent.js'],
|
||||||
|
cwd: '/tmp/test',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spawn an agent with fake timers active. The `sleep()` inside
|
||||||
|
* `spawnAgentAsync` uses `setTimeout`, so we must advance fake timers
|
||||||
|
* while the spawn promise is pending.
|
||||||
|
*/
|
||||||
|
async function spawnWithTimers(
|
||||||
|
backend: TmuxBackend,
|
||||||
|
config: AgentSpawnConfig,
|
||||||
|
): Promise<void> {
|
||||||
|
const promise = backend.spawnAgent(config);
|
||||||
|
// Advance past INTERNAL_LAYOUT_SETTLE_MS (200) / EXTERNAL_LAYOUT_SETTLE_MS (120)
|
||||||
|
// and the 100ms triggerMainProcessRedraw timeout
|
||||||
|
await vi.advanceTimersByTimeAsync(300);
|
||||||
|
await promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupDefaultMocks(): void {
|
||||||
|
hoistedVerifyTmux.mockResolvedValue(undefined);
|
||||||
|
hoistedTmuxHasSession.mockResolvedValue(false);
|
||||||
|
hoistedTmuxHasWindow.mockResolvedValue(false);
|
||||||
|
hoistedTmuxNewSession.mockResolvedValue(undefined);
|
||||||
|
hoistedTmuxNewWindow.mockResolvedValue(undefined);
|
||||||
|
hoistedTmuxGetFirstPaneId.mockResolvedValue('%0');
|
||||||
|
hoistedTmuxRespawnPane.mockResolvedValue(undefined);
|
||||||
|
hoistedTmuxSplitWindow.mockResolvedValue('%1');
|
||||||
|
hoistedTmuxSetOption.mockResolvedValue(undefined);
|
||||||
|
hoistedTmuxSelectPaneTitle.mockResolvedValue(undefined);
|
||||||
|
hoistedTmuxSelectPaneStyle.mockResolvedValue(undefined);
|
||||||
|
hoistedTmuxSelectLayout.mockResolvedValue(undefined);
|
||||||
|
hoistedTmuxSelectPane.mockResolvedValue(undefined);
|
||||||
|
hoistedTmuxResizePane.mockResolvedValue(undefined);
|
||||||
|
hoistedTmuxListPanes.mockResolvedValue([]);
|
||||||
|
hoistedTmuxSendKeys.mockResolvedValue(undefined);
|
||||||
|
hoistedTmuxKillPane.mockResolvedValue(undefined);
|
||||||
|
hoistedTmuxKillSession.mockResolvedValue(undefined);
|
||||||
|
hoistedTmuxCurrentPaneId.mockResolvedValue('%0');
|
||||||
|
hoistedTmuxCurrentWindowTarget.mockResolvedValue('main:0');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TmuxBackend', () => {
|
||||||
|
let backend: TmuxBackend;
|
||||||
|
let savedTmuxEnv: string | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
savedTmuxEnv = process.env['TMUX'];
|
||||||
|
// Default: running outside tmux
|
||||||
|
delete process.env['TMUX'];
|
||||||
|
setupDefaultMocks();
|
||||||
|
backend = new TmuxBackend();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await backend.cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.useRealTimers();
|
||||||
|
if (savedTmuxEnv !== undefined) {
|
||||||
|
process.env['TMUX'] = savedTmuxEnv;
|
||||||
|
} else {
|
||||||
|
delete process.env['TMUX'];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Initialization ─────────────────────────────────────────
|
||||||
|
|
||||||
|
it('throws if spawnAgent is called before init', async () => {
|
||||||
|
await expect(backend.spawnAgent(makeConfig('a1'))).rejects.toThrow(
|
||||||
|
'not initialized',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('init verifies tmux availability', async () => {
|
||||||
|
await backend.init();
|
||||||
|
expect(hoistedVerifyTmux).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('init is idempotent', async () => {
|
||||||
|
await backend.init();
|
||||||
|
await backend.init();
|
||||||
|
expect(hoistedVerifyTmux).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Spawning (outside tmux) ──────────────────────────────
|
||||||
|
|
||||||
|
it('spawns first agent outside tmux by respawning the initial pane', async () => {
|
||||||
|
await backend.init();
|
||||||
|
await spawnWithTimers(backend, makeConfig('agent-1'));
|
||||||
|
|
||||||
|
expect(hoistedTmuxNewSession).toHaveBeenCalled();
|
||||||
|
expect(hoistedTmuxRespawnPane).toHaveBeenCalledWith(
|
||||||
|
'%0',
|
||||||
|
expect.any(String),
|
||||||
|
expect.any(String),
|
||||||
|
);
|
||||||
|
expect(backend.getActiveAgentId()).toBe('agent-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('spawns second agent outside tmux by splitting', async () => {
|
||||||
|
await backend.init();
|
||||||
|
await spawnWithTimers(backend, makeConfig('agent-1'));
|
||||||
|
|
||||||
|
// For second agent, list-panes returns the first agent pane
|
||||||
|
hoistedTmuxListPanes.mockResolvedValue([
|
||||||
|
{ paneId: '%0', dead: false, deadStatus: 0 },
|
||||||
|
]);
|
||||||
|
hoistedTmuxSplitWindow.mockResolvedValue('%2');
|
||||||
|
|
||||||
|
await spawnWithTimers(backend, makeConfig('agent-2'));
|
||||||
|
|
||||||
|
expect(hoistedTmuxSplitWindow).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects duplicate agent IDs', async () => {
|
||||||
|
await backend.init();
|
||||||
|
await spawnWithTimers(backend, makeConfig('dup'));
|
||||||
|
|
||||||
|
await expect(backend.spawnAgent(makeConfig('dup'))).rejects.toThrow(
|
||||||
|
'already exists',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Spawning (inside tmux) ───────────────────────────────
|
||||||
|
|
||||||
|
it('spawns first agent inside tmux by splitting from main pane', async () => {
|
||||||
|
process.env['TMUX'] = '/tmp/tmux-1000/default,12345,0';
|
||||||
|
backend = new TmuxBackend();
|
||||||
|
await backend.init();
|
||||||
|
|
||||||
|
hoistedTmuxListPanes.mockResolvedValue([
|
||||||
|
{ paneId: '%0', dead: false, deadStatus: 0 },
|
||||||
|
]);
|
||||||
|
hoistedTmuxSplitWindow.mockResolvedValue('%1');
|
||||||
|
|
||||||
|
await spawnWithTimers(backend, makeConfig('agent-1'));
|
||||||
|
|
||||||
|
// Should have split horizontally with firstSplitPercent
|
||||||
|
expect(hoistedTmuxSplitWindow).toHaveBeenCalledWith(
|
||||||
|
'%0',
|
||||||
|
expect.objectContaining({ horizontal: true, percent: 70 }),
|
||||||
|
);
|
||||||
|
// Should refocus on main pane (inside tmux, no server name arg)
|
||||||
|
expect(hoistedTmuxSelectPane).toHaveBeenCalledWith('%0');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Navigation ───────────────────────────────────────────
|
||||||
|
|
||||||
|
it('switchTo changes active agent', async () => {
|
||||||
|
await backend.init();
|
||||||
|
await spawnWithTimers(backend, makeConfig('a'));
|
||||||
|
|
||||||
|
hoistedTmuxListPanes.mockResolvedValue([
|
||||||
|
{ paneId: '%0', dead: false, deadStatus: 0 },
|
||||||
|
]);
|
||||||
|
hoistedTmuxSplitWindow.mockResolvedValue('%2');
|
||||||
|
await spawnWithTimers(backend, makeConfig('b'));
|
||||||
|
|
||||||
|
backend.switchTo('b');
|
||||||
|
expect(backend.getActiveAgentId()).toBe('b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switchTo throws for unknown agent', async () => {
|
||||||
|
await backend.init();
|
||||||
|
expect(() => backend.switchTo('ghost')).toThrow('not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switchToNext and switchToPrevious cycle correctly', async () => {
|
||||||
|
await backend.init();
|
||||||
|
await spawnWithTimers(backend, makeConfig('a'));
|
||||||
|
|
||||||
|
hoistedTmuxListPanes.mockResolvedValue([
|
||||||
|
{ paneId: '%0', dead: false, deadStatus: 0 },
|
||||||
|
]);
|
||||||
|
hoistedTmuxSplitWindow.mockResolvedValue('%2');
|
||||||
|
await spawnWithTimers(backend, makeConfig('b'));
|
||||||
|
|
||||||
|
expect(backend.getActiveAgentId()).toBe('a');
|
||||||
|
backend.switchToNext();
|
||||||
|
expect(backend.getActiveAgentId()).toBe('b');
|
||||||
|
backend.switchToNext();
|
||||||
|
expect(backend.getActiveAgentId()).toBe('a');
|
||||||
|
backend.switchToPrevious();
|
||||||
|
expect(backend.getActiveAgentId()).toBe('b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switchToNext does nothing with a single agent', async () => {
|
||||||
|
await backend.init();
|
||||||
|
await spawnWithTimers(backend, makeConfig('solo'));
|
||||||
|
backend.switchToNext();
|
||||||
|
expect(backend.getActiveAgentId()).toBe('solo');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Stop & Cleanup ──────────────────────────────────────
|
||||||
|
|
||||||
|
it('stopAgent kills the pane', async () => {
|
||||||
|
await backend.init();
|
||||||
|
await spawnWithTimers(backend, makeConfig('a'));
|
||||||
|
backend.stopAgent('a');
|
||||||
|
expect(hoistedTmuxKillPane).toHaveBeenCalledWith('%0', expect.any(String));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stopAll kills all running panes', async () => {
|
||||||
|
await backend.init();
|
||||||
|
await spawnWithTimers(backend, makeConfig('a'));
|
||||||
|
|
||||||
|
hoistedTmuxListPanes.mockResolvedValue([
|
||||||
|
{ paneId: '%0', dead: false, deadStatus: 0 },
|
||||||
|
]);
|
||||||
|
hoistedTmuxSplitWindow.mockResolvedValue('%2');
|
||||||
|
await spawnWithTimers(backend, makeConfig('b'));
|
||||||
|
|
||||||
|
backend.stopAll();
|
||||||
|
// Should have killed both panes
|
||||||
|
expect(hoistedTmuxKillPane).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cleanup kills panes and the external session', async () => {
|
||||||
|
await backend.init();
|
||||||
|
await spawnWithTimers(backend, makeConfig('a'));
|
||||||
|
await backend.cleanup();
|
||||||
|
|
||||||
|
expect(hoistedTmuxKillPane).toHaveBeenCalledWith('%0', expect.any(String));
|
||||||
|
expect(hoistedTmuxKillSession).toHaveBeenCalled();
|
||||||
|
expect(backend.getActiveAgentId()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cleanup does not kill session when running inside tmux', async () => {
|
||||||
|
process.env['TMUX'] = '/tmp/tmux-1000/default,12345,0';
|
||||||
|
backend = new TmuxBackend();
|
||||||
|
await backend.init();
|
||||||
|
|
||||||
|
hoistedTmuxListPanes.mockResolvedValue([
|
||||||
|
{ paneId: '%0', dead: false, deadStatus: 0 },
|
||||||
|
]);
|
||||||
|
hoistedTmuxSplitWindow.mockResolvedValue('%1');
|
||||||
|
await spawnWithTimers(backend, makeConfig('a'));
|
||||||
|
|
||||||
|
hoistedTmuxKillSession.mockClear();
|
||||||
|
await backend.cleanup();
|
||||||
|
|
||||||
|
expect(hoistedTmuxKillSession).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Exit Detection (Bug #1: missing pane → exited) ──────
|
||||||
|
|
||||||
|
it('marks agent as exited when pane disappears from tmux', async () => {
|
||||||
|
await backend.init();
|
||||||
|
await spawnWithTimers(backend, makeConfig('a'));
|
||||||
|
|
||||||
|
const exitCallback = vi.fn();
|
||||||
|
backend.setOnAgentExit(exitCallback);
|
||||||
|
|
||||||
|
// Polling returns no panes → agent's pane is gone
|
||||||
|
hoistedTmuxListPanes.mockResolvedValue([]);
|
||||||
|
|
||||||
|
// Advance timer to trigger poll
|
||||||
|
await vi.advanceTimersByTimeAsync(600);
|
||||||
|
|
||||||
|
expect(exitCallback).toHaveBeenCalledWith('a', 1, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks agent as exited when pane reports dead', async () => {
|
||||||
|
await backend.init();
|
||||||
|
await spawnWithTimers(backend, makeConfig('a'));
|
||||||
|
|
||||||
|
const exitCallback = vi.fn();
|
||||||
|
backend.setOnAgentExit(exitCallback);
|
||||||
|
|
||||||
|
// Polling returns the pane as dead with exit code 42
|
||||||
|
hoistedTmuxListPanes.mockResolvedValue([
|
||||||
|
{ paneId: '%0', dead: true, deadStatus: 42 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(600);
|
||||||
|
|
||||||
|
expect(exitCallback).toHaveBeenCalledWith('a', 42, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── waitForAll (Bug #3: cleanup resolves waiters) ────────
|
||||||
|
|
||||||
|
it('waitForAll resolves when all agents exit', async () => {
|
||||||
|
await backend.init();
|
||||||
|
await spawnWithTimers(backend, makeConfig('a'));
|
||||||
|
|
||||||
|
hoistedTmuxListPanes.mockResolvedValue([
|
||||||
|
{ paneId: '%0', dead: true, deadStatus: 0 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const waitPromise = backend.waitForAll();
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(600);
|
||||||
|
|
||||||
|
const result = await waitPromise;
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('waitForAll resolves after cleanup is called', async () => {
|
||||||
|
await backend.init();
|
||||||
|
await spawnWithTimers(backend, makeConfig('a'));
|
||||||
|
|
||||||
|
// Pane stays alive — without cleanup, waitForAll would hang
|
||||||
|
hoistedTmuxListPanes.mockResolvedValue([
|
||||||
|
{ paneId: '%0', dead: false, deadStatus: 0 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const waitPromise = backend.waitForAll();
|
||||||
|
|
||||||
|
// Advance a bit (poll runs but agent still alive)
|
||||||
|
await vi.advanceTimersByTimeAsync(600);
|
||||||
|
|
||||||
|
// Now cleanup
|
||||||
|
await backend.cleanup();
|
||||||
|
|
||||||
|
// Advance again so the waitForAll interval fires
|
||||||
|
await vi.advanceTimersByTimeAsync(600);
|
||||||
|
|
||||||
|
const result = await waitPromise;
|
||||||
|
// The key thing is the promise resolves instead of hanging forever.
|
||||||
|
// allExited() returns true since panes were cleared in cleanup.
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('waitForAll returns false on timeout', async () => {
|
||||||
|
await backend.init();
|
||||||
|
await spawnWithTimers(backend, makeConfig('a'));
|
||||||
|
|
||||||
|
// Pane stays alive
|
||||||
|
hoistedTmuxListPanes.mockResolvedValue([
|
||||||
|
{ paneId: '%0', dead: false, deadStatus: 0 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const waitPromise = backend.waitForAll(1000);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(1100);
|
||||||
|
|
||||||
|
const result = await waitPromise;
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Input ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('forwardInput sends literal keys to active agent pane', async () => {
|
||||||
|
await backend.init();
|
||||||
|
await spawnWithTimers(backend, makeConfig('a'));
|
||||||
|
|
||||||
|
const result = backend.forwardInput('hello');
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(hoistedTmuxSendKeys).toHaveBeenCalledWith(
|
||||||
|
'%0',
|
||||||
|
'hello',
|
||||||
|
{ literal: true },
|
||||||
|
expect.any(String),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwardInput returns false with no active agent', async () => {
|
||||||
|
await backend.init();
|
||||||
|
expect(backend.forwardInput('hello')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Snapshots ────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('getActiveSnapshot returns null (tmux handles rendering)', async () => {
|
||||||
|
await backend.init();
|
||||||
|
await spawnWithTimers(backend, makeConfig('a'));
|
||||||
|
expect(backend.getActiveSnapshot()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getAgentScrollbackLength returns 0', async () => {
|
||||||
|
await backend.init();
|
||||||
|
await spawnWithTimers(backend, makeConfig('a'));
|
||||||
|
expect(backend.getAgentScrollbackLength('a')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── getAttachHint ────────────────────────────────────────
|
||||||
|
|
||||||
|
it('returns attach command when outside tmux', async () => {
|
||||||
|
await backend.init();
|
||||||
|
const hint = backend.getAttachHint();
|
||||||
|
expect(hint).toMatch(/^tmux -L arena-server-\d+ a$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when inside tmux', async () => {
|
||||||
|
process.env['TMUX'] = '/tmp/tmux-1000/default,12345,0';
|
||||||
|
backend = new TmuxBackend();
|
||||||
|
await backend.init();
|
||||||
|
expect(backend.getAttachHint()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Spawn failure handling ───────────────────────────────
|
||||||
|
|
||||||
|
it('registers failed agent and fires exit callback on spawn error', async () => {
|
||||||
|
await backend.init();
|
||||||
|
|
||||||
|
// Make the external session setup fail
|
||||||
|
hoistedTmuxHasSession.mockRejectedValueOnce(new Error('tmux exploded'));
|
||||||
|
|
||||||
|
const exitCallback = vi.fn();
|
||||||
|
backend.setOnAgentExit(exitCallback);
|
||||||
|
|
||||||
|
await spawnWithTimers(backend, makeConfig('fail'));
|
||||||
|
|
||||||
|
expect(exitCallback).toHaveBeenCalledWith('fail', 1, null);
|
||||||
|
});
|
||||||
|
});
|
||||||
813
packages/core/src/agents/backends/TmuxBackend.ts
Normal file
813
packages/core/src/agents/backends/TmuxBackend.ts
Normal file
|
|
@ -0,0 +1,813 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @fileoverview TmuxBackend implements Backend using tmux split-pane.
|
||||||
|
*
|
||||||
|
* Layout (inside tmux): main process on the left (leader pane ~30%),
|
||||||
|
* agent panes on the right, arranged via `main-vertical`.
|
||||||
|
*
|
||||||
|
* ┌────────────┬──────────────────────────────────┐
|
||||||
|
* │ │ Agent 1 │
|
||||||
|
* │ Leader ├──────────────────────────────────┤
|
||||||
|
* │ (30%) │ Agent 2 │
|
||||||
|
* │ ├──────────────────────────────────┤
|
||||||
|
* │ │ Agent 3 │
|
||||||
|
* └────────────┴──────────────────────────────────┘
|
||||||
|
*
|
||||||
|
* Outside tmux: a dedicated tmux server is created and panes are arranged
|
||||||
|
* using `tiled` layout in a separate session/window.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createDebugLogger } from '../../utils/debugLogger.js';
|
||||||
|
import type { AnsiOutput } from '../../utils/terminalSerializer.js';
|
||||||
|
import { DISPLAY_MODE } from './types.js';
|
||||||
|
import type { AgentSpawnConfig, AgentExitCallback, Backend } from './types.js';
|
||||||
|
import {
|
||||||
|
verifyTmux,
|
||||||
|
tmuxCurrentWindowTarget,
|
||||||
|
tmuxCurrentPaneId,
|
||||||
|
tmuxHasSession,
|
||||||
|
tmuxHasWindow,
|
||||||
|
tmuxNewSession,
|
||||||
|
tmuxNewWindow,
|
||||||
|
tmuxSplitWindow,
|
||||||
|
tmuxSendKeys,
|
||||||
|
tmuxSelectPane,
|
||||||
|
tmuxSelectPaneTitle,
|
||||||
|
tmuxSelectPaneStyle,
|
||||||
|
tmuxSelectLayout,
|
||||||
|
tmuxListPanes,
|
||||||
|
tmuxSetOption,
|
||||||
|
tmuxRespawnPane,
|
||||||
|
tmuxKillPane,
|
||||||
|
tmuxKillSession,
|
||||||
|
tmuxResizePane,
|
||||||
|
tmuxGetFirstPaneId,
|
||||||
|
type TmuxPaneInfo,
|
||||||
|
} from './tmux-commands.js';
|
||||||
|
|
||||||
|
const debugLogger = createDebugLogger('TMUX_BACKEND');
|
||||||
|
|
||||||
|
/** Polling interval for exit detection (ms) */
|
||||||
|
const EXIT_POLL_INTERVAL_MS = 500;
|
||||||
|
|
||||||
|
/** Default tmux server name prefix (for -L) when running outside tmux.
|
||||||
|
* Actual name is `${prefix}-${process.pid}` so each leader process is isolated. */
|
||||||
|
const TMUX_SERVER_PREFIX = 'arena-server';
|
||||||
|
/** Default tmux session name when running outside tmux */
|
||||||
|
const DEFAULT_TMUX_SESSION = 'arena-view';
|
||||||
|
/** Default tmux window name when running outside tmux */
|
||||||
|
const DEFAULT_TMUX_WINDOW = 'arena-view';
|
||||||
|
/** Default leader pane width percent (main pane) */
|
||||||
|
const DEFAULT_LEADER_WIDTH_PERCENT = 30;
|
||||||
|
/** Default first split percent (right side) */
|
||||||
|
const DEFAULT_FIRST_SPLIT_PERCENT = 70;
|
||||||
|
/** Default pane border format */
|
||||||
|
const DEFAULT_PANE_BORDER_FORMAT = '#{pane_title}';
|
||||||
|
/** Layout settle delays */
|
||||||
|
const INTERNAL_LAYOUT_SETTLE_MS = 200;
|
||||||
|
const EXTERNAL_LAYOUT_SETTLE_MS = 120;
|
||||||
|
|
||||||
|
interface TmuxAgentPane {
|
||||||
|
agentId: string;
|
||||||
|
paneId: string;
|
||||||
|
status: 'running' | 'exited';
|
||||||
|
exitCode: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolvedTmuxOptions {
|
||||||
|
serverName: string;
|
||||||
|
sessionName: string;
|
||||||
|
windowName: string;
|
||||||
|
paneTitle: string;
|
||||||
|
paneBorderStyle?: string;
|
||||||
|
paneActiveBorderStyle?: string;
|
||||||
|
paneBorderFormat: string;
|
||||||
|
paneBorderStatus?: 'top' | 'bottom' | 'off';
|
||||||
|
leaderPaneWidthPercent: number;
|
||||||
|
firstSplitPercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TmuxBackend implements Backend {
|
||||||
|
readonly type = DISPLAY_MODE.TMUX;
|
||||||
|
|
||||||
|
/** The pane ID where the main process runs (left side) */
|
||||||
|
private mainPaneId = '';
|
||||||
|
/** Window target (session:window) */
|
||||||
|
private windowTarget = '';
|
||||||
|
/** Whether we are running inside tmux */
|
||||||
|
private insideTmux = false;
|
||||||
|
/** External tmux server name (when outside tmux) */
|
||||||
|
private serverName: string | null = null;
|
||||||
|
/** External tmux session name (when outside tmux) */
|
||||||
|
private sessionName: string | null = null;
|
||||||
|
/** External tmux window name (when outside tmux) */
|
||||||
|
private windowName: string | null = null;
|
||||||
|
|
||||||
|
private panes: Map<string, TmuxAgentPane> = new Map();
|
||||||
|
private agentOrder: string[] = [];
|
||||||
|
private activeAgentId: string | null = null;
|
||||||
|
private onExitCallback: AgentExitCallback | null = null;
|
||||||
|
private exitPollTimer: NodeJS.Timeout | null = null;
|
||||||
|
private initialized = false;
|
||||||
|
/** Whether cleanup() has been called */
|
||||||
|
private cleanedUp = false;
|
||||||
|
/** Number of agents currently being spawned asynchronously */
|
||||||
|
private pendingSpawns = 0;
|
||||||
|
/** Queue to serialize spawn operations (prevents race conditions) */
|
||||||
|
private spawnQueue: Promise<void> = Promise.resolve();
|
||||||
|
async init(): Promise<void> {
|
||||||
|
if (this.initialized) return;
|
||||||
|
|
||||||
|
// Verify tmux is available and version is sufficient
|
||||||
|
await verifyTmux();
|
||||||
|
|
||||||
|
this.insideTmux = Boolean(process.env['TMUX']);
|
||||||
|
|
||||||
|
if (this.insideTmux) {
|
||||||
|
// Get the current pane ID (this is where the main process runs)
|
||||||
|
this.mainPaneId = await tmuxCurrentPaneId();
|
||||||
|
this.windowTarget = await tmuxCurrentWindowTarget();
|
||||||
|
debugLogger.info(
|
||||||
|
`Initialized inside tmux: pane ${this.mainPaneId}, window ${this.windowTarget}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
debugLogger.info(
|
||||||
|
'Initialized outside tmux; will use external tmux server',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Agent Lifecycle ────────────────────────────────────────
|
||||||
|
|
||||||
|
async spawnAgent(config: AgentSpawnConfig): Promise<void> {
|
||||||
|
if (!this.initialized) {
|
||||||
|
throw new Error('TmuxBackend not initialized. Call init() first.');
|
||||||
|
}
|
||||||
|
if (this.panes.has(config.agentId)) {
|
||||||
|
throw new Error(`Agent "${config.agentId}" already exists.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the shell command string for the agent
|
||||||
|
const cmd = this.buildShellCommand(config);
|
||||||
|
|
||||||
|
// Track pending spawn so waitForAll/allExited don't return
|
||||||
|
// prematurely before the pane is registered.
|
||||||
|
this.pendingSpawns++;
|
||||||
|
|
||||||
|
// Chain spawn operations to ensure they run sequentially.
|
||||||
|
// This prevents race conditions where multiple agents all see
|
||||||
|
// panes.size === 0 and try to split from mainPaneId.
|
||||||
|
const spawnPromise = this.spawnQueue.then(() =>
|
||||||
|
this.spawnAgentAsync(config, cmd),
|
||||||
|
);
|
||||||
|
this.spawnQueue = spawnPromise;
|
||||||
|
|
||||||
|
// Wait for this specific spawn to complete
|
||||||
|
await spawnPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async spawnAgentAsync(
|
||||||
|
config: AgentSpawnConfig,
|
||||||
|
cmd: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const { agentId } = config;
|
||||||
|
const options = this.resolveTmuxOptions(config);
|
||||||
|
|
||||||
|
debugLogger.info(
|
||||||
|
`[spawnAgentAsync] Starting spawn for agent "${agentId}", mainPane="${this.mainPaneId}", currentPanesCount=${this.panes.size}`,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
let paneId = '';
|
||||||
|
if (this.insideTmux) {
|
||||||
|
paneId = await this.spawnInsideTmux(cmd, options);
|
||||||
|
} else {
|
||||||
|
paneId = await this.spawnOutsideTmux(config, cmd, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverName = this.getServerName();
|
||||||
|
|
||||||
|
// Set remain-on-exit so we can detect when the process exits
|
||||||
|
await tmuxSetOption(paneId, 'remain-on-exit', 'on', serverName);
|
||||||
|
|
||||||
|
// Apply pane title/border styling
|
||||||
|
await this.applyPaneDecorations(paneId, options, serverName);
|
||||||
|
|
||||||
|
if (this.insideTmux) {
|
||||||
|
await this.applyInsideLayout(options);
|
||||||
|
await this.sleep(INTERNAL_LAYOUT_SETTLE_MS);
|
||||||
|
// Keep focus on the main pane
|
||||||
|
await tmuxSelectPane(this.mainPaneId);
|
||||||
|
this.triggerMainProcessRedraw();
|
||||||
|
} else {
|
||||||
|
await this.applyExternalLayout(serverName);
|
||||||
|
await this.sleep(EXTERNAL_LAYOUT_SETTLE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentPane: TmuxAgentPane = {
|
||||||
|
agentId,
|
||||||
|
paneId,
|
||||||
|
status: 'running',
|
||||||
|
exitCode: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.panes.set(agentId, agentPane);
|
||||||
|
this.agentOrder.push(agentId);
|
||||||
|
|
||||||
|
// First agent becomes active
|
||||||
|
if (this.activeAgentId === null) {
|
||||||
|
this.activeAgentId = agentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start exit polling if not already running
|
||||||
|
this.startExitPolling();
|
||||||
|
|
||||||
|
debugLogger.info(
|
||||||
|
`[spawnAgentAsync] Spawned agent "${agentId}" in pane ${paneId} — SUCCESS`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
debugLogger.error(
|
||||||
|
`[spawnAgentAsync] Failed to spawn agent "${agentId}":`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
// Still register the agent as failed so exit callback fires
|
||||||
|
this.panes.set(agentId, {
|
||||||
|
agentId,
|
||||||
|
paneId: '',
|
||||||
|
status: 'exited',
|
||||||
|
exitCode: 1,
|
||||||
|
});
|
||||||
|
this.agentOrder.push(agentId);
|
||||||
|
this.onExitCallback?.(agentId, 1, null);
|
||||||
|
} finally {
|
||||||
|
this.pendingSpawns--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger terminal redraw in main process after pane layout changes.
|
||||||
|
* Uses multiple methods to ensure Ink picks up the new terminal size.
|
||||||
|
*/
|
||||||
|
private triggerMainProcessRedraw(): void {
|
||||||
|
if (!this.insideTmux) return;
|
||||||
|
// Small delay to let tmux finish the resize operation
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
// Method 1: Emit resize event on stdout (Ink listens to this)
|
||||||
|
if (process.stdout.isTTY) {
|
||||||
|
process.stdout.emit('resize');
|
||||||
|
debugLogger.info(
|
||||||
|
'[triggerMainProcessRedraw] Emitted stdout resize event',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 2: Send SIGWINCH signal
|
||||||
|
process.kill(process.pid, 'SIGWINCH');
|
||||||
|
debugLogger.info('[triggerMainProcessRedraw] Sent SIGWINCH');
|
||||||
|
} catch (error) {
|
||||||
|
debugLogger.info(`[triggerMainProcessRedraw] Failed: ${error}`);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
stopAgent(agentId: string): void {
|
||||||
|
const pane = this.panes.get(agentId);
|
||||||
|
if (!pane || pane.status !== 'running') return;
|
||||||
|
// Kill the pane outright — a single Ctrl-C only cancels the current
|
||||||
|
// turn in interactive CLI agents and does not reliably exit the process.
|
||||||
|
if (pane.paneId) {
|
||||||
|
void tmuxKillPane(pane.paneId, this.getServerName());
|
||||||
|
}
|
||||||
|
pane.status = 'exited';
|
||||||
|
debugLogger.info(`Killed pane for agent "${agentId}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
stopAll(): void {
|
||||||
|
for (const [agentId, pane] of this.panes.entries()) {
|
||||||
|
if (pane.status === 'running') {
|
||||||
|
if (pane.paneId) {
|
||||||
|
void tmuxKillPane(pane.paneId, this.getServerName());
|
||||||
|
}
|
||||||
|
pane.status = 'exited';
|
||||||
|
debugLogger.info(`Killed pane for agent "${agentId}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cleanup(): Promise<void> {
|
||||||
|
this.cleanedUp = true;
|
||||||
|
this.stopExitPolling();
|
||||||
|
|
||||||
|
// Kill all agent panes (but not the main pane)
|
||||||
|
for (const pane of this.panes.values()) {
|
||||||
|
if (pane.paneId) {
|
||||||
|
try {
|
||||||
|
await tmuxKillPane(pane.paneId, this.getServerName());
|
||||||
|
debugLogger.info(`Killed agent pane ${pane.paneId}`);
|
||||||
|
} catch (_error) {
|
||||||
|
// Pane may already be gone
|
||||||
|
debugLogger.info(
|
||||||
|
`Failed to kill pane ${pane.paneId} (may already be gone)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kill the external tmux session/server if we created one
|
||||||
|
if (!this.insideTmux && this.sessionName && this.serverName) {
|
||||||
|
try {
|
||||||
|
await tmuxKillSession(this.sessionName, this.serverName);
|
||||||
|
debugLogger.info(
|
||||||
|
`Killed external tmux session "${this.sessionName}" on server "${this.serverName}"`,
|
||||||
|
);
|
||||||
|
} catch (_error) {
|
||||||
|
debugLogger.info(
|
||||||
|
`Failed to kill external tmux session (may already be gone)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.panes.clear();
|
||||||
|
this.agentOrder = [];
|
||||||
|
this.activeAgentId = null;
|
||||||
|
this.serverName = null;
|
||||||
|
this.sessionName = null;
|
||||||
|
this.windowName = null;
|
||||||
|
this.windowTarget = '';
|
||||||
|
this.mainPaneId = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
setOnAgentExit(callback: AgentExitCallback): void {
|
||||||
|
this.onExitCallback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForAll(timeoutMs?: number): Promise<boolean> {
|
||||||
|
if (this.allExited() || this.cleanedUp) return this.allExited();
|
||||||
|
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
let timeoutHandle: NodeJS.Timeout | undefined;
|
||||||
|
|
||||||
|
const checkInterval = setInterval(() => {
|
||||||
|
if (this.allExited() || this.cleanedUp) {
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||||
|
resolve(this.allExited());
|
||||||
|
}
|
||||||
|
}, EXIT_POLL_INTERVAL_MS);
|
||||||
|
|
||||||
|
if (timeoutMs !== undefined) {
|
||||||
|
timeoutHandle = setTimeout(() => {
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
resolve(false);
|
||||||
|
}, timeoutMs);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Active Agent & Navigation ──────────────────────────────
|
||||||
|
|
||||||
|
switchTo(agentId: string): void {
|
||||||
|
if (!this.panes.has(agentId)) {
|
||||||
|
throw new Error(`Agent "${agentId}" not found.`);
|
||||||
|
}
|
||||||
|
const pane = this.panes.get(agentId)!;
|
||||||
|
this.activeAgentId = agentId;
|
||||||
|
void tmuxSelectPane(pane.paneId, this.getServerName());
|
||||||
|
}
|
||||||
|
|
||||||
|
switchToNext(): void {
|
||||||
|
if (this.agentOrder.length <= 1) return;
|
||||||
|
const currentIndex = this.agentOrder.indexOf(this.activeAgentId ?? '');
|
||||||
|
const nextIndex = (currentIndex + 1) % this.agentOrder.length;
|
||||||
|
this.switchTo(this.agentOrder[nextIndex]!);
|
||||||
|
}
|
||||||
|
|
||||||
|
switchToPrevious(): void {
|
||||||
|
if (this.agentOrder.length <= 1) return;
|
||||||
|
const currentIndex = this.agentOrder.indexOf(this.activeAgentId ?? '');
|
||||||
|
const prevIndex =
|
||||||
|
(currentIndex - 1 + this.agentOrder.length) % this.agentOrder.length;
|
||||||
|
this.switchTo(this.agentOrder[prevIndex]!);
|
||||||
|
}
|
||||||
|
|
||||||
|
getActiveAgentId(): string | null {
|
||||||
|
return this.activeAgentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Screen Capture ─────────────────────────────────────────
|
||||||
|
|
||||||
|
getActiveSnapshot(): AnsiOutput | null {
|
||||||
|
if (!this.activeAgentId) return null;
|
||||||
|
return this.getAgentSnapshot(this.activeAgentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAgentSnapshot(
|
||||||
|
agentId: string,
|
||||||
|
_scrollOffset: number = 0,
|
||||||
|
): AnsiOutput | null {
|
||||||
|
// tmux panes are rendered by tmux itself. capture-pane is available
|
||||||
|
// but returns raw text. For the progress bar we don't need snapshots;
|
||||||
|
// full rendering is handled by tmux directly.
|
||||||
|
// Return null — the UI doesn't use snapshots for split-pane backends.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAgentScrollbackLength(_agentId: string): number {
|
||||||
|
// Scrollback is managed by tmux, not by us
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Input ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
forwardInput(data: string): boolean {
|
||||||
|
if (!this.activeAgentId) return false;
|
||||||
|
return this.writeToAgent(this.activeAgentId, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
writeToAgent(agentId: string, data: string): boolean {
|
||||||
|
const pane = this.panes.get(agentId);
|
||||||
|
if (!pane || pane.status !== 'running') return false;
|
||||||
|
void tmuxSendKeys(
|
||||||
|
pane.paneId,
|
||||||
|
data,
|
||||||
|
{ literal: true },
|
||||||
|
this.getServerName(),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Resize ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
resizeAll(_cols: number, _rows: number): void {
|
||||||
|
// tmux manages pane sizes automatically based on the terminal window
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── External Session Info ─────────────────────────────────
|
||||||
|
|
||||||
|
getAttachHint(): string | null {
|
||||||
|
if (this.insideTmux) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// When outside tmux, the server name is determined at init time
|
||||||
|
// (per-process unique). Return the attach command even before
|
||||||
|
// ensureExternalSession runs, since the server name is deterministic.
|
||||||
|
const server = this.serverName ?? `${TMUX_SERVER_PREFIX}-${process.pid}`;
|
||||||
|
return `tmux -L ${server} a`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Private ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private resolveTmuxOptions(config: AgentSpawnConfig): ResolvedTmuxOptions {
|
||||||
|
const opts = config.backend?.tmux ?? {};
|
||||||
|
return {
|
||||||
|
serverName: opts.serverName ?? `${TMUX_SERVER_PREFIX}-${process.pid}`,
|
||||||
|
sessionName: opts.sessionName ?? DEFAULT_TMUX_SESSION,
|
||||||
|
windowName: opts.windowName ?? DEFAULT_TMUX_WINDOW,
|
||||||
|
paneTitle: opts.paneTitle ?? config.agentId,
|
||||||
|
paneBorderStyle: opts.paneBorderStyle,
|
||||||
|
paneActiveBorderStyle: opts.paneActiveBorderStyle,
|
||||||
|
paneBorderFormat: opts.paneBorderFormat ?? DEFAULT_PANE_BORDER_FORMAT,
|
||||||
|
paneBorderStatus:
|
||||||
|
opts.paneBorderStatus ?? (this.insideTmux ? undefined : 'top'),
|
||||||
|
leaderPaneWidthPercent:
|
||||||
|
opts.leaderPaneWidthPercent ?? DEFAULT_LEADER_WIDTH_PERCENT,
|
||||||
|
firstSplitPercent: opts.firstSplitPercent ?? DEFAULT_FIRST_SPLIT_PERCENT,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getServerName(): string | undefined {
|
||||||
|
return this.insideTmux ? undefined : (this.serverName ?? undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureExternalSession(
|
||||||
|
config: AgentSpawnConfig,
|
||||||
|
options: ResolvedTmuxOptions,
|
||||||
|
): Promise<void> {
|
||||||
|
if (
|
||||||
|
this.windowTarget &&
|
||||||
|
this.serverName &&
|
||||||
|
this.sessionName &&
|
||||||
|
this.windowName
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.serverName = options.serverName;
|
||||||
|
this.sessionName = options.sessionName;
|
||||||
|
this.windowName = options.windowName;
|
||||||
|
|
||||||
|
const serverName = this.serverName;
|
||||||
|
const sessionExists = await tmuxHasSession(this.sessionName, serverName);
|
||||||
|
|
||||||
|
if (!sessionExists) {
|
||||||
|
await tmuxNewSession(
|
||||||
|
this.sessionName,
|
||||||
|
{
|
||||||
|
cols: config.cols,
|
||||||
|
rows: config.rows,
|
||||||
|
windowName: this.windowName,
|
||||||
|
},
|
||||||
|
serverName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const windowExists = sessionExists
|
||||||
|
? await tmuxHasWindow(this.sessionName, this.windowName, serverName)
|
||||||
|
: true;
|
||||||
|
|
||||||
|
if (!windowExists) {
|
||||||
|
await tmuxNewWindow(this.sessionName, this.windowName, serverName);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.windowTarget = `${this.sessionName}:${this.windowName}`;
|
||||||
|
|
||||||
|
if (!this.mainPaneId) {
|
||||||
|
this.mainPaneId = await tmuxGetFirstPaneId(this.windowTarget, serverName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async spawnInsideTmux(
|
||||||
|
cmd: string,
|
||||||
|
options: ResolvedTmuxOptions,
|
||||||
|
): Promise<string> {
|
||||||
|
if (!this.windowTarget) {
|
||||||
|
throw new Error('Tmux window target not initialized.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const panes = await tmuxListPanes(this.windowTarget);
|
||||||
|
const paneCount = panes.length;
|
||||||
|
if (paneCount === 1) {
|
||||||
|
debugLogger.info(
|
||||||
|
`[spawnInsideTmux] First agent — split -h -l ${options.firstSplitPercent}% from ${this.mainPaneId}`,
|
||||||
|
);
|
||||||
|
return await tmuxSplitWindow(this.mainPaneId, {
|
||||||
|
horizontal: true,
|
||||||
|
percent: options.firstSplitPercent,
|
||||||
|
command: cmd,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const splitTarget = this.pickMiddlePane(panes).paneId;
|
||||||
|
const horizontal = this.shouldSplitHorizontally(paneCount);
|
||||||
|
debugLogger.info(
|
||||||
|
`[spawnInsideTmux] Split from middle pane ${splitTarget} (${paneCount} panes, ${horizontal ? 'horizontal' : 'vertical'})`,
|
||||||
|
);
|
||||||
|
return await tmuxSplitWindow(splitTarget, {
|
||||||
|
horizontal,
|
||||||
|
command: cmd,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async spawnOutsideTmux(
|
||||||
|
config: AgentSpawnConfig,
|
||||||
|
cmd: string,
|
||||||
|
options: ResolvedTmuxOptions,
|
||||||
|
): Promise<string> {
|
||||||
|
await this.ensureExternalSession(config, options);
|
||||||
|
if (!this.windowTarget) {
|
||||||
|
throw new Error('External tmux window target not initialized.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverName = this.getServerName();
|
||||||
|
|
||||||
|
if (this.panes.size === 0) {
|
||||||
|
const firstPaneId = await tmuxGetFirstPaneId(
|
||||||
|
this.windowTarget,
|
||||||
|
serverName,
|
||||||
|
);
|
||||||
|
this.mainPaneId = firstPaneId;
|
||||||
|
debugLogger.info(
|
||||||
|
`[spawnOutsideTmux] First agent — respawn in pane ${firstPaneId}`,
|
||||||
|
);
|
||||||
|
await tmuxRespawnPane(firstPaneId, cmd, serverName);
|
||||||
|
return firstPaneId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const panes = await tmuxListPanes(this.windowTarget, serverName);
|
||||||
|
const splitTarget = this.pickMiddlePane(panes).paneId;
|
||||||
|
const horizontal = this.shouldSplitHorizontally(panes.length);
|
||||||
|
debugLogger.info(
|
||||||
|
`[spawnOutsideTmux] Split from middle pane ${splitTarget} (${panes.length} panes, ${horizontal ? 'horizontal' : 'vertical'})`,
|
||||||
|
);
|
||||||
|
return await tmuxSplitWindow(
|
||||||
|
splitTarget,
|
||||||
|
{ horizontal, command: cmd },
|
||||||
|
serverName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private pickMiddlePane(panes: TmuxPaneInfo[]): TmuxPaneInfo {
|
||||||
|
if (panes.length === 0) {
|
||||||
|
throw new Error('No panes available to split.');
|
||||||
|
}
|
||||||
|
return panes[Math.floor(panes.length / 2)]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldSplitHorizontally(paneCount: number): boolean {
|
||||||
|
return paneCount % 2 === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async applyPaneDecorations(
|
||||||
|
paneId: string,
|
||||||
|
options: ResolvedTmuxOptions,
|
||||||
|
serverName?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!this.windowTarget) return;
|
||||||
|
|
||||||
|
if (options.paneBorderStatus) {
|
||||||
|
await tmuxSetOption(
|
||||||
|
this.windowTarget,
|
||||||
|
'pane-border-status',
|
||||||
|
options.paneBorderStatus,
|
||||||
|
serverName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.paneBorderFormat) {
|
||||||
|
await tmuxSetOption(
|
||||||
|
this.windowTarget,
|
||||||
|
'pane-border-format',
|
||||||
|
options.paneBorderFormat,
|
||||||
|
serverName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.paneBorderStyle) {
|
||||||
|
await tmuxSetOption(
|
||||||
|
this.windowTarget,
|
||||||
|
'pane-border-style',
|
||||||
|
options.paneBorderStyle,
|
||||||
|
serverName,
|
||||||
|
);
|
||||||
|
await tmuxSelectPaneStyle(paneId, options.paneBorderStyle, serverName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.paneActiveBorderStyle) {
|
||||||
|
await tmuxSetOption(
|
||||||
|
this.windowTarget,
|
||||||
|
'pane-active-border-style',
|
||||||
|
options.paneActiveBorderStyle,
|
||||||
|
serverName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await tmuxSelectPaneTitle(paneId, options.paneTitle, serverName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async applyInsideLayout(options: ResolvedTmuxOptions): Promise<void> {
|
||||||
|
if (!this.windowTarget || !this.mainPaneId) return;
|
||||||
|
await tmuxSelectLayout(this.windowTarget, 'main-vertical');
|
||||||
|
await tmuxResizePane(this.mainPaneId, {
|
||||||
|
width: `${options.leaderPaneWidthPercent}%`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async applyExternalLayout(serverName?: string): Promise<void> {
|
||||||
|
if (!this.windowTarget) return;
|
||||||
|
await tmuxSelectLayout(this.windowTarget, 'tiled', serverName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sleep(ms: number): Promise<void> {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildShellCommand(config: AgentSpawnConfig): string {
|
||||||
|
// Build env prefix + command + args
|
||||||
|
const envParts: string[] = [];
|
||||||
|
if (config.env) {
|
||||||
|
for (const [key, value] of Object.entries(config.env)) {
|
||||||
|
envParts.push(`${key}=${shellQuote(value)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cmdParts = [
|
||||||
|
shellQuote(config.command),
|
||||||
|
...config.args.map(shellQuote),
|
||||||
|
];
|
||||||
|
|
||||||
|
// cd to the working directory first
|
||||||
|
const parts = [`cd ${shellQuote(config.cwd)}`];
|
||||||
|
if (envParts.length > 0) {
|
||||||
|
parts.push(`env ${envParts.join(' ')} ${cmdParts.join(' ')}`);
|
||||||
|
} else {
|
||||||
|
parts.push(cmdParts.join(' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullCommand = parts.join(' && ');
|
||||||
|
debugLogger.info(
|
||||||
|
`[buildShellCommand] agentId=${config.agentId}, command=${config.command}, args=${JSON.stringify(config.args)}, cwd=${config.cwd}`,
|
||||||
|
);
|
||||||
|
debugLogger.info(`[buildShellCommand] full shell command: ${fullCommand}`);
|
||||||
|
return fullCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
private allExited(): boolean {
|
||||||
|
if (this.pendingSpawns > 0) return false;
|
||||||
|
if (this.panes.size === 0) return true;
|
||||||
|
for (const pane of this.panes.values()) {
|
||||||
|
if (pane.status === 'running') return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private startExitPolling(): void {
|
||||||
|
if (this.exitPollTimer) return;
|
||||||
|
|
||||||
|
this.exitPollTimer = setInterval(() => {
|
||||||
|
void this.pollPaneStatus();
|
||||||
|
}, EXIT_POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopExitPolling(): void {
|
||||||
|
if (this.exitPollTimer) {
|
||||||
|
clearInterval(this.exitPollTimer);
|
||||||
|
this.exitPollTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pollPaneStatus(): Promise<void> {
|
||||||
|
let paneInfos: TmuxPaneInfo[];
|
||||||
|
const serverName = this.getServerName();
|
||||||
|
try {
|
||||||
|
if (!this.windowTarget) return;
|
||||||
|
// List panes in the active window
|
||||||
|
paneInfos = await tmuxListPanes(this.windowTarget, serverName);
|
||||||
|
} catch (err) {
|
||||||
|
// Window may have been killed externally
|
||||||
|
debugLogger.info(
|
||||||
|
`[pollPaneStatus] Failed to list panes for window "${this.windowTarget}": ${err}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a lookup: paneId → TmuxPaneInfo
|
||||||
|
const paneMap = new Map<string, TmuxPaneInfo>();
|
||||||
|
for (const info of paneInfos) {
|
||||||
|
paneMap.set(info.paneId, info);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log all pane statuses for debugging (only when there are agent panes)
|
||||||
|
if (this.panes.size > 0) {
|
||||||
|
debugLogger.info(
|
||||||
|
`[pollPaneStatus] paneCount=${paneInfos.length}, agentPanes=${JSON.stringify(
|
||||||
|
Array.from(this.panes.values()).map((p) => {
|
||||||
|
const info = paneMap.get(p.paneId);
|
||||||
|
return {
|
||||||
|
agentId: p.agentId,
|
||||||
|
paneId: p.paneId,
|
||||||
|
status: p.status,
|
||||||
|
dead: info?.dead,
|
||||||
|
deadStatus: info?.deadStatus,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const agent of this.panes.values()) {
|
||||||
|
if (agent.status !== 'running') continue;
|
||||||
|
|
||||||
|
const info = paneMap.get(agent.paneId);
|
||||||
|
if (!info) {
|
||||||
|
// Pane was killed externally — treat as exited
|
||||||
|
agent.status = 'exited';
|
||||||
|
agent.exitCode = 1;
|
||||||
|
debugLogger.info(
|
||||||
|
`[pollPaneStatus] Agent "${agent.agentId}" pane ${agent.paneId} not found in tmux list — marking as exited`,
|
||||||
|
);
|
||||||
|
this.onExitCallback?.(agent.agentId, 1, null);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.dead) {
|
||||||
|
agent.status = 'exited';
|
||||||
|
agent.exitCode = info.deadStatus;
|
||||||
|
|
||||||
|
debugLogger.info(
|
||||||
|
`[pollPaneStatus] Agent "${agent.agentId}" (pane ${agent.paneId}) detected as DEAD with exit code ${info.deadStatus}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.onExitCallback?.(agent.agentId, info.deadStatus, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop polling if all agents have exited
|
||||||
|
if (this.allExited()) {
|
||||||
|
this.stopExitPolling();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple shell quoting for building command strings.
|
||||||
|
* Wraps value in single quotes, escaping any internal single quotes.
|
||||||
|
*/
|
||||||
|
function shellQuote(value: string): string {
|
||||||
|
return `'${value.replace(/'/g, "'\\''")}'`;
|
||||||
|
}
|
||||||
88
packages/core/src/agents/backends/detect.ts
Normal file
88
packages/core/src/agents/backends/detect.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createDebugLogger } from '../../utils/debugLogger.js';
|
||||||
|
import type { Config } from '../../config/config.js';
|
||||||
|
// import { TmuxBackend } from './TmuxBackend.js';
|
||||||
|
import { InProcessBackend } from './InProcessBackend.js';
|
||||||
|
import { type Backend, DISPLAY_MODE, type DisplayMode } from './types.js';
|
||||||
|
// import { isTmuxAvailable } from './tmux-commands.js';
|
||||||
|
|
||||||
|
const debugLogger = createDebugLogger('BACKEND_DETECT');
|
||||||
|
|
||||||
|
export interface DetectBackendResult {
|
||||||
|
backend: Backend;
|
||||||
|
warning?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect and create the appropriate Backend.
|
||||||
|
*
|
||||||
|
* Detection priority:
|
||||||
|
* 1. User explicit preference (--display=in-process|tmux|iterm2)
|
||||||
|
* 2. Auto-detect:
|
||||||
|
* - inside tmux: TmuxBackend
|
||||||
|
* - other terminals: tmux external session mode when tmux is available
|
||||||
|
* - fallback to InProcessBackend
|
||||||
|
*
|
||||||
|
* @param preference - Optional display mode preference
|
||||||
|
* @param runtimeContext - Runtime config for in-process fallback
|
||||||
|
*/
|
||||||
|
export async function detectBackend(
|
||||||
|
preference: DisplayMode | undefined,
|
||||||
|
runtimeContext: Config,
|
||||||
|
): Promise<DetectBackendResult> {
|
||||||
|
// Currently only in-process mode is supported. Other backends (tmux,
|
||||||
|
// iterm2) are kept in the codebase but not wired up as entry points.
|
||||||
|
const warning =
|
||||||
|
preference && preference !== DISPLAY_MODE.IN_PROCESS
|
||||||
|
? `Display mode "${preference}" is not currently supported. Using in-process mode instead.`
|
||||||
|
: undefined;
|
||||||
|
debugLogger.info('Using InProcessBackend');
|
||||||
|
return { backend: new InProcessBackend(runtimeContext), warning };
|
||||||
|
|
||||||
|
// --- Disabled backends (kept for future use) ---
|
||||||
|
// // 1. User explicit preference
|
||||||
|
// if (preference === DISPLAY_MODE.IN_PROCESS) {
|
||||||
|
// debugLogger.info('Using InProcessBackend (user preference)');
|
||||||
|
// return { backend: new InProcessBackend(runtimeContext) };
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// if (preference === DISPLAY_MODE.ITERM2) {
|
||||||
|
// throw new Error(
|
||||||
|
// `Arena display mode "${DISPLAY_MODE.ITERM2}" is not implemented yet. Please use "${DISPLAY_MODE.TMUX}" or "${DISPLAY_MODE.IN_PROCESS}".`,
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// if (preference === DISPLAY_MODE.TMUX) {
|
||||||
|
// debugLogger.info('Using TmuxBackend (user preference)');
|
||||||
|
// return { backend: new TmuxBackend() };
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // 2. Auto-detect
|
||||||
|
// if (process.env['TMUX']) {
|
||||||
|
// debugLogger.info('Detected $TMUX — attempting TmuxBackend');
|
||||||
|
// return { backend: new TmuxBackend() };
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Other terminals (including iTerm2): use tmux external session mode if available.
|
||||||
|
// if (isTmuxAvailable()) {
|
||||||
|
// debugLogger.info(
|
||||||
|
// 'tmux is available — using TmuxBackend external session mode',
|
||||||
|
// );
|
||||||
|
// return { backend: new TmuxBackend() };
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Fallback: use InProcessBackend
|
||||||
|
// debugLogger.info(
|
||||||
|
// 'No PTY backend available — falling back to InProcessBackend',
|
||||||
|
// );
|
||||||
|
// return {
|
||||||
|
// backend: new InProcessBackend(runtimeContext),
|
||||||
|
// warning:
|
||||||
|
// 'tmux is not available. Using in-process mode (no split-pane terminal view).',
|
||||||
|
// };
|
||||||
|
}
|
||||||
19
packages/core/src/agents/backends/index.ts
Normal file
19
packages/core/src/agents/backends/index.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { DISPLAY_MODE } from './types.js';
|
||||||
|
export type {
|
||||||
|
Backend,
|
||||||
|
DisplayMode,
|
||||||
|
AgentSpawnConfig,
|
||||||
|
AgentExitCallback,
|
||||||
|
TmuxBackendOptions,
|
||||||
|
InProcessSpawnConfig,
|
||||||
|
} from './types.js';
|
||||||
|
export { TmuxBackend } from './TmuxBackend.js';
|
||||||
|
export { ITermBackend } from './ITermBackend.js';
|
||||||
|
export { InProcessBackend } from './InProcessBackend.js';
|
||||||
|
export { detectBackend, type DetectBackendResult } from './detect.js';
|
||||||
318
packages/core/src/agents/backends/iterm-it2.test.ts
Normal file
318
packages/core/src/agents/backends/iterm-it2.test.ts
Normal file
|
|
@ -0,0 +1,318 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
|
||||||
|
// ─── Hoisted mocks for shell-utils ──────────────────────────────
|
||||||
|
const hoistedExecCommand = vi.hoisted(() => vi.fn());
|
||||||
|
const hoistedIsCommandAvailable = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock('../../utils/shell-utils.js', () => ({
|
||||||
|
execCommand: hoistedExecCommand,
|
||||||
|
isCommandAvailable: hoistedIsCommandAvailable,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../utils/debugLogger.js', () => ({
|
||||||
|
createDebugLogger: () => ({
|
||||||
|
info: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
isIt2Available,
|
||||||
|
ensureIt2Installed,
|
||||||
|
verifyITerm,
|
||||||
|
itermSplitPane,
|
||||||
|
itermRunCommand,
|
||||||
|
itermFocusSession,
|
||||||
|
itermSendText,
|
||||||
|
itermCloseSession,
|
||||||
|
} from './iterm-it2.js';
|
||||||
|
|
||||||
|
describe('iterm-it2', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── isIt2Available ─────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('isIt2Available', () => {
|
||||||
|
it('returns true when it2 is on PATH', () => {
|
||||||
|
hoistedIsCommandAvailable.mockReturnValue({ available: true });
|
||||||
|
expect(isIt2Available()).toBe(true);
|
||||||
|
expect(hoistedIsCommandAvailable).toHaveBeenCalledWith('it2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when it2 is not on PATH', () => {
|
||||||
|
hoistedIsCommandAvailable.mockReturnValue({ available: false });
|
||||||
|
expect(isIt2Available()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── ensureIt2Installed ──────────────────────────────────────
|
||||||
|
|
||||||
|
describe('ensureIt2Installed', () => {
|
||||||
|
it('does nothing if it2 is already available', async () => {
|
||||||
|
hoistedIsCommandAvailable.mockReturnValue({ available: true });
|
||||||
|
await ensureIt2Installed();
|
||||||
|
expect(hoistedExecCommand).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('installs via uv when uv is available', async () => {
|
||||||
|
// isIt2Available() → false; uv available; install succeeds; recheck → true
|
||||||
|
hoistedIsCommandAvailable
|
||||||
|
.mockReturnValueOnce({ available: false }) // isIt2Available() initial
|
||||||
|
.mockReturnValueOnce({ available: true }); // uv available
|
||||||
|
hoistedExecCommand.mockResolvedValue({
|
||||||
|
code: 0,
|
||||||
|
stdout: '',
|
||||||
|
stderr: '',
|
||||||
|
});
|
||||||
|
// After install, it2 is available
|
||||||
|
hoistedIsCommandAvailable.mockReturnValueOnce({ available: true });
|
||||||
|
|
||||||
|
await ensureIt2Installed();
|
||||||
|
|
||||||
|
expect(hoistedExecCommand).toHaveBeenCalledWith(
|
||||||
|
'uv',
|
||||||
|
['tool', 'install', 'it2'],
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to pipx when uv is unavailable', async () => {
|
||||||
|
hoistedIsCommandAvailable
|
||||||
|
.mockReturnValueOnce({ available: false }) // isIt2Available()
|
||||||
|
.mockReturnValueOnce({ available: false }) // uv not available
|
||||||
|
.mockReturnValueOnce({ available: true }); // pipx available
|
||||||
|
hoistedExecCommand.mockResolvedValue({
|
||||||
|
code: 0,
|
||||||
|
stdout: '',
|
||||||
|
stderr: '',
|
||||||
|
});
|
||||||
|
hoistedIsCommandAvailable.mockReturnValueOnce({ available: true }); // recheck
|
||||||
|
|
||||||
|
await ensureIt2Installed();
|
||||||
|
|
||||||
|
expect(hoistedExecCommand).toHaveBeenCalledWith(
|
||||||
|
'pipx',
|
||||||
|
['install', 'it2'],
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to pip when uv and pipx are unavailable', async () => {
|
||||||
|
hoistedIsCommandAvailable
|
||||||
|
.mockReturnValueOnce({ available: false }) // isIt2Available()
|
||||||
|
.mockReturnValueOnce({ available: false }) // uv
|
||||||
|
.mockReturnValueOnce({ available: false }) // pipx
|
||||||
|
.mockReturnValueOnce({ available: true }); // pip available
|
||||||
|
hoistedExecCommand.mockResolvedValue({
|
||||||
|
code: 0,
|
||||||
|
stdout: '',
|
||||||
|
stderr: '',
|
||||||
|
});
|
||||||
|
hoistedIsCommandAvailable.mockReturnValueOnce({ available: true }); // recheck
|
||||||
|
|
||||||
|
await ensureIt2Installed();
|
||||||
|
|
||||||
|
expect(hoistedExecCommand).toHaveBeenCalledWith(
|
||||||
|
'pip',
|
||||||
|
['install', '--user', 'it2'],
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws if no installer succeeds', async () => {
|
||||||
|
hoistedIsCommandAvailable.mockReturnValue({ available: false });
|
||||||
|
|
||||||
|
await expect(ensureIt2Installed()).rejects.toThrow(
|
||||||
|
'it2 is not installed',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── verifyITerm ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('verifyITerm', () => {
|
||||||
|
it('succeeds when session list returns code 0', async () => {
|
||||||
|
hoistedIsCommandAvailable.mockReturnValue({ available: true });
|
||||||
|
hoistedExecCommand.mockResolvedValue({
|
||||||
|
code: 0,
|
||||||
|
stdout: 'session1\n',
|
||||||
|
stderr: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(verifyITerm()).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws Python API error when stderr mentions "api"', async () => {
|
||||||
|
hoistedIsCommandAvailable.mockReturnValue({ available: true });
|
||||||
|
hoistedExecCommand.mockResolvedValue({
|
||||||
|
code: 1,
|
||||||
|
stdout: '',
|
||||||
|
stderr: 'Python API not enabled',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(verifyITerm()).rejects.toThrow('Python API not enabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws Python API error when stderr mentions "connection refused"', async () => {
|
||||||
|
hoistedIsCommandAvailable.mockReturnValue({ available: true });
|
||||||
|
hoistedExecCommand.mockResolvedValue({
|
||||||
|
code: 1,
|
||||||
|
stdout: '',
|
||||||
|
stderr: 'Connection refused to iTerm2',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(verifyITerm()).rejects.toThrow('Python API not enabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws generic error for unrecognized failures', async () => {
|
||||||
|
hoistedIsCommandAvailable.mockReturnValue({ available: true });
|
||||||
|
hoistedExecCommand.mockResolvedValue({
|
||||||
|
code: 1,
|
||||||
|
stdout: '',
|
||||||
|
stderr: 'some unknown error',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(verifyITerm()).rejects.toThrow('it2 session list failed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── itermSplitPane ──────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('itermSplitPane', () => {
|
||||||
|
it('splits vertically without session ID', async () => {
|
||||||
|
hoistedExecCommand.mockResolvedValue({
|
||||||
|
code: 0,
|
||||||
|
stdout: 'Created new pane: w0t1p2\n',
|
||||||
|
stderr: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const paneId = await itermSplitPane();
|
||||||
|
expect(paneId).toBe('w0t1p2');
|
||||||
|
expect(hoistedExecCommand).toHaveBeenCalledWith(
|
||||||
|
'it2',
|
||||||
|
['session', 'split', '-v'],
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes -s flag when session ID is provided', async () => {
|
||||||
|
hoistedExecCommand.mockResolvedValue({
|
||||||
|
code: 0,
|
||||||
|
stdout: 'Created new pane: w0t1p3\n',
|
||||||
|
stderr: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
await itermSplitPane('sess-123');
|
||||||
|
expect(hoistedExecCommand).toHaveBeenCalledWith(
|
||||||
|
'it2',
|
||||||
|
['session', 'split', '-v', '-s', 'sess-123'],
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws if pane ID cannot be parsed from output', async () => {
|
||||||
|
hoistedExecCommand.mockResolvedValue({
|
||||||
|
code: 0,
|
||||||
|
stdout: 'Unexpected output\n',
|
||||||
|
stderr: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(itermSplitPane()).rejects.toThrow('Unable to parse');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on non-zero exit code', async () => {
|
||||||
|
hoistedExecCommand.mockResolvedValue({
|
||||||
|
code: 1,
|
||||||
|
stdout: '',
|
||||||
|
stderr: 'split failed',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(itermSplitPane()).rejects.toThrow('split failed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── itermRunCommand ──────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('itermRunCommand', () => {
|
||||||
|
it('calls it2 session run with correct args', async () => {
|
||||||
|
hoistedExecCommand.mockResolvedValue({
|
||||||
|
code: 0,
|
||||||
|
stdout: '',
|
||||||
|
stderr: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
await itermRunCommand('sess-1', 'ls -la');
|
||||||
|
expect(hoistedExecCommand).toHaveBeenCalledWith(
|
||||||
|
'it2',
|
||||||
|
['session', 'run', '-s', 'sess-1', 'ls -la'],
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── itermFocusSession ────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('itermFocusSession', () => {
|
||||||
|
it('calls it2 session focus with correct args', async () => {
|
||||||
|
hoistedExecCommand.mockResolvedValue({
|
||||||
|
code: 0,
|
||||||
|
stdout: '',
|
||||||
|
stderr: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
await itermFocusSession('sess-1');
|
||||||
|
expect(hoistedExecCommand).toHaveBeenCalledWith(
|
||||||
|
'it2',
|
||||||
|
['session', 'focus', 'sess-1'],
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── itermSendText ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('itermSendText', () => {
|
||||||
|
it('calls it2 session send with correct args', async () => {
|
||||||
|
hoistedExecCommand.mockResolvedValue({
|
||||||
|
code: 0,
|
||||||
|
stdout: '',
|
||||||
|
stderr: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
await itermSendText('sess-1', 'hello world');
|
||||||
|
expect(hoistedExecCommand).toHaveBeenCalledWith(
|
||||||
|
'it2',
|
||||||
|
['session', 'send', '-s', 'sess-1', 'hello world'],
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── itermCloseSession ────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('itermCloseSession', () => {
|
||||||
|
it('calls it2 session close with correct args', async () => {
|
||||||
|
hoistedExecCommand.mockResolvedValue({
|
||||||
|
code: 0,
|
||||||
|
stdout: '',
|
||||||
|
stderr: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
await itermCloseSession('sess-1');
|
||||||
|
expect(hoistedExecCommand).toHaveBeenCalledWith(
|
||||||
|
'it2',
|
||||||
|
['session', 'close', '-s', 'sess-1'],
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
141
packages/core/src/agents/backends/iterm-it2.ts
Normal file
141
packages/core/src/agents/backends/iterm-it2.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @fileoverview Type-safe async wrappers for iTerm2 it2 CLI commands.
|
||||||
|
*
|
||||||
|
* The it2 CLI talks to iTerm2's Python API. We use it2 directly and avoid
|
||||||
|
* AppleScript to match the Team design spec.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execCommand, isCommandAvailable } from '../../utils/shell-utils.js';
|
||||||
|
import { createDebugLogger } from '../../utils/debugLogger.js';
|
||||||
|
|
||||||
|
const debugLogger = createDebugLogger('ITERM_IT2');
|
||||||
|
|
||||||
|
// ─── Helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function it2Result(
|
||||||
|
args: string[],
|
||||||
|
): Promise<{ stdout: string; stderr: string; code: number }> {
|
||||||
|
debugLogger.info(`it2 ${args.join(' ')}`);
|
||||||
|
const result = await execCommand('it2', args, {
|
||||||
|
preserveOutputOnError: true,
|
||||||
|
});
|
||||||
|
if (result.code !== 0 && result.stderr.trim()) {
|
||||||
|
debugLogger.error(`it2 error: ${result.stderr.trim()}`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function it2(args: string[]): Promise<string> {
|
||||||
|
const result = await it2Result(args);
|
||||||
|
if (result.code !== 0) {
|
||||||
|
const message = result.stderr.trim() || result.stdout.trim();
|
||||||
|
throw new Error(message || 'it2 command failed');
|
||||||
|
}
|
||||||
|
return result.stdout;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCreatedPaneId(output: string): string {
|
||||||
|
const match = output.match(/Created new pane:\s*(\S+)/);
|
||||||
|
if (!match?.[1]) {
|
||||||
|
throw new Error(`Unable to parse it2 split output: ${output.trim()}`);
|
||||||
|
}
|
||||||
|
return match[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Installation & Verification ───────────────────────────────
|
||||||
|
|
||||||
|
export function isIt2Available(): boolean {
|
||||||
|
return isCommandAvailable('it2').available;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryInstallIt2(
|
||||||
|
command: string,
|
||||||
|
args: string[],
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!isCommandAvailable(command).available) return false;
|
||||||
|
const result = await execCommand(command, args, {
|
||||||
|
preserveOutputOnError: true,
|
||||||
|
});
|
||||||
|
return result.code === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureIt2Installed(): Promise<void> {
|
||||||
|
if (isIt2Available()) return;
|
||||||
|
|
||||||
|
const installers: Array<{ cmd: string; args: string[] }> = [
|
||||||
|
{ cmd: 'uv', args: ['tool', 'install', 'it2'] },
|
||||||
|
{ cmd: 'pipx', args: ['install', 'it2'] },
|
||||||
|
{ cmd: 'pip', args: ['install', '--user', 'it2'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const installer of installers) {
|
||||||
|
const installed = await tryInstallIt2(installer.cmd, installer.args);
|
||||||
|
if (installed && isIt2Available()) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
'it2 is not installed. Install it2 via "uv tool install it2", "pipx install it2", or "pip install --user it2".',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyITerm(): Promise<void> {
|
||||||
|
await ensureIt2Installed();
|
||||||
|
|
||||||
|
const result = await it2Result(['session', 'list']);
|
||||||
|
if (result.code === 0) return;
|
||||||
|
|
||||||
|
const combined = `${result.stdout}\n${result.stderr}`.toLowerCase();
|
||||||
|
if (
|
||||||
|
combined.includes('api') ||
|
||||||
|
combined.includes('python') ||
|
||||||
|
combined.includes('connection refused') ||
|
||||||
|
combined.includes('not enabled')
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'iTerm2 Python API not enabled. Enable it in iTerm2 → Settings → General → Magic → Enable Python API, then restart iTerm2.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`it2 session list failed: ${result.stderr.trim() || result.stdout.trim()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Public API ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function itermSplitPane(sessionId?: string): Promise<string> {
|
||||||
|
const args = ['session', 'split', '-v'];
|
||||||
|
if (sessionId) {
|
||||||
|
args.push('-s', sessionId);
|
||||||
|
}
|
||||||
|
const output = await it2(args);
|
||||||
|
return parseCreatedPaneId(output);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function itermRunCommand(
|
||||||
|
sessionId: string,
|
||||||
|
command: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await it2(['session', 'run', '-s', sessionId, command]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function itermFocusSession(sessionId: string): Promise<void> {
|
||||||
|
await it2(['session', 'focus', sessionId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function itermSendText(
|
||||||
|
sessionId: string,
|
||||||
|
text: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await it2(['session', 'send', '-s', sessionId, text]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function itermCloseSession(sessionId: string): Promise<void> {
|
||||||
|
await it2(['session', 'close', '-s', sessionId]);
|
||||||
|
}
|
||||||
60
packages/core/src/agents/backends/tmux-commands.test.ts
Normal file
60
packages/core/src/agents/backends/tmux-commands.test.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { parseTmuxListPanes } from './tmux-commands.js';
|
||||||
|
|
||||||
|
describe('parseTmuxListPanes', () => {
|
||||||
|
it('parses a single running pane', () => {
|
||||||
|
const output = '%0 0 0\n';
|
||||||
|
const result = parseTmuxListPanes(output);
|
||||||
|
expect(result).toEqual([{ paneId: '%0', dead: false, deadStatus: 0 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses a single dead pane with exit code', () => {
|
||||||
|
const output = '%1 1 42\n';
|
||||||
|
const result = parseTmuxListPanes(output);
|
||||||
|
expect(result).toEqual([{ paneId: '%1', dead: true, deadStatus: 42 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses multiple panes with mixed statuses', () => {
|
||||||
|
const output = '%0 0 0\n%1 1 1\n%2 0 0\n%3 1 137\n';
|
||||||
|
const result = parseTmuxListPanes(output);
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ paneId: '%0', dead: false, deadStatus: 0 },
|
||||||
|
{ paneId: '%1', dead: true, deadStatus: 1 },
|
||||||
|
{ paneId: '%2', dead: false, deadStatus: 0 },
|
||||||
|
{ paneId: '%3', dead: true, deadStatus: 137 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array for empty output', () => {
|
||||||
|
expect(parseTmuxListPanes('')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array for whitespace-only output', () => {
|
||||||
|
expect(parseTmuxListPanes(' \n \n')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips lines with insufficient fields', () => {
|
||||||
|
const output = '%0\n%1 1 0\n';
|
||||||
|
const result = parseTmuxListPanes(output);
|
||||||
|
expect(result).toEqual([{ paneId: '%1', dead: true, deadStatus: 0 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults deadStatus to 0 when missing', () => {
|
||||||
|
// tmux might omit the third field when pane is alive
|
||||||
|
const output = '%0 0\n';
|
||||||
|
const result = parseTmuxListPanes(output);
|
||||||
|
expect(result).toEqual([{ paneId: '%0', dead: false, deadStatus: 0 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles extra whitespace gracefully', () => {
|
||||||
|
const output = ' %5 1 99 \n';
|
||||||
|
const result = parseTmuxListPanes(output);
|
||||||
|
expect(result).toEqual([{ paneId: '%5', dead: true, deadStatus: 99 }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue