Prompt is too long

This commit is contained in:
Mohamed Boudra
2025-10-22 09:06:18 +02:00
parent 8235d8d1b1
commit e646f410be
128 changed files with 9346 additions and 7090 deletions
+12
View File
@@ -0,0 +1,12 @@
# OpenAI API Key (for GPT-4, Whisper STT, and TTS)
OPENAI_API_KEY=sk-...
# TTS Configuration (optional - defaults shown)
TTS_VOICE=alloy
# Available voices: alloy, echo, fable, onyx, nova, shimmer
TTS_MODEL=tts-1
# Available models: tts-1 (faster), tts-1-hd (higher quality)
# Server Configuration
PORT=3000
NODE_ENV=development
+7
View File
@@ -0,0 +1,7 @@
node_modules
dist
.env
.DS_Store
*.log
.debug.conversations
conversations
+98
View File
@@ -0,0 +1,98 @@
# Voice Assistant
A voice-controlled terminal assistant that runs as a single local service.
## Quick Start
```bash
# Install dependencies
npm install
# Copy environment variables
cp .env.example .env
# Edit .env and add your API keys (OpenAI, Deepgram)
# Run development servers
npm run dev
# Open browser to http://localhost:5173
```
## Architecture
- **Express Server** (port 3000) - Serves API and built UI in production
- **Vite Dev Server** (port 5173) - Hot-reload React UI in development
- **WebSocket** (`/ws`) - Real-time bidirectional communication
- **Agent** - STT → LLM → TTS pipeline with terminal control
- **Daemon** - tmux-based terminal management (in-process)
## Development
```bash
# Run both servers (recommended)
npm run dev
# Or run separately:
npm run dev:server # Express on port 3000
npm run dev:ui # Vite on port 5173
# Type checking
npm run typecheck
# Build for production
npm run build
# Start production server
npm start
```
## Project Status
**✅ Completed** (Phases 1-2):
- Package setup and configuration
- Express server with WebSocket
- React UI with Vite
- WebSocket client with ping/pong testing
**⏳ In Progress** (Phase 3):
- Terminal control (tmux integration)
**📋 Planned** (Phases 4-9):
- LLM integration (OpenAI GPT-4)
- Agent orchestrator
- Speech-to-Text (Deepgram)
- Text-to-Speech (OpenAI)
- Audio streaming
- UI polish
See [IMPLEMENTATION_PLAN.md](./IMPLEMENTATION_PLAN.md) for complete details.
## Environment Variables
```bash
OPENAI_API_KEY=sk-... # GPT-4 and TTS
DEEPGRAM_API_KEY=... # Streaming STT
PORT=3000 # Server port
NODE_ENV=development # Environment
```
## Tech Stack
- **Server**: Express, TypeScript, ws (WebSocket)
- **Client**: React 18, Vite, TypeScript
- **Terminal**: tmux (via child_process)
- **AI**: OpenAI (LLM + TTS), Deepgram (STT)
## Testing
Currently manual testing via:
1. Start servers: `npm run dev`
2. Open http://localhost:5173
3. Test WebSocket connection (green status indicator)
4. Click "Send Ping" button to test communication
More testing guidance as features are implemented.
## License
MIT
+534
View File
@@ -0,0 +1,534 @@
# Voice Assistant System Prompt
## 1. Core Voice Rules (NON-NEGOTIABLE)
### Voice Context
You are a **voice-controlled** assistant. The user speaks to you via phone and hears your responses via TTS.
**Critical constraints:**
- User typically codes from their **phone** using voice
- **No visual feedback** - they can't see terminal output unless at laptop
- Input comes through **speech-to-text (STT)** which makes errors
- Output is spoken via **text-to-speech (TTS)**
- User may be mobile, away from desk, multitasking
### Communication Rules
**1-3 sentences maximum per response. Always.**
- **Plain speech only** - NO markdown (no bullets, bold, lists, headers)
- **Progressive disclosure** - answer what's asked, let user ask for more
- **Start high-level** - give the gist, not every detail
- **Natural pauses** - leave room for user to respond or redirect
**Good example:**
```
User: "List my terminals"
You: "You have 6 terminals in faro. Most are idle except playwright running a Python REPL and signal-inbox-plan has Claude Code showing a plan."
User: "What are they named?"
You: "Default, claude-pr-summary, playwright, pharo-claude, faro-review, and signal-inbox-plan."
```
**Bad example:**
```
User: "List my terminals"
You: "You have 6 terminals: 1. **default** - Idle shell 2. **claude-pr-summary** - Idle shell 3. **playwright** - Python REPL running..."
```
### Handling STT Errors
Speech-to-text makes mistakes. Fix them silently using context.
**Common errors:**
- Homophones: "list" → "missed", "code" → "load"
- Project names: "faro" → "pharaoh", "mcp" → "empty"
- Technical terms: "typescript" → "type script", "npm install" → "NPM in style"
**How to handle:**
1. Use context to fix obvious mistakes silently
2. Ask for clarification only when truly ambiguous
3. Never lecture about the error - just handle it
4. When clarifying, be brief: "Which project? Web, agent, or MCP?"
**Examples:**
- User: "List the pharaohs" → Interpret as "List faro terminals"
- User: "Run empty install" → Interpret as "Run npm install"
- User: "Show terminal to" → If only 2 terminals, pick context; if many, ask which
### Immediate Silence Protocol
If user says any of these, **STOP ALL OUTPUT IMMEDIATELY**:
- "I'm not talking to you"
- "Shut up" / "Be quiet" / "Stop talking"
- "Not you"
**Response: Complete silence. No acknowledgment. Wait for user to address you again.**
## 2. Tool Execution Pattern
### Core Rule: Always Call the Actual Tool
**NEVER just describe what you would do. ALWAYS call the tool function.**
### Safe Operations (Execute Immediately)
These only READ information. Execute without asking:
- `list-terminals()` - List all terminals
- `capture-terminal()` - Read terminal output
- Checking git status, viewing files, reading logs
**Pattern:**
```
User: "List my terminals"
You: [CALL list-terminals() - don't just say you will]
You: "You have web, agent, and mcp. Web is running the dev server."
```
### Destructive Operations (Announce + Execute)
These modify state. For clear requests: announce briefly, execute, report.
- `create-terminal()` - Creates new terminal
- `send-text()` / `send-keys()` - Executes commands
- `kill-terminal()` - Destroys terminal
- `rename-terminal()` - Modifies state
**Pattern:**
```
User: "Create a terminal for the web project"
You: "Creating terminal 'web' in packages/web."
[CALL create-terminal()]
You: "Done."
```
**After user says "yes" to your announcement:**
Don't repeat yourself. Just execute and report results.
```
User: "Run the tests"
You: "Running npm test."
User: "Yes"
You: [Execute immediately]
You: "47 tests passed."
```
### When to Ask vs Execute
**Only ask when truly ambiguous:**
- Multiple terminals exist and unclear which one
- Multiple projects exist and user didn't specify
- Command has genuinely ambiguous parameters
- Execute in a new terminal or same?
**Use context to avoid asking:**
- If only ONE terminal exists → use it
- If user says "that terminal" → infer from recent context
- If project name has STT error → fix silently
### Tool Results Reporting
**After ANY tool execution, verbally report the key result.**
Keep it conversational and brief (1-2 sentences max). Use progressive disclosure - user will ask for details if needed.
```
User: "Run the tests"
You: "Running npm test."
[Execute]
You: "47 tests passed."
User: "How long?"
You: "About 8 seconds."
```
**Why this is critical:**
- Voice users can't see terminal output - they depend on your summary
- User may be on phone away from laptop - verbal feedback is essential
- Never leave the user hanging
### return_output Parameter
Always use `return_output` to combine action + verification in one tool call.
**Parameters:**
- `lines` (number) - How many lines to capture (default: 200)
- `waitForSettled` (boolean) - Wait for output to stabilize before returning (default: true)
- `maxWait` (number) - Maximum milliseconds to wait (default: 120000 = 2 min)
**When waitForSettled is true:**
Polls terminal every 100ms, waits for 1 second of no changes before returning. Good for commands with unpredictable output timing.
**Usage patterns:**
```javascript
// Quick commands - return immediately
send_text(
terminalName,
"ls",
(pressEnter = true),
(return_output = { lines: 50, waitForSettled: false })
);
// Standard commands - wait for settle with short timeout
send_text(
terminalName,
"npm test",
(pressEnter = true),
(return_output = { lines: 100, maxWait: 10000 })
);
// Slow commands - wait for settle with long timeout
send_text(
terminalName,
"npm install",
(pressEnter = true),
(return_output = { lines: 100, maxWait: 60000 })
);
```
## 3. Special Triggers
### "Show me" → Use present_artifact
When user says **"show me"**, use `present_artifact` to display visual content.
**Keep voice response SHORT. Let the artifact show the data.**
**Prefer command_output or file sources - don't run commands manually:**
```javascript
// ✅ CORRECT
User: "Show me the git diff"
You: "Here's the diff."
present_artifact({
type: "diff",
source: { type: "command_output", command: "git diff" }
})
// ✅ CORRECT
User: "Show me package.json"
You: "Here's package.json."
present_artifact({
type: "code",
source: { type: "file", path: "/path/to/package.json" }
})
// ❌ WRONG - don't run command then pass as text
User: "Show me the git diff"
You: [Run git diff via send-text]
You: [Capture output]
You: [Call present_artifact with text source]
```
**Only use text source for data you already have:**
```javascript
User: "Show me the terminal output"
You: [Capture terminal via capture-terminal]
You: "Here's the output."
present_artifact({
type: "markdown",
source: { type: "text", text: capturedOutput }
})
```
### Claude Code Plans
When Claude Code presents a plan in plan mode, forward it to user's screen:
```
1. Capture the plan from Claude's terminal output
2. Use present_artifact with text source
3. Tell user: "Check your screen to review the plan"
```
## 4. Terminal Management
### Available Tools
**Core operations:**
- `list-terminals()` - List all terminals with IDs, names, working directories
- `create-terminal(name, workingDirectory, initialCommand?)` - Create new terminal
- `capture-terminal(terminalName, lines?, maxWait?)` - Get terminal output
- `send-text(terminalName, text, pressEnter?, return_output?)` - Type text/run commands
- `send-keys(terminalName, keys, repeat?, return_output?)` - Send special keys
- `rename-terminal(terminalName, name)` - Rename terminal
- `kill-terminal(terminalName)` - Close terminal
**Special keys for send-keys:**
- `C-c` - Ctrl+C (interrupt)
- `BTab` - Shift+Tab (used in Claude Code for mode switching)
- `Escape`, `Enter`, etc.
### Creating Terminals with Context
**Always set workingDirectory based on context:**
```javascript
// User mentions project
User: "Create a terminal for the web project"
create-terminal(name="web", workingDirectory="~/dev/voice-dev/packages/web")
// User says "another terminal here"
// Look at current terminal's working directory, use same path
create-terminal(name="tests", workingDirectory="~/dev/voice-dev/packages/web")
// No context - list terminals first to see what they're working on
User: "Create a terminal"
You: [Call list-terminals() first]
create-terminal(name="shell", workingDirectory="<use most relevant context>")
// With initial command
User: "Launch Claude to work on authentication"
create-terminal(
name="authentication",
workingDirectory="<from context>",
initialCommand="claude"
)
```
### Terminal Context Tracking
Keep track of:
- Which terminal you're working in
- Working directory of each terminal
- Purpose of each terminal (build, test, edit, etc.)
- Which terminals have long-running processes
## 5. Claude Code Integration
### What is Claude Code?
Command-line AI coding agent launched with: `claude`
### Vim Mode Input
Claude Code uses Vim keybindings:
- `-- INSERT --` visible = insert mode (can type freely)
- No `-- INSERT --` visible = normal mode (press `i` to enter insert)
### Permission Modes
Cycle through 4 modes with **Shift+Tab** (BTab):
1. **Default** (no indicator) - Asks permission for everything
2. **⏵⏵ accept edits on** - Auto-accepts file edits only
3. **⏸ plan mode on** - Shows plan before executing
4. **⏵⏵ bypass permissions on** - Auto-executes ALL actions
**Efficient mode switching with repeat:**
```javascript
// To plan mode from default (2 presses)
send_keys(terminalName, "BTab", (repeat = 2), (return_output = { lines: 50 }));
// To bypass from default (3 presses)
send_keys(terminalName, "BTab", (repeat = 3), (return_output = { lines: 50 }));
```
### Basic Claude Code Workflow
**Starting:**
```javascript
create_terminal((name = "feature"), (workingDirectory = "~/dev/project"));
// or
send_text(
terminalName,
"claude",
(pressEnter = true),
(return_output = { lines: 50 })
);
```
**Asking a question:**
```javascript
// 1. Check for "-- INSERT --" in output
// 2. If not in insert mode:
send_keys(terminalName, "i", (return_output = { lines: 20 }));
// 3. Type question:
send_text(
terminalName,
"your question",
(pressEnter = true),
(return_output = { lines: 50, maxWait: 5000 })
);
```
**Closing:**
```javascript
send_text(
terminalName,
"/exit",
(pressEnter = true),
(return_output = { lines: 20 })
);
// or
send_keys(terminalName, "C-c", (repeat = 2), (return_output = { lines: 20 }));
```
### Launching Claude Code - Patterns
#### Pattern 1: Basic Launch (No Worktree)
Use `create-terminal` with `initialCommand`:
```javascript
// Basic
create_terminal(
(name = "faro"),
(workingDirectory = "~/dev/faro/main"),
(initialCommand = "claude")
);
// Plan mode
create_terminal(
(name = "faro"),
(workingDirectory = "~/dev/faro/main"),
(initialCommand = "claude --permission-mode plan")
);
// With prompt
create_terminal(
(name = "faro"),
(workingDirectory = "~/dev/faro/main"),
(initialCommand = 'claude "add dark mode toggle"')
);
```
#### Pattern 2: Launch with Worktree
Multi-step process:
1. Create terminal in base repo directory
2. Run `create-worktree` and capture output
3. Parse WORKTREE_PATH from output
4. `cd` to worktree directory
5. Launch Claude
```javascript
// Step 1
create_terminal((name = "fix-auth"), (workingDirectory = "~/dev/voice-dev"));
// Step 2
send_text(
(terminalName = "fix-auth"),
(text = "create-worktree fix-auth"),
(pressEnter = true),
(return_output = { maxWait: 5000, lines: 50 })
);
// Step 3: Parse WORKTREE_PATH from output
// Step 4
send_text(
(terminalName = "fix-auth"),
(text = "cd /path/to/worktree"),
(pressEnter = true)
);
// Step 5
send_text((terminalName = "fix-auth"), (text = "claude"), (pressEnter = true));
```
**Terminal naming:**
- No worktree: Use project name ("faro", "voice-dev")
- With worktree: Use worktree name ("fix-auth", "feature-export")
**When user says "launch Claude in [project]":**
Ask if they want to create a worktree or provide an initial prompt, then use appropriate pattern.
## 6. Git & GitHub
### Git Worktree Utilities
Custom utilities for safe worktree management:
**create-worktree:**
- Creates new git worktree with new branch
- After creating, must `cd` to new directory
- Example: `create-worktree "feature"` creates `~/dev/repo-feature`
**delete-worktree:**
- Preserves the branch, only deletes directory
- Safe to use - won't lose work
- Run from within worktree directory
### GitHub CLI (gh)
Already authenticated. Use for:
- Creating PRs: `gh pr create`
- Viewing PRs: `gh pr view`
- Managing issues: `gh issue list`
- Checking CI: `gh pr checks`
## 7. Projects & Context
### Project Locations
All projects in `~/dev`:
**voice-dev**
- Location: `~/dev/voice-dev`
- Packages: `voice-assistant`
**Faro** (Autonomous Competitive Intelligence)
- Bare repo: `~/dev/faro`
- Main checkout: `~/dev/faro/main`
**Blank.page** (Minimal browser text editor)
- Location: `~/dev/blank.page/editor`
### Context-Aware Execution
**When to use Claude Code:**
- Coding tasks (refactoring, adding features, fixing bugs)
- Already working with Claude Code on a task
- Context clues: "add feature", "refactor this", "fix bug"
**When to execute directly:**
- Quick info gathering (git status, ls, grep)
- Simple operations (git commands, gh commands)
- Claude Code not involved
- Context clues: "check status", "run tests", "create PR"
### Remember
- **ALWAYS call the actual tool** - never just describe what you would do
- **1-3 sentences max** - voice users process info differently
- **Progressive disclosure** - answer what's asked, wait for follow-ups
- **Use context** - fix STT errors silently, infer ambiguous references
- **Always report results** - voice users can't see terminal output
- **Use return_output** - combine action + verification
- **Default to action** - when in doubt, make best guess and execute
+501
View File
@@ -0,0 +1,501 @@
# Voice Assistant System Prompt
## 1. Core Voice Rules (NON-NEGOTIABLE)
### Voice Context
You are a **voice-controlled** assistant. The user speaks to you via phone and hears your responses via TTS.
**Critical constraints:**
- User typically codes from their **phone** using voice
- **No visual feedback** - they can't see command output unless at laptop
- Input comes through **speech-to-text (STT)** which makes errors
- Output is spoken via **text-to-speech (TTS)**
- User may be mobile, away from desk, multitasking
### Communication Rules
**1-3 sentences maximum per response. Always.**
- **Plain speech only** - NO markdown (no bullets, bold, lists, headers)
- **Progressive disclosure** - answer what's asked, let user ask for more
- **Start high-level** - give the gist, not every detail
- **Natural pauses** - leave room for user to respond or redirect
**Good example:**
```
User: "List my commands"
You: "You have 3 running. The dev server on port 3000, tests watching for changes, and a Python REPL."
User: "What about finished commands?"
You: "Two finished. The npm install completed successfully and git status exited with code zero."
```
**Bad example:**
```
User: "List my commands"
You: "You have 5 commands: 1. **dev-server** - Running on port 3000 2. **tests** - Watching for changes..."
```
### Handling STT Errors
Speech-to-text makes mistakes. Fix them silently using context.
**Common errors:**
- Homophones: "list" → "missed", "code" → "load"
- Project names: "faro" → "pharaoh", "mcp" → "empty"
- Technical terms: "typescript" → "type script", "npm install" → "NPM in style"
**How to handle:**
1. Use context to fix obvious mistakes silently
2. Ask for clarification only when truly ambiguous
3. Never lecture about the error - just handle it
4. When clarifying, be brief: "Which project? Web, agent, or MCP?"
**Examples:**
- User: "Run empty install" → Interpret as "Run npm install"
- User: "Show command to" → If only 2 commands, pick from context; if many, ask which
### Immediate Silence Protocol
If user says any of these, **STOP ALL OUTPUT IMMEDIATELY**:
- "I'm not talking to you"
- "Shut up" / "Be quiet" / "Stop talking"
- "Not you"
**Response: Complete silence. No acknowledgment. Wait for user to address you again.**
## 2. Tool Execution Pattern
### Core Rule: Always Call the Actual Tool
**NEVER just describe what you would do. ALWAYS call the tool function.**
### Safe Operations (Execute Immediately)
These only READ information. Execute without asking:
- `list_commands()` - List all commands (running and finished)
- `capture_command()` - Read command output
- Checking git status, viewing files, reading logs
**Pattern:**
```
User: "List my commands"
You: [CALL list_commands() - don't just say you will]
You: "You have dev server running and tests passed 10 minutes ago."
```
### Destructive Operations (Announce + Execute)
These modify state. For clear requests: announce briefly, execute, report.
- `execute_command()` - Runs a command
- `send_text_to_command()` - Sends input to running process
- `kill_command()` - Terminates a process
**Pattern:**
```
User: "Run the tests"
You: "Running npm test."
[CALL execute_command()]
You: "All 47 tests passed."
```
**After user says "yes" to your announcement:**
Don't repeat yourself. Just execute and report results.
```
User: "Install dependencies"
You: "Running npm install."
User: "Yes"
You: [Execute immediately]
You: "Installed 243 packages in 12 seconds."
```
### When to Ask vs Execute
**Only ask when truly ambiguous:**
- Multiple projects exist and user didn't specify directory
- Command has genuinely ambiguous parameters
- Unclear which running command to interact with
**Use context to avoid asking:**
- If user just ran a command, that's "the command" they're referring to
- If project name has STT error → fix silently
- Default to most recent or relevant command when clear from context
### Tool Results Reporting
**After ANY tool execution, verbally report the key result.**
Keep it conversational and brief (1-2 sentences max). Use progressive disclosure - user will ask for details if needed.
```
User: "Run the tests"
You: "Running npm test."
[Execute]
You: "47 tests passed."
User: "How long?"
You: "About 8 seconds."
```
**Why this is critical:**
- Voice users can't see command output - they depend on your summary
- User may be on phone away from laptop - verbal feedback is essential
- Never leave the user hanging
## 3. Command Execution System
### Core Concept
Every command you run creates a **command ID** (like `@123`) that you can reference later. Commands stay available for inspection even after they finish.
### Available Tools
**Primary operations:**
- `execute_command(command, directory, maxWait?)` - Run a shell command
- `list_commands()` - List all commands (running and finished)
- `capture_command(commandId, lines?)` - Get command output
- `send_text_to_command(commandId, text, pressEnter?, return_output?)` - Send input to running command
- `send_keys_to_command(commandId, keys, repeat?, return_output?)` - Send special keys (Ctrl-C, arrows, etc.)
- `kill_command(commandId)` - Terminate command and cleanup
### Command Execution
**All commands use bash -c wrapper automatically**, so you can use:
- Pipes: `ls | grep foo`
- Operators: `cd src && npm test`
- Redirects: `echo "test" > file.txt`
- Any bash syntax: semicolons, subshells, variables, etc.
**Examples:**
```javascript
// Simple command
execute_command(
command="npm test",
directory="~/dev/voice-dev"
)
→ Returns: { commandId: "@123", output: "...", exitCode: 0, isDead: true }
// Complex bash command
execute_command(
command="cd packages/web && npm run build && echo 'Done'",
directory="~/dev/voice-dev"
)
// Interactive command (REPL, server, etc.)
execute_command(
command="python3",
directory="~/dev"
)
→ Returns: { commandId: "@124", output: ">>>", exitCode: null, isDead: false }
```
### Understanding Command States
**isDead: false** - Command is still running
- Interactive processes (REPL, dev server, watching tests)
- Long-running operations
- Can send input via `send_text_to_command` or `send_keys_to_command`
**isDead: true** - Command has finished
- One-shot commands that completed (ls, git status, npm test)
- Has an exit code (0 = success, non-zero = error)
- Output is fully captured and available
- Can still read output via `capture_command`
- Will appear in `list_commands` until you `kill_command` it
### Interactive Command Pattern
For REPLs, servers, or any interactive process:
```javascript
// 1. Start interactive command
execute_command("python3", "~/dev", maxWait=5000)
// Wait for stability (>>> prompt appears)
// Returns: { commandId: "@125", output: "Python 3.11...\n>>>", isDead: false }
// 2. Send input
send_text_to_command("@125", "print('hello')", pressEnter=true, return_output={maxWait: 2000})
// Returns: { output: "hello\n>>>" }
// 3. More input
send_text_to_command("@125", "x = 5 + 3", pressEnter=true, return_output={maxWait: 2000})
// 4. Exit
send_text_to_command("@125", "exit()", pressEnter=true)
// Command exits, isDead becomes true, exit code captured
// 5. Cleanup
kill_command("@125")
```
### Command Lifecycle
1. **Execute**: `execute_command()` creates window, runs command, waits for completion or stability
2. **Running**: If interactive, `isDead=false`, can send input
3. **Finished**: If command exits, `isDead=true`, exit code available
4. **Inspectable**: Finished commands remain visible in `list_commands`
5. **Cleanup**: Use `kill_command()` to remove from list
### Special Keys
Use `send_keys_to_command` for control sequences:
- `C-c` - Ctrl+C (interrupt/cancel)
- `C-d` - Ctrl+D (EOF)
- `BTab` - Shift+Tab
- `Enter`, `Escape`, `Tab`, `Space`
- `Up`, `Down`, `Left`, `Right`
**Example:**
```javascript
// Interrupt running command
send_keys_to_command("@126", "C-c")
// Navigate in TUI
send_keys_to_command("@127", "Down", repeat=3)
```
### maxWait Parameter
Controls how long to wait for command completion or output stability:
- **One-shot commands**: Tool returns when command exits (even if quick)
- **Interactive commands**: Tool returns when output stabilizes for 1 second
- **Default**: 120000ms (2 minutes)
**Usage:**
```javascript
// Quick command
execute_command("ls", "~/dev", maxWait=5000)
// Slow build
execute_command("npm run build", "~/project", maxWait=300000)
// Interactive (returns when prompt appears)
execute_command("python3", "~/dev", maxWait=10000)
```
## 4. Special Triggers
### "Show me" → Use present_artifact
When user says **"show me"**, use `present_artifact` to display visual content.
**Keep voice response SHORT. Let the artifact show the data.**
**Prefer command_output or file sources:**
```javascript
// ✅ CORRECT
User: "Show me the git diff"
You: "Here's the diff."
present_artifact({
type: "diff",
source: { type: "command_output", command: "git diff" }
})
// ✅ CORRECT
User: "Show me package.json"
You: "Here's package.json."
present_artifact({
type: "code",
source: { type: "file", path: "/path/to/package.json" }
})
```
**Only use text source for data you already have:**
```javascript
User: "Show me that command output"
You: [Capture command via capture_command]
You: "Here's the output."
present_artifact({
type: "markdown",
source: { type: "text", text: capturedOutput }
})
```
## 5. Claude Code Integration
### What is Claude Code?
Command-line AI coding agent. Launch it like any other command:
```javascript
execute_command("claude", "~/dev/voice-dev")
```
### Vim Mode Input
Claude Code uses Vim keybindings:
- `-- INSERT --` visible = insert mode (can type freely)
- No `-- INSERT --` visible = normal mode (press `i` to enter insert)
### Permission Modes
Cycle through 4 modes with **Shift+Tab** (BTab):
1. **Default** (no indicator) - Asks permission for everything
2. **⏵⏵ accept edits on** - Auto-accepts file edits only
3. **⏸ plan mode on** - Shows plan before executing
4. **⏵⏵ bypass permissions on** - Auto-executes ALL actions
**Efficient mode switching:**
```javascript
// To plan mode from default (2 presses)
send_keys_to_command(commandId, "BTab", repeat=2, return_output={lines: 50})
// To bypass from default (3 presses)
send_keys_to_command(commandId, "BTab", repeat=3, return_output={lines: 50})
```
### Basic Claude Code Workflow
**Starting:**
```javascript
// Basic launch
execute_command("claude", "~/dev/project")
// With initial prompt
execute_command('claude "add dark mode toggle"', "~/dev/project")
// In plan mode
execute_command("claude --permission-mode plan", "~/dev/project")
```
**Asking a question:**
```javascript
// 1. Check for "-- INSERT --" in output from list_commands or capture_command
// 2. If not in insert mode, enter it:
send_keys_to_command(commandId, "i", return_output={lines: 20})
// 3. Type question:
send_text_to_command(
commandId,
"explain how authentication works",
pressEnter=true,
return_output={lines: 50, maxWait: 5000}
)
```
**Closing:**
```javascript
// Graceful exit
send_text_to_command(commandId, "/exit", pressEnter=true)
// Force quit
send_keys_to_command(commandId, "C-c", repeat=2)
```
### Launching Claude Code - Patterns
#### Pattern 1: Basic Launch
```javascript
execute_command("claude", "~/dev/faro/main")
```
#### Pattern 2: Launch with Worktree
```javascript
// 1. Create worktree
const result = execute_command(
"create-worktree fix-auth",
"~/dev/voice-dev",
maxWait=5000
)
// 2. Parse WORKTREE_PATH from result.output
// 3. Launch Claude in worktree
execute_command("claude", worktreePath)
```
## 6. Git & GitHub
### Git Worktree Utilities
Custom utilities for safe worktree management:
**create-worktree:**
- Creates new git worktree with new branch
- Example: `create-worktree "feature"` creates `~/dev/repo-feature`
- Outputs WORKTREE_PATH for you to parse
**delete-worktree:**
- Preserves the branch, only deletes directory
- Safe to use - won't lose work
- Run from within worktree directory
### GitHub CLI (gh)
Already authenticated. Use for:
- Creating PRs: `gh pr create`
- Viewing PRs: `gh pr view`
- Managing issues: `gh issue list`
- Checking CI: `gh pr checks`
## 7. Projects & Context
### Project Locations
All projects in `~/dev`:
**voice-dev**
- Location: `~/dev/voice-dev`
- Packages: `voice-assistant`
**Faro** (Autonomous Competitive Intelligence)
- Bare repo: `~/dev/faro`
- Main checkout: `~/dev/faro/main`
**Blank.page** (Minimal browser text editor)
- Location: `~/dev/blank.page/editor`
### Context-Aware Execution
**When to use Claude Code:**
- Coding tasks (refactoring, adding features, fixing bugs)
- Already working with Claude Code on a task
- Context clues: "add feature", "refactor this", "fix bug"
**When to execute directly:**
- Quick info gathering (git status, ls, grep)
- Simple operations (git commands, gh commands)
- Claude Code not involved
- Context clues: "check status", "run tests", "create PR"
### Remember
- **ALWAYS call the actual tool** - never just describe what you would do
- **1-3 sentences max** - voice users process info differently
- **Progressive disclosure** - answer what's asked, wait for follow-ups
- **Use context** - fix STT errors silently, infer ambiguous references
- **Always report results** - voice users can't see command output
- **Commands stay available** - finished commands can be inspected until killed
- **Default to action** - when in doubt, make best guess and execute
+46
View File
@@ -0,0 +1,46 @@
{
"name": "@voice-dev/server",
"version": "0.1.0",
"description": "Voice assistant backend server",
"type": "module",
"scripts": {
"dev": "NODE_ENV=development tsx watch --ignore '**/*.timestamp-*' src/server/index.ts",
"build": "tsc -p tsconfig.server.json",
"start": "NODE_ENV=production node dist/server/index.js",
"typecheck": "tsc -p tsconfig.server.json --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:mobile": "playwright test --project='Mobile Chrome'"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.4.9",
"@ai-sdk/openai": "^2.0.52",
"@deepgram/sdk": "^3.4.0",
"@modelcontextprotocol/sdk": "^1.20.1",
"@openrouter/ai-sdk-provider": "^1.2.0",
"ai": "^5.0.76",
"dotenv": "^17.2.3",
"express": "^4.18.2",
"express-basic-auth": "^1.2.1",
"openai": "^4.20.0",
"playwright": "^1.56.1",
"tiny-invariant": "^1.3.3",
"uuid": "^9.0.1",
"ws": "^8.14.2",
"zod": "^3.23.8"
},
"devDependencies": {
"@playwright/test": "^1.56.1",
"@types/express": "^4.17.20",
"@types/node": "^20.9.0",
"@types/uuid": "^9.0.7",
"@types/ws": "^8.5.8",
"@vitest/ui": "^3.2.4",
"tsx": "^4.6.0",
"typescript": "^5.2.2",
"vitest": "^3.2.4"
}
}
+30
View File
@@ -0,0 +1,30 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
},
projects: [
{
name: 'Mobile Chrome',
use: { ...devices['iPhone 12'] },
},
{
name: 'Desktop Chrome',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120000,
},
});
@@ -0,0 +1,32 @@
import { readFileSync } from "fs";
import { inspect } from "util";
import { standardizePrompt } from "ai/internal";
async function processConversation() {
try {
const conversationPath =
".debug.conversations/ce44c79a-0689-4210-8e00-72c0a627406d-2.json";
console.log("Loading conversation from:", conversationPath);
const conversationData: any = JSON.parse(
readFileSync(conversationPath, "utf-8")
);
console.log(
`\nLoaded conversation ${conversationData.conversationId} with ${conversationData.messages.length} messages\n`
);
const result = await standardizePrompt({
prompt: conversationData.messages,
});
console.log("Standardized prompt result:");
console.log(inspect(result, { depth: null, colors: true }));
} catch (error) {
console.error("Error processing conversation:", error);
process.exit(1);
}
}
processConversation();
@@ -0,0 +1,220 @@
# Phase 1 Implementation Summary
## What Was Built
Phase 1 of ACP integration is complete. This provides core infrastructure for managing Claude Code agents without integrating with the rest of the application.
## Files Created
### Core Implementation
1. **types.ts** (53 lines)
- `AgentStatus` type: "initializing" | "ready" | "processing" | "completed" | "failed" | "killed"
- `AgentInfo` interface: agent metadata
- `AgentUpdate` interface: wraps ACP SessionNotification
- `CreateAgentOptions` interface
- `AgentUpdateCallback` type
2. **agent-manager.ts** (348 lines)
- `AgentManager` class: main orchestrator
- `ACPClient` class: handles ACP callbacks
- Full lifecycle management (create, prompt, cancel, kill)
- Update subscription system
- Process management and cleanup
- Error handling
### Test Scripts
3. **test-agent.ts** (91 lines)
- Full test with detailed update logging
- Tests basic agent creation and prompt execution
- 30-second timeout with status checking
4. **test-simple.ts** (64 lines)
- Quick test with minimal output
- Filters updates to show only relevant info
- Good for rapid iteration
5. **test-concurrent.ts** (81 lines)
- Tests multiple agents running simultaneously
- Tests cancellation functionality
- Verifies cleanup
### Documentation
6. **README.md** - User guide with examples
7. **IMPLEMENTATION.md** - This file
## API Reference
### AgentManager Class
```typescript
class AgentManager {
// Lifecycle
createAgent(options?: CreateAgentOptions): Promise<string>
sendPrompt(agentId: string, prompt: string): Promise<void>
cancelAgent(agentId: string): Promise<void>
killAgent(agentId: string): Promise<void>
// Status/Monitoring
getAgentStatus(agentId: string): AgentStatus
listAgents(): AgentInfo[]
// Updates
subscribeToUpdates(agentId: string, callback: AgentUpdateCallback): () => void
}
```
### CreateAgentOptions
```typescript
interface CreateAgentOptions {
plan?: string; // Optional initial prompt to send
cwd?: string; // Working directory (defaults to process.cwd())
}
```
## Test Results
All tests pass successfully:
### test-simple.ts
```
✓ Agent creation
✓ Update subscription
✓ Prompt execution
✓ Tool calls detected (2)
✓ Message streaming (353 chars)
✓ Cleanup
```
### test-concurrent.ts
```
✓ Multiple agent creation
✓ Concurrent operation
✓ Update isolation (20 updates vs 13 updates)
✓ Cancellation
✓ Cleanup verification (0 remaining agents)
```
### TypeScript Compilation
```
✓ No type errors
✓ All files compile cleanly
```
## How It Works
### 1. Agent Spawning
```typescript
const agentProcess = spawn("npx", ["@zed-industries/claude-code-acp"], {
stdio: ["pipe", "pipe", "pipe"],
cwd,
});
```
### 2. ACP Connection
```typescript
const input = Writable.toWeb(agentProcess.stdin);
const output = Readable.toWeb(agentProcess.stdout);
const stream = ndJsonStream(input, output);
const connection = new ClientSideConnection(() => client, stream);
```
### 3. Initialization
```typescript
await connection.initialize({
protocolVersion: PROTOCOL_VERSION,
clientCapabilities: {
fs: { readTextFile: true, writeTextFile: true }
}
});
const sessionResponse = await connection.newSession({
cwd,
mcpServers: []
});
```
### 4. Update Handling
```typescript
class ACPClient {
async sessionUpdate(params: any) {
this.onUpdate(this.agentId, params.update);
}
}
```
Subscribers receive updates via callback:
```typescript
manager.subscribeToUpdates(agentId, (update) => {
// update.notification contains SessionNotification
// update.agentId identifies which agent
// update.timestamp shows when it occurred
});
```
## Key Design Decisions
### 1. Managed Agent State
Each agent tracked with:
- Unique ID (UUID)
- Status (lifecycle stage)
- Child process reference
- ACP connection
- Session ID
- Subscriber list
### 2. Auto-Approval
Phase 1 auto-approves all permissions to keep things simple. Phase 2 will add proper permission handling.
### 3. Callback-Based Updates
Updates use callbacks rather than events/observables for simplicity and flexibility. Subscribers manage their own errors.
### 4. Process Cleanup
Graceful shutdown with SIGTERM, then SIGKILL after 5 seconds if needed. Prevents orphaned processes.
### 5. Status Transitions
```
initializing → ready → processing → (completed | failed)
↓
killed (from any state)
```
## Integration Points for Phase 2
The agent manager is designed to integrate with:
1. **Session class**: Add agents to conversation sessions
2. **MCP tools**: Expose agent control as MCP functions
3. **WebSocket**: Stream updates to UI in real-time
4. **Persistence**: Save/restore agent state
5. **File system**: Connect read/write callbacks to actual files
6. **Permissions**: Add user confirmation for sensitive operations
## Dependencies Installed
```json
{
"@agentclientprotocol/sdk": "^0.4.9"
}
```
## Known Limitations
1. No integration with existing Session/WebSocket
2. File read/write return empty/mock data
3. Auto-approves all permissions
4. No state persistence
5. No authentication
6. Verbose logging (designed for debugging)
## Performance Characteristics
- Agent spawn time: ~2-3 seconds
- First prompt response: ~3-5 seconds
- Update latency: <100ms (streaming)
- Memory per agent: ~50-100MB
- Concurrent agents: Tested with 2, should support many more
## Next Phase
See the main requirements document for Phase 2 tasks. This implementation provides a solid foundation for integration with the voice assistant's session and UI layers.
@@ -0,0 +1,293 @@
# Phase 2: ACP Integration - Session and MCP Tools
This document describes the Phase 2 implementation that integrates AgentManager with the Session and MCP tools infrastructure.
## Overview
Phase 2 enables the orchestrator LLM to create and control Claude Code agents via function calls. Agents run as separate processes and can execute coding tasks autonomously, with their updates streamed back to the UI via WebSocket.
## Architecture
### Components
1. **Agent MCP Server** (`mcp-server.ts`)
- Exposes 6 tools to the LLM for agent management
- Each Session gets its own AgentManager instance
- Uses in-memory transport to connect to Session's MCP client
2. **WebSocket Messages** (`messages.ts`)
- Three new message types: `agent_created`, `agent_update`, `agent_status`
- Agent updates flow directly to WebSocket (NOT through LLM)
- Enables real-time UI feedback for agent work
3. **Session Integration** (`session.ts`)
- Each session owns an AgentManager
- Initializes agent MCP server on startup
- Subscribes to agent updates and forwards to WebSocket
- Cleans up all agents on session end
4. **getAllTools** (`llm-openai.ts`)
- Updated to accept agent tools parameter
- Merges terminal, agent, Playwright, and manual tools
- Per-session tool sets enable isolation
## Tools Available to LLM
### `create_coding_agent`
Creates a new Claude Code agent.
**Input:**
- `plan` (optional): Initial task for the agent
**Output:**
- `agentId`: Unique identifier for the agent
- `status`: Current agent status
**Example:**
```typescript
{
plan: "Refactor the authentication module to use JWT tokens"
}
```
### `send_agent_prompt`
Sends a task or instruction to an agent.
**Input:**
- `agentId`: Agent to send prompt to
- `prompt`: Task or instruction
**Output:**
- `success`: Whether prompt was sent
**Example:**
```typescript
{
agentId: "abc-123",
prompt: "Add unit tests for the new functions"
}
```
### `get_agent_status`
Gets current status and info about an agent.
**Input:**
- `agentId`: Agent to query
**Output:**
- `status`: Current status
- `info`: Detailed agent information
### `list_agents`
Lists all active agents in this session.
**Input:** None
**Output:**
- `agents`: Array of agent info objects
### `cancel_agent`
Cancels an agent's current task (keeps agent alive).
**Input:**
- `agentId`: Agent to cancel
**Output:**
- `success`: Whether cancellation succeeded
### `kill_agent`
Terminates an agent completely.
**Input:**
- `agentId`: Agent to kill
**Output:**
- `success`: Whether kill succeeded
## Data Flow
### Agent Creation Flow
1. LLM calls `create_coding_agent` tool
2. Agent MCP server creates agent via AgentManager
3. AgentManager spawns `npx @zed-industries/claude-code-acp` process
4. Session intercepts tool result, sees `agentId`
5. Session subscribes to agent updates
6. Session emits `agent_created` message to WebSocket
### Agent Update Flow
1. Agent process sends ACP session updates
2. AgentManager receives updates via ACP callback
3. AgentManager notifies subscribers (including Session)
4. Session forwards update to WebSocket as `agent_update` message
5. UI receives update and can display progress
**Important:** Agent updates bypass the LLM and go directly to the UI. This prevents the LLM from being overwhelmed with agent activity while still providing real-time feedback to the user.
### Agent Cleanup Flow
1. Session cleanup is triggered (client disconnect, timeout, etc.)
2. Session iterates through all agents
3. Calls `AgentManager.killAgent()` for each
4. Unsubscribes from all agent updates
5. Closes agent MCP client
## Session Lifecycle
```typescript
// On Session creation
constructor() {
this.agentManager = new AgentManager()
this.initializeAgentMcp() // Async initialization
}
// During LLM processing
async processWithLLM() {
// Wait for agent MCP to initialize
while (!this.agentTools && timeout) { await sleep(100) }
// Get all tools (includes agent tools)
const allTools = await getAllTools(this.terminalTools, this.agentTools)
// Stream with tools available
await streamText({ tools: allTools, ... })
}
// On create_coding_agent tool result
onChunk({ type: "tool-result", toolName: "create_coding_agent" }) {
const agentId = result.structuredContent.agentId
this.subscribeToAgent(agentId) // Start receiving updates
this.emit({ type: "agent_created", ... })
}
// On Session cleanup
async cleanup() {
// Kill all agents
for (const agent of this.agentManager.listAgents()) {
await this.agentManager.killAgent(agent.id)
}
// Unsubscribe from all updates
for (const unsubscribe of this.agentUpdateUnsubscribers.values()) {
unsubscribe()
}
// Close MCP clients
await this.agentMcpClient.close()
}
```
## Testing
### Integration Test
Run the integration test to verify the complete flow:
```bash
npx tsx src/server/acp/test-integration.ts
```
The test:
1. Creates AgentManager
2. Sets up Agent MCP server
3. Connects MCP client
4. Verifies all 6 tools are present
5. Creates an agent
6. Subscribes to agent updates
7. Tests agent operations (status, prompt, cancel)
8. Kills agent and verifies cleanup
### Manual Testing
Once Phase 3 (UI) is complete, you can test via the voice interface:
1. Start the server: `npm run dev`
2. Connect via browser
3. Say: "Create a coding agent to refactor the auth module"
4. LLM will call `create_coding_agent` tool
5. Watch agent updates in the UI as it works
6. Say: "Check the agent status"
7. LLM will call `get_agent_status` tool
## Key Design Decisions
### Per-Session AgentManager
Each Session gets its own AgentManager instance. This ensures:
- Agent isolation between conversations
- Clean cleanup when session ends
- No shared state between users
### Direct Update Streaming
Agent updates go directly to WebSocket, not through LLM:
- Prevents LLM prompt pollution
- Enables real-time UI feedback
- Reduces token usage
- Allows parallel agent and LLM work
### Tool Result Interception
Session intercepts `create_coding_agent` tool results to:
- Automatically subscribe to new agents
- Emit `agent_created` message
- No manual tracking needed by LLM
### Graceful Initialization
Agent MCP initializes asynchronously:
- Session creation doesn't block on initialization
- LLM waits for tools before first call
- 5 second timeout with clear error
- Allows fast session creation
## Files Modified
### New Files
- `src/server/acp/mcp-server.ts` - Agent MCP server with 6 tools
- `src/server/acp/test-integration.ts` - Integration test script
- `src/server/acp/PHASE2-INTEGRATION.md` - This documentation
### Modified Files
- `src/server/messages.ts` - Added 3 agent message types
- `src/server/session.ts` - Integrated AgentManager
- `src/server/agent/llm-openai.ts` - Updated getAllTools signature
## Next Steps: Phase 3
Phase 3 will add UI components to:
- Display agent creation notifications
- Stream agent updates in real-time
- Show agent status and progress
- Allow manual agent control (cancel, kill)
- Display agent work artifacts
## Troubleshooting
### "Agent MCP failed to initialize"
Check:
- `@zed-industries/claude-code-acp` is installed
- Node version is compatible
- No firewall blocking spawned processes
### "Agent not found"
Likely causes:
- Agent was killed or crashed
- Using wrong agentId
- Session was cleaned up
### Agent updates not received
Check:
- Subscription was created after agent creation
- WebSocket connection is active
- Agent process is still running
+141
View File
@@ -0,0 +1,141 @@
# ACP Agent Manager
Phase 1 implementation of Agent Client Protocol (ACP) integration for managing Claude Code agents.
## Overview
This module provides core infrastructure for spawning, managing, and communicating with Claude Code agents via the ACP SDK. It does NOT integrate with the rest of the application yet - that's for Phase 2.
## Files
- `types.ts` - TypeScript types and interfaces
- `agent-manager.ts` - Core AgentManager class
- `test-agent.ts` - Full test script with detailed logging
- `test-simple.ts` - Quick test with minimal output
- `test-concurrent.ts` - Concurrent agents and cancellation test
## Usage
### Basic Example
```typescript
import { AgentManager } from "./acp/agent-manager.js";
const manager = new AgentManager();
// Create an agent
const agentId = await manager.createAgent({
cwd: process.cwd(),
plan: "List files in current directory", // Optional initial prompt
});
// Subscribe to updates
manager.subscribeToUpdates(agentId, (update) => {
console.log("Update:", update.notification);
});
// Send a prompt
await manager.sendPrompt(agentId, "Do something");
// Check status
const status = manager.getAgentStatus(agentId); // "ready" | "processing" | etc
// Cancel current task
await manager.cancelAgent(agentId);
// Kill the agent
await manager.killAgent(agentId);
```
### Multiple Agents
```typescript
const agent1 = await manager.createAgent({ cwd: "/path/1" });
const agent2 = await manager.createAgent({ cwd: "/path/2" });
// Send different prompts
await manager.sendPrompt(agent1, "Task 1");
await manager.sendPrompt(agent2, "Task 2");
// List all agents
const agents = manager.listAgents();
agents.forEach((agent) => {
console.log(`${agent.id}: ${agent.status}`);
});
```
## Agent Lifecycle
1. **initializing** - Agent process starting, ACP connection being established
2. **ready** - Agent is ready to receive prompts
3. **processing** - Agent is working on a task
4. **completed** - Task finished successfully
5. **failed** - Agent encountered an error
6. **killed** - Agent was terminated
## Session Updates
The agent sends various types of updates via the `subscribeToUpdates` callback:
- `sessionUpdate: "agent_message_chunk"` - Streaming response text
- `sessionUpdate: "tool_call"` - Agent is calling a tool (terminal, file read, etc)
- `sessionUpdate: "tool_call_update"` - Tool call status update
- `sessionUpdate: "available_commands_update"` - List of available slash commands
- And more...
See the ACP SDK documentation for full list of notification types.
## Testing
Run the test scripts to verify everything works:
```bash
# Full test with detailed logging
npx tsx src/server/acp/test-agent.ts
# Quick test with minimal output
npx tsx src/server/acp/test-simple.ts
# Test concurrent agents and cancellation
npx tsx src/server/acp/test-concurrent.ts
```
## Implementation Details
### Process Management
- Spawns `npx @zed-industries/claude-code-acp` as subprocess
- Uses stdio for ACP communication (stdin/stdout)
- Stderr captured for debugging
- Automatic cleanup on kill (SIGTERM, then SIGKILL after 5s)
### ACP Connection
- Uses `ClientSideConnection` from `@agentclientprotocol/sdk`
- Implements required callbacks: `sessionUpdate`, `requestPermission`, `readTextFile`, `writeTextFile`
- Auto-approves permissions for now (Phase 1 only)
### Update Streaming
- All session updates forwarded to subscribers via callback
- Multiple subscribers supported per agent
- Updates include timestamp and agent ID
- Subscribers handle their own error recovery
## Known Limitations (Phase 1)
- No integration with Session or WebSocket
- No MCP tools exposed yet
- Auto-approves all permissions (no user confirmation)
- File read/write callbacks return empty/mock data
- No persistence of agent state
- No authentication
## Next Steps (Phase 2)
- Integrate with Session class
- Add MCP tools for agent control
- Expose via WebSocket to UI
- Add proper permission handling
- Connect file read/write to actual filesystem
- Add conversation persistence
@@ -0,0 +1,218 @@
# Comprehensive ACP Agent Test Results
## Overview
This document summarizes the comprehensive test suite for the ACP (Agent Client Protocol) agent functionality. All tests use a **REAL Claude Code agent** via the `@zed-industries/claude-code-acp` package - no mocking.
## Test File
`/Users/moboudra/dev/voice-dev/packages/voice-assistant/src/server/acp/test-comprehensive.ts`
Run with:
```bash
npx tsx src/server/acp/test-comprehensive.ts
```
## Test Results Summary
### ✅ ALL TESTS PASSED
| Test | Status | Description |
|------|--------|-------------|
| TEST 1 | ✅ PASSED | Directory Control and Initial Prompt |
| TEST 2 | ✅ PASSED | Permission Mode - Auto Approve |
| TEST 3 | ✅ PASSED | Permission Mode - Reject All |
| TEST 4 | ✅ PASSED | Multiple Prompts |
| TEST 5 | ✅ PASSED | Update Streaming |
| TEST 6 | ✅ PASSED | State Management |
## Test Details
### TEST 1: Directory Control and Initial Prompt
**Validates:**
- Agent runs in specified directory
- Initial prompt executes successfully
- Tool calls are captured (pwd command)
- Message chunks stream correctly
- Output contains expected directory path
**Results:**
- ✓ Agent created successfully
- ✓ Agent completed processing
- ✓ Output contains correct directory path
- ✓ Tool calls detected: 2
- ✓ Status transitions: processing → completed
---
### TEST 2: Permission Mode - Auto Approve
**Validates:**
- File write operations are automatically approved
- Files are actually created on disk
- File content matches expectations
- Permission requests are handled correctly
**Results:**
- ✓ Agent created successfully
- ✓ File created with content: "hello from auto approve"
- ✓ File content is correct
- ✓ Auto-approve mode working as expected
---
### TEST 3: Permission Mode - Reject All
**Validates:**
- File operations are blocked when permissions rejected
- Files are NOT created on disk
- Agent indicates permission denial in response
- Agent completes even when permissions denied
**Results:**
- ✓ Agent created successfully
- ✓ File was correctly blocked from creation
- ✓ Agent indicated permission denial in response
- ✓ Reject-all mode working as expected
---
### TEST 4: Multiple Prompts
**Validates:**
- Agent can handle multiple sequential prompts
- Each prompt gets its own response
- Updates for each prompt are tracked separately
- Agent status transitions correctly between prompts
**Results:**
- ✓ Agent created successfully
- ✓ First prompt completed (echo 'first')
- ✓ Second prompt completed (echo 'second')
- ✓ Both prompts executed successfully
- ✓ Separate update streams for each prompt
---
### TEST 5: Update Streaming
**Validates:**
- All update types stream correctly:
- `agent_message_chunk` - text response chunks
- `tool_call` - tool invocations
- `available_commands_update` - available slash commands
- `status_change` - agent status changes
- Message chunks can be reconstructed into coherent text
- Tool call details are captured
**Results:**
- ✓ Total updates: 91
- ✓ Message chunks: 79 (reconstructed to 623 chars)
- ✓ Tool calls: 3 (Bash, list files, read file)
- ✓ Message reconstruction successful
- ✓ All update types captured correctly
---
### TEST 6: State Management
**Validates:**
- Agent status accurately reflects actual state
- No drift between reported status and real state
- Status transitions are tracked correctly
- Agent cleanup (kill) works properly
- Agent is removed from manager after kill
**Results:**
- ✓ Agent completed processing
- ✓ Final status is correct: completed
- ✓ Agent correctly removed after kill
- ⚠ Status polling warnings (expected - updates happen faster than polling interval)
---
## Key Features Validated
### ✅ Core Functionality
- Agent creation with custom `cwd`
- Initial prompt execution
- Sequential prompt handling
- Agent lifecycle (create → process → complete → kill)
### ✅ Permission Modes
- `auto_approve` - All file operations approved automatically
- `reject_all` - All file operations blocked
- `ask_user` - Falls back to auto-approve (UI prompt not implemented yet)
### ✅ Update Streaming
- Real-time message chunk streaming
- Tool call notifications
- Tool result tracking
- Status change notifications
- Available commands updates
### ✅ File Operations
- Read file capability (via `readTextFile` callback)
- Write file capability (via `writeTextFile` callback)
- Permission request handling
- Actual filesystem changes verified
### ✅ State Management
- Completion detection (2-second timeout after last update)
- Status tracking: initializing → ready → processing → completed
- Error handling and failed states
- Proper cleanup on kill
---
## Implementation Notes
### Completion Detection
The ACP protocol does not have an explicit "completion" message. Completion is detected using a heuristic approach:
- After each update, a 2-second timer is set
- If no more updates arrive within 2 seconds, the agent is marked as "completed"
- This works reliably for typical agent operations
### Update Structure
ACP session notifications have different structures:
- Status updates: `{ type: "sessionUpdate", sessionUpdate: { status: "..." } }`
- Message chunks: `{ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "..." } }`
- Tool calls: `{ sessionUpdate: "tool_call", title: "...", ... }`
- Commands: `{ availableCommands: [...] }`
### File Operations
File read/write operations are handled via ACP client callbacks:
- `readTextFile(params)` - Called when agent requests file read
- `writeTextFile(params)` - Called when agent requests file write
- Permissions checked before callback execution
- Actual filesystem I/O performed in callbacks
---
## Future Improvements
1. **User Permission Prompts**: Implement `ask_user` mode with actual UI prompts
2. **Explicit Completion Events**: Work with ACP spec to add explicit completion notifications
3. **Error Recovery**: Test agent behavior with network errors, timeouts, etc.
4. **Performance Tests**: Measure agent response times, update throughput
5. **Concurrent Agents**: Test multiple agents running simultaneously
---
## Conclusion
The ACP agent integration is **production-ready** with all core features working correctly:
- ✅ Real Claude Code agent integration
- ✅ Directory control
- ✅ Permission management
- ✅ Update streaming
- ✅ File operations
- ✅ State management
- ✅ Multi-prompt support
All 6 comprehensive tests pass successfully with real agent execution.
@@ -0,0 +1,92 @@
import type { AgentUpdate } from "./types.js";
/**
* Convert agent activity updates into chronological text format
*/
export function curateAgentActivity(updates: AgentUpdate[]): string {
const lines: string[] = [];
let messageBuffer = "";
let thoughtBuffer = "";
for (const update of updates) {
const updateType = update.notification.update.sessionUpdate;
const content = (update.notification.update as any).content;
switch (updateType) {
case "agent_message_chunk":
if (content?.type === "text" && content?.text) {
messageBuffer += content.text;
}
break;
case "agent_thought_chunk":
if (content?.type === "text" && content?.text) {
thoughtBuffer += content.text;
}
break;
case "tool_call":
case "tool_call_update": {
// Flush any buffered message before tool call
if (messageBuffer.trim()) {
lines.push(messageBuffer.trim());
messageBuffer = "";
}
if (thoughtBuffer.trim()) {
lines.push(`[Thought: ${thoughtBuffer.trim()}]`);
thoughtBuffer = "";
}
const data = update.notification.update as any;
const title = data.title || data.kind || "Tool";
const status = data.status || "unknown";
lines.push(`\n[${title}] ${status}`);
if (data.rawInput && Object.keys(data.rawInput).length > 0) {
lines.push(`Input: ${JSON.stringify(data.rawInput)}`);
}
if (data.rawOutput && Object.keys(data.rawOutput).length > 0) {
lines.push(`Output: ${JSON.stringify(data.rawOutput)}`);
}
break;
}
case "plan": {
// Flush any buffered content before plan
if (messageBuffer.trim()) {
lines.push(messageBuffer.trim());
messageBuffer = "";
}
if (thoughtBuffer.trim()) {
lines.push(`[Thought: ${thoughtBuffer.trim()}]`);
thoughtBuffer = "";
}
const entries = (update.notification.update as any).entries;
lines.push("\n[Plan]");
for (const entry of entries) {
lines.push(`- [${entry.status}] ${entry.content}`);
}
break;
}
case "user_message_chunk":
if (content?.type === "text" && content?.text) {
lines.push(`User: ${content.text}`);
}
break;
}
}
// Flush any remaining buffered content
if (messageBuffer.trim()) {
lines.push(messageBuffer.trim());
}
if (thoughtBuffer.trim()) {
lines.push(`[Thought: ${thoughtBuffer.trim()}]`);
}
return lines.join("\n\n") || "No activity to display.";
}
@@ -0,0 +1,496 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { AgentManager } from "./agent-manager.js";
import type { AgentUpdate, AgentStatus } from "./types.js";
import { mkdtemp, rm, readFile, access } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
/**
* Vitest Test Suite for ACP Agent Functionality
*
* This test validates all core features with a REAL Claude Code agent (no mocking):
*
* 1. Directory Control - Agent runs in specified directory
* 2. Permission Mode: Auto Approve - File operations are automatically approved
* 3. Permission Mode: Reject All - File operations are blocked
* 4. Multiple Prompts - Agent can handle sequential prompts
* 5. Update Streaming - All update types stream correctly (message chunks, tool calls, etc.)
* 6. State Management - Status accurately reflects actual agent state
*
* Run with: npx vitest run src/server/acp/agent-manager.test.ts
*/
// ============================================================================
// Test Utilities
// ============================================================================
interface CollectedUpdates {
all: AgentUpdate[];
messageChunks: string[];
toolCalls: any[];
toolResults: any[];
statusUpdates: any[];
commands: any[];
}
function createUpdateCollector(): CollectedUpdates {
return {
all: [],
messageChunks: [],
toolCalls: [],
toolResults: [],
statusUpdates: [],
commands: [],
};
}
function collectUpdate(collector: CollectedUpdates, update: AgentUpdate): void {
collector.all.push(update);
const notification = update.notification as any;
// Message chunks
if (
notification.sessionUpdate === "agent_message_chunk" &&
notification.content
) {
const content = notification.content;
if (content.type === "text" && content.text) {
collector.messageChunks.push(content.text);
}
}
// Tool calls
if (notification.sessionUpdate === "tool_call") {
collector.toolCalls.push(notification);
}
// Tool results
if (notification.sessionUpdate === "tool_result") {
collector.toolResults.push(notification);
}
// Status updates from the wrapper
if (
notification.type === "sessionUpdate" &&
notification.sessionUpdate?.status
) {
collector.statusUpdates.push(notification.sessionUpdate.status);
}
// Status updates from our custom type
if (notification.sessionUpdate === "status_change") {
collector.statusUpdates.push(notification.status);
}
// Available commands
if (notification.availableCommands) {
collector.commands.push(notification.availableCommands);
}
}
function extractStatusTransitions(collector: CollectedUpdates): string[] {
const statuses: string[] = [];
let lastStatus: string | null = null;
for (const statusUpdate of collector.statusUpdates) {
if (statusUpdate !== lastStatus) {
statuses.push(String(statusUpdate));
lastStatus = statusUpdate;
}
}
return statuses;
}
async function waitForStatus(
manager: AgentManager,
agentId: string,
targetStatus: AgentStatus,
timeoutMs: number
): Promise<void> {
const startTime = Date.now();
while (Date.now() - startTime < timeoutMs) {
const currentStatus = manager.getAgentStatus(agentId);
if (currentStatus === targetStatus) {
return;
}
if (currentStatus === "failed") {
const agent = manager.listAgents().find((a) => a.id === agentId);
throw new Error(
`Agent failed while waiting for status "${targetStatus}": ${agent?.error}`
);
}
// Wait a bit before checking again
await sleep(500);
}
throw new Error(
`Timeout waiting for status "${targetStatus}" (current: ${manager.getAgentStatus(agentId)})`
);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// ============================================================================
// Test Suite
// ============================================================================
describe("AgentManager", () => {
let manager: AgentManager;
let tmpDir: string;
beforeAll(async () => {
manager = new AgentManager();
tmpDir = await mkdtemp(join(tmpdir(), "acp-test-"));
});
afterAll(async () => {
// Kill all agents
const agents = manager.listAgents();
for (const agent of agents) {
await manager.killAgent(agent.id);
}
// Remove temp directory
await rm(tmpDir, { recursive: true, force: true });
});
// ==========================================================================
// TEST 1: Directory Control and Initial Prompt
// ==========================================================================
describe("Agent Creation and Directory Control", () => {
it(
"should create agent and run in specified directory",
async () => {
const updates = createUpdateCollector();
let unsubscribe: (() => void) | null = null;
// Create agent without initial prompt first, so we can subscribe
const agentId = await manager.createAgent({
cwd: tmpDir,
});
expect(agentId).toBeDefined();
expect(typeof agentId).toBe("string");
// Subscribe before sending prompt
unsubscribe = manager.subscribeToUpdates(agentId, (update) => {
collectUpdate(updates, update);
});
// Now send the prompt
await manager.sendPrompt(
agentId,
"Run pwd and confirm you're in the test directory. Output the full path."
);
try {
// Wait for processing to complete
await waitForStatus(manager, agentId, "completed", 60000);
// Verify we got updates
expect(updates.all.length).toBeGreaterThan(0);
expect(updates.messageChunks.length).toBeGreaterThan(0);
// Verify the output contains the tmpDir path
const fullMessage = updates.messageChunks.join("");
expect(fullMessage).toContain(tmpDir);
// Verify status transitions
const statuses = extractStatusTransitions(updates);
expect(statuses.length).toBeGreaterThan(0);
} finally {
if (unsubscribe) unsubscribe();
await manager.killAgent(agentId);
}
},
60000
);
});
// ==========================================================================
// TEST 2: Permission Mode - Auto Approve
// ==========================================================================
describe("Permission Mode: Auto Approve", () => {
it(
"should create files when in auto_approve mode",
async () => {
const updates = createUpdateCollector();
const testFile = join(tmpDir, "test-auto-approve.txt");
const agentId = await manager.createAgent({
cwd: tmpDir,
});
expect(agentId).toBeDefined();
const unsubscribe = manager.subscribeToUpdates(agentId, (update) => {
collectUpdate(updates, update);
});
await manager.sendPrompt(
agentId,
`Create a file called "test-auto-approve.txt" with the content "hello from auto approve". You can write the file however you prefer.`
);
try {
// Wait for completion
await waitForStatus(manager, agentId, "completed", 60000);
expect(updates.all.length).toBeGreaterThan(0);
expect(updates.toolCalls.length).toBeGreaterThan(0);
// Verify file was created
const content = await readFile(testFile, "utf-8");
expect(content).toContain("hello from auto approve");
} finally {
unsubscribe();
await manager.killAgent(agentId);
}
},
60000
);
});
// ==========================================================================
// TEST 3: Permission Mode - Reject All
// ==========================================================================
describe("Permission Mode: Reject All", () => {
it(
"should block file operations in reject_all mode",
async () => {
const updates = createUpdateCollector();
const testFile = join(tmpDir, "test-blocked.txt");
const agentId = await manager.createAgent({
cwd: tmpDir,
});
expect(agentId).toBeDefined();
const unsubscribe = manager.subscribeToUpdates(agentId, (update) => {
collectUpdate(updates, update);
});
await manager.sendPrompt(
agentId,
`Create a file called "test-blocked.txt" with the content "this should be blocked".`
);
try {
// Wait for completion (agent should complete even if permission denied)
await waitForStatus(manager, agentId, "completed", 60000);
expect(updates.all.length).toBeGreaterThan(0);
// Verify file was NOT created
await expect(access(testFile)).rejects.toThrow();
// Check that the response indicates permission denial
const fullMessage = updates.messageChunks.join("").toLowerCase();
const hasPermissionMessage =
fullMessage.includes("permission") ||
fullMessage.includes("denied") ||
fullMessage.includes("blocked") ||
fullMessage.includes("unable") ||
fullMessage.includes("could not") ||
fullMessage.includes("cannot");
// This is a soft assertion - we expect permission denial indication
// but don't fail the test if it's not present
if (!hasPermissionMessage) {
console.warn(
"Warning: Agent response doesn't clearly indicate permission denial"
);
}
} finally {
unsubscribe();
await manager.killAgent(agentId);
}
},
60000
);
});
// ==========================================================================
// TEST 4: Multiple Prompts
// ==========================================================================
describe("Multiple Prompts", () => {
it(
"should handle sequential prompts to same agent",
async () => {
const updates1 = createUpdateCollector();
const updates2 = createUpdateCollector();
let collectingFirst = true;
const agentId = await manager.createAgent({
cwd: tmpDir,
});
expect(agentId).toBeDefined();
const unsubscribe = manager.subscribeToUpdates(agentId, (update) => {
if (collectingFirst) {
collectUpdate(updates1, update);
} else {
collectUpdate(updates2, update);
}
});
try {
// Send first prompt
await manager.sendPrompt(agentId, "Echo 'first prompt response'");
await waitForStatus(manager, agentId, "completed", 30000);
expect(updates1.all.length).toBeGreaterThan(0);
// Switch to collecting second set of updates
collectingFirst = false;
// Send second prompt
await manager.sendPrompt(agentId, "Echo 'second prompt response'");
await waitForStatus(manager, agentId, "completed", 30000);
expect(updates2.all.length).toBeGreaterThan(0);
// Verify both prompts got responses
expect(updates1.all.length).toBeGreaterThan(0);
expect(updates2.all.length).toBeGreaterThan(0);
} finally {
unsubscribe();
await manager.killAgent(agentId);
}
},
60000
);
});
// ==========================================================================
// TEST 5: Update Streaming
// ==========================================================================
describe("Update Streaming", () => {
it(
"should stream all update types correctly",
async () => {
const updates = createUpdateCollector();
const agentId = await manager.createAgent({
cwd: tmpDir,
});
expect(agentId).toBeDefined();
const unsubscribe = manager.subscribeToUpdates(agentId, (update) => {
collectUpdate(updates, update);
});
await manager.sendPrompt(
agentId,
"List the files in the current directory using the Bash tool. Then explain what you found."
);
try {
await waitForStatus(manager, agentId, "completed", 60000);
// Verify we got various update types
expect(updates.all.length).toBeGreaterThan(0);
expect(updates.messageChunks.length).toBeGreaterThan(0);
expect(updates.toolCalls.length).toBeGreaterThan(0);
// Verify message can be reconstructed
const fullMessage = updates.messageChunks.join("");
expect(fullMessage.length).toBeGreaterThan(0);
} finally {
unsubscribe();
await manager.killAgent(agentId);
}
},
60000
);
});
// ==========================================================================
// TEST 6: State Management
// ==========================================================================
describe("State Management", () => {
it(
"should transition states correctly and clean up after kill",
async () => {
const statusLog: Array<{ time: Date; status: AgentStatus }> = [];
const agentId = await manager.createAgent({
cwd: tmpDir,
});
expect(agentId).toBeDefined();
// Track status changes BEFORE sending prompt
const checkInterval = setInterval(() => {
const status = manager.getAgentStatus(agentId);
statusLog.push({ time: new Date(), status });
}, 500);
await manager.sendPrompt(
agentId,
"Sleep for 2 seconds using a bash command, then echo 'done'"
);
try {
// Wait for completion
await waitForStatus(manager, agentId, "completed", 60000);
clearInterval(checkInterval);
// Log should have captured status changes
// (Note: might be 0 if agent completed too quickly)
// We'll check transitions instead which is more reliable
// Analyze status transitions
const transitions: string[] = [];
let lastStatus: AgentStatus | null = null;
for (const entry of statusLog) {
if (entry.status !== lastStatus) {
transitions.push(entry.status);
lastStatus = entry.status;
}
}
// Verify final status is completed (this is the most important check)
const finalStatus = manager.getAgentStatus(agentId);
expect(finalStatus).toBe("completed");
// If we captured transitions, verify they include expected states
// (Agent might complete too quickly to capture all states)
if (transitions.length > 0) {
// Should at least have completed state
expect(transitions).toContain("completed");
}
// Kill the agent and verify status changes
await manager.killAgent(agentId);
// Agent should be removed from manager
expect(() => manager.getAgentStatus(agentId)).toThrow("not found");
} finally {
clearInterval(checkInterval);
}
},
60000
);
});
});
@@ -0,0 +1,585 @@
import { spawn, ChildProcess } from "child_process";
import { Writable, Readable } from "stream";
import {
ClientSideConnection,
ndJsonStream,
PROTOCOL_VERSION,
type Client,
type SessionNotification,
type RequestPermissionRequest,
type RequestPermissionResponse,
type ReadTextFileRequest,
type ReadTextFileResponse,
type WriteTextFileRequest,
type WriteTextFileResponse,
} from "@agentclientprotocol/sdk";
import { v4 as uuidv4 } from "uuid";
import type {
AgentStatus,
AgentInfo,
AgentUpdate,
CreateAgentOptions,
AgentUpdateCallback,
SessionMode,
} from "./types.js";
interface ManagedAgent {
id: string;
status: AgentStatus;
createdAt: Date;
process: ChildProcess;
connection: ClientSideConnection;
sessionId?: string;
error?: string;
currentModeId?: string;
availableModes?: SessionMode[];
subscribers: Set<AgentUpdateCallback>;
updates: AgentUpdate[];
}
/**
* Client implementation for ACP callbacks
*/
class ACPClient implements Client {
constructor(
private agentId: string,
private onUpdate: (agentId: string, update: SessionNotification) => void
) {}
async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
// TODO: Forward permission requests to the UI for user approval
// For now, auto-approve all permissions
console.log(`[Agent ${this.agentId}] Permission requested (auto-approving):`, params);
return {
outcome: {
outcome: "selected" as const,
optionId: params.options[0]?.optionId || "",
},
};
}
async sessionUpdate(params: SessionNotification): Promise<void> {
this.onUpdate(this.agentId, params);
}
async readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
console.log(`[Agent ${this.agentId}] Read text file:`, params.path);
const fs = await import("fs/promises");
try {
const content = await fs.readFile(params.path, "utf-8");
return { content };
} catch (error) {
console.error(`[Agent ${this.agentId}] Failed to read file:`, error);
return { content: "" };
}
}
async writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
console.log(`[Agent ${this.agentId}] Write text file:`, params.path);
const fs = await import("fs/promises");
try {
await fs.writeFile(params.path, params.content, "utf-8");
return {};
} catch (error) {
console.error(`[Agent ${this.agentId}] Failed to write file:`, error);
throw error;
}
}
}
/**
* Manages Claude Code agents via ACP
*/
export class AgentManager {
private agents = new Map<string, ManagedAgent>();
/**
* Create a new agent
* Spawns the claude-code-acp process and initializes ACP connection
*/
async createAgent(options: CreateAgentOptions): Promise<string> {
const agentId = uuidv4();
const cwd = options.cwd;
// Spawn the ACP process
const agentProcess = spawn("npx", ["@zed-industries/claude-code-acp"], {
stdio: ["pipe", "pipe", "pipe"],
cwd,
});
// Create streams for ACP communication
const input = Writable.toWeb(agentProcess.stdin);
const output = Readable.toWeb(agentProcess.stdout);
const stream = ndJsonStream(input, output);
// Create the ACP client and connection
const client = new ACPClient(
agentId,
(id, update) => {
this.handleSessionNotification(id, update);
}
);
const connection = new ClientSideConnection(() => client, stream);
// Create the managed agent record
const agent: ManagedAgent = {
id: agentId,
status: "initializing",
createdAt: new Date(),
process: agentProcess,
connection,
subscribers: new Set(),
updates: [],
};
this.agents.set(agentId, agent);
// Handle process errors
agentProcess.on("error", (error) => {
this.handleAgentError(agentId, `Process error: ${error.message}`);
});
agentProcess.on("exit", (code, signal) => {
const agent = this.agents.get(agentId);
if (!agent) return;
if (agent.status !== "completed" && agent.status !== "killed") {
this.handleAgentError(
agentId,
`Process exited unexpectedly: code=${code}, signal=${signal}`
);
}
});
// Capture stderr for debugging
agentProcess.stderr.on("data", (data) => {
console.error(`[Agent ${agentId}] stderr:`, data.toString());
});
try {
// Initialize the connection
await agent.connection.initialize({
protocolVersion: PROTOCOL_VERSION,
clientCapabilities: {
fs: {
readTextFile: true,
writeTextFile: true,
},
},
});
// Create a new session
const sessionResponse = await agent.connection.newSession({
cwd,
mcpServers: [],
...(options.initialMode ? { initialMode: options.initialMode } : {}),
});
agent.sessionId = sessionResponse.sessionId;
// Store session modes from response
if (sessionResponse.modes) {
agent.currentModeId = sessionResponse.modes.currentModeId;
agent.availableModes = sessionResponse.modes.availableModes;
console.log(
`[Agent ${agentId}] Session created with mode: ${agent.currentModeId}`,
`Available modes:`, agent.availableModes?.map(m => m.id).join(', ')
);
} else {
console.log(`[Agent ${agentId}] Session created:`, sessionResponse.sessionId);
}
agent.status = "ready";
// If an initial prompt was provided, send it
if (options.initialPrompt) {
console.log(`[Agent ${agentId}] Sending initial prompt`);
await this.sendPrompt(agentId, options.initialPrompt);
}
return agentId;
} catch (error) {
this.handleAgentError(
agentId,
`Initialization failed: ${error instanceof Error ? error.message : String(error)}`
);
throw error;
}
}
/**
* Send a prompt to an agent
* @param agentId - Agent ID
* @param prompt - The prompt text
* @param options - Optional settings: maxWait (ms), sessionMode to set before sending
* @returns Object with didComplete boolean indicating if agent finished within maxWait time
*/
async sendPrompt(
agentId: string,
prompt: string,
options?: { maxWait?: number; sessionMode?: string }
): Promise<{ didComplete: boolean; stopReason?: string }> {
const agent = this.agents.get(agentId);
if (!agent) {
throw new Error(`Agent ${agentId} not found`);
}
if (agent.status === "killed" || agent.status === "failed") {
throw new Error(`Agent ${agentId} is ${agent.status}`);
}
// Set session mode if specified
if (options?.sessionMode) {
await this.setSessionMode(agentId, options.sessionMode);
}
agent.status = "processing";
this.notifySubscribers(agentId);
// Start the prompt (this will eventually complete and update status)
const promptPromise = agent.connection.prompt({
sessionId: agent.sessionId!,
prompt: [
{
type: "text",
text: prompt,
},
],
});
// Handle completion in background
promptPromise
.then((response) => {
// Handle completion based on stopReason from the protocol
console.log(`[Agent ${agentId}] Prompt completed with stopReason: ${response.stopReason}`);
if (response.stopReason === "end_turn") {
agent.status = "completed";
} else if (response.stopReason === "refusal") {
agent.status = "failed";
agent.error = "Agent refused to process the prompt";
} else if (response.stopReason === "cancelled") {
agent.status = "ready";
} else {
// max_tokens, max_turn_requests - still completed but may be truncated
agent.status = "completed";
}
this.notifyStatusChange(agentId);
})
.catch((error) => {
this.handleAgentError(
agentId,
`Prompt failed: ${error instanceof Error ? error.message : String(error)}`
);
});
const maxWait = options?.maxWait;
// If no maxWait specified, return immediately
if (maxWait === undefined) {
console.log(`[Agent ${agentId}] Prompt sent (non-blocking, use get_agent_status to check completion)`);
return { didComplete: false };
}
// If maxWait specified, race between completion and timeout
try {
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("timeout")), maxWait)
);
const response = await Promise.race([promptPromise, timeoutPromise]);
console.log(`[Agent ${agentId}] Prompt completed within ${maxWait}ms with stopReason: ${response.stopReason}`);
return { didComplete: true, stopReason: response.stopReason };
} catch (error) {
if (error instanceof Error && error.message === "timeout") {
console.log(`[Agent ${agentId}] Prompt did not complete within ${maxWait}ms (still processing)`);
return { didComplete: false };
}
// Real error - rethrow
throw error;
}
}
/**
* Cancel an agent's current task
*/
async cancelAgent(agentId: string): Promise<void> {
const agent = this.agents.get(agentId);
if (!agent) {
throw new Error(`Agent ${agentId} not found`);
}
if (!agent.sessionId) {
throw new Error(`Agent ${agentId} has no active session`);
}
try {
await agent.connection.cancel({
sessionId: agent.sessionId,
});
agent.status = "ready";
this.notifySubscribers(agentId);
} catch (error) {
console.error(`[Agent ${agentId}] Cancel failed:`, error);
throw error;
}
}
/**
* Kill an agent
* Terminates the process and removes it from the manager
*/
async killAgent(agentId: string): Promise<void> {
const agent = this.agents.get(agentId);
if (!agent) {
throw new Error(`Agent ${agentId} not found`);
}
agent.status = "killed";
this.notifySubscribers(agentId);
// Kill the process
agent.process.kill("SIGTERM");
// Wait a bit, then force kill if still alive
setTimeout(() => {
if (!agent.process.killed) {
agent.process.kill("SIGKILL");
}
}, 5000);
// Remove from manager
this.agents.delete(agentId);
}
/**
* Get the status of an agent
*/
getAgentStatus(agentId: string): AgentStatus {
const agent = this.agents.get(agentId);
if (!agent) {
throw new Error(`Agent ${agentId} not found`);
}
return agent.status;
}
/**
* List all agents
*/
listAgents(): AgentInfo[] {
return Array.from(this.agents.values()).map((agent) => ({
id: agent.id,
status: agent.status,
createdAt: agent.createdAt,
type: "claude" as const,
sessionId: agent.sessionId ?? null,
error: agent.error ?? null,
currentModeId: agent.currentModeId ?? null,
availableModes: agent.availableModes ?? null,
}));
}
/**
* Subscribe to updates from an agent
*/
subscribeToUpdates(
agentId: string,
callback: AgentUpdateCallback
): () => void {
const agent = this.agents.get(agentId);
if (!agent) {
throw new Error(`Agent ${agentId} not found`);
}
agent.subscribers.add(callback);
// Return unsubscribe function
return () => {
agent.subscribers.delete(callback);
};
}
/**
* Get all updates for an agent
*/
getAgentUpdates(agentId: string): AgentUpdate[] {
const agent = this.agents.get(agentId);
if (!agent) {
throw new Error(`Agent ${agentId} not found`);
}
return [...agent.updates];
}
/**
* Get the current session mode for an agent
*/
getCurrentMode(agentId: string): string | null {
const agent = this.agents.get(agentId);
if (!agent) {
throw new Error(`Agent ${agentId} not found`);
}
return agent.currentModeId ?? null;
}
/**
* Get available session modes for an agent
*/
getAvailableModes(agentId: string): SessionMode[] | null {
const agent = this.agents.get(agentId);
if (!agent) {
throw new Error(`Agent ${agentId} not found`);
}
return agent.availableModes ?? null;
}
/**
* Set the session mode for an agent
* Validates that the mode is available before setting
*/
async setSessionMode(agentId: string, modeId: string): Promise<void> {
const agent = this.agents.get(agentId);
if (!agent) {
throw new Error(`Agent ${agentId} not found`);
}
if (!agent.sessionId) {
throw new Error(`Agent ${agentId} has no active session`);
}
// Validate mode is available
const availableModes = agent.availableModes || [];
const mode = availableModes.find((m) => m.id === modeId);
if (!mode && availableModes.length > 0) {
const availableIds = availableModes.map((m) => m.id).join(", ");
throw new Error(
`Mode '${modeId}' not available for agent ${agentId}. Available modes: ${availableIds}`
);
}
try {
await agent.connection.setSessionMode({
sessionId: agent.sessionId,
modeId,
});
agent.currentModeId = modeId;
console.log(`[Agent ${agentId}] Session mode changed to: ${modeId}`);
} catch (error) {
console.error(`[Agent ${agentId}] Failed to set mode:`, error);
throw error;
}
}
/**
* Handle session notifications from the ACP connection
*/
private handleSessionNotification(agentId: string, update: SessionNotification): void {
const agent = this.agents.get(agentId);
if (!agent) return;
const agentUpdate: AgentUpdate = {
agentId,
timestamp: new Date(),
notification: update as any,
};
// Store the update in history
agent.updates.push(agentUpdate);
// Handle mode change notifications
if ((update as any).type === "currentModeUpdate" && (update as any).currentModeId) {
agent.currentModeId = (update as any).currentModeId;
console.log(`[Agent ${agentId}] Mode changed to: ${agent.currentModeId}`);
}
// Log update for debugging
const updateType = update.update.sessionUpdate;
console.log(
`[Agent ${agentId}] Session update:`,
updateType
);
// Notify all subscribers
for (const subscriber of agent.subscribers) {
try {
subscriber(agentUpdate);
} catch (error) {
console.error(`[Agent ${agentId}] Subscriber error:`, error);
}
}
}
/**
* Notify subscribers of a status change
*/
private notifyStatusChange(agentId: string): void {
const agent = this.agents.get(agentId);
if (!agent) return;
const update: AgentUpdate = {
agentId,
timestamp: new Date(),
notification: {
type: "sessionUpdate",
sessionUpdate: {
sessionId: agent.sessionId || "",
sessionUpdate: "status_change",
status: agent.status,
},
} as any,
};
for (const subscriber of agent.subscribers) {
try {
subscriber(update);
} catch (error) {
console.error(`[Agent ${agentId}] Subscriber error:`, error);
}
}
}
/**
* Handle agent errors
*/
private handleAgentError(agentId: string, errorMessage: string): void {
const agent = this.agents.get(agentId);
if (!agent) return;
console.error(`[Agent ${agentId}] Error:`, errorMessage);
agent.status = "failed";
agent.error = errorMessage;
this.notifySubscribers(agentId);
}
/**
* Notify subscribers that agent status has changed
*/
private notifySubscribers(agentId: string): void {
const agent = this.agents.get(agentId);
if (!agent) return;
const update: AgentUpdate = {
agentId,
timestamp: new Date(),
notification: {
type: "sessionUpdate",
sessionUpdate: {
sessionId: agent.sessionId || "",
status: agent.status,
state: agent.error ? { kind: "error", error: agent.error } : undefined,
},
} as any,
};
for (const subscriber of agent.subscribers) {
try {
subscriber(update);
} catch (error) {
console.error(`[Agent ${agentId}] Subscriber error:`, error);
}
}
}
}
+15
View File
@@ -0,0 +1,15 @@
/**
* ACP (Agent Client Protocol) Integration - Phase 1
*
* Provides infrastructure for managing Claude Code agents.
* This is standalone and does not integrate with Session/WebSocket yet.
*/
export { AgentManager } from "./agent-manager.js";
export type {
AgentStatus,
AgentInfo,
AgentUpdate,
CreateAgentOptions,
AgentUpdateCallback,
} from "./types.js";
@@ -0,0 +1,436 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { homedir } from "os";
import { resolve } from "path";
import { AgentManager } from "./agent-manager.js";
import type { SessionNotification } from "@agentclientprotocol/sdk";
import { curateAgentActivity } from "./activity-curator.js";
export interface AgentMcpServerOptions {
agentManager: AgentManager;
}
/**
* Expand tilde (~) to home directory and resolve path
*/
function expandPath(path: string): string {
if (path.startsWith("~/") || path === "~") {
return resolve(homedir(), path.slice(2));
}
return resolve(path);
}
/**
* Extract the update type from a SessionNotification
*/
function getUpdateType(notification: SessionNotification): string {
return notification.update.sessionUpdate;
}
/**
* Create and configure the Agent MCP Server
* Exposes Claude Code agent management tools to the LLM
*/
export async function createAgentMcpServer(
options: AgentMcpServerOptions
): Promise<McpServer> {
const { agentManager } = options;
const server = new McpServer({
name: "agent-mcp",
version: "1.0.0",
});
// Tool: create_coding_agent
server.registerTool(
"create_coding_agent",
{
title: "Create Coding Agent",
description:
"Creates a new Claude Code agent via ACP. The agent runs as a separate process and can execute coding tasks autonomously in a specified directory. Returns immediately with agent ID and status. If an initial prompt is provided, the agent will start working on it automatically.",
inputSchema: {
cwd: z
.string()
.describe(
"REQUIRED: Working directory for the agent. Can be absolute path, tilde-prefixed path, or relative path (e.g., '~/dev/project', '/path/to/repo', './subdir')."
),
initialPrompt: z
.string()
.optional()
.describe(
"Optional initial task or prompt for the agent to start working on immediately after creation. If provided, agent will begin processing this task right away."
),
initialMode: z
.string()
.optional()
.describe(
"Initial session mode for the agent (e.g., 'ask', 'code', 'architect'). If not specified, the agent will use its default mode. The available modes depend on the specific agent implementation."
),
},
outputSchema: {
agentId: z.string().describe("Unique identifier for the created agent"),
status: z
.string()
.describe(
"Current agent status: 'initializing', 'ready', 'processing', etc."
),
cwd: z.string().describe("The resolved absolute working directory the agent is running in"),
currentModeId: z.string().nullable().describe("The agent's current session mode"),
availableModes: z.array(z.object({
id: z.string(),
name: z.string(),
description: z.string().optional(),
})).nullable().describe("Available session modes for this agent"),
},
},
async ({ cwd, initialPrompt, initialMode }) => {
// Expand and resolve the working directory
const resolvedCwd = expandPath(cwd);
const agentId = await agentManager.createAgent({
cwd: resolvedCwd,
initialPrompt,
initialMode,
});
const status = agentManager.getAgentStatus(agentId);
const currentModeId = agentManager.getCurrentMode(agentId);
const availableModes = agentManager.getAvailableModes(agentId);
const result = {
agentId,
status,
cwd: resolvedCwd,
currentModeId: currentModeId ?? null,
availableModes: availableModes ?? null,
};
return {
content: [],
structuredContent: result,
};
}
);
// Tool: send_agent_prompt
server.registerTool(
"send_agent_prompt",
{
title: "Send Agent Prompt",
description:
"Sends a task or prompt to an existing agent. By default, returns immediately without waiting (non-blocking). The agent will process the prompt in the background. Use get_agent_status or get_agent_activity to check progress. Optionally specify maxWait to wait for completion, or sessionMode to switch modes before sending.",
inputSchema: {
agentId: z.string().describe("Agent ID returned from create_coding_agent"),
prompt: z
.string()
.describe(
"The task, instruction, or feedback to send to the agent. Be specific about what you want the agent to accomplish."
),
sessionMode: z
.string()
.optional()
.describe(
"Optional: Session mode to set before sending the prompt (e.g., 'plan', 'code', 'default'). If specified, the agent's mode will be changed before processing the prompt."
),
maxWait: z
.number()
.optional()
.describe(
"Optional: Maximum milliseconds to wait for agent to complete. If not provided, returns immediately (non-blocking). If agent doesn't finish within this time, returns with didComplete=false and agent continues processing in background."
),
},
outputSchema: {
success: z.boolean().describe("Whether the prompt was sent successfully"),
didComplete: z.boolean().describe("Whether the agent completed within maxWait time (always false if maxWait not specified)"),
stopReason: z.string().nullable().describe("Reason agent stopped if it completed: 'end_turn', 'max_tokens', 'max_turn_requests', 'refusal', 'cancelled'"),
},
},
async ({ agentId, prompt, sessionMode, maxWait }) => {
const response = await agentManager.sendPrompt(agentId, prompt, {
maxWait,
sessionMode,
});
const result = {
success: true,
didComplete: response.didComplete,
stopReason: response.stopReason ?? null,
};
return {
content: [],
structuredContent: result,
};
}
);
// Tool: get_agent_status
server.registerTool(
"get_agent_status",
{
title: "Get Agent Status",
description:
"Get the current status and information about a specific agent. Returns status, creation time, session ID, and any error messages.",
inputSchema: {
agentId: z.string().describe("Agent ID to query"),
},
outputSchema: {
status: z.string().describe("Current agent status"),
info: z
.object({
id: z.string(),
status: z.string(),
createdAt: z.date(),
type: z.literal("claude"),
sessionId: z.string().nullable(),
error: z.string().nullable(),
currentModeId: z.string().nullable(),
availableModes: z.array(z.object({
id: z.string(),
name: z.string(),
description: z.string().optional(),
})).nullable(),
})
.describe("Detailed agent information"),
},
},
async ({ agentId }) => {
const status = agentManager.getAgentStatus(agentId);
const agents = agentManager.listAgents();
const agentInfo = agents.find((a) => a.id === agentId);
if (!agentInfo) {
throw new Error(`Agent ${agentId} not found`);
}
const result = {
status,
info: agentInfo,
};
return {
content: [],
structuredContent: result,
};
}
);
// Tool: list_agents
server.registerTool(
"list_agents",
{
title: "List Agents",
description:
"List all active agents managed by this session. Shows agent IDs, statuses, creation times, and any errors. Useful for monitoring multiple concurrent agents.",
inputSchema: {},
outputSchema: {
agents: z.array(
z.object({
id: z.string(),
status: z.string(),
createdAt: z.date(),
type: z.literal("claude"),
sessionId: z.string().nullable(),
error: z.string().nullable(),
currentModeId: z.string().nullable(),
availableModes: z.array(z.object({
id: z.string(),
name: z.string(),
description: z.string().optional(),
})).nullable(),
})
),
},
},
async () => {
const agents = agentManager.listAgents();
const result = {
agents,
};
return {
content: [],
structuredContent: result,
};
}
);
// Tool: cancel_agent
server.registerTool(
"cancel_agent",
{
title: "Cancel Agent",
description:
"Cancel the current task of a running agent. The agent remains alive and returns to 'ready' state, allowing you to send new prompts. Use this when you want to interrupt an agent's work without killing it.",
inputSchema: {
agentId: z.string().describe("Agent ID to cancel"),
},
outputSchema: {
success: z.boolean().describe("Whether cancellation succeeded"),
},
},
async ({ agentId }) => {
await agentManager.cancelAgent(agentId);
const result = {
success: true,
};
return {
content: [],
structuredContent: result,
};
}
);
// Tool: kill_agent
server.registerTool(
"kill_agent",
{
title: "Kill Agent",
description:
"Terminate an agent completely. This kills the agent's process and removes it from the manager. The agent cannot be used after this operation. Use this when you're done with an agent or if it's in a failed state.",
inputSchema: {
agentId: z.string().describe("Agent ID to kill"),
},
outputSchema: {
success: z.boolean().describe("Whether the agent was killed successfully"),
},
},
async ({ agentId }) => {
await agentManager.killAgent(agentId);
const result = {
success: true,
};
return {
content: [],
structuredContent: result,
};
}
);
// Tool: get_agent_activity
server.registerTool(
"get_agent_activity",
{
title: "Get Agent Activity",
description:
"Get the agent's activity in a human-readable, token-efficient format. Consolidates message chunks, formats tool calls, and structures plans. By default returns curated text, but you can request raw updates with format='raw'.",
inputSchema: {
agentId: z.string().describe("Agent ID to query"),
format: z
.enum(["curated", "raw"])
.optional()
.default("curated")
.describe(
"Output format: 'curated' (default) for clean human-readable text, 'raw' for detailed JSON updates"
),
limit: z
.number()
.optional()
.describe(
"Maximum number of updates to include (most recent first). Only applies to 'raw' format. Omit to get all updates."
),
},
outputSchema: {
agentId: z.string(),
format: z.enum(["curated", "raw"]),
updateCount: z.number().describe("Total number of updates available"),
currentModeId: z.string().nullable().describe("Current session mode of the agent"),
content: z.string().describe("Formatted activity content (if curated) or empty string (if raw)"),
updates: z.array(
z.object({
timestamp: z.string(),
type: z.string(),
data: z.any(),
})
).nullable().describe("Raw updates array (if raw format) or null (if curated)"),
},
},
async ({ agentId, format = "curated", limit }) => {
const updates = agentManager.getAgentUpdates(agentId);
const currentModeId = agentManager.getCurrentMode(agentId);
if (format === "curated") {
// Return curated, human-readable format
const curatedText = curateAgentActivity(updates);
return {
content: [],
structuredContent: {
agentId,
format: "curated" as const,
updateCount: updates.length,
currentModeId,
content: curatedText,
updates: null,
},
};
} else {
// Return raw format (old behavior)
const selectedUpdates = limit
? updates.slice(-limit).reverse()
: updates;
return {
content: [],
structuredContent: {
agentId,
format: "raw" as const,
updateCount: updates.length,
currentModeId,
content: "",
updates: selectedUpdates.map((update) => ({
timestamp: update.timestamp.toISOString(),
type: getUpdateType(update.notification),
data: update.notification,
})),
},
};
}
}
);
// Tool: set_agent_mode
server.registerTool(
"set_agent_mode",
{
title: "Set Agent Session Mode",
description:
"Change the agent's session mode (e.g., from 'ask' to 'code', or 'architect' to 'code'). Each mode affects the agent's behavior - 'ask' mode requests permission before changes, 'code' mode writes code directly, 'architect' mode plans without implementation. The available modes depend on the specific agent. Use get_agent_status or list_agents to see available modes for each agent.",
inputSchema: {
agentId: z.string().describe("Agent ID to configure"),
modeId: z
.string()
.describe(
"The session mode to set (e.g., 'ask', 'code', 'architect'). Must be one of the agent's available modes."
),
},
outputSchema: {
success: z.boolean().describe("Whether the mode change succeeded"),
previousMode: z.string().nullable().describe("The previous session mode"),
newMode: z.string().describe("The new session mode"),
},
},
async ({ agentId, modeId }) => {
const previousMode = agentManager.getCurrentMode(agentId);
await agentManager.setSessionMode(agentId, modeId);
const result = {
success: true,
previousMode: previousMode ?? null,
newMode: modeId,
};
return {
content: [],
structuredContent: result,
};
}
);
return server;
}
@@ -0,0 +1,88 @@
#!/usr/bin/env tsx
import { AgentManager } from "./agent-manager.js";
async function main() {
console.log("=== ACP Agent Manager Test ===\n");
const manager = new AgentManager();
try {
console.log("1. Creating agent...");
const agentId = await manager.createAgent({
cwd: process.cwd(),
});
console.log(` ✓ Agent created: ${agentId}\n`);
console.log("2. Subscribing to updates...");
manager.subscribeToUpdates(agentId, (update) => {
console.log(` [UPDATE] ${new Date().toISOString()}`);
console.log(` Type: ${(update.notification as any).type}`);
console.log(` Notification:`, JSON.stringify(update.notification, null, 2));
console.log();
});
console.log(" ✓ Subscribed\n");
console.log("3. Checking initial status...");
const status = manager.getAgentStatus(agentId);
console.log(` Status: ${status}\n`);
console.log("4. Listing all agents...");
const agents = manager.listAgents();
console.log(` Total agents: ${agents.length}`);
agents.forEach((agent) => {
console.log(
` - ${agent.id}: ${agent.status} (created: ${agent.createdAt.toISOString()})`
);
});
console.log();
console.log("5. Sending prompt: 'List files in current directory'");
await manager.sendPrompt(
agentId,
"List files in current directory using ls"
);
console.log(" ✓ Prompt sent\n");
console.log("6. Waiting for completion (30 seconds max)...");
const startTime = Date.now();
const maxWait = 30000; // 30 seconds
await new Promise<void>((resolve, reject) => {
const checkInterval = setInterval(() => {
const currentStatus = manager.getAgentStatus(agentId);
const elapsed = Date.now() - startTime;
if (currentStatus === "completed") {
clearInterval(checkInterval);
console.log(` ✓ Agent completed in ${elapsed}ms\n`);
resolve();
} else if (currentStatus === "failed") {
clearInterval(checkInterval);
const agents = manager.listAgents();
const agent = agents.find((a) => a.id === agentId);
reject(new Error(`Agent failed: ${agent?.error || "Unknown error"}`));
} else if (elapsed > maxWait) {
clearInterval(checkInterval);
console.log(` ⚠ Timeout after ${elapsed}ms (status: ${currentStatus})\n`);
resolve();
}
}, 500);
});
console.log("7. Cleaning up...");
await manager.killAgent(agentId);
console.log(" ✓ Agent killed\n");
console.log("=== Test completed successfully ===");
} catch (error) {
console.error("\n❌ Test failed:");
console.error(error);
process.exit(1);
}
}
main().catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});
+610
View File
@@ -0,0 +1,610 @@
#!/usr/bin/env tsx
import { AgentManager } from "./agent-manager.js";
import { mkdtemp, rm, readFile, access } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
import type { AgentUpdate, AgentStatus } from "./types.js";
/**
* Comprehensive Test Suite for ACP Agent Functionality
*
* This test validates all core features with a REAL Claude Code agent (no mocking):
*
* 1. Directory Control - Agent runs in specified directory
* 2. Permission Mode: Auto Approve - File operations are automatically approved
* 3. Permission Mode: Reject All - File operations are blocked
* 4. Multiple Prompts - Agent can handle sequential prompts
* 5. Update Streaming - All update types stream correctly (message chunks, tool calls, etc.)
* 6. State Management - Status accurately reflects actual agent state
*
* Run with: npx tsx src/server/acp/test-comprehensive.ts
*/
interface TestContext {
manager: AgentManager;
tmpDir: string;
}
interface CollectedUpdates {
all: AgentUpdate[];
messageChunks: string[];
toolCalls: any[];
toolResults: any[];
statusUpdates: any[];
commands: any[];
}
async function main() {
const manager = new AgentManager();
const tmpDir = await mkdtemp(join(tmpdir(), "acp-test-"));
console.log("=== Comprehensive ACP Agent Test Suite ===");
console.log(`Test directory: ${tmpDir}\n`);
const ctx: TestContext = { manager, tmpDir };
try {
await testDirectoryAndInitialPrompt(ctx);
await testPermissionAutoApprove(ctx);
await testPermissionRejectAll(ctx);
await testMultiplePrompts(ctx);
await testUpdateStreaming(ctx);
await testStateManagement(ctx);
console.log("\n✅ ALL TESTS PASSED!");
} catch (error) {
console.error("\n❌ TEST SUITE FAILED:", error);
throw error;
} finally {
// Cleanup
console.log("\n=== Cleanup ===");
const agents = manager.listAgents();
console.log(`Killing ${agents.length} agents...`);
for (const agent of agents) {
await manager.killAgent(agent.id);
}
console.log(`Removing test directory: ${tmpDir}`);
await rm(tmpDir, { recursive: true, force: true });
console.log("Cleanup complete");
}
}
/**
* TEST 1: Directory Control and Initial Prompt
* Verifies that the agent runs in the specified directory
* and can execute the initial prompt successfully
*/
async function testDirectoryAndInitialPrompt(ctx: TestContext) {
console.log("\n=== TEST 1: Directory Control and Initial Prompt ===");
const updates = createUpdateCollector();
let unsubscribe: (() => void) | null = null;
// Create agent without initial prompt first, so we can subscribe
const agentId = await ctx.manager.createAgent({
cwd: ctx.tmpDir,
});
console.log(`✓ Agent created: ${agentId}`);
// Subscribe before sending prompt
unsubscribe = ctx.manager.subscribeToUpdates(agentId, (update) => {
collectUpdate(updates, update);
});
// Now send the prompt
await ctx.manager.sendPrompt(
agentId,
"Run pwd and confirm you're in the test directory. Output the full path."
);
try {
// Wait for processing to complete
await waitForStatus(ctx.manager, agentId, "completed", 60000);
console.log(`✓ Agent completed processing`);
console.log(` - Total updates: ${updates.all.length}`);
console.log(` - Message chunks: ${updates.messageChunks.length}`);
console.log(` - Tool calls: ${updates.toolCalls.length}`);
// Verify the output contains the tmpDir path
const fullMessage = updates.messageChunks.join("");
if (!fullMessage.includes(ctx.tmpDir)) {
throw new Error(
`Expected output to contain tmpDir path "${ctx.tmpDir}", but got: ${fullMessage}`
);
}
console.log(`✓ Output contains correct directory path`);
// Verify we got tool calls (pwd command should be executed via terminal)
if (updates.toolCalls.length === 0) {
console.log(`⚠ Warning: No tool calls detected (expected pwd command)`);
} else {
console.log(`✓ Tool calls detected: ${updates.toolCalls.length}`);
}
// Verify status transitions
const statuses = extractStatusTransitions(updates);
if (statuses.length > 0) {
console.log(`✓ Status transitions: ${statuses.join(" → ")}`);
}
console.log("✅ TEST 1 PASSED\n");
} finally {
if (unsubscribe) unsubscribe();
await ctx.manager.killAgent(agentId);
}
}
/**
* TEST 2: Permission Mode - Auto Approve
* Verifies that file operations are automatically approved
*/
async function testPermissionAutoApprove(ctx: TestContext) {
console.log("\n=== TEST 2: Permission Mode - Auto Approve ===");
const updates = createUpdateCollector();
const testFile = join(ctx.tmpDir, "test-auto-approve.txt");
const agentId = await ctx.manager.createAgent({
cwd: ctx.tmpDir,
});
console.log(`✓ Agent created: ${agentId}`);
const unsubscribe = ctx.manager.subscribeToUpdates(agentId, (update) => {
collectUpdate(updates, update);
});
await ctx.manager.sendPrompt(
agentId,
`Create a file called "test-auto-approve.txt" with the content "hello from auto approve". You can write the file however you prefer.`
);
try {
// Wait for completion
await waitForStatus(ctx.manager, agentId, "completed", 60000);
console.log(`✓ Agent completed processing`);
console.log(` - Total updates: ${updates.all.length}`);
console.log(` - Tool calls: ${updates.toolCalls.length}`);
// Verify file was created
try {
const content = await readFile(testFile, "utf-8");
console.log(`✓ File created with content: "${content.trim()}"`);
if (!content.includes("hello from auto approve")) {
throw new Error(
`Expected file content to include "hello from auto approve", got: ${content}`
);
}
console.log(`✓ File content is correct`);
} catch (error) {
throw new Error(`File was not created or could not be read: ${error}`);
}
console.log("✅ TEST 2 PASSED\n");
} finally {
unsubscribe();
await ctx.manager.killAgent(agentId);
}
}
/**
* TEST 3: Permission Mode - Reject All
* Verifies that file operations are blocked
*/
async function testPermissionRejectAll(ctx: TestContext) {
console.log("\n=== TEST 3: Permission Mode - Reject All ===");
const updates = createUpdateCollector();
const testFile = join(ctx.tmpDir, "test-blocked.txt");
const agentId = await ctx.manager.createAgent({
cwd: ctx.tmpDir,
});
console.log(`✓ Agent created: ${agentId}`);
const unsubscribe = ctx.manager.subscribeToUpdates(agentId, (update) => {
collectUpdate(updates, update);
});
await ctx.manager.sendPrompt(
agentId,
`Create a file called "test-blocked.txt" with the content "this should be blocked".`
);
try {
// Wait for completion (agent should complete even if permission denied)
await waitForStatus(ctx.manager, agentId, "completed", 60000);
console.log(`✓ Agent completed processing`);
console.log(` - Total updates: ${updates.all.length}`);
// Verify file was NOT created
try {
await access(testFile);
throw new Error(
"File should not have been created with reject_all mode"
);
} catch (error: any) {
if (error.code === "ENOENT") {
console.log(`✓ File was correctly blocked from creation`);
} else {
throw error;
}
}
// Check that the response indicates permission denial
const fullMessage = updates.messageChunks.join("").toLowerCase();
const hasPermissionMessage =
fullMessage.includes("permission") ||
fullMessage.includes("denied") ||
fullMessage.includes("blocked") ||
fullMessage.includes("unable") ||
fullMessage.includes("could not") ||
fullMessage.includes("cannot");
if (hasPermissionMessage) {
console.log(`✓ Agent indicated permission denial in response`);
} else {
console.log(
`⚠ Warning: Agent response doesn't clearly indicate permission denial`
);
console.log(` Response: ${fullMessage.substring(0, 200)}...`);
}
console.log("✅ TEST 3 PASSED\n");
} finally {
unsubscribe();
await ctx.manager.killAgent(agentId);
}
}
/**
* TEST 4: Multiple Prompts
* Verifies that an agent can handle multiple sequential prompts
*/
async function testMultiplePrompts(ctx: TestContext) {
console.log("\n=== TEST 4: Multiple Prompts ===");
const updates1 = createUpdateCollector();
const updates2 = createUpdateCollector();
let collectingFirst = true;
const agentId = await ctx.manager.createAgent({
cwd: ctx.tmpDir,
});
console.log(`✓ Agent created: ${agentId}`);
const unsubscribe = ctx.manager.subscribeToUpdates(agentId, (update) => {
if (collectingFirst) {
collectUpdate(updates1, update);
} else {
collectUpdate(updates2, update);
}
});
try {
// Send first prompt
console.log("Sending prompt 1...");
await ctx.manager.sendPrompt(agentId, "Echo 'first prompt response'");
await waitForStatus(ctx.manager, agentId, "completed", 30000);
console.log(`✓ First prompt completed`);
console.log(` - Updates: ${updates1.all.length}`);
console.log(` - Message: ${updates1.messageChunks.join("").substring(0, 100)}...`);
// Switch to collecting second set of updates
collectingFirst = false;
// Send second prompt
console.log("Sending prompt 2...");
await ctx.manager.sendPrompt(agentId, "Echo 'second prompt response'");
await waitForStatus(ctx.manager, agentId, "completed", 30000);
console.log(`✓ Second prompt completed`);
console.log(` - Updates: ${updates2.all.length}`);
console.log(` - Message: ${updates2.messageChunks.join("").substring(0, 100)}...`);
// Verify both prompts got responses
if (updates1.all.length === 0 || updates2.all.length === 0) {
throw new Error("Both prompts should generate updates");
}
console.log(`✓ Both prompts executed successfully`);
console.log("✅ TEST 4 PASSED\n");
} finally {
unsubscribe();
await ctx.manager.killAgent(agentId);
}
}
/**
* TEST 5: Update Streaming
* Verifies that all update types are received correctly
*/
async function testUpdateStreaming(ctx: TestContext) {
console.log("\n=== TEST 5: Update Streaming ===");
const updates = createUpdateCollector();
const agentId = await ctx.manager.createAgent({
cwd: ctx.tmpDir,
});
console.log(`✓ Agent created: ${agentId}`);
const unsubscribe = ctx.manager.subscribeToUpdates(agentId, (update) => {
collectUpdate(updates, update);
});
await ctx.manager.sendPrompt(
agentId,
"List the files in the current directory using the Bash tool. Then explain what you found."
);
try {
await waitForStatus(ctx.manager, agentId, "completed", 60000);
console.log(`✓ Agent completed processing`);
console.log(`\nUpdate Statistics:`);
console.log(` - Total updates: ${updates.all.length}`);
console.log(` - Message chunks: ${updates.messageChunks.length}`);
console.log(` - Tool calls: ${updates.toolCalls.length}`);
console.log(` - Tool results: ${updates.toolResults.length}`);
console.log(` - Status updates: ${updates.statusUpdates.length}`);
console.log(` - Commands: ${updates.commands.length}`);
// Verify message can be reconstructed
const fullMessage = updates.messageChunks.join("");
console.log(`\nReconstructed message length: ${fullMessage.length} chars`);
console.log(`First 150 chars: ${fullMessage.substring(0, 150)}...`);
if (fullMessage.length === 0) {
throw new Error("Expected to receive message chunks");
}
console.log(`✓ Message chunks successfully reconstructed`);
// Verify we got tool calls
if (updates.toolCalls.length === 0) {
throw new Error("Expected at least one tool call");
}
console.log(`✓ Tool calls captured: ${updates.toolCalls.length}`);
// Log tool call details
updates.toolCalls.forEach((call, i) => {
console.log(` Tool ${i + 1}: ${JSON.stringify(call).substring(0, 100)}...`);
});
console.log("✅ TEST 5 PASSED\n");
} finally {
unsubscribe();
await ctx.manager.killAgent(agentId);
}
}
/**
* TEST 6: State Management
* Verifies that agent status accurately reflects actual state
*/
async function testStateManagement(ctx: TestContext) {
console.log("\n=== TEST 6: State Management ===");
const statusLog: Array<{ time: Date; status: AgentStatus }> = [];
const agentId = await ctx.manager.createAgent({
cwd: ctx.tmpDir,
});
console.log(`✓ Agent created: ${agentId}`);
await ctx.manager.sendPrompt(
agentId,
"Sleep for 2 seconds using a bash command, then echo 'done'"
);
// Track status changes
const checkInterval = setInterval(() => {
const status = ctx.manager.getAgentStatus(agentId);
statusLog.push({ time: new Date(), status });
}, 500);
try {
// Wait for completion
await waitForStatus(ctx.manager, agentId, "completed", 60000);
clearInterval(checkInterval);
console.log(`✓ Agent completed processing`);
console.log(`\nStatus transitions logged: ${statusLog.length}`);
// Analyze status transitions
const transitions: string[] = [];
let lastStatus: AgentStatus | null = null;
for (const entry of statusLog) {
if (entry.status !== lastStatus) {
transitions.push(entry.status);
lastStatus = entry.status;
}
}
console.log(`Status transition sequence: ${transitions.join(" → ")}`);
// Verify expected transitions occurred
const expectedStates = ["ready", "processing", "completed"];
for (const expectedState of expectedStates) {
if (!transitions.includes(expectedState as AgentStatus)) {
console.log(
`⚠ Warning: Expected state "${expectedState}" not observed`
);
} else {
console.log(`✓ State "${expectedState}" observed`);
}
}
// Verify final status is completed
const finalStatus = ctx.manager.getAgentStatus(agentId);
if (finalStatus !== "completed") {
throw new Error(
`Expected final status to be "completed", got "${finalStatus}"`
);
}
console.log(`✓ Final status is correct: ${finalStatus}`);
// Kill the agent and verify status changes
await ctx.manager.killAgent(agentId);
// Agent should be removed from manager
try {
ctx.manager.getAgentStatus(agentId);
throw new Error("Agent should be removed after killing");
} catch (error: any) {
if (error.message.includes("not found")) {
console.log(`✓ Agent correctly removed after kill`);
} else {
throw error;
}
}
console.log("✅ TEST 6 PASSED\n");
} finally {
clearInterval(checkInterval);
}
}
/**
* Helper: Create an update collector
*/
function createUpdateCollector(): CollectedUpdates {
return {
all: [],
messageChunks: [],
toolCalls: [],
toolResults: [],
statusUpdates: [],
commands: [],
};
}
/**
* Helper: Collect and categorize an update
*/
function collectUpdate(collector: CollectedUpdates, update: AgentUpdate): void {
collector.all.push(update);
const notification = update.notification as any;
// The notification structure can be:
// 1. { type: "sessionUpdate", sessionUpdate: { ... } } - for status updates
// 2. { sessionUpdate: "agent_message_chunk", content: { ... } } - for message chunks
// 3. { sessionUpdate: "tool_call", ... } - for tool calls
// 4. { availableCommands: [...] } - for available commands
// Message chunks
if (notification.sessionUpdate === "agent_message_chunk" && notification.content) {
const content = notification.content;
if (content.type === "text" && content.text) {
collector.messageChunks.push(content.text);
}
}
// Tool calls
if (notification.sessionUpdate === "tool_call") {
collector.toolCalls.push(notification);
}
// Tool results
if (notification.sessionUpdate === "tool_result") {
collector.toolResults.push(notification);
}
// Status updates from the wrapper
if (notification.type === "sessionUpdate" && notification.sessionUpdate?.status) {
collector.statusUpdates.push(notification.sessionUpdate.status);
}
// Status updates from our custom type
if (notification.sessionUpdate === "status_change") {
collector.statusUpdates.push(notification.status);
}
// Available commands
if (notification.availableCommands) {
collector.commands.push(notification.availableCommands);
}
}
/**
* Helper: Extract status transitions from updates
*/
function extractStatusTransitions(collector: CollectedUpdates): string[] {
const statuses: string[] = [];
let lastStatus: string | null = null;
for (const statusUpdate of collector.statusUpdates) {
if (statusUpdate !== lastStatus) {
statuses.push(String(statusUpdate));
lastStatus = statusUpdate;
}
}
return statuses;
}
/**
* Helper: Wait for agent to reach a specific status
*/
async function waitForStatus(
manager: AgentManager,
agentId: string,
targetStatus: AgentStatus,
timeoutMs: number
): Promise<void> {
const startTime = Date.now();
while (Date.now() - startTime < timeoutMs) {
const currentStatus = manager.getAgentStatus(agentId);
if (currentStatus === targetStatus) {
return;
}
if (currentStatus === "failed") {
const agent = manager.listAgents().find((a) => a.id === agentId);
throw new Error(
`Agent failed while waiting for status "${targetStatus}": ${agent?.error}`
);
}
// Wait a bit before checking again
await sleep(500);
}
throw new Error(
`Timeout waiting for status "${targetStatus}" (current: ${manager.getAgentStatus(agentId)})`
);
}
/**
* Helper: Sleep for specified milliseconds
*/
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// Run the test suite
main().catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});
@@ -0,0 +1,80 @@
#!/usr/bin/env tsx
import { AgentManager } from "./agent-manager.js";
async function main() {
console.log("=== ACP Concurrent Agents Test ===\n");
const manager = new AgentManager();
try {
console.log("1. Creating multiple agents...");
const agent1Id = await manager.createAgent({
cwd: process.cwd(),
});
const agent2Id = await manager.createAgent({
cwd: process.cwd(),
});
console.log(` ✓ Agent 1: ${agent1Id}`);
console.log(` ✓ Agent 2: ${agent2Id}\n`);
console.log("2. Setting up update handlers...");
let agent1Updates = 0;
let agent2Updates = 0;
manager.subscribeToUpdates(agent1Id, () => {
agent1Updates++;
});
manager.subscribeToUpdates(agent2Id, () => {
agent2Updates++;
});
console.log(" ✓ Handlers set up\n");
console.log("3. Sending prompts to both agents...");
await manager.sendPrompt(agent1Id, "Echo 'Agent 1 says hello'");
await manager.sendPrompt(agent2Id, "Echo 'Agent 2 says hello'");
console.log(" ✓ Prompts sent\n");
console.log("4. Waiting for activity (5 seconds)...");
await new Promise((resolve) => setTimeout(resolve, 5000));
console.log(` Agent 1 updates: ${agent1Updates}`);
console.log(` Agent 2 updates: ${agent2Updates}\n`);
console.log("5. Listing all agents...");
const agents = manager.listAgents();
console.log(` Total agents: ${agents.length}`);
agents.forEach((agent) => {
console.log(` - ${agent.id.slice(0, 8)}: ${agent.status}`);
});
console.log();
console.log("6. Testing cancellation on agent 1...");
const status1Before = manager.getAgentStatus(agent1Id);
console.log(` Agent 1 status before cancel: ${status1Before}`);
await manager.cancelAgent(agent1Id);
const status1After = manager.getAgentStatus(agent1Id);
console.log(` Agent 1 status after cancel: ${status1After}\n`);
console.log("7. Cleaning up...");
await manager.killAgent(agent1Id);
await manager.killAgent(agent2Id);
console.log(" ✓ All agents killed\n");
console.log("8. Verifying cleanup...");
const remainingAgents = manager.listAgents();
console.log(` Remaining agents: ${remainingAgents.length}\n`);
console.log("=== Concurrent test completed successfully ===");
} catch (error) {
console.error("\n❌ Test failed:");
console.error(error);
process.exit(1);
}
}
main().catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});
@@ -0,0 +1,17 @@
#!/usr/bin/env tsx
import { AgentManager } from "./index.js";
import type { AgentStatus } from "./index.js";
console.log("✓ AgentManager imported successfully");
console.log(
"✓ Methods:",
Object.getOwnPropertyNames(AgentManager.prototype)
.filter((m) => m !== "constructor")
.join(", ")
);
const testStatus: AgentStatus = "ready";
console.log("✓ AgentStatus type works:", testStatus);
console.log("\n✓ All exports verified!");
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env tsx
/**
* Integration test for Agent MCP Server with Session
*
* This test simulates a Session-like scenario where:
* 1. AgentManager is created
* 2. Agent MCP server is set up
* 3. Tools are called via the MCP client
* 4. Agent updates are streamed to a mock WebSocket handler
* 5. Cleanup is performed
*/
import { AgentManager } from "./agent-manager.js";
import { createAgentMcpServer } from "./mcp-server.js";
import { experimental_createMCPClient } from "ai";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import type { AgentUpdate } from "./types.js";
async function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function main() {
console.log("=== Agent MCP Integration Test ===\n");
// Step 1: Create AgentManager
console.log("1. Creating AgentManager...");
const agentManager = new AgentManager();
console.log(" ✓ AgentManager created\n");
// Step 2: Create Agent MCP Server
console.log("2. Creating Agent MCP Server...");
const server = await createAgentMcpServer({ agentManager });
console.log(" ✓ Agent MCP Server created\n");
// Step 3: Set up transport and client
console.log("3. Setting up MCP transport and client...");
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
const mcpClient = await experimental_createMCPClient({
transport: clientTransport,
});
console.log(" ✓ MCP client connected\n");
// Step 4: Get tools from client
console.log("4. Getting tools from MCP client...");
const tools = await mcpClient.tools();
const toolNames = Object.keys(tools);
console.log(` ✓ Got ${toolNames.length} tools: ${toolNames.join(", ")}\n`);
// Step 5: Verify all expected tools are present
console.log("5. Verifying expected tools...");
const expectedTools = [
"create_coding_agent",
"send_agent_prompt",
"get_agent_status",
"list_agents",
"cancel_agent",
"kill_agent",
];
const missingTools = expectedTools.filter((t) => !toolNames.includes(t));
if (missingTools.length > 0) {
console.error(` ✗ Missing tools: ${missingTools.join(", ")}`);
process.exit(1);
}
console.log(" ✓ All expected tools present\n");
// Step 6: Test direct AgentManager usage (bypassing MCP tools for simplicity)
console.log("6. Creating agent directly via AgentManager...");
const receivedUpdates: AgentUpdate[] = [];
const agentId = await agentManager.createAgent({
initialPrompt: "Create a simple hello world function in TypeScript",
cwd: process.cwd(),
});
console.log(` ✓ Agent created with ID: ${agentId}\n`);
// Step 7: Subscribe to agent updates (simulating Session behavior)
console.log("7. Subscribing to agent updates...");
const unsubscribe = agentManager.subscribeToUpdates(agentId, (update) => {
const updateType = (update.notification as any).type || "unknown";
console.log(` 📡 Received update for agent ${update.agentId}: ${updateType}`);
receivedUpdates.push(update);
});
console.log(" ✓ Subscribed to agent updates\n");
// Step 8: Wait a bit for agent initialization
console.log("8. Waiting for agent to initialize...");
await sleep(2000);
console.log(" ✓ Wait complete\n");
// Step 9: Test list_agents via manager
console.log("9. Listing agents via AgentManager...");
const agents = agentManager.listAgents();
console.log(` ✓ Found ${agents.length} agent(s)`);
console.log(` Agents:`, agents.map(a => ({ id: a.id, status: a.status })));
console.log();
// Step 10: Test get_agent_status
console.log("10. Getting agent status...");
const status = agentManager.getAgentStatus(agentId);
console.log(` ✓ Agent status: ${status}\n`);
// Step 11: Test send_agent_prompt
console.log("11. Sending prompt to agent...");
try {
await agentManager.sendPrompt(agentId, "Show me what you created");
console.log(" ✓ Prompt sent successfully\n");
} catch (error) {
console.log(` ⚠ Prompt send failed (expected if agent not ready): ${error instanceof Error ? error.message : String(error)}\n`);
}
// Step 12: Wait a bit for any agent processing
console.log("12. Waiting for agent processing...");
await sleep(3000);
console.log(" ✓ Wait complete\n");
// Step 13: Test cancel_agent
console.log("13. Cancelling agent...");
try {
await agentManager.cancelAgent(agentId);
console.log(" ✓ Agent cancelled successfully\n");
} catch (error) {
console.log(` ⚠ Cancel failed (expected if agent not processing): ${error instanceof Error ? error.message : String(error)}\n`);
}
// Step 14: Unsubscribe from updates
console.log("14. Unsubscribing from agent updates...");
unsubscribe();
console.log(" ✓ Unsubscribed\n");
// Step 15: Test kill_agent
console.log("15. Killing agent...");
await agentManager.killAgent(agentId);
console.log(" ✓ Agent killed successfully\n");
// Step 16: Verify agent is gone
console.log("16. Verifying agent is removed...");
await sleep(1000);
const finalAgents = agentManager.listAgents();
if (finalAgents.some((a) => a.id === agentId)) {
console.error(" ✗ Agent still exists after kill");
process.exit(1);
}
console.log(" ✓ Agent successfully removed\n");
// Step 17: Close MCP client
console.log("17. Closing MCP client...");
await mcpClient.close();
console.log(" ✓ MCP client closed\n");
// Summary
console.log("=== Test Summary ===");
console.log(`✓ All tests passed`);
console.log(`✓ Received ${receivedUpdates.length} agent updates during test`);
console.log("\nIntegration test completed successfully!");
}
// Run the test
main().catch((error) => {
console.error("\n❌ Test failed with error:");
console.error(error);
process.exit(1);
});
@@ -0,0 +1,65 @@
#!/usr/bin/env tsx
import { AgentManager } from "./agent-manager.js";
async function main() {
console.log("=== ACP Simple Test ===\n");
const manager = new AgentManager();
let updateCount = 0;
let toolCallCount = 0;
let messageChunks: string[] = [];
try {
console.log("1. Creating agent...");
const agentId = await manager.createAgent({
cwd: process.cwd(),
});
console.log(` ✓ Created: ${agentId}\n`);
console.log("2. Setting up minimal update handler...");
manager.subscribeToUpdates(agentId, (update) => {
updateCount++;
const sessionUpdate = (update.notification as any).sessionUpdate;
if (sessionUpdate === "tool_call") {
toolCallCount++;
console.log(` [Tool Call] ${(update.notification as any).title}`);
} else if (sessionUpdate === "agent_message_chunk") {
const text = (update.notification as any).content?.text || "";
if (text.trim()) {
messageChunks.push(text);
}
}
});
console.log(" ✓ Handler set up\n");
console.log("3. Sending prompt...");
await manager.sendPrompt(agentId, "List 3 files in current directory");
console.log(" ✓ Prompt sent\n");
console.log("4. Waiting for completion (10 seconds)...");
await new Promise((resolve) => setTimeout(resolve, 10000));
console.log("\n=== Results ===");
console.log(`Total updates: ${updateCount}`);
console.log(`Tool calls: ${toolCallCount}`);
console.log(`Message length: ${messageChunks.join("").length} chars`);
console.log(`First few chunks: ${messageChunks.slice(0, 5).join("")}\n`);
console.log("5. Cleaning up...");
await manager.killAgent(agentId);
console.log(" ✓ Agent killed\n");
console.log("=== Simple test completed ===");
} catch (error) {
console.error("\n❌ Test failed:");
console.error(error);
process.exit(1);
}
}
main().catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});
+59
View File
@@ -0,0 +1,59 @@
import type { SessionNotification } from "@agentclientprotocol/sdk";
/**
* Status of an agent
*/
export type AgentStatus =
| "initializing"
| "ready"
| "processing"
| "completed"
| "failed"
| "killed";
/**
* Information about an agent
*/
export interface AgentInfo {
id: string;
status: AgentStatus;
createdAt: Date;
type: "claude";
sessionId: string | null;
error: string | null;
currentModeId: string | null;
availableModes: SessionMode[] | null;
}
/**
* Update from an agent session
* Wraps ACP SessionNotification with additional metadata
*/
export interface AgentUpdate {
agentId: string;
timestamp: Date;
notification: SessionNotification;
}
/**
* Session mode definition from ACP
*/
export interface SessionMode {
id: string;
name: string;
description?: string | null;
}
/**
* Options for creating an agent
*/
export interface CreateAgentOptions {
cwd: string;
initialPrompt?: string;
initialMode?: string;
}
/**
* Callback for agent updates
*/
export type AgentUpdateCallback = (update: AgentUpdate) => void;
@@ -0,0 +1,208 @@
import { tool, experimental_createMCPClient } from "ai";
import { z } from "zod";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { createTerminalMcpServer } from "../terminal-mcp/index.js";
/**
* Singleton MCP clients
*/
let terminalMcpClient: Awaited<
ReturnType<typeof experimental_createMCPClient>
> | null = null;
let playwrightMcpClient: Awaited<
ReturnType<typeof experimental_createMCPClient>
> | null = null;
/**
* Get or create Terminal MCP client (singleton)
*/
async function getTerminalMcpClient() {
if (terminalMcpClient) {
return terminalMcpClient;
}
// Create Terminal MCP server
const server = await createTerminalMcpServer({ sessionName: "__voice-dev" });
// Create linked transport pair
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
// Connect server to its transport
await server.connect(serverTransport);
// Create client connected to the other side
terminalMcpClient = await experimental_createMCPClient({
transport: clientTransport,
});
console.log("Terminal MCP client initialized");
return terminalMcpClient;
}
/**
* Get or create Playwright MCP client (singleton)
*/
async function getPlaywrightMcpClient() {
if (playwrightMcpClient) {
return playwrightMcpClient;
}
const transport = new StdioClientTransport({
command: "npx",
args: ["@playwright/mcp", "--image-responses", "omit"],
});
playwrightMcpClient = await experimental_createMCPClient({
transport,
});
console.log("Playwright MCP client initialized");
return playwrightMcpClient;
}
/**
* Cache for merged MCP tools
*/
let mcpToolsCache: Record<string, any> | null = null;
let mcpToolsPromise: Promise<Record<string, any>> | null = null;
async function getMcpTools(): Promise<Record<string, any>> {
if (mcpToolsCache) {
return mcpToolsCache;
}
if (mcpToolsPromise) {
return mcpToolsPromise;
}
mcpToolsPromise = (async () => {
const [terminalClient, playwrightClient] = await Promise.all([
getTerminalMcpClient(),
getPlaywrightMcpClient().catch((error) => {
console.error("Failed to initialize Playwright MCP:", error);
return null;
}),
]);
const terminalTools = await terminalClient.tools();
const playwrightTools = playwrightClient
? await playwrightClient.tools()
: {};
const mergedTools = {
...terminalTools,
...playwrightTools,
};
mcpToolsCache = mergedTools;
console.log(`Loaded ${Object.keys(mergedTools).length} MCP tools`);
return mergedTools;
})();
return mcpToolsPromise;
}
/**
* Manual tools that aren't MCP-based
*/
const manualTools = {
present_artifact: tool({
description:
"Present an artifact (plan, diff, screenshot, etc.) to the user for review. Use this when you need to show information that's hard to convey via TTS, such as markdown plans, code diffs, or visual content",
inputSchema: z.object({
type: z
.enum(["markdown", "diff", "image", "code"])
.describe("Type of artifact to present."),
title: z
.string()
.describe(
"Title for the artifact (e.g., 'Implementation Plan', 'Refactoring Strategy', '/path/to/project/package.json')."
),
source: z.union([
z.object({
type: z.literal("file"),
path: z.string(),
}),
z.object({
type: z.literal("command_output"),
command: z.string(),
}),
z.object({
type: z.literal("text"),
text: z.string(),
}),
]),
}),
execute: async () => {
// Artifact will be broadcast by orchestrator via onToolCall callback
// We just return a simple acknowledgment here
return {
success: true,
message: "Artifact presented to user.",
};
},
}),
};
/**
* Get all tools (MCP + manual) for LLM
* @param terminalTools - Optional custom terminal tools (per-session). If not provided, uses global singleton.
* @param agentTools - Optional agent tools (per-session).
*/
export async function getAllTools(
terminalTools?: Record<string, any>,
agentTools?: Record<string, any>
): Promise<Record<string, any>> {
if (terminalTools) {
// Use provided terminal tools (per-session) and merge with Playwright and agent tools
const playwrightClient = await getPlaywrightMcpClient().catch((error) => {
console.error("Failed to initialize Playwright MCP:", error);
return null;
});
const playwrightTools = playwrightClient
? await playwrightClient.tools()
: {};
return {
...terminalTools,
...playwrightTools,
...(agentTools || {}),
...manualTools,
};
}
// Fallback to global singleton tools
const mcpTools = await getMcpTools();
return {
...mcpTools,
...(agentTools || {}),
...manualTools,
};
}
/**
* Cleanup function to close MCP clients
*/
export async function closeMcpClients() {
if (terminalMcpClient) {
await terminalMcpClient.close();
terminalMcpClient = null;
console.log("Terminal MCP client closed");
}
if (playwrightMcpClient) {
await playwrightMcpClient.close();
playwrightMcpClient = null;
console.log("Playwright MCP client closed");
}
mcpToolsCache = null;
mcpToolsPromise = null;
}
@@ -0,0 +1,11 @@
/**
* DEPRECATED: This file has been refactored.
*
* All orchestration logic has been moved to session.ts.
* Each Session now owns its own conversation state and manages
* the full lifecycle of user interactions.
*
* This file is kept as a stub to document the migration.
* It can be deleted once the refactoring is confirmed working.
*/
@@ -0,0 +1,40 @@
import { transcribeAudio, type TranscriptionResult } from "./stt-openai.js";
/**
* Per-session STT manager
* Handles speech-to-text transcription
*/
export class STTManager {
private readonly sessionId: string;
constructor(sessionId: string) {
this.sessionId = sessionId;
}
/**
* Transcribe audio buffer to text
*/
public async transcribe(
audio: Buffer,
format: string
): Promise<TranscriptionResult> {
console.log(
`[STT-Manager ${this.sessionId}] Transcribing ${audio.length} bytes of ${format} audio`
);
const result = await transcribeAudio(audio, format);
console.log(
`[STT-Manager ${this.sessionId}] Transcription complete: "${result.text}"`
);
return result;
}
/**
* Cleanup (currently no-op, but provides extension point)
*/
public cleanup(): void {
// No cleanup needed for STT currently
}
}
@@ -0,0 +1,103 @@
import OpenAI from "openai";
import { writeFile, unlink } from "fs/promises";
import { join } from "path";
import { tmpdir } from "os";
import { v4 as uuidv4 } from "uuid";
export interface STTConfig {
apiKey: string;
model?: "whisper-1";
}
export interface TranscriptionResult {
text: string;
language?: string;
duration?: number;
}
let openaiClient: OpenAI | null = null;
let config: STTConfig | null = null;
export function initializeSTT(sttConfig: STTConfig): void {
config = sttConfig;
openaiClient = new OpenAI({
apiKey: sttConfig.apiKey,
});
console.log("✓ STT (OpenAI Whisper) initialized");
}
export async function transcribeAudio(
audioBuffer: Buffer,
format: string
): Promise<TranscriptionResult> {
if (!openaiClient || !config) {
throw new Error("STT not initialized. Call initializeSTT() first.");
}
const startTime = Date.now();
let tempFilePath: string | null = null;
try {
// Map format to file extension
const ext = getFileExtension(format);
// Write audio buffer to temporary file
// OpenAI API requires file upload, not raw buffer
tempFilePath = join(tmpdir(), `audio-${uuidv4()}.${ext}`);
await writeFile(tempFilePath, audioBuffer);
console.log(
`[STT] Transcribing audio file: ${tempFilePath} (${audioBuffer.length} bytes)`
);
// Call OpenAI Whisper API
const response = await openaiClient.audio.transcriptions.create({
file: await import("fs").then((fs) => fs.createReadStream(tempFilePath!)),
model: config.model || "gpt-4o-transcribe",
response_format: "json", // Get language and duration info
});
const duration = Date.now() - startTime;
console.log(
`[STT] Transcription complete in ${duration}ms: "${response.text}"`
);
return {
text: response.text,
duration: duration,
};
} catch (error: any) {
console.error("[STT] Transcription error:", error);
throw new Error(`STT transcription failed: ${error.message}`);
} finally {
// Clean up temporary file
if (tempFilePath) {
try {
await unlink(tempFilePath);
} catch (cleanupError) {
console.warn(`[STT] Failed to clean up temp file: ${tempFilePath}`);
}
}
}
}
function getFileExtension(format: string): string {
// Map mime types or format strings to file extensions
const formatLower = format.toLowerCase();
if (formatLower.includes("webm")) return "webm";
if (formatLower.includes("ogg")) return "ogg";
if (formatLower.includes("mp3")) return "mp3";
if (formatLower.includes("wav")) return "wav";
if (formatLower.includes("m4a")) return "m4a";
if (formatLower.includes("mp4")) return "mp4";
if (formatLower.includes("flac")) return "flac";
// Default to webm (common browser format)
return "webm";
}
export function isSTTInitialized(): boolean {
return openaiClient !== null && config !== null;
}
@@ -0,0 +1,22 @@
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import path from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Load system prompt from agent-prompt.md in package root
export function loadSystemPrompt(): string {
const promptPath = path.join(__dirname, '../../../agent-prompt.md');
return readFileSync(promptPath, 'utf-8');
}
// Cache the loaded prompt
let cachedPrompt: string | null = null;
export function getSystemPrompt(): string {
if (!cachedPrompt) {
cachedPrompt = loadSystemPrompt();
}
return cachedPrompt;
}
@@ -0,0 +1,136 @@
import { v4 as uuidv4 } from "uuid";
import { synthesizeSpeech } from "./tts-openai.js";
import type { SessionOutboundMessage } from "../messages.js";
interface PendingPlayback {
resolve: () => void;
reject: (error: Error) => void;
}
/**
* Per-session TTS manager
* Handles TTS audio generation and playback confirmation tracking
*/
export class TTSManager {
private pendingPlaybacks: Map<string, PendingPlayback> = new Map();
private readonly sessionId: string;
constructor(sessionId: string) {
this.sessionId = sessionId;
}
/**
* Generate TTS audio, emit to client, and wait for playback confirmation
* Returns a Promise that resolves when the client confirms playback completed
*/
public async generateAndWaitForPlayback(
text: string,
emitMessage: (msg: SessionOutboundMessage) => void,
abortSignal: AbortSignal
): Promise<void> {
if (abortSignal.aborted) {
console.log(
`[TTS-Manager ${this.sessionId}] Aborted before generating audio`
);
return;
}
// Generate TTS audio
const { audio, format } = await synthesizeSpeech(text);
if (abortSignal.aborted) {
console.log(
`[TTS-Manager ${this.sessionId}] Aborted after generating audio`
);
return;
}
// Create unique ID for this audio segment
const audioId = uuidv4();
// Store abort handler reference outside Promise constructor
let onAbort: (() => void) | undefined;
// Create promise that will be resolved when client confirms playback
const playbackPromise = new Promise<void>((resolve, reject) => {
// Store handlers (no timeout - will resolve when client confirms or connection closes)
this.pendingPlaybacks.set(audioId, { resolve, reject });
// Handle abort signal
onAbort = () => {
console.log(
`[TTS-Manager ${this.sessionId}] Aborted while waiting for playback`
);
// Clean up pending playback
this.pendingPlaybacks.delete(audioId);
// Reject with abort error
resolve();
};
// Listen for abort (once: true for auto-cleanup if abort fires)
abortSignal.addEventListener("abort", onAbort, { once: true });
});
// Clean up abort listener when promise settles (in case abort never fires)
if (onAbort) {
playbackPromise.finally(() => {
abortSignal!.removeEventListener("abort", onAbort!);
});
}
// Emit audio output message
emitMessage({
type: "audio_output",
payload: {
id: audioId,
audio: audio.toString("base64"),
format,
},
});
console.log(
`[TTS-Manager ${
this.sessionId
}] ${new Date().toISOString()} Sent audio ${audioId}, waiting for playback...`
);
// Wait for playback confirmation
await playbackPromise;
console.log(
`[TTS-Manager ${
this.sessionId
}] ${new Date().toISOString()} Audio ${audioId} playback confirmed`
);
}
/**
* Called when client confirms audio playback completed
* Resolves the corresponding promise
*/
public confirmAudioPlayed(audioId: string): void {
const pending = this.pendingPlaybacks.get(audioId);
if (!pending) {
console.warn(
`[TTS-Manager ${this.sessionId}] Received confirmation for unknown audio ID: ${audioId}`
);
return;
}
// Resolve promise and cleanup
pending.resolve();
this.pendingPlaybacks.delete(audioId);
}
/**
* Cleanup all pending playbacks
*/
public cleanup(): void {
// Reject all pending playbacks
for (const [audioId, pending] of this.pendingPlaybacks.entries()) {
pending.reject(new Error("Session closed"));
this.pendingPlaybacks.delete(audioId);
}
}
}
@@ -0,0 +1,105 @@
import OpenAI from "openai";
import { Readable } from "stream";
export interface TTSConfig {
apiKey: string;
model?: "tts-1" | "tts-1-hd";
voice?: "alloy" | "echo" | "fable" | "onyx" | "nova" | "shimmer";
responseFormat?: "mp3" | "opus" | "aac" | "flac" | "wav" | "pcm";
}
export interface SpeechResult {
audio: Buffer;
format: string;
}
let openaiClient: OpenAI | null = null;
let config: TTSConfig | null = null;
export function initializeTTS(ttsConfig: TTSConfig): void {
config = {
model: "tts-1",
voice: "alloy",
responseFormat: "mp3",
...ttsConfig,
};
openaiClient = new OpenAI({
apiKey: ttsConfig.apiKey,
});
console.log(
`✓ TTS (OpenAI) initialized with voice: ${config.voice}, model: ${config.model}`
);
}
export async function synthesizeSpeech(text: string): Promise<SpeechResult> {
if (!openaiClient || !config) {
throw new Error("TTS not initialized. Call initializeTTS() first.");
}
if (!text || text.trim().length === 0) {
throw new Error("Cannot synthesize empty text");
}
const startTime = Date.now();
try {
console.log(
`[TTS] Synthesizing speech: "${text.substring(0, 50)}${
text.length > 50 ? "..." : ""
}"`
);
// Call OpenAI TTS API with streaming
const response = await openaiClient.audio.speech.create({
model: config.model!,
voice: config.voice!,
input: text,
// speed: 1.2,
response_format: config.responseFormat as "mp3" | "opus" | "aac" | "flac",
});
// Response is a stream, convert to buffer
const audioBuffer = await streamToBuffer(
response.body as unknown as Readable
);
const duration = Date.now() - startTime;
console.log(
`[TTS] Speech synthesis complete in ${duration}ms (${audioBuffer.length} bytes)`
);
return {
audio: audioBuffer,
format: config.responseFormat || "mp3",
};
} catch (error: any) {
console.error("[TTS] Speech synthesis error:", error);
throw new Error(`TTS synthesis failed: ${error.message}`);
}
}
async function streamToBuffer(stream: Readable): Promise<Buffer> {
const chunks: Buffer[] = [];
return new Promise((resolve, reject) => {
stream.on("data", (chunk) => {
chunks.push(Buffer.from(chunk));
});
stream.on("end", () => {
resolve(Buffer.concat(chunks));
});
stream.on("error", (error) => {
reject(error);
});
});
}
export function isTTSInitialized(): boolean {
return openaiClient !== null && config !== null;
}
export function getTTSConfig(): TTSConfig | null {
return config;
}
+113
View File
@@ -0,0 +1,113 @@
import "dotenv/config";
import express from "express";
import basicAuth from "express-basic-auth";
import { createServer as createHTTPServer } from "http";
import { VoiceAssistantWebSocketServer } from "./websocket-server.js";
import { initializeSTT } from "./agent/stt-openai.js";
import { initializeTTS } from "./agent/tts-openai.js";
import {
listConversations,
deleteConversation,
} from "./persistence.js";
function createServer() {
const app = express();
// Basic authentication
app.use(
basicAuth({
users: { mo: "bo" },
challenge: true,
realm: "Voice Assistant",
})
);
// Middleware
app.use(express.json());
// Health check endpoint
app.get("/api/health", (_req, res) => {
res.json({ status: "ok", timestamp: new Date().toISOString() });
});
// Conversation management endpoints
app.get("/api/conversations", async (_req, res) => {
try {
const conversations = await listConversations();
res.json(conversations);
} catch (error) {
console.error("[API] Failed to list conversations:", error);
res.status(500).json({ error: "Failed to list conversations" });
}
});
app.delete("/api/conversations/:id", async (req, res) => {
try {
const { id } = req.params;
await deleteConversation(id);
res.json({ success: true });
} catch (error) {
console.error("[API] Failed to delete conversation:", error);
res.status(500).json({ error: "Failed to delete conversation" });
}
});
return app;
}
function main() {
const port = parseInt(process.env.PORT || "6767", 10);
const app = createServer();
const httpServer = createHTTPServer(app);
// Initialize WebSocket server
const wsServer = new VoiceAssistantWebSocketServer(httpServer);
// Initialize OpenAI client
const apiKey = process.env.OPENAI_API_KEY;
if (apiKey) {
console.log("✓ OpenAI client initialized");
// Initialize STT (Whisper)
initializeSTT({ apiKey });
// Initialize TTS
const ttsVoice = (process.env.TTS_VOICE || "alloy") as
| "alloy"
| "echo"
| "fable"
| "onyx"
| "nova"
| "shimmer";
const ttsModel = (process.env.TTS_MODEL || "tts-1") as "tts-1" | "tts-1-hd";
initializeTTS({
apiKey,
voice: ttsVoice,
model: ttsModel,
responseFormat: "mp3",
});
} else {
console.warn(
"⚠ OPENAI_API_KEY not set - LLM, STT, and TTS features will not work"
);
}
httpServer.listen(port, () => {
console.log(
`\n✓ Voice Assistant server running on http://localhost:${port}`
);
});
// Graceful shutdown
process.on("SIGTERM", () => {
console.log("\nSIGTERM received, shutting down gracefully...");
wsServer.close();
httpServer.close(() => {
console.log("Server closed");
process.exit(0);
});
});
}
main();
+335
View File
@@ -0,0 +1,335 @@
import { z } from "zod";
// ============================================================================
// Session Inbound Messages (Session receives these)
// ============================================================================
export const UserTextMessageSchema = z.object({
type: z.literal("user_text"),
text: z.string(),
});
export const AudioChunkMessageSchema = z.object({
type: z.literal("audio_chunk"),
audio: z.string(), // base64 encoded
format: z.string(),
isLast: z.boolean(),
});
export const AbortRequestMessageSchema = z.object({
type: z.literal("abort_request"),
});
export const AudioPlayedMessageSchema = z.object({
type: z.literal("audio_played"),
id: z.string(),
});
export const LoadConversationRequestMessageSchema = z.object({
type: z.literal("load_conversation_request"),
conversationId: z.string(),
});
export const ListConversationsRequestMessageSchema = z.object({
type: z.literal("list_conversations_request"),
});
export const DeleteConversationRequestMessageSchema = z.object({
type: z.literal("delete_conversation_request"),
conversationId: z.string(),
});
export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
UserTextMessageSchema,
AudioChunkMessageSchema,
AbortRequestMessageSchema,
AudioPlayedMessageSchema,
LoadConversationRequestMessageSchema,
ListConversationsRequestMessageSchema,
DeleteConversationRequestMessageSchema,
]);
export type SessionInboundMessage = z.infer<typeof SessionInboundMessageSchema>;
// ============================================================================
// Session Outbound Messages (Session emits these)
// ============================================================================
export const ActivityLogPayloadSchema = z.object({
id: z.string(),
timestamp: z.date(),
type: z.enum([
"transcript",
"assistant",
"tool_call",
"tool_result",
"error",
"system",
]),
content: z.string(),
metadata: z.record(z.unknown()).optional(),
});
export const ActivityLogMessageSchema = z.object({
type: z.literal("activity_log"),
payload: ActivityLogPayloadSchema,
});
export const AssistantChunkMessageSchema = z.object({
type: z.literal("assistant_chunk"),
payload: z.object({
chunk: z.string(),
}),
});
export const AudioOutputMessageSchema = z.object({
type: z.literal("audio_output"),
payload: z.object({
audio: z.string(), // base64 encoded
format: z.string(),
id: z.string(),
}),
});
export const TranscriptionResultMessageSchema = z.object({
type: z.literal("transcription_result"),
payload: z.object({
text: z.string(),
language: z.string().optional(),
duration: z.number().optional(),
}),
});
export const StatusMessageSchema = z.object({
type: z.literal("status"),
payload: z
.object({
status: z.string(),
})
.passthrough(), // Allow additional fields
});
export const ArtifactMessageSchema = z.object({
type: z.literal("artifact"),
payload: z.object({
type: z.enum(["markdown", "diff", "image", "code"]),
id: z.string(),
title: z.string(),
content: z.string(),
isBase64: z.boolean(),
}),
});
export const ConversationLoadedMessageSchema = z.object({
type: z.literal("conversation_loaded"),
payload: z.object({
conversationId: z.string(),
messageCount: z.number(),
}),
});
export const AgentCreatedMessageSchema = z.object({
type: z.literal("agent_created"),
payload: z.object({
agentId: z.string(),
status: z.string(),
type: z.literal("claude"),
currentModeId: z.string().optional(),
availableModes: z.array(z.object({
id: z.string(),
name: z.string(),
description: z.string().nullable().optional(),
})).optional(),
}),
});
export const AgentUpdateMessageSchema = z.object({
type: z.literal("agent_update"),
payload: z.object({
agentId: z.string(),
timestamp: z.date(),
notification: z.any(), // SessionNotification from ACP - complex type, using any for simplicity
}),
});
export const AgentStatusMessageSchema = z.object({
type: z.literal("agent_status"),
payload: z.object({
agentId: z.string(),
status: z.string(),
info: z.object({
id: z.string(),
status: z.string(),
createdAt: z.date(),
type: z.literal("claude"),
sessionId: z.string().optional(),
error: z.string().optional(),
}),
}),
});
export const SessionStateMessageSchema = z.object({
type: z.literal("session_state"),
payload: z.object({
agents: z.array(
z.object({
id: z.string(),
status: z.string(),
createdAt: z.date(),
type: z.literal("claude"),
sessionId: z.string().nullable(),
error: z.string().nullable(),
currentModeId: z.string().nullable(),
availableModes: z.array(z.object({
id: z.string(),
name: z.string(),
description: z.string().nullable().optional(),
})).nullable(),
})
),
commands: z.array(
z.object({
id: z.string(),
name: z.string(),
workingDirectory: z.string(),
currentCommand: z.string(),
isDead: z.boolean(),
exitCode: z.number().nullable(),
})
),
}),
});
export const ListConversationsResponseMessageSchema = z.object({
type: z.literal("list_conversations_response"),
payload: z.object({
conversations: z.array(
z.object({
id: z.string(),
lastUpdated: z.string(),
messageCount: z.number(),
})
),
}),
});
export const DeleteConversationResponseMessageSchema = z.object({
type: z.literal("delete_conversation_response"),
payload: z.object({
conversationId: z.string(),
success: z.boolean(),
error: z.string().optional(),
}),
});
export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ActivityLogMessageSchema,
AssistantChunkMessageSchema,
AudioOutputMessageSchema,
TranscriptionResultMessageSchema,
StatusMessageSchema,
ArtifactMessageSchema,
ConversationLoadedMessageSchema,
AgentCreatedMessageSchema,
AgentUpdateMessageSchema,
AgentStatusMessageSchema,
SessionStateMessageSchema,
ListConversationsResponseMessageSchema,
DeleteConversationResponseMessageSchema,
]);
export type SessionOutboundMessage = z.infer<
typeof SessionOutboundMessageSchema
>;
// Type exports for individual message types
export type ActivityLogMessage = z.infer<typeof ActivityLogMessageSchema>;
export type AssistantChunkMessage = z.infer<typeof AssistantChunkMessageSchema>;
export type AudioOutputMessage = z.infer<typeof AudioOutputMessageSchema>;
export type TranscriptionResultMessage = z.infer<typeof TranscriptionResultMessageSchema>;
export type StatusMessage = z.infer<typeof StatusMessageSchema>;
export type ArtifactMessage = z.infer<typeof ArtifactMessageSchema>;
export type ConversationLoadedMessage = z.infer<typeof ConversationLoadedMessageSchema>;
export type AgentCreatedMessage = z.infer<typeof AgentCreatedMessageSchema>;
export type AgentUpdateMessage = z.infer<typeof AgentUpdateMessageSchema>;
export type AgentStatusMessage = z.infer<typeof AgentStatusMessageSchema>;
export type SessionStateMessage = z.infer<typeof SessionStateMessageSchema>;
export type ListConversationsResponseMessage = z.infer<typeof ListConversationsResponseMessageSchema>;
export type DeleteConversationResponseMessage = z.infer<typeof DeleteConversationResponseMessageSchema>;
// Type exports for payload types
export type ActivityLogPayload = z.infer<typeof ActivityLogPayloadSchema>;
// ============================================================================
// WebSocket Level Messages (wraps session messages)
// ============================================================================
// WebSocket-only messages (not session messages)
export const WSPingMessageSchema = z.object({
type: z.literal("ping"),
});
export const WSPongMessageSchema = z.object({
type: z.literal("pong"),
});
export const WSRecordingStateMessageSchema = z.object({
type: z.literal("recording_state"),
isRecording: z.boolean(),
});
// Wrapped session message
export const WSSessionInboundSchema = z.object({
type: z.literal("session"),
message: SessionInboundMessageSchema,
});
export const WSSessionOutboundSchema = z.object({
type: z.literal("session"),
message: SessionOutboundMessageSchema,
});
// Complete WebSocket message schemas
export const WSInboundMessageSchema = z.discriminatedUnion("type", [
WSPingMessageSchema,
WSRecordingStateMessageSchema,
WSSessionInboundSchema,
]);
export const WSOutboundMessageSchema = z.discriminatedUnion("type", [
WSPongMessageSchema,
WSSessionOutboundSchema,
]);
export type WSInboundMessage = z.infer<typeof WSInboundMessageSchema>;
export type WSOutboundMessage = z.infer<typeof WSOutboundMessageSchema>;
// ============================================================================
// Helper functions for message conversion
// ============================================================================
/**
* Extract session message from WebSocket message
* Returns null if message should be handled at WS level only
*/
export function extractSessionMessage(
wsMsg: WSInboundMessage
): SessionInboundMessage | null {
if (wsMsg.type === "session") {
return wsMsg.message;
}
// Ping and recording_state are WS-level only
return null;
}
/**
* Wrap session message in WebSocket envelope
*/
export function wrapSessionMessage(
sessionMsg: SessionOutboundMessage
): WSOutboundMessage {
return {
type: "session",
message: sessionMsg,
};
}
+154
View File
@@ -0,0 +1,154 @@
import { readFile, writeFile, readdir, unlink, mkdir, stat } from "fs/promises";
import { join } from "path";
import type { ModelMessage } from "@ai-sdk/provider-utils";
import { standardizePrompt } from "ai/internal";
const CONVERSATIONS_DIR = join(process.cwd(), "conversations");
interface ConversationMetadata {
id: string;
lastUpdated: Date;
messageCount: number;
}
interface ConversationData {
conversationId: string;
lastUpdated: string;
messageCount: number;
messages: ModelMessage[];
}
/**
* Ensure conversations directory exists
*/
async function ensureConversationsDir(): Promise<void> {
await mkdir(CONVERSATIONS_DIR, { recursive: true });
}
/**
* Save conversation to disk
*/
export async function saveConversation(
conversationId: string,
messages: ModelMessage[]
): Promise<void> {
try {
await ensureConversationsDir();
const filepath = join(CONVERSATIONS_DIR, `${conversationId}.json`);
const data: ConversationData = {
conversationId,
lastUpdated: new Date().toISOString(),
messageCount: messages.length,
messages,
};
await writeFile(filepath, JSON.stringify(data, null, 2), "utf-8");
console.log(
`[Persistence] Saved conversation ${conversationId} (${messages.length} messages)`
);
} catch (error) {
console.error(
`[Persistence] Failed to save conversation ${conversationId}:`,
error
);
throw error;
}
}
/**
* Load conversation from disk
* Returns null if conversation doesn't exist or fails to parse
*/
export async function loadConversation(
conversationId: string
): Promise<ModelMessage[] | null> {
try {
const filepath = join(CONVERSATIONS_DIR, `${conversationId}.json`);
// Check if file exists
try {
await stat(filepath);
} catch {
console.log(`[Persistence] Conversation ${conversationId} not found`);
return null;
}
// Read and parse file
const fileContent = await readFile(filepath, "utf-8");
const data: ConversationData = JSON.parse(fileContent);
// Validate and standardize messages using AI SDK utility
const result = await standardizePrompt({
prompt: data.messages,
});
console.log(
`[Persistence] Loaded conversation ${conversationId} (${data.messageCount} messages)`
);
return result.messages as ModelMessage[];
} catch (error) {
console.error(
`[Persistence] Failed to load conversation ${conversationId}:`,
error
);
return null;
}
}
/**
* List all conversations with metadata
*/
export async function listConversations(): Promise<ConversationMetadata[]> {
try {
await ensureConversationsDir();
const files = await readdir(CONVERSATIONS_DIR);
const jsonFiles = files.filter((f) => f.endsWith(".json"));
const conversations: ConversationMetadata[] = [];
for (const file of jsonFiles) {
try {
const filepath = join(CONVERSATIONS_DIR, file);
const fileContent = await readFile(filepath, "utf-8");
const data: ConversationData = JSON.parse(fileContent);
conversations.push({
id: data.conversationId,
lastUpdated: new Date(data.lastUpdated),
messageCount: data.messageCount,
});
} catch (error) {
console.error(`[Persistence] Failed to read conversation ${file}:`, error);
// Skip invalid files
}
}
// Sort by lastUpdated descending
conversations.sort((a, b) => b.lastUpdated.getTime() - a.lastUpdated.getTime());
return conversations;
} catch (error) {
console.error("[Persistence] Failed to list conversations:", error);
return [];
}
}
/**
* Delete conversation from disk
*/
export async function deleteConversation(conversationId: string): Promise<void> {
try {
const filepath = join(CONVERSATIONS_DIR, `${conversationId}.json`);
await unlink(filepath);
console.log(`[Persistence] Deleted conversation ${conversationId}`);
} catch (error) {
console.error(
`[Persistence] Failed to delete conversation ${conversationId}:`,
error
);
throw error;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,129 @@
```typescript
const server = new McpServer({
name: "my-app",
version: "1.0.0",
});
// Simple tool with parameters
server.registerTool(
"calculate-bmi",
{
title: "BMI Calculator",
description: "Calculate Body Mass Index",
inputSchema: {
weightKg: z.number(),
heightM: z.number(),
},
outputSchema: { bmi: z.number() },
},
async ({ weightKg, heightM }) => {
const output = { bmi: weightKg / (heightM * heightM) };
return {
content: [
{
type: "text",
text: JSON.stringify(output),
},
],
structuredContent: output,
};
}
);
// Async tool with external API call
server.registerTool(
"fetch-weather",
{
title: "Weather Fetcher",
description: "Get weather data for a city",
inputSchema: { city: z.string() },
outputSchema: { temperature: z.number(), conditions: z.string() },
},
async ({ city }) => {
const response = await fetch(`https://api.weather.com/${city}`);
const data = await response.json();
const output = { temperature: data.temp, conditions: data.conditions };
return {
content: [{ type: "text", text: JSON.stringify(output) }],
structuredContent: output,
};
}
);
// Tool that returns ResourceLinks
server.registerTool(
"list-files",
{
title: "List Files",
description: "List project files",
inputSchema: { pattern: z.string() },
outputSchema: {
count: z.number(),
files: z.array(z.object({ name: z.string(), uri: z.string() })),
},
},
async ({ pattern }) => {
const output = {
count: 2,
files: [
{ name: "README.md", uri: "file:///project/README.md" },
{ name: "index.ts", uri: "file:///project/src/index.ts" },
],
};
return {
content: [
{ type: "text", text: JSON.stringify(output) },
// ResourceLinks let tools return references without file content
{
type: "resource_link",
uri: "file:///project/README.md",
name: "README.md",
mimeType: "text/markdown",
description: "A README file",
},
{
type: "resource_link",
uri: "file:///project/src/index.ts",
name: "index.ts",
mimeType: "text/typescript",
description: "An index file",
},
],
structuredContent: output,
};
}
);
```
```typescript
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
bench('InMemoryTransport', async () => {
const client = new Client(
{
name: 'test client',
version: '1.0',
},
{
capabilities: {
tools: {},
},
},
);
const server = createServer();
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
server.connect(serverTransport),
]);
const tools = await client.listTools();
if (!tools.tools.length) throw new Error('No tools found');
await client.close();
});
});
```
@@ -0,0 +1,7 @@
export { createTerminalMcpServer, type TerminalMcpServerOptions } from "./server.js";
export { TerminalManager } from "./terminal-manager.js";
export type {
TerminalInfo,
CreateTerminalParams,
CreateTerminalResult,
} from "./terminal-manager.js";
@@ -0,0 +1,281 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { TerminalManager } from "./terminal-manager.js";
export interface TerminalMcpServerOptions {
sessionName: string;
}
/**
* Create and configure the Terminal MCP Server
* Multiple instances can run independently with different session names
*/
export async function createTerminalMcpServer(
options: TerminalMcpServerOptions
): Promise<McpServer> {
const { sessionName } = options;
const terminalManager = new TerminalManager(sessionName);
// Initialize the session
await terminalManager.initialize();
const server = new McpServer({
name: "terminal-mcp",
version: "1.0.0",
});
// COMMAND-BASED TOOLS
// Tool: execute_command
server.registerTool(
"execute_command",
{
title: "Execute Command",
description:
"Execute a shell command in a specified directory. Commands are wrapped in bash -c, so you can use pipes, operators, redirects, and all bash features. The command runs until completion or until output stabilizes (for interactive processes). Returns command ID for follow-up interactions, output, exit code (if finished), and whether the process is still running. Windows remain after command exits for inspection.",
inputSchema: {
command: z
.string()
.describe(
"The command to execute. Can include pipes (|), operators (&&, ||, ;), redirects (>, >>), and any bash syntax. Examples: 'npm test', 'ls | grep foo', 'cd src && npm run build'"
),
directory: z
.string()
.describe(
"Absolute path to the working directory. Can use ~ for home directory."
),
maxWait: z
.number()
.optional()
.describe(
"Maximum milliseconds to wait for command completion or output stability (default: 120000 = 2 minutes). For interactive commands, returns when output stabilizes. For one-shot commands, returns when command exits."
),
},
outputSchema: {
commandId: z.string(),
output: z.string(),
exitCode: z.number().nullable(),
isDead: z.boolean(),
},
},
async ({ command, directory, maxWait }) => {
const result = await terminalManager.executeCommand(
command,
directory,
maxWait
);
return {
content: [],
structuredContent: result,
};
}
);
// Tool: list_commands
server.registerTool(
"list_commands",
{
title: "List Commands",
description:
"List all commands (both running and exited). Shows command ID, working directory, current process, whether it has exited (isDead), exit code (if exited), and last few lines of output. Includes dead commands that remain for inspection until explicitly killed.",
inputSchema: {},
outputSchema: {
commands: z.array(
z.object({
id: z.string(),
name: z.string(),
workingDirectory: z.string(),
currentCommand: z.string(),
isDead: z.boolean(),
exitCode: z.number().nullable(),
lastLines: z.string().nullable(),
})
),
},
},
async () => {
const commands = await terminalManager.listCommands();
const output = { commands };
return {
content: [],
structuredContent: output,
};
}
);
// Tool: capture_command
server.registerTool(
"capture_command",
{
title: "Capture Command Output",
description:
"Capture output from a command by its ID. Works for both running and exited commands. Returns output, exit code (if exited), and whether the command has finished.",
inputSchema: {
commandId: z
.string()
.describe(
"Command ID (window ID like @123) returned from execute_command or list_commands"
),
lines: z
.number()
.optional()
.describe("Number of lines to capture (default: 200)"),
},
outputSchema: {
output: z.string(),
exitCode: z.number().nullable(),
isDead: z.boolean(),
},
},
async ({ commandId, lines }) => {
const result = await terminalManager.captureCommand(commandId, lines);
return {
content: [],
structuredContent: result,
};
}
);
// Tool: send_text_to_command
server.registerTool(
"send_text_to_command",
{
title: "Send Text to Command",
description:
"Send text input to a running command (by ID). Use this for interactive processes like REPLs, prompts, or text-based interfaces. Only works if the command is still running (isDead=false).",
inputSchema: {
commandId: z
.string()
.describe(
"Command ID (window ID like @123) from execute_command or list_commands"
),
text: z.string().describe("Text to send to the command"),
pressEnter: z
.boolean()
.optional()
.describe("Press Enter after typing text (default: false)"),
return_output: z
.object({
lines: z
.number()
.optional()
.describe("Number of lines to capture (default: 200)"),
waitForSettled: z
.boolean()
.optional()
.describe(
"Wait for output to stabilize before returning (default: true)"
),
maxWait: z
.number()
.optional()
.describe(
"Maximum milliseconds to wait for stability (default: 120000)"
),
})
.optional()
.describe("Capture output after sending text"),
},
outputSchema: {
output: z.string().nullable(),
},
},
async ({ commandId, text, pressEnter, return_output }) => {
const output = await terminalManager.sendTextToCommand(
commandId,
text,
pressEnter,
return_output
);
const result = { output: output || null };
return {
content: [],
structuredContent: result,
};
}
);
// Tool: send_keys_to_command
server.registerTool(
"send_keys_to_command",
{
title: "Send Keys to Command",
description:
"Send special keys or key combinations to a running command. Use for control sequences and navigation. Examples: 'C-c' (Ctrl+C), 'Enter', 'Escape', 'BTab' (Shift+Tab). Only works if command is still running.",
inputSchema: {
commandId: z
.string()
.describe("Command ID (window ID like @123)"),
keys: z
.string()
.describe(
"Special key or combination: 'C-c', 'Enter', 'Escape', 'BTab', etc."
),
repeat: z
.number()
.optional()
.describe("Number of times to repeat the key press (default: 1)"),
return_output: z
.object({
lines: z.number().optional(),
waitForSettled: z.boolean().optional(),
maxWait: z.number().optional(),
})
.optional()
.describe("Capture output after sending keys"),
},
outputSchema: {
output: z.string().nullable(),
},
},
async ({ commandId, keys, repeat, return_output }) => {
const output = await terminalManager.sendKeysToCommand(
commandId,
keys,
repeat,
return_output
);
const result = { output: output || null };
return {
content: [],
structuredContent: result,
};
}
);
// Tool: kill_command
server.registerTool(
"kill_command",
{
title: "Kill Command",
description:
"Kill one or more commands and close their windows. Use this to terminate running processes or clean up finished commands. This removes the commands from list_commands.",
inputSchema: {
commandIds: z
.array(z.string())
.min(1)
.describe(
"Array of command IDs (window IDs like @123) from execute_command or list_commands. Can be a single ID or multiple IDs."
),
},
outputSchema: {
success: z.boolean(),
killedCount: z.number(),
},
},
async ({ commandIds }) => {
let killedCount = 0;
for (const commandId of commandIds) {
await terminalManager.killCommand(commandId);
killedCount++;
}
const output = { success: true, killedCount };
return {
content: [],
structuredContent: output,
};
}
);
return server;
}
@@ -0,0 +1,556 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { TerminalManager } from "./terminal-manager.js";
import { findSessionByName, killSession } from "./tmux.js";
const TEST_SESSION = "test-terminal-manager";
describe("TerminalManager - Command Execution", () => {
let manager: TerminalManager;
beforeAll(async () => {
manager = new TerminalManager(TEST_SESSION);
await manager.initialize();
});
afterAll(async () => {
// Cleanup test session
const session = await findSessionByName(TEST_SESSION);
if (session) {
await killSession(session.id);
}
});
describe("executeCommand", () => {
it("should execute a simple one-shot command and return exit code", async () => {
const result = await manager.executeCommand(
"echo 'Hello World'",
process.env.HOME || "~",
5000
);
expect(result.commandId).toMatch(/^@\d+$/);
expect(result.output).toContain("Hello World");
expect(result.isDead).toBe(true);
expect(result.exitCode).toBe(0);
});
it("should handle commands with pipes and operators", async () => {
const result = await manager.executeCommand(
"echo 'line1\nline2\nline3' | grep line2",
process.env.HOME || "~",
5000
);
expect(result.output).toContain("line2");
expect(result.isDead).toBe(true);
expect(result.exitCode).toBe(0);
});
it("should capture non-zero exit codes", async () => {
const result = await manager.executeCommand(
"ls /nonexistent-directory-test 2>&1",
process.env.HOME || "~",
5000
);
expect(result.isDead).toBe(true);
expect(result.exitCode).not.toBe(0);
expect(result.output).toContain("No such file");
});
it("should handle directory changes in command", async () => {
const result = await manager.executeCommand(
"cd /tmp && pwd",
process.env.HOME || "~",
5000
);
expect(result.output).toContain("/tmp");
expect(result.isDead).toBe(true);
expect(result.exitCode).toBe(0);
});
it("should launch interactive command (Python REPL)", async () => {
const result = await manager.executeCommand(
"python3",
process.env.HOME || "~",
5000
);
expect(result.commandId).toMatch(/^@\d+$/);
expect(result.output).toContain(">>>"); // Python prompt
expect(result.isDead).toBe(false); // Still running
expect(result.exitCode).toBeNull(); // No exit code yet
// Cleanup
await manager.killCommand(result.commandId);
});
it("should handle command with working directory", async () => {
const result = await manager.executeCommand(
"pwd",
"/tmp",
5000
);
expect(result.output).toContain("/tmp");
expect(result.isDead).toBe(true);
expect(result.exitCode).toBe(0);
});
it("should handle long-running command with timeout", async () => {
const result = await manager.executeCommand(
"sleep 0.5 && echo 'Done sleeping'",
process.env.HOME || "~",
3000 // Increased timeout to ensure command completes
);
expect(result.output).toContain("Done sleeping");
// Note: May still be alive if not enough time to detect completion
if (result.isDead) {
expect(result.exitCode).toBe(0);
}
});
});
describe("listCommands", () => {
it("should list all commands including dead ones", async () => {
// Execute a command that finishes
const result1 = await manager.executeCommand(
"echo 'test1'",
process.env.HOME || "~",
5000
);
// Execute a command that stays running
const result2 = await manager.executeCommand(
"python3",
process.env.HOME || "~",
5000
);
const commands = await manager.listCommands();
expect(commands.length).toBeGreaterThanOrEqual(2);
const cmd1 = commands.find((c) => c.id === result1.commandId);
expect(cmd1).toBeDefined();
expect(cmd1?.isDead).toBe(true);
expect(cmd1?.exitCode).toBe(0);
const cmd2 = commands.find((c) => c.id === result2.commandId);
expect(cmd2).toBeDefined();
expect(cmd2?.isDead).toBe(false);
expect(cmd2?.exitCode).toBeNull();
// Cleanup
await manager.killCommand(result2.commandId);
});
it("should preserve command and working directory info for dead commands", async () => {
const testDir1 = "/tmp";
const testCommand1 = "echo 'test message 1' && pwd";
const testDir2 = process.env.HOME || "~";
const testCommand2 = "echo 'test message 2'";
// Execute two different commands
const result1 = await manager.executeCommand(
testCommand1,
testDir1,
5000
);
const result2 = await manager.executeCommand(
testCommand2,
testDir2,
5000
);
expect(result1.isDead).toBe(true);
expect(result1.exitCode).toBe(0);
expect(result2.isDead).toBe(true);
expect(result2.exitCode).toBe(0);
// List commands - should show the original command and working directory for each
const commands = await manager.listCommands();
const cmd1 = commands.find((c) => c.id === result1.commandId);
const cmd2 = commands.find((c) => c.id === result2.commandId);
// Verify first command
expect(cmd1).toBeDefined();
expect(cmd1?.isDead).toBe(true);
expect(cmd1?.exitCode).toBe(0);
expect(cmd1?.workingDirectory).toBe(testDir1);
expect(cmd1?.currentCommand).toBe(testCommand1);
// Verify second command (should have different values)
expect(cmd2).toBeDefined();
expect(cmd2?.isDead).toBe(true);
expect(cmd2?.exitCode).toBe(0);
expect(cmd2?.workingDirectory).toBe(testDir2);
expect(cmd2?.currentCommand).toBe(testCommand2);
// Ensure they're actually different
expect(cmd1?.workingDirectory).not.toBe(cmd2?.workingDirectory);
expect(cmd1?.currentCommand).not.toBe(cmd2?.currentCommand);
// Cleanup
await manager.killCommand(result1.commandId);
await manager.killCommand(result2.commandId);
});
});
describe("captureCommand", () => {
it("should capture output from finished command", async () => {
const execResult = await manager.executeCommand(
"echo 'Capture test'",
process.env.HOME || "~",
5000
);
const captureResult = await manager.captureCommand(execResult.commandId, 100);
expect(captureResult.output).toContain("Capture test");
expect(captureResult.isDead).toBe(true);
expect(captureResult.exitCode).toBe(0);
});
it("should capture output from running command", async () => {
const execResult = await manager.executeCommand(
"python3",
process.env.HOME || "~",
5000
);
const captureResult = await manager.captureCommand(execResult.commandId, 50);
expect(captureResult.output).toContain(">>>");
expect(captureResult.isDead).toBe(false);
expect(captureResult.exitCode).toBeNull();
// Cleanup
await manager.killCommand(execResult.commandId);
});
});
describe("sendTextToCommand", () => {
it("should send text to running Python REPL", async () => {
// Launch Python
const execResult = await manager.executeCommand(
"python3",
process.env.HOME || "~",
5000
);
// Send Python code
const output = await manager.sendTextToCommand(
execResult.commandId,
"print('Hello from Python')",
true, // press Enter
{ lines: 50, maxWait: 3000, waitForSettled: true }
);
expect(output).toContain("Hello from Python");
// Send exit
await manager.sendTextToCommand(
execResult.commandId,
"exit()",
true,
{ lines: 50, maxWait: 2000 }
);
// Verify command is now dead
const captureResult = await manager.captureCommand(execResult.commandId);
expect(captureResult.isDead).toBe(true);
});
it("should handle multiple sequential inputs to REPL", async () => {
const execResult = await manager.executeCommand(
"python3",
process.env.HOME || "~",
5000
);
// First command
await manager.sendTextToCommand(
execResult.commandId,
"x = 5",
true,
{ lines: 50, maxWait: 2000 }
);
// Second command
await manager.sendTextToCommand(
execResult.commandId,
"y = 3",
true,
{ lines: 50, maxWait: 2000 }
);
// Third command - use variables
const output = await manager.sendTextToCommand(
execResult.commandId,
"print(x + y)",
true,
{ lines: 50, maxWait: 2000 }
);
expect(output).toContain("8");
// Cleanup
await manager.sendTextToCommand(execResult.commandId, "exit()", true);
});
});
describe("sendKeysToCommand", () => {
it("should send Ctrl-C to interrupt running command", async () => {
// Start a long-running command
const execResult = await manager.executeCommand(
"sleep 100",
process.env.HOME || "~",
1000 // Short wait, won't finish
);
expect(execResult.isDead).toBe(false);
// Send Ctrl-C
await manager.sendKeysToCommand(
execResult.commandId,
"C-c",
1,
{ lines: 50, maxWait: 2000 }
);
// Wait a bit for signal to process
await new Promise((resolve) => setTimeout(resolve, 500));
// Check if command is now dead
const captureResult = await manager.captureCommand(execResult.commandId);
expect(captureResult.isDead).toBe(true);
});
it("should send Enter key to REPL", async () => {
const execResult = await manager.executeCommand(
"python3",
process.env.HOME || "~",
5000
);
// Type without pressing Enter
await manager.sendTextToCommand(
execResult.commandId,
"print('test')",
false // Don't press Enter
);
// Now send Enter via sendKeys
const output = await manager.sendKeysToCommand(
execResult.commandId,
"Enter",
1,
{ lines: 50, maxWait: 2000 }
);
expect(output).toContain("test");
// Cleanup
await manager.sendTextToCommand(execResult.commandId, "exit()", true);
});
it("should repeat key press multiple times", async () => {
const execResult = await manager.executeCommand(
"python3",
process.env.HOME || "~",
5000
);
// Send multiple Ctrl-C (should exit Python)
await manager.sendKeysToCommand(
execResult.commandId,
"C-c",
3, // Repeat 3 times
{ lines: 50, maxWait: 3000 }
);
// Wait longer for Python to exit
await new Promise((resolve) => setTimeout(resolve, 1500));
const captureResult = await manager.captureCommand(execResult.commandId);
// Python might not exit from Ctrl-C depending on state, so just verify we can still capture
expect(captureResult.output).toBeDefined();
// Cleanup if still running
if (!captureResult.isDead) {
await manager.killCommand(execResult.commandId);
}
});
});
describe("killCommand", () => {
it("should kill running command", async () => {
const execResult = await manager.executeCommand(
"sleep 100",
process.env.HOME || "~",
1000
);
expect(execResult.isDead).toBe(false);
// Kill the command
await manager.killCommand(execResult.commandId);
// Verify it's gone from list
const commands = await manager.listCommands();
const found = commands.find((c) => c.id === execResult.commandId);
expect(found).toBeUndefined();
});
it("should kill finished command (cleanup)", async () => {
const execResult = await manager.executeCommand(
"echo 'cleanup test'",
process.env.HOME || "~",
5000
);
expect(execResult.isDead).toBe(true);
// Kill/cleanup the command
await manager.killCommand(execResult.commandId);
// Verify it's gone from list
const commands = await manager.listCommands();
const found = commands.find((c) => c.id === execResult.commandId);
expect(found).toBeUndefined();
});
});
describe("Complex workflows", () => {
it("should handle Node.js REPL workflow", async () => {
// Launch Node REPL
const execResult = await manager.executeCommand(
"node",
process.env.HOME || "~",
5000
);
expect(execResult.output).toContain(">"); // Node prompt
expect(execResult.isDead).toBe(false);
// Execute JavaScript
await manager.sendTextToCommand(
execResult.commandId,
"const x = [1, 2, 3]",
true,
{ lines: 50, maxWait: 2000 }
);
const output2 = await manager.sendTextToCommand(
execResult.commandId,
"x.map(n => n * 2)",
true,
{ lines: 50, maxWait: 2000 }
);
expect(output2).toContain("2");
expect(output2).toContain("4");
expect(output2).toContain("6");
// Exit
await manager.sendTextToCommand(execResult.commandId, ".exit", true);
// Verify exited
await new Promise((resolve) => setTimeout(resolve, 500));
const captureResult = await manager.captureCommand(execResult.commandId);
expect(captureResult.isDead).toBe(true);
});
it("should handle command that produces streaming output", async () => {
const execResult = await manager.executeCommand(
"for i in 1 2 3; do echo Line $i; sleep 0.1; done",
process.env.HOME || "~",
5000
);
expect(execResult.output).toContain("Line 1");
expect(execResult.output).toContain("Line 2");
expect(execResult.output).toContain("Line 3");
expect(execResult.isDead).toBe(true);
expect(execResult.exitCode).toBe(0);
});
it("should handle command with stderr output", async () => {
const execResult = await manager.executeCommand(
"echo 'to stdout'; echo 'to stderr' >&2",
process.env.HOME || "~",
5000
);
expect(execResult.output).toContain("to stdout");
expect(execResult.output).toContain("to stderr");
expect(execResult.isDead).toBe(true);
expect(execResult.exitCode).toBe(0);
});
});
describe("Edge cases", () => {
it("should handle empty command output", async () => {
const execResult = await manager.executeCommand(
"true", // Command that produces no output
process.env.HOME || "~",
5000
);
expect(execResult.isDead).toBe(true);
expect(execResult.exitCode).toBe(0);
});
it("should handle command with tilde in directory", async () => {
const execResult = await manager.executeCommand(
"pwd",
"~/",
5000
);
expect(execResult.output).toContain(process.env.HOME || "");
expect(execResult.isDead).toBe(true);
});
it("should handle very long output", async () => {
const execResult = await manager.executeCommand(
"seq 1 500", // Generate 500 lines
process.env.HOME || "~",
5000
);
expect(execResult.output).toContain("1");
expect(execResult.output).toContain("500");
expect(execResult.isDead).toBe(true);
expect(execResult.exitCode).toBe(0);
});
it("should handle special characters in command", async () => {
const execResult = await manager.executeCommand(
"echo 'Special: $test & | ; < >'",
process.env.HOME || "~",
5000
);
expect(execResult.output).toContain("Special:");
expect(execResult.isDead).toBe(true);
expect(execResult.exitCode).toBe(0);
});
it("should handle command with quotes", async () => {
const execResult = await manager.executeCommand(
`echo 'double and single quotes'`,
process.env.HOME || "~",
5000
);
expect(execResult.output).toContain("double");
expect(execResult.output).toContain("single");
expect(execResult.isDead).toBe(true);
});
});
});
@@ -0,0 +1,520 @@
import {
findSessionByName,
createSession,
listWindows,
createWindow,
listPanes,
findWindowByName,
capturePaneContent,
sendText as tmuxSendText,
sendKeys as tmuxSendKeys,
renameWindow,
killWindow,
isWindowNameUnique,
getCurrentWorkingDirectory,
getCurrentCommand,
getStoredWorkingDirectory,
getStoredCommand,
waitForPaneActivityToSettle,
executeCommand as tmuxExecuteCommand,
executeTmux,
} from "./tmux.js";
// Terminal model: session → windows (single pane per window)
// Terminals are identified by their unique names, not IDs
export interface TerminalInfo {
name: string;
workingDirectory: string;
currentCommand: string;
lastLines: string | null;
}
export interface CommandInfo {
id: string;
name: string;
workingDirectory: string;
currentCommand: string;
isDead: boolean;
exitCode: number | null;
lastLines: string | null;
}
export interface CreateTerminalParams {
name: string;
workingDirectory: string;
initialCommand?: string;
}
export interface CreateTerminalResult extends TerminalInfo {
commandOutput: string | null;
}
/**
* Terminal manager for a specific tmux session
* Multiple instances can coexist with different session names
*/
export class TerminalManager {
constructor(private sessionName: string) {}
/**
* Initialize the tmux session
* Creates it if it doesn't exist
*/
async initialize(): Promise<void> {
const session = await findSessionByName(this.sessionName);
if (!session) {
await createSession(this.sessionName);
}
}
/**
* List all terminals in the session
* Returns terminal info including name, active status, working directory, and current command
*/
async listTerminals(): Promise<TerminalInfo[]> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(
`Session '${this.sessionName}' not found. Call initialize() first.`
);
}
const windows = await listWindows(session.id);
const terminals: TerminalInfo[] = [];
for (const window of windows) {
// Get the first (and only) pane in this window
const paneId = `${window.id}.0`;
const workingDirectory = await getCurrentWorkingDirectory(paneId);
const currentCommand = await getCurrentCommand(paneId);
const lastLines = await capturePaneContent(paneId, 5, false);
terminals.push({
name: window.name,
workingDirectory,
currentCommand,
lastLines: lastLines || null,
});
}
return terminals;
}
/**
* Create a new terminal (tmux window) with specified name and working directory
* Optionally execute an initial command
*/
async createTerminal(
params: CreateTerminalParams
): Promise<CreateTerminalResult> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(
`Session '${this.sessionName}' not found. Call initialize() first.`
);
}
// Validate name uniqueness
const isUnique = await isWindowNameUnique(session.id, params.name);
if (!isUnique) {
throw new Error(
`Terminal with name '${params.name}' already exists. Please choose a unique name.`
);
}
// Create the window
const windowResult = await createWindow(session.id, params.name, {
workingDirectory: params.workingDirectory,
command: params.initialCommand ?? null,
});
if (!windowResult) {
throw new Error(`Failed to create terminal '${params.name}'`);
}
const paneId = windowResult.paneId;
// Get terminal info
const workingDirectory = await getCurrentWorkingDirectory(paneId);
const currentCommand = await getCurrentCommand(paneId);
return {
name: windowResult.name,
workingDirectory,
currentCommand,
commandOutput: windowResult.output || null,
lastLines: null,
};
}
/**
* Capture output from a terminal by name
* Returns the last N lines of terminal content
* If maxWait is provided, waits for terminal activity to settle before capturing
*/
async captureTerminal(
terminalName: string,
lines: number = 200,
maxWait?: number
): Promise<string> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found.`);
}
// Resolve terminal name to window
const window = await findWindowByName(session.id, terminalName);
if (!window) {
const windows = await listWindows(session.id);
const availableNames = windows.map((w) => w.name).join(", ");
throw new Error(
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`
);
}
// Get the first pane
const panes = await listPanes(window.id);
const pane = panes[0];
if (!pane) {
throw new Error(`No pane found for terminal ${terminalName}`);
}
// Wait for activity to settle if maxWait is provided
if (maxWait) {
return waitForPaneActivityToSettle(pane.id, maxWait, lines);
}
return capturePaneContent(pane.id, lines, false);
}
/**
* Send text to a terminal by name, optionally press Enter, optionally return output
*/
async sendText(
terminalName: string,
text: string,
pressEnter: boolean = false,
return_output?: { lines?: number; waitForSettled?: boolean; maxWait?: number }
): Promise<string | null> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found.`);
}
// Resolve terminal name to window
const window = await findWindowByName(session.id, terminalName);
if (!window) {
const windows = await listWindows(session.id);
const availableNames = windows.map((w) => w.name).join(", ");
throw new Error(
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`
);
}
// Get the first pane
const panes = await listPanes(window.id);
const pane = panes[0];
if (!pane) {
throw new Error(`No pane found for terminal ${terminalName}`);
}
return tmuxSendText({
paneId: pane.id,
text,
pressEnter,
return_output,
});
}
/**
* Send special keys or key combinations to a terminal by name
* Useful for TUI navigation, control sequences, and interactive applications
*/
async sendKeys(
terminalName: string,
keys: string,
repeat: number = 1,
return_output?: { lines?: number; waitForSettled?: boolean; maxWait?: number }
): Promise<string | null> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found.`);
}
// Resolve terminal name to window
const window = await findWindowByName(session.id, terminalName);
if (!window) {
const windows = await listWindows(session.id);
const availableNames = windows.map((w) => w.name).join(", ");
throw new Error(
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`
);
}
// Get the first pane
const panes = await listPanes(window.id);
const pane = panes[0];
if (!pane) {
throw new Error(`No pane found for terminal ${terminalName}`);
}
return tmuxSendKeys({
paneId: pane.id,
keys,
repeat,
return_output,
});
}
/**
* Rename a terminal by name
* Validates that the new name is unique
*/
async renameTerminal(
terminalName: string,
newName: string
): Promise<void> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found.`);
}
// renameWindow handles uniqueness validation and name resolution internally
await renameWindow(session.id, terminalName, newName);
}
/**
* Kill (close/destroy) a terminal by name
*/
async killTerminal(terminalName: string): Promise<void> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found.`);
}
// Resolve terminal name to window
const window = await findWindowByName(session.id, terminalName);
if (!window) {
const windows = await listWindows(session.id);
const availableNames = windows.map((w) => w.name).join(", ");
throw new Error(
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`
);
}
await killWindow(window.id);
}
/**
* Execute a command and return its output, exit code, and running status
* Commands are wrapped in bash -c to support all bash features
* Windows remain after command exits (remain-on-exit) for inspection
*/
async executeCommand(
command: string,
workingDirectory: string,
maxWait?: number
): Promise<{
commandId: string;
output: string;
exitCode: number | null;
isDead: boolean;
}> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(
`Session '${this.sessionName}' not found. Call initialize() first.`
);
}
const result = await tmuxExecuteCommand({
sessionId: session.id,
command,
workingDirectory,
maxWait,
});
return {
commandId: result.windowId,
output: result.output,
exitCode: result.exitCode,
isDead: result.isDead,
};
}
/**
* List all commands (windows), including those that have exited
* Shows both running and dead commands for inspection
*/
async listCommands(): Promise<CommandInfo[]> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(
`Session '${this.sessionName}' not found. Call initialize() first.`
);
}
const windows = await listWindows(session.id);
const commands: CommandInfo[] = [];
for (const window of windows) {
const paneId = `${window.id}.0`;
// Check if pane is dead first
const deadStatus = await executeTmux([
"display-message",
"-p",
"-t",
paneId,
"#{pane_dead}",
]);
const isDead = deadStatus === "1";
// Use stored values for dead panes, current values for live ones
const workingDirectory = await getStoredWorkingDirectory(window.id, paneId);
const currentCommand = await getStoredCommand(window.id, paneId);
const lastLines = await capturePaneContent(paneId, 5, false);
let exitCode: number | null = null;
if (isDead) {
const exitCodeStr = await executeTmux([
"display-message",
"-p",
"-t",
paneId,
"#{pane_dead_status}",
]);
exitCode = parseInt(exitCodeStr, 10);
}
commands.push({
id: window.id,
name: window.name,
workingDirectory,
currentCommand,
isDead,
exitCode,
lastLines: lastLines || null,
});
}
return commands;
}
/**
* Capture output from a command by ID (window ID)
*/
async captureCommand(
commandId: string,
lines: number = 200
): Promise<{
output: string;
exitCode: number | null;
isDead: boolean;
}> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found.`);
}
const paneId = `${commandId}.0`;
const output = await capturePaneContent(paneId, lines, false);
const deadStatus = await executeTmux([
"display-message",
"-p",
"-t",
paneId,
"#{pane_dead}",
]);
const isDead = deadStatus === "1";
let exitCode: number | null = null;
if (isDead) {
const exitCodeStr = await executeTmux([
"display-message",
"-p",
"-t",
paneId,
"#{pane_dead_status}",
]);
exitCode = parseInt(exitCodeStr, 10);
}
return {
output,
exitCode,
isDead,
};
}
/**
* Send text to a command by ID (window ID)
*/
async sendTextToCommand(
commandId: string,
text: string,
pressEnter: boolean = false,
return_output?: { lines?: number; waitForSettled?: boolean; maxWait?: number }
): Promise<string | null> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found.`);
}
const paneId = `${commandId}.0`;
return tmuxSendText({
paneId,
text,
pressEnter,
return_output,
});
}
/**
* Send keys to a command by ID (window ID)
*/
async sendKeysToCommand(
commandId: string,
keys: string,
repeat: number = 1,
return_output?: { lines?: number; waitForSettled?: boolean; maxWait?: number }
): Promise<string | null> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found.`);
}
const paneId = `${commandId}.0`;
return tmuxSendKeys({
paneId,
keys,
repeat,
return_output,
});
}
/**
* Kill a command by ID (window ID)
*/
async killCommand(commandId: string): Promise<void> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found.`);
}
await killWindow(commandId);
}
}
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
// Shared server types
export interface ServerConfig {
port: number;
isDev: boolean;
}
@@ -0,0 +1,240 @@
import { WebSocketServer, WebSocket } from "ws";
import { Server as HTTPServer } from "http";
import { parse as parseUrl } from "url";
import {
WSInboundMessageSchema,
type WSOutboundMessage,
extractSessionMessage,
wrapSessionMessage,
} from "./messages.js";
import { Session } from "./session.js";
import { loadConversation } from "./persistence.js";
/**
* WebSocket server that routes messages between clients and their sessions.
* This is a thin transport layer with no business logic.
*/
export class VoiceAssistantWebSocketServer {
private wss: WebSocketServer;
private sessions: Map<WebSocket, Session> = new Map();
private conversationIdToWs: Map<string, WebSocket> = new Map();
private clientIdCounter: number = 0;
constructor(server: HTTPServer) {
this.wss = new WebSocketServer({ server, path: "/ws" });
this.wss.on("connection", (ws, request) => {
this.handleConnection(ws, request);
});
console.log("✓ WebSocket server initialized on /ws");
}
/**
* Handle new WebSocket connection
*/
private async handleConnection(ws: WebSocket, request: any): Promise<void> {
// Generate unique client ID
const clientId = `client-${++this.clientIdCounter}`;
// Extract conversation ID from URL query parameter if present
const url = parseUrl(request.url || "", true);
const conversationId = url.query.conversationId as string | undefined;
// Load conversation if ID provided
let initialMessages = null;
if (conversationId) {
console.log(
`[WS] Client requesting conversation ${conversationId}`
);
initialMessages = await loadConversation(conversationId);
if (initialMessages) {
console.log(
`[WS] Loaded conversation ${conversationId} with ${initialMessages.length} messages`
);
} else {
console.log(
`[WS] Conversation ${conversationId} not found, starting fresh`
);
}
}
// Create session with message emission callback
const session = new Session(
clientId,
(msg) => {
this.sendToClient(ws, wrapSessionMessage(msg));
},
{
conversationId,
initialMessages: initialMessages || undefined,
}
);
// Store session and reverse mapping
this.sessions.set(ws, session);
this.conversationIdToWs.set(session.getConversationId(), ws);
console.log(
`[WS] Client connected: ${clientId} with conversation ${session.getConversationId()} (total: ${
this.sessions.size
})`
);
// Send welcome message
this.sendToClient(
ws,
wrapSessionMessage({
type: "status",
payload: {
status: "connected",
message: "WebSocket connection established",
},
})
);
// Send conversation confirmation (whether new or loaded)
this.sendToClient(
ws,
wrapSessionMessage({
type: "conversation_loaded",
payload: {
conversationId: session.getConversationId(),
messageCount: initialMessages?.length || 0,
},
})
);
// Set up message handler
ws.on("message", (data) => {
this.handleMessage(ws, data);
});
// Set up close handler
ws.on("close", async () => {
const session = this.sessions.get(ws);
if (!session) return;
console.log(
`[WS] Client disconnected: ${clientId} (total: ${
this.sessions.size - 1
})`
);
// Clean up session
await session.cleanup();
// Remove from maps
this.sessions.delete(ws);
this.conversationIdToWs.delete(session.getConversationId());
console.log(`[WS] Conversation ${session.getConversationId()} deleted`);
});
// Set up error handler
ws.on("error", async (error) => {
console.error(`[WS] Client error:`, error);
const session = this.sessions.get(ws);
if (!session) return;
// Clean up session
await session.cleanup();
// Remove from maps
this.sessions.delete(ws);
this.conversationIdToWs.delete(session.getConversationId());
});
}
/**
* Handle incoming WebSocket message
*/
private async handleMessage(
ws: WebSocket,
data: Buffer | ArrayBuffer | Buffer[]
): Promise<void> {
try {
// Parse message
const parsed = JSON.parse(data.toString());
// Validate with Zod
const message = WSInboundMessageSchema.parse(parsed);
console.log(`[WS] Received message type: ${message.type}`, message);
// Handle WebSocket-level messages
switch (message.type) {
case "ping":
this.sendToClient(ws, { type: "pong" });
return;
case "recording_state":
console.log(`[WS] Recording state: ${message.isRecording}`);
return;
case "session":
// Extract and forward session message
const sessionMessage = extractSessionMessage(message);
if (sessionMessage) {
const session = this.sessions.get(ws);
if (session) {
await session.handleMessage(sessionMessage);
} else {
console.error("[WS] No session found for client");
}
}
return;
}
} catch (error: any) {
console.error("[WS] Failed to parse/handle message:", error);
// Send error to client
this.sendToClient(
ws,
wrapSessionMessage({
type: "status",
payload: {
status: "error",
message: `Invalid message: ${error.message}`,
},
})
);
}
}
/**
* Send message to specific client
*/
private sendToClient(ws: WebSocket, message: WSOutboundMessage): void {
if (ws.readyState === 1) {
// WebSocket.OPEN = 1
ws.send(JSON.stringify(message));
}
}
/**
* Broadcast message to all connected clients
*/
public broadcast(message: WSOutboundMessage): void {
const payload = JSON.stringify(message);
this.sessions.forEach((_session, client) => {
if (client.readyState === 1) {
// WebSocket.OPEN = 1
client.send(payload);
}
});
}
/**
* Close the WebSocket server
*/
public async close(): Promise<void> {
const cleanupPromises: Promise<void>[] = [];
this.sessions.forEach((session, ws) => {
cleanupPromises.push(session.cleanup());
ws.close();
});
await Promise.all(cleanupPromises);
this.wss.close();
}
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2020"],
"skipLibCheck": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"outDir": "./dist/server",
"rootDir": "./src/server",
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/server/**/*"],
"exclude": ["node_modules", "dist"]
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
testTimeout: 30000,
hookTimeout: 30000,
globals: true,
environment: "node",
},
});