diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 615e6a95..e93058ab 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -12,7 +12,17 @@ permissions: jobs: build-macos: - runs-on: macos-latest + strategy: + fail-fast: false + matrix: + include: + - os: macos-13 + arch: x64 + build_script: build:mac:x64 + - os: macos-latest + arch: arm64 + build_script: build:mac:arm64 + runs-on: ${{ matrix.os }} defaults: run: working-directory: packages/launcher @@ -62,7 +72,13 @@ jobs: echo "${{ secrets.APPLE_API_KEY }}" | base64 --decode > "$API_KEY_PATH" export APPLE_API_KEY="$API_KEY_PATH" - npm run build:mac + npm run ${{ matrix.build_script }} + + - name: Verify macOS artifacts + run: | + ls -la dist + ls dist/*-mac-${{ matrix.arch }}.dmg >/dev/null + ls dist/*-mac-${{ matrix.arch }}.zip >/dev/null - name: Clean up keychain if: always() @@ -72,10 +88,10 @@ jobs: - name: Upload macOS artifacts uses: actions/upload-artifact@v4 with: - name: launcher-macos + name: launcher-macos-${{ matrix.arch }} path: | - packages/launcher/dist/*.dmg - packages/launcher/dist/*.zip + packages/launcher/dist/*-mac-${{ matrix.arch }}.dmg + packages/launcher/dist/*-mac-${{ matrix.arch }}.zip if-no-files-found: warn build-windows: diff --git a/.github/workflows/test-desktop.yml b/.github/workflows/test-desktop.yml index ca248631..02286d76 100644 --- a/.github/workflows/test-desktop.yml +++ b/.github/workflows/test-desktop.yml @@ -92,7 +92,7 @@ jobs: Stop-Process -Name "OpenAgents Launcher" -Force -ErrorAction SilentlyContinue Stop-Process -Name "electron" -Force -ErrorAction SilentlyContinue - test-macos: + test-macos-arm64: runs-on: [self-hosted, macos, desktop-test] timeout-minutes: 45 steps: @@ -107,12 +107,7 @@ jobs: run: | TAG="${{ github.event.inputs.tag }}" echo "Downloading $TAG..." - ARCH=$(uname -m) - if [ "$ARCH" = "arm64" ]; then - PATTERN="mac-arm64.dmg" - else - PATTERN="mac-x64.dmg" - fi + PATTERN="mac-arm64.dmg" API_URL="https://api.github.com/repos/openagents-org/openagents/releases/tags/$TAG" DOWNLOAD_URL=$(curl -s "$API_URL" | python3 -c "import sys,json; assets=json.load(sys.stdin)['assets']; print([a['browser_download_url'] for a in assets if '$PATTERN' in a['name']][0])") echo "Downloading: $DOWNLOAD_URL" @@ -169,7 +164,83 @@ jobs: if: always() uses: actions/upload-artifact@v4 with: - name: test-screenshots-macos + name: test-screenshots-macos-arm64 + path: infra/screenshot_*.png + + - name: Cleanup + if: always() + run: | + pkill -f "OpenAgents Launcher" 2>/dev/null || true + pkill -f Electron 2>/dev/null || true + + test-macos-x64: + runs-on: macos-13 + timeout-minutes: 45 + steps: + - name: Clean environment + run: | + pkill -f "OpenAgents Launcher" 2>/dev/null || true + pkill -f Electron 2>/dev/null || true + rm -rf ~/.openagents 2>/dev/null || true + echo "Environment cleaned" + + - name: Download release + run: | + TAG="${{ github.event.inputs.tag }}" + echo "Downloading $TAG..." + PATTERN="mac-x64.dmg" + API_URL="https://api.github.com/repos/openagents-org/openagents/releases/tags/$TAG" + DOWNLOAD_URL=$(curl -s "$API_URL" | python3 -c "import sys,json; assets=json.load(sys.stdin)['assets']; print([a['browser_download_url'] for a in assets if '$PATTERN' in a['name']][0])") + echo "Downloading: $DOWNLOAD_URL" + curl -L -o /tmp/launcher-test.dmg "$DOWNLOAD_URL" + echo "Downloaded: /tmp/launcher-test.dmg" + + - name: Install app + run: | + hdiutil detach /Volumes/OpenAgents* 2>/dev/null || true + hdiutil attach /tmp/launcher-test.dmg -nobrowse + rm -rf "/Applications/OpenAgents Launcher.app" + VOLUME=$(ls -d /Volumes/OpenAgents* | head -1) + cp -R "$VOLUME/OpenAgents Launcher.app" /Applications/ + hdiutil detach "$VOLUME" 2>/dev/null || true + xattr -cr "/Applications/OpenAgents Launcher.app" + echo "Installed to /Applications" + + - name: Launch app with CDP + run: | + open "/Applications/OpenAgents Launcher.app" --args --remote-debugging-port=9333 --remote-allow-origins=* + for i in $(seq 1 30); do + sleep 1 + if curl -s http://127.0.0.1:9333/json > /dev/null 2>&1; then + echo "CDP ready after ${i}s" + break + fi + done + + - name: Setup Node.js for Playwright + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Checkout test script + uses: actions/checkout@v4 + with: + sparse-checkout: infra/test_app.js + sparse-checkout-cone-mode: false + + - name: Install Playwright and run tests + run: | + cd infra + npm init -y + npm install playwright + npx playwright install chromium + node test_app.js + + - name: Upload screenshots + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-screenshots-macos-x64 path: infra/screenshot_*.png - name: Cleanup diff --git a/packages/agent-connector/registry.json b/packages/agent-connector/registry.json index 936d8dd2..d3f4bdc1 100644 --- a/packages/agent-connector/registry.json +++ b/packages/agent-connector/registry.json @@ -186,7 +186,7 @@ { "name": "cursor", "label": "Cursor", - "description": "AI-powered code editor with agent mode CLI", + "description": "Cursor official CLI agent", "homepage": "https://cursor.com", "tags": [ "coding", @@ -196,9 +196,9 @@ ], "builtin": true, "install": { - "binary": "cursor", - "verify": "cursor --version 2>/dev/null | grep -qi cursor", - "verify_win": "cursor --version 2>nul | findstr /i cursor", + "binary": "cursor-agent", + "verify": "cursor-agent --version >/dev/null 2>&1", + "verify_win": "cursor-agent --version >nul 2>nul", "requires": [ null ], @@ -217,28 +217,30 @@ }, "env_config": [ { - "name": "OPENAI_API_KEY", - "description": "API key for LLM inference", + "name": "CURSOR_API_KEY", + "description": "Cursor API key", "required": true, "password": true + }, + { + "name": "CURSOR_MODEL", + "description": "Optional Cursor model override", + "required": false, + "placeholder": "claude-4-sonnet, gpt-5, auto, etc." } ], "check_ready": { "env_vars": [ - "OPENAI_API_KEY" + "CURSOR_API_KEY" ], - "saved_env_key": "OPENAI_API_KEY", - "not_ready_message": "No API key \u2014 press e to configure" + "saved_env_key": "CURSOR_API_KEY", + "not_ready_message": "Cursor API key not configured \u2014 press e to configure" }, "resolve_env": { "rules": [ { "from": "LLM_API_KEY", - "to": "OPENAI_API_KEY" - }, - { - "from": "LLM_BASE_URL", - "to": "OPENAI_BASE_URL" + "to": "CURSOR_API_KEY" }, { "from": "LLM_MODEL", @@ -521,4 +523,4 @@ "windows": "pip install sweagent" } } -] \ No newline at end of file +] diff --git a/packages/agent-connector/src/adapters/cursor.js b/packages/agent-connector/src/adapters/cursor.js index d1d10627..39e00d50 100644 --- a/packages/agent-connector/src/adapters/cursor.js +++ b/packages/agent-connector/src/adapters/cursor.js @@ -1,22 +1,307 @@ /** - * Cursor adapter — AI-powered code editor agent mode. + * Cursor CLI adapter for OpenAgents workspace. * - * Uses direct LLM API mode (OpenAI-compatible chat completions). - * Port of Python: src/openagents/adapters/cursor.py + * Bridges the official `cursor-agent` CLI to an OpenAgents workspace via: + * - Polling loop for incoming messages + * - Cursor CLI subprocess execution with JSON output + * - OpenAgents MCP server exposed through Cursor's runtime mcp.json + * - Per-channel session resume for conversation continuity */ 'use strict'; -const LlmDirectAdapter = require('./llm-direct'); +const os = require('os'); +const path = require('path'); +const { spawn } = require('child_process'); -class CursorAdapter extends LlmDirectAdapter { +const BaseAdapter = require('./base'); +const { + buildWorkspaceMcpServer, + ensureRuntimeEnvHome, + findExecutable, + firstText, + formatAttachmentsForPrompt, + writeJsonFile, +} = require('./utils'); +const { buildOpenclawSystemPrompt } = require('./workspace-prompt'); + +const IS_WINDOWS = process.platform === 'win32'; + +class CursorAdapter extends BaseAdapter { constructor(opts) { - super({ - ...opts, - adapterLabel: 'Cursor', - modelEnvVar: 'CURSOR_MODEL', + super(opts); + this.disabledModules = opts.disabledModules || new Set(); + + const env = this.agentEnv || process.env; + this._cursorBinary = findExecutable('cursor-agent', 'cursor'); + this._cursorModel = (env.CURSOR_MODEL || '').trim(); + this._sessionsFile = path.join( + os.homedir(), '.openagents', 'sessions', + `${this.workspaceId}_${this.agentName}_cursor.json` + ); + this._runtimeRoot = path.join( + os.homedir(), '.openagents', 'runtime', 'cursor', + `${this.workspaceId}_${this.agentName}` + ); + this._channelSessions = {}; + this._loadSessions(); + } + + _loadSessions() { + try { + if (!require('fs').existsSync(this._sessionsFile)) return; + const data = JSON.parse(require('fs').readFileSync(this._sessionsFile, 'utf-8')); + if (!data || typeof data !== 'object') return; + for (const [channel, sessionId] of Object.entries(data)) { + if (sessionId) this._channelSessions[String(channel)] = String(sessionId); + } + } catch { + this._log('Could not load Cursor session state'); + } + } + + _saveSessions() { + try { + writeJsonFile(this._sessionsFile, this._channelSessions); + } catch { + this._log('Could not save Cursor session state'); + } + } + + _buildSystemPrompt(channelName) { + return buildOpenclawSystemPrompt({ + agentName: this.agentName, + workspaceId: this.workspaceId, + channelName, + endpoint: this.endpoint, + token: this.token, + mode: this._mode, + disabledModules: this.disabledModules, }); } + + _cursorEnv(channelName) { + const runtimeHome = path.join(this._runtimeRoot, 'home'); + const useSystemHome = process.platform === 'darwin'; + const env = useSystemHome + ? { ...(this.agentEnv || process.env) } + : ensureRuntimeEnvHome(this.agentEnv || process.env, runtimeHome); + env.CURSOR_API_KEY = (this.agentEnv && this.agentEnv.CURSOR_API_KEY) + || process.env.CURSOR_API_KEY + || ''; + + const mcpConfig = { + mcpServers: buildWorkspaceMcpServer( + this.workspaceId, + channelName, + this.agentName, + this.endpoint, + this.token, + { + disableFiles: this.disabledModules.has('files'), + disableBrowser: this.disabledModules.has('browser'), + } + ), + }; + const cursorHome = useSystemHome + ? (env.HOME || os.homedir()) + : runtimeHome; + writeJsonFile(path.join(cursorHome, '.cursor', 'mcp.json'), mcpConfig); + return env; + } + + _extractToolName(event) { + for (const key of ['tool_name', 'toolName', 'name']) { + if (typeof event[key] === 'string' && event[key]) return event[key]; + } + for (const containerKey of ['tool_call', 'tool', 'call']) { + const container = event[containerKey]; + if (!container || typeof container !== 'object') continue; + for (const key of ['name', 'tool', 'toolName']) { + if (typeof container[key] === 'string' && container[key]) return container[key]; + } + } + return ''; + } + + _extractSessionId(event) { + for (const key of ['session_id', 'sessionId', 'conversation_id', 'conversationId']) { + const value = event[key]; + if (typeof value === 'string' && value) return value; + } + const result = event.result; + if (result && typeof result === 'object') { + for (const key of ['session_id', 'sessionId', 'conversation_id', 'conversationId']) { + const value = result[key]; + if (typeof value === 'string' && value) return value; + } + } + return ''; + } + + _extractResultText(event) { + for (const candidate of [ + event.result, + event.message, + event.content, + event.text, + event.output, + event.assistant_message, + ]) { + const text = firstText(candidate).trim(); + if (text) return text; + } + return ''; + } + + _buildCursorCmd(prompt, channelName) { + if (!this._cursorBinary) { + throw new Error( + "cursor-agent CLI not found. Install Cursor CLI and ensure 'cursor-agent' is on PATH." + ); + } + + const fullPrompt = `${this._buildSystemPrompt(channelName)}\n\n---\n\n${prompt}`; + const cmd = [ + this._cursorBinary, + '-p', + fullPrompt, + '--print', + '--output-format', + 'stream-json', + '--trust', + ]; + if (this._mode !== 'plan') cmd.push('--force'); + if (this._cursorModel) cmd.push('--model', this._cursorModel); + if (this.workingDir) cmd.push('--workspace', this.workingDir); + + const sessionId = this._channelSessions[channelName]; + if (sessionId) cmd.push('--resume', sessionId); + return cmd; + } + + _runCursor(prompt, channelName) { + const env = this._cursorEnv(channelName); + const cmd = this._buildCursorCmd(prompt, channelName); + + let spawnBinary = cmd[0]; + let spawnArgs = cmd.slice(1); + if (IS_WINDOWS && spawnBinary.toLowerCase().endsWith('.cmd')) { + spawnArgs = ['/C', spawnBinary, ...spawnArgs]; + spawnBinary = process.env.ComSpec || process.env.COMSPEC || 'cmd.exe'; + } + + return new Promise((resolve, reject) => { + const proc = spawn(spawnBinary, spawnArgs, { + stdio: ['ignore', 'pipe', 'pipe'], + env, + cwd: this.workingDir, + timeout: 300000, + }); + + let lineBuffer = ''; + let stderrText = ''; + const finalChunks = []; + let lastText = ''; + + const processLine = (rawLine) => { + const raw = rawLine.trim(); + if (!raw) return; + + let event; + try { + event = JSON.parse(raw); + } catch { + return; + } + + const sessionId = this._extractSessionId(event); + if (sessionId) { + this._channelSessions[channelName] = sessionId; + this._saveSessions(); + } + + const eventType = event.type || ''; + if (eventType === 'tool_call_started') { + const toolName = this._extractToolName(event) || 'tool'; + void this.sendStatus(channelName, `Using tool: \`${toolName}\``); + return; + } + if (eventType === 'tool_call_completed' || eventType === 'tool_call_finished') { + return; + } + + const text = this._extractResultText(event); + if (!text || text === lastText) return; + lastText = text; + + if (eventType === 'result') { + finalChunks.length = 0; + finalChunks.push(text); + } else { + finalChunks.push(text); + } + }; + + if (proc.stdout) { + proc.stdout.on('data', (chunk) => { + lineBuffer += chunk.toString('utf-8'); + const lines = lineBuffer.split(/\r?\n/); + lineBuffer = lines.pop() || ''; + for (const line of lines) processLine(line); + }); + } + + if (proc.stderr) { + proc.stderr.on('data', (chunk) => { + stderrText += chunk.toString('utf-8'); + }); + } + + proc.on('error', reject); + proc.on('close', (code) => { + if (lineBuffer.trim()) processLine(lineBuffer); + const stderr = stderrText.trim(); + if (code !== 0) { + reject(new Error(stderr || 'cursor-agent exited with an error')); + return; + } + + const response = finalChunks.filter(Boolean).join('\n').trim() || stderr; + resolve(response); + }); + }); + } + + async _handleMessage(msg) { + let content = (msg.content || '').trim(); + const attachments = msg.attachments || []; + const attText = formatAttachmentsForPrompt(attachments); + if (attText) content = content ? content + attText : attText.trim(); + if (!content) return; + + const env = this.agentEnv || process.env; + const msgChannel = msg.sessionId || this.channelName; + if (!env.CURSOR_API_KEY) { + await this.sendError( + msgChannel, + 'Cursor is not configured. Set CURSOR_API_KEY and restart the agent.' + ); + return; + } + + const sender = msg.senderName || msg.senderType || 'user'; + this._log(`Processing Cursor message from ${sender} in channel ${msgChannel}: ${content.slice(0, 80)}...`); + await this._autoTitleChannel(msgChannel, content); + await this.sendStatus(msgChannel, 'thinking...'); + + try { + const response = await this._runCursor(content, msgChannel); + await this.sendResponse(msgChannel, response || 'No response generated. Please try again.'); + } catch (e) { + await this.sendError(msgChannel, (e.message || String(e)).slice(0, 1200)); + } + } } module.exports = CursorAdapter; diff --git a/packages/agent-connector/src/adapters/opencode.js b/packages/agent-connector/src/adapters/opencode.js index 38ea006e..367e7e32 100644 --- a/packages/agent-connector/src/adapters/opencode.js +++ b/packages/agent-connector/src/adapters/opencode.js @@ -1,46 +1,49 @@ /** - * OpenCode adapter for OpenAgents workspace. + * OpenCode CLI adapter for OpenAgents workspace. * - * Bridges OpenCode (opencode-ai) to an OpenAgents workspace by running - * `opencode run --format json` as a subprocess. OpenCode handles its own - * model configuration, provider selection, and tool chain. - * - * Port of Python PR #316: src/openagents/adapters/opencode.py + * Bridges the official `opencode` CLI to an OpenAgents workspace via: + * - Polling loop for incoming messages + * - `opencode run --format json` subprocess execution + * - Runtime-injected OpenCode config for model/provider/MCP settings + * - Per-channel session resume for conversation continuity */ 'use strict'; const fs = require('fs'); -const path = require('path'); const os = require('os'); +const path = require('path'); const { execSync, spawn } = require('child_process'); const BaseAdapter = require('./base'); -const { formatAttachmentsForPrompt } = require('./utils'); -const { buildOpenCodeSkillMd, buildOpenCodeSystemPrompt } = require('./workspace-prompt'); +const { + ensureRuntimeEnvHome, + findExecutable, + firstText, + formatAttachmentsForPrompt, + getCliInvocation, +} = require('./utils'); +const { buildOpenCodeSystemPrompt } = require('./workspace-prompt'); const IS_WINDOWS = process.platform === 'win32'; class OpenCodeAdapter extends BaseAdapter { - /** - * @param {object} opts - BaseAdapter opts plus: - * @param {Set} [opts.disabledModules] - * @param {string} [opts.workingDir] - */ constructor(opts) { super(opts); this.disabledModules = opts.disabledModules || new Set(); - - // Agent home directory: ~/.openagents/agents/{agentName}/ - this.agentHome = path.join(os.homedir(), '.openagents', 'agents', this.agentName); - fs.mkdirSync(this.agentHome, { recursive: true }); - + this._opencodeBinary = findExecutable('opencode') || this._findOpencodeBinary(); + this._runtimeRoot = path.join( + os.homedir(), '.openagents', 'runtime', 'opencode', + `${this.workspaceId}_${this.agentName}` + ); + this._sessionsFile = path.join( + os.homedir(), '.openagents', 'sessions', + `${this.workspaceId}_${this.agentName}_opencode.json` + ); this._channelSessions = {}; - this._sessionsFile = path.join(this.agentHome, 'sessions.json'); this._migrateSessionsFile(); this._loadSessions(); - this._opencodeBinary = this._findOpencodeBinary(); if (this._opencodeBinary) { this._log(`Using OpenCode subprocess mode: ${this._opencodeBinary}`); } else { @@ -48,18 +51,14 @@ class OpenCodeAdapter extends BaseAdapter { } } - /** - * Migrate sessions file from old location to agent home. - */ _migrateSessionsFile() { const oldPath = path.join( - os.homedir(), '.openagents', 'sessions', - `${this.workspaceId}_${this.agentName}_opencode.json` + os.homedir(), '.openagents', 'agents', this.agentName, 'sessions.json' ); try { if (fs.existsSync(oldPath) && !fs.existsSync(this._sessionsFile)) { + fs.mkdirSync(path.dirname(this._sessionsFile), { recursive: true }); fs.copyFileSync(oldPath, this._sessionsFile); - fs.unlinkSync(oldPath); this._log(`Migrated sessions file from ${oldPath}`); } } catch {} @@ -67,42 +66,21 @@ class OpenCodeAdapter extends BaseAdapter { _loadSessions() { try { - if (fs.existsSync(this._sessionsFile)) { - const data = JSON.parse(fs.readFileSync(this._sessionsFile, 'utf-8')); - if (data && typeof data === 'object') { - Object.assign(this._channelSessions, data); - this._log(`Loaded ${Object.keys(data).length} session(s)`); - } + if (!fs.existsSync(this._sessionsFile)) return; + const data = JSON.parse(fs.readFileSync(this._sessionsFile, 'utf-8')); + if (!data || typeof data !== 'object') return; + for (const [channel, sessionId] of Object.entries(data)) { + if (sessionId) this._channelSessions[String(channel)] = String(sessionId); } } catch { - this._log('Could not load sessions file, starting fresh'); + this._log('Could not load OpenCode session state'); } } _saveSessions() { try { fs.mkdirSync(path.dirname(this._sessionsFile), { recursive: true }); - fs.writeFileSync(this._sessionsFile, JSON.stringify(this._channelSessions)); - } catch {} - } - - /** - * Write workspace skill to OpenCode's skill directory for auto-discovery. - */ - _ensureWorkspaceSkill(channelName) { - const skillDir = path.join(this.agentHome, '.opencode', 'skills'); - const skillFile = path.join(skillDir, 'openagents-workspace.md'); - try { - const content = buildOpenCodeSkillMd({ - endpoint: this.endpoint, - workspaceId: this.workspaceId, - token: this.token, - agentName: this.agentName, - channelName, - disabledModules: this.disabledModules, - }); - fs.mkdirSync(skillDir, { recursive: true }); - fs.writeFileSync(skillFile, content, 'utf-8'); + fs.writeFileSync(this._sessionsFile, JSON.stringify(this._channelSessions, null, 2)); } catch {} } @@ -118,262 +96,373 @@ class OpenCodeAdapter extends BaseAdapter { }); } - // ------------------------------------------------------------------ - // Binary discovery - // ------------------------------------------------------------------ + _resolveProviderConfig() { + const env = this.agentEnv || process.env; + let modelName = ( + env.OPENCODE_MODEL || + env.LLM_MODEL || + env.OPENCLAW_MODEL || + '' + ).trim(); + const llmBaseUrl = (env.LLM_BASE_URL || env.OPENAI_BASE_URL || '').trim(); + const llmApiKey = (env.LLM_API_KEY || env.OPENAI_API_KEY || env.ANTHROPIC_API_KEY || '').trim(); + + if (llmBaseUrl) { + modelName = modelName || 'gpt-5.4'; + const providerId = 'openagents-openai-compatible'; + return { + modelRef: `${providerId}/${modelName}`, + provider: { + [providerId]: { + npm: '@ai-sdk/openai-compatible', + name: 'OpenAgents OpenAI-compatible', + options: { + baseURL: llmBaseUrl, + apiKey: llmApiKey, + }, + models: { + [modelName]: { + name: modelName, + }, + }, + }, + }, + }; + } + + if (env.ANTHROPIC_API_KEY && (modelName.startsWith('claude') || !modelName)) { + modelName = modelName || 'claude-sonnet-4-5'; + return { + modelRef: `anthropic/${modelName}`, + provider: { + anthropic: { + options: { + apiKey: env.ANTHROPIC_API_KEY, + }, + }, + }, + }; + } + + modelName = modelName || 'gpt-5.4'; + return { + modelRef: `openai/${modelName}`, + provider: { + openai: { + options: { + apiKey: env.OPENAI_API_KEY || llmApiKey, + }, + }, + }, + }; + } + + _runtimeEnv(channelName) { + const { modelRef, provider } = this._resolveProviderConfig(); + fs.mkdirSync(this._runtimeRoot, { recursive: true }); + + const cli = getCliInvocation(); + const command = [ + cli.command, + ...cli.args, + 'mcp-server', + '--workspace-id', + this.workspaceId, + '--channel-name', + channelName, + '--agent-name', + this.agentName, + '--endpoint', + this.endpoint, + ]; + if (this.disabledModules.has('files')) command.push('--disable-files'); + if (this.disabledModules.has('browser')) command.push('--disable-browser'); + + const config = { + $schema: 'https://opencode.ai/config.json', + model: modelRef, + provider, + permission: this._mode !== 'plan' + ? { '*': 'allow' } + : { '*': 'allow', edit: 'deny', bash: 'deny', task: 'deny' }, + mcp: { + 'openagents-workspace': { + type: 'local', + command, + enabled: true, + environment: { + OA_WORKSPACE_TOKEN: this.token, + }, + }, + }, + }; + + const env = ensureRuntimeEnvHome(this.agentEnv || process.env, path.join(this._runtimeRoot, 'home')); + env.OPENCODE_CONFIG_CONTENT = JSON.stringify(config); + env.XDG_CONFIG_HOME = path.join(this._runtimeRoot, 'config'); + env.XDG_DATA_HOME = path.join(this._runtimeRoot, 'data'); + env.XDG_STATE_HOME = path.join(this._runtimeRoot, 'state'); + return { env, modelRef }; + } _findOpencodeBinary() { - // Tier 1: PATH try { if (IS_WINDOWS) { - const r = execSync('where opencode.cmd 2>nul || where opencode.exe 2>nul || where opencode 2>nul', { - encoding: 'utf-8', timeout: 5000, + const result = execSync('where opencode.cmd 2>nul || where opencode.exe 2>nul || where opencode 2>nul', { + encoding: 'utf-8', + timeout: 5000, }); - return r.split(/\r?\n/)[0].trim(); - } else { - return execSync('which opencode', { encoding: 'utf-8', timeout: 5000 }).trim(); + return result.split(/\r?\n/)[0].trim(); } + return execSync('which opencode', { encoding: 'utf-8', timeout: 5000 }).trim(); } catch {} - // Tier 2: Next to Node.js const ext = IS_WINDOWS ? '.cmd' : ''; const nearNode = path.join(path.dirname(process.execPath), `opencode${ext}`); if (fs.existsSync(nearNode)) return nearNode; - // Tier 3: Common locations const home = os.homedir(); - const candidates = IS_WINDOWS ? [ - path.join(process.env.APPDATA || '', 'npm', 'opencode.cmd'), - ] : [ - path.join(home, '.openagents', 'npm-global', 'bin', 'opencode'), - path.join(home, '.npm-global', 'bin', 'opencode'), - path.join(home, '.local', 'bin', 'opencode'), - '/usr/local/bin/opencode', - ]; - for (const c of candidates) { - if (fs.existsSync(c)) return c; + const candidates = IS_WINDOWS + ? [path.join(process.env.APPDATA || '', 'npm', 'opencode.cmd')] + : [ + path.join(home, '.openagents', 'npm-global', 'bin', 'opencode'), + path.join(home, '.npm-global', 'bin', 'opencode'), + path.join(home, '.local', 'bin', 'opencode'), + '/usr/local/bin/opencode', + ]; + for (const candidate of candidates) { + if (fs.existsSync(candidate)) return candidate; } return null; } - // ------------------------------------------------------------------ - // Message handler - // ------------------------------------------------------------------ - - async _handleMessage(msg) { - let content = (msg.content || '').trim(); - const attachments = msg.attachments || []; - - const attText = formatAttachmentsForPrompt(attachments); - if (attText) { - content = content ? content + attText : attText.trim(); - } - - if (!content) return; - - const msgChannel = msg.sessionId || this.channelName; - const sender = msg.senderName || msg.senderType || 'user'; - this._log(`Processing message from ${sender} in ${msgChannel}: ${content.slice(0, 80)}...`); - - await this._autoTitleChannel(msgChannel, content); - await this.sendStatus(msgChannel, 'thinking...'); - - try { - const responseText = await this._runOpencode(content, msgChannel); - - if (responseText) { - await this.sendResponse(msgChannel, responseText); - } else { - await this.sendResponse(msgChannel, 'No response generated. Please try again.'); + _extractSessionId(event) { + const properties = event.properties; + if (properties && typeof properties === 'object') { + if (typeof properties.sessionID === 'string' && properties.sessionID) { + return properties.sessionID; } - } catch (e) { - this._log(`Error handling message: ${e.message}`); - await this.sendError(msgChannel, `Error processing message: ${e.message}`); - } - } - - // ------------------------------------------------------------------ - // JSON output parsing - // ------------------------------------------------------------------ - - /** - * Split a string containing concatenated JSON objects. - */ - static _splitJsonObjects(raw) { - const objects = []; - raw = raw.trim(); - let pos = 0; - while (pos < raw.length) { - if (' \t\r\n'.includes(raw[pos])) { pos++; continue; } - if (raw[pos] !== '{') { pos++; continue; } - // Find matching brace - let depth = 0; - let inStr = false; - let escape = false; - let start = pos; - for (let i = pos; i < raw.length; i++) { - const ch = raw[i]; - if (escape) { escape = false; continue; } - if (ch === '\\' && inStr) { escape = true; continue; } - if (ch === '"') { inStr = !inStr; continue; } - if (inStr) continue; - if (ch === '{') depth++; - else if (ch === '}') { - depth--; - if (depth === 0) { - try { - const obj = JSON.parse(raw.slice(start, i + 1)); - if (typeof obj === 'object' && obj !== null) objects.push(obj); - } catch {} - pos = i + 1; - break; - } - } - if (i === raw.length - 1) pos = raw.length; // no match, skip + const info = properties.info; + if (info && typeof info === 'object' && typeof info.id === 'string' && info.id) { + return info.id; } - if (depth !== 0) break; // unbalanced, stop } - return objects; + return ''; } - /** - * Extract user-visible text from a single opencode JSON event. - */ - static _extractTextFromEvent(event) { - const eventType = event.type || ''; - if (['step_start', 'step_finish', 'tool_use'].includes(eventType)) return null; - - const part = event.part; - if (part && typeof part === 'object') { - const text = part.text || part.content || ''; - if (text) return text; + _toolStatusText(part) { + const toolName = part.tool || 'tool'; + const state = part.state || {}; + const status = state.status; + const title = firstText(state.title || state.metadata || '').trim(); + if (status === 'running') return title || `Using tool: \`${toolName}\``; + if (status === 'completed') return title || `Completed tool: \`${toolName}\``; + if (status === 'error') { + const error = firstText(state.error).trim(); + return error || `Tool failed: \`${toolName}\``; } - - const item = event.item || event; - const text = item.text || item.content || ''; - return text || null; + return `Using tool: \`${toolName}\``; } - /** - * Extract human-readable text from opencode --format json output. - */ - static _extractTextFromJson(raw) { - const events = OpenCodeAdapter._splitJsonObjects(raw); - if (!events.length) return raw.trim(); - - const texts = []; - for (const event of events) { - const text = OpenCodeAdapter._extractTextFromEvent(event); - if (text) texts.push(text); + _buildOpencodeCmd(prompt, channelName, modelRef) { + if (!this._opencodeBinary) { + throw new Error( + "opencode CLI not found. Install it with 'openagents install opencode' or ensure 'opencode' is on PATH." + ); } - return texts.length ? texts.join('\n').trim() : raw.trim(); - } - /** - * Extract and persist session_id from OpenCode JSON events. - */ - _persistSessionId(channel, rawOutput) { - const events = OpenCodeAdapter._splitJsonObjects(rawOutput); - let sessionId = null; - for (const event of events) { - let sid = event.sessionID; - if (!sid && event.session && typeof event.session === 'object') { - sid = event.session.id; - } - if (!sid && event.part && typeof event.part === 'object') { - sid = event.part.sessionID; - } - if (sid && typeof sid === 'string') sessionId = sid; - } - - if (sessionId) { - const prev = this._channelSessions[channel]; - this._channelSessions[channel] = sessionId; - this._saveSessions(); - if (prev !== sessionId) { - this._log(`OpenCode session for channel ${channel}: ${sessionId}`); - } - } + const cmd = [ + this._opencodeBinary, + 'run', + '--format', + 'json', + '--model', + modelRef, + ]; + const sessionId = this._channelSessions[channelName]; + if (sessionId) cmd.push('--session', sessionId); + cmd.push(`${this._buildSystemContext(channelName)}\n\n---\n\n${prompt}`); + return cmd; } - // ------------------------------------------------------------------ - // Subprocess execution - // ------------------------------------------------------------------ - - _runOpencode(content, msgChannel) { - const binary = this._opencodeBinary || this._findOpencodeBinary(); - if (binary) this._opencodeBinary = binary; - if (!binary) { - return Promise.reject(new Error( - 'opencode CLI not found. Install with: npm install -g opencode-ai@latest' - )); - } - - const cmd = [binary, 'run', '--format', 'json', '--dir', this.agentHome]; - - const sessionId = this._channelSessions[msgChannel]; - let fullPrompt; - if (sessionId) { - fullPrompt = content; - cmd.push('--session', sessionId); - } else { - this._ensureWorkspaceSkill(msgChannel); - const context = this._buildSystemContext(msgChannel); - fullPrompt = `${context}\n\n---\n\n${content}`; - } - - this._log(`CLI: ${binary} ${cmd.slice(1, 5).join(' ')} ...`); - - const spawnEnv = { ...(this.agentEnv || process.env) }; + _runOpencode(prompt, channelName) { + const { env, modelRef } = this._runtimeEnv(channelName); + const cmd = this._buildOpencodeCmd(prompt, channelName, modelRef); let spawnBinary = cmd[0]; let spawnArgs = cmd.slice(1); if (IS_WINDOWS && spawnBinary.toLowerCase().endsWith('.cmd')) { spawnArgs = ['/C', spawnBinary, ...spawnArgs]; - spawnBinary = process.env.COMSPEC || 'cmd.exe'; + spawnBinary = process.env.ComSpec || process.env.COMSPEC || 'cmd.exe'; } return new Promise((resolve, reject) => { const proc = spawn(spawnBinary, spawnArgs, { - stdio: ['pipe', 'pipe', 'pipe'], - env: spawnEnv, - cwd: this.agentHome, - timeout: 300000, // 5 minutes + stdio: ['ignore', 'pipe', 'pipe'], + env, + cwd: this.workingDir, + timeout: 300000, }); - let stdout = ''; + let lineBuffer = ''; let stderr = ''; + const assistantMessageIds = new Set(); + const assistantParts = {}; + let lastStatus = ''; + + const processLine = (rawLine) => { + const raw = rawLine.trim(); + if (!raw) return; + + let event; + try { + event = JSON.parse(raw); + } catch { + return; + } + + const sessionId = this._extractSessionId(event); + if (sessionId) { + this._channelSessions[channelName] = sessionId; + this._saveSessions(); + } + + const eventType = event.type || ''; + const properties = event.properties || {}; + + if (eventType === 'message.updated') { + const info = properties.info || {}; + if (info.role === 'assistant' && info.id) assistantMessageIds.add(info.id); + return; + } + + if (eventType === 'message.part.updated') { + const part = properties.part || {}; + const partType = part.type; + const messageId = part.messageID; + + if (partType === 'tool') { + const statusText = this._toolStatusText(part); + if (statusText && statusText !== lastStatus) { + lastStatus = statusText; + void this.sendStatus(channelName, statusText); + } + return; + } + + if (partType === 'patch') { + const files = part.files || []; + if (files.length > 0) { + const summary = files.slice(0, 5).map((name) => `\`${name}\``).join(', '); + void this.sendStatus(channelName, `Editing: ${summary}`); + } + return; + } + + if (partType === 'text' && (assistantMessageIds.has(messageId) || assistantMessageIds.size === 0)) { + let text = properties.delta || part.text || ''; + if (!text && part.id && assistantParts[part.id]) { + text = assistantParts[part.id]; + } + if (text) { + if (properties.delta && part.id && assistantParts[part.id]) { + assistantParts[part.id] += String(text); + } else if (part.id) { + assistantParts[part.id] = String(text); + } + } + return; + } + } + + if (eventType === 'command.executed') { + const name = properties.name || 'command'; + const argumentsText = String(properties.arguments || '').trim(); + void this.sendStatus(channelName, `Running: \`${`${name} ${argumentsText}`.trim()}\``); + return; + } + + if (eventType === 'file.edited') { + const fileName = properties.file || ''; + if (fileName) void this.sendStatus(channelName, `Edited: \`${fileName}\``); + return; + } - if (proc.stdout) proc.stdout.on('data', (d) => { stdout += d; }); - if (proc.stderr) proc.stderr.on('data', (d) => { stderr += d; }); + if (eventType === 'session.compacted') { + void this.sendStatus(channelName, 'Compacting conversation...'); + return; + } - // Send the prompt via stdin - if (proc.stdin) { - proc.stdin.write(fullPrompt, 'utf-8'); - proc.stdin.end(); + if (eventType === 'session.error') { + const error = firstText(properties.error).trim(); + if (error) void this.sendError(channelName, error.slice(0, 1200)); + } + }; + + if (proc.stdout) { + proc.stdout.on('data', (chunk) => { + lineBuffer += chunk.toString('utf-8'); + const lines = lineBuffer.split(/\r?\n/); + lineBuffer = lines.pop() || ''; + for (const line of lines) processLine(line); + }); } - proc.on('error', (err) => reject(err)); - proc.on('exit', (code) => { - stdout = stdout.trim(); - stderr = stderr.trim(); + if (proc.stderr) { + proc.stderr.on('data', (chunk) => { + stderr += chunk.toString('utf-8'); + }); + } + proc.on('error', reject); + proc.on('close', (code) => { + if (lineBuffer.trim()) processLine(lineBuffer); + const stderrText = stderr.trim(); if (code !== 0) { - this._log(`opencode exited with code ${code}: ${stderr.slice(0, 300)}`); + reject(new Error(stderrText || 'opencode exited with an error')); + return; } - if (stdout) { - this._persistSessionId(msgChannel, stdout); - resolve(OpenCodeAdapter._extractTextFromJson(stdout)); - } else { - if (stderr) { - this._log(`opencode stderr: ${stderr.slice(0, 300)}`); - } - resolve(''); - } + const response = Object.values(assistantParts) + .map((text) => text.trim()) + .filter(Boolean) + .join('\n') + .trim() || stderrText; + resolve(response); }); }); } + + async _handleMessage(msg) { + let content = (msg.content || '').trim(); + const attachments = msg.attachments || []; + const attText = formatAttachmentsForPrompt(attachments); + if (attText) content = content ? content + attText : attText.trim(); + if (!content) return; + + const env = this.agentEnv || process.env; + const msgChannel = msg.sessionId || this.channelName; + if (!(env.LLM_API_KEY || env.OPENAI_API_KEY || env.ANTHROPIC_API_KEY)) { + await this.sendError( + msgChannel, + 'OpenCode is not configured. Set LLM_API_KEY, OPENAI_API_KEY, or ANTHROPIC_API_KEY and restart the agent.' + ); + return; + } + + const sender = msg.senderName || msg.senderType || 'user'; + this._log(`Processing message from ${sender} in ${msgChannel}: ${content.slice(0, 80)}...`); + await this._autoTitleChannel(msgChannel, content); + await this.sendStatus(msgChannel, 'thinking...'); + + try { + const responseText = await this._runOpencode(content, msgChannel); + await this.sendResponse(msgChannel, responseText || 'No response generated. Please try again.'); + } catch (e) { + this._log(`Error handling message: ${e.message}`); + await this.sendError(msgChannel, `Error processing message: ${e.message}`); + } + } } module.exports = OpenCodeAdapter; diff --git a/packages/agent-connector/src/adapters/utils.js b/packages/agent-connector/src/adapters/utils.js index fd4bb666..885a20e0 100644 --- a/packages/agent-connector/src/adapters/utils.js +++ b/packages/agent-connector/src/adapters/utils.js @@ -6,6 +6,10 @@ 'use strict'; +const fs = require('fs'); +const path = require('path'); +const { whichBinary } = require('../paths'); + const SESSION_DEFAULT_RE = /^(Session \d+|session-[0-9a-f]+|channel-[0-9a-f]+)$/; /** @@ -76,8 +80,127 @@ function formatAttachmentsForPrompt(attachments) { return lines.join('\n'); } +/** + * Find an executable on PATH, preferring Windows wrappers when needed. + */ +function findExecutable(...names) { + if (!names || names.length === 0) return null; + + if (process.platform === 'win32') { + for (const name of names) { + for (const candidate of [`${name}.cmd`, `${name}.exe`, name]) { + const found = whichBinary(candidate); + if (found) return found; + } + } + return null; + } + + for (const name of names) { + const found = whichBinary(name); + if (found) return found; + } + return null; +} + +/** + * Return an env dict with HOME-style variables pointed at a runtime dir. + */ +function ensureRuntimeEnvHome(env, homeDir) { + const updated = { ...(env || process.env) }; + const home = String(homeDir); + updated.HOME = home; + if (process.platform === 'win32') { + updated.USERPROFILE = home; + } + return updated; +} + +/** + * Create parent directories and write JSON payload to disk. + */ +function writeJsonFile(filePath, payload) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf-8'); + return filePath; +} + +/** + * Best-effort extraction of human-readable text from nested event payloads. + */ +function firstText(value) { + if (value === null || value === undefined) return ''; + if (typeof value === 'string') return value; + if (Array.isArray(value)) { + return value.map((item) => firstText(item)).filter(Boolean).join('\n').trim(); + } + if (typeof value === 'object') { + for (const key of ['text', 'delta', 'content', 'message', 'result', 'output', 'title', 'arguments']) { + const extracted = firstText(value[key]); + if (extracted) return extracted; + } + return ''; + } + return String(value); +} + +/** + * Resolve how to invoke this package's CLI from child processes. + */ +function getCliInvocation() { + return { + command: process.execPath, + args: [path.join(__dirname, '..', '..', 'bin', 'agent-connector.js')], + }; +} + +/** + * Build an MCP server config entry for the OpenAgents workspace server. + */ +function buildWorkspaceMcpServer( + workspaceId, + channelName, + agentName, + endpoint, + token, + { serverName = 'openagents-workspace', disableFiles = false, disableBrowser = false } = {} +) { + const cli = getCliInvocation(); + const args = [ + ...cli.args, + 'mcp-server', + '--workspace-id', + workspaceId, + '--channel-name', + channelName, + '--agent-name', + agentName, + '--endpoint', + endpoint, + ]; + if (disableFiles) args.push('--disable-files'); + if (disableBrowser) args.push('--disable-browser'); + + return { + [serverName]: { + type: 'stdio', + command: cli.command, + args, + env: { + OA_WORKSPACE_TOKEN: token, + }, + }, + }; +} + module.exports = { SESSION_DEFAULT_RE, generateSessionTitle, formatAttachmentsForPrompt, + findExecutable, + ensureRuntimeEnvHome, + writeJsonFile, + firstText, + getCliInvocation, + buildWorkspaceMcpServer, }; diff --git a/packages/agent-connector/test/adapters.test.js b/packages/agent-connector/test/adapters.test.js new file mode 100644 index 00000000..0c93358a --- /dev/null +++ b/packages/agent-connector/test/adapters.test.js @@ -0,0 +1,188 @@ +'use strict'; + +const { describe, it, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const events = require('events'); + +const { Registry } = require('../src/registry'); + +const childProcess = require('child_process'); +const originalSpawn = childProcess.spawn; + +function loadAdapter(modulePath) { + delete require.cache[require.resolve(modulePath)]; + return require(modulePath); +} + +function makeFakeProcess(stdoutChunks = [], { stderr = '', code = 0 } = {}) { + const proc = new events.EventEmitter(); + proc.stdout = new events.EventEmitter(); + proc.stderr = new events.EventEmitter(); + + process.nextTick(() => { + for (const chunk of stdoutChunks) { + proc.stdout.emit('data', Buffer.from(chunk)); + } + if (stderr) proc.stderr.emit('data', Buffer.from(stderr)); + proc.emit('exit', code); + proc.emit('close', code); + }); + + return proc; +} + +afterEach(() => { + childProcess.spawn = originalSpawn; +}); + +describe('adapter registry alignment', () => { + it('cursor registry entry matches the CLI-based runtime', () => { + const reg = new Registry(path.join(os.tmpdir(), 'ac-registry-adapters')); + const entry = reg.getEntry('cursor'); + + assert.equal(entry.install.binary, 'cursor-agent'); + assert.ok(entry.env_config.find((field) => field.name === 'CURSOR_API_KEY')); + assert.equal(entry.check_ready.saved_env_key, 'CURSOR_API_KEY'); + }); +}); + +describe('CursorAdapter', () => { + it('builds a cursor-agent command with model and session resume', () => { + const CursorAdapter = loadAdapter('../src/adapters/cursor'); + const adapter = new CursorAdapter({ + workspaceId: 'ws-1', + channelName: 'general', + token: 'tok', + agentName: 'cursor-bot', + endpoint: 'https://workspace-endpoint.openagents.org', + agentEnv: { + CURSOR_API_KEY: 'cursor-key', + CURSOR_MODEL: 'gpt-5', + }, + workingDir: '/tmp/project', + }); + + adapter._cursorBinary = '/usr/local/bin/cursor-agent'; + adapter._channelSessions.general = 'session-123'; + + const cmd = adapter._buildCursorCmd('hello world', 'general'); + assert.equal(cmd[0], '/usr/local/bin/cursor-agent'); + assert.ok(cmd.includes('--trust')); + assert.ok(cmd.includes('--force')); + assert.ok(cmd.includes('--resume')); + assert.ok(cmd.includes('session-123')); + assert.ok(cmd.includes('--model')); + assert.ok(cmd.includes('gpt-5')); + assert.ok(cmd.includes('--workspace')); + assert.ok(cmd.includes('/tmp/project')); + }); + + it('runs cursor with the requested working directory and runtime env', async () => { + let captured = null; + childProcess.spawn = (bin, args, opts) => { + captured = { bin, args, opts }; + return makeFakeProcess([ + JSON.stringify({ type: 'result', result: { text: 'Cursor says hi' } }) + '\n', + ]); + }; + + const CursorAdapter = loadAdapter('../src/adapters/cursor'); + const adapter = new CursorAdapter({ + workspaceId: 'ws-2', + channelName: 'general', + token: 'tok', + agentName: 'cursor-bot', + endpoint: 'https://workspace-endpoint.openagents.org', + agentEnv: { + CURSOR_API_KEY: 'cursor-key', + CURSOR_MODEL: 'gpt-5', + }, + workingDir: '/tmp/openagents-project', + }); + adapter._cursorBinary = '/usr/local/bin/cursor-agent'; + + const response = await adapter._runCursor('hello', 'general'); + assert.equal(response, 'Cursor says hi'); + assert.equal(captured.opts.cwd, '/tmp/openagents-project'); + assert.equal(captured.opts.env.CURSOR_API_KEY, 'cursor-key'); + + const mcpPath = path.join( + os.homedir(), + '.openagents', + 'runtime', + 'cursor', + `${adapter.workspaceId}_${adapter.agentName}`, + 'home', + '.cursor', + 'mcp.json' + ); + assert.ok(fs.existsSync(mcpPath), 'cursor runtime should write mcp.json'); + }); +}); + +describe('OpenCodeAdapter', () => { + it('builds runtime env from provider credentials', () => { + const OpenCodeAdapter = loadAdapter('../src/adapters/opencode'); + const adapter = new OpenCodeAdapter({ + workspaceId: 'ws-3', + channelName: 'general', + token: 'tok', + agentName: 'opencode-bot', + endpoint: 'https://workspace-endpoint.openagents.org', + agentEnv: { + OPENAI_API_KEY: 'openai-key', + LLM_MODEL: 'gpt-5.4', + }, + workingDir: '/tmp/project-opencode', + }); + + const { env, modelRef } = adapter._runtimeEnv('general'); + assert.equal(modelRef, 'openai/gpt-5.4'); + assert.ok(env.OPENCODE_CONFIG_CONTENT.includes('"openagents-workspace"')); + assert.ok(env.XDG_CONFIG_HOME.includes(path.join('.openagents', 'runtime', 'opencode'))); + }); + + it('runs opencode in the configured working directory', async () => { + let captured = null; + childProcess.spawn = (bin, args, opts) => { + captured = { bin, args, opts }; + return makeFakeProcess([ + JSON.stringify({ + type: 'message.updated', + properties: { info: { role: 'assistant', id: 'msg-1' } }, + }) + '\n', + JSON.stringify({ + type: 'message.part.updated', + properties: { + delta: 'OpenCode says hi', + part: { type: 'text', id: 'part-1', messageID: 'msg-1' }, + }, + }) + '\n', + ]); + }; + + const OpenCodeAdapter = loadAdapter('../src/adapters/opencode'); + const adapter = new OpenCodeAdapter({ + workspaceId: 'ws-4', + channelName: 'general', + token: 'tok', + agentName: 'opencode-bot', + endpoint: 'https://workspace-endpoint.openagents.org', + agentEnv: { + OPENAI_API_KEY: 'openai-key', + LLM_MODEL: 'gpt-5.4', + }, + workingDir: '/tmp/openagents-repo', + }); + adapter._opencodeBinary = '/usr/local/bin/opencode'; + + const response = await adapter._runOpencode('hello', 'general'); + assert.equal(response, 'OpenCode says hi'); + assert.equal(captured.opts.cwd, '/tmp/openagents-repo'); + assert.ok(captured.args.includes('--model')); + assert.ok(captured.opts.env.OPENCODE_CONFIG_CONTENT.includes('"openagents-workspace"')); + }); +}); diff --git a/packages/agent-connector/test/cli.test.js b/packages/agent-connector/test/cli.test.js index c94fc783..a2775b7d 100644 --- a/packages/agent-connector/test/cli.test.js +++ b/packages/agent-connector/test/cli.test.js @@ -26,7 +26,7 @@ function runWithConfig(tmpDir, ...args) { describe('CLI', () => { it('help', () => { const out = run('help'); - assert.ok(out.includes('Usage: agent-connector')); + assert.ok(out.includes('Usage: agn')); assert.ok(out.includes('up')); assert.ok(out.includes('down')); assert.ok(out.includes('search')); @@ -34,12 +34,12 @@ describe('CLI', () => { it('--help flag', () => { const out = run('--help'); - assert.ok(out.includes('Usage: agent-connector')); + assert.ok(out.includes('Usage: agn')); }); it('version', () => { const out = run('version'); - assert.ok(out.includes('@openagents-org/agent-connector')); + assert.ok(out.includes('@openagents-org/agent-launcher')); const pkg = require('../package.json'); assert.ok(out.includes(pkg.version)); }); diff --git a/packages/agent-connector/test/installer.test.js b/packages/agent-connector/test/installer.test.js index 3c025117..fd7ae320 100644 --- a/packages/agent-connector/test/installer.test.js +++ b/packages/agent-connector/test/installer.test.js @@ -81,10 +81,9 @@ describe('Installer', () => { it('_deriveUninstallCommand handles npm', () => { const inst = new Installer(mockRegistry, tmpDir); - assert.equal( - inst._deriveUninstallCommand('npm install -g testpkg@latest'), - 'npm uninstall -g testpkg' - ); + const cmd = inst._deriveUninstallCommand('npm install -g testpkg@latest'); + assert.ok(cmd.startsWith('npm uninstall')); + assert.ok(cmd.includes('testpkg')); }); it('_deriveUninstallCommand handles pip', () => { diff --git a/packages/launcher/package-lock.json b/packages/launcher/package-lock.json index 9ea9c206..ba8d394e 100644 --- a/packages/launcher/package-lock.json +++ b/packages/launcher/package-lock.json @@ -1,12 +1,12 @@ { "name": "openagents-launcher", - "version": "0.6.0", + "version": "0.6.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openagents-launcher", - "version": "0.6.0", + "version": "0.6.9", "license": "MIT", "devDependencies": { "electron": "^33.0.0", diff --git a/packages/launcher/package.json b/packages/launcher/package.json index c875a31f..39312166 100644 --- a/packages/launcher/package.json +++ b/packages/launcher/package.json @@ -7,7 +7,9 @@ "start": "electron .", "dev": "electron . --remote-debugging-port=9222 --remote-allow-origins=*", "build:win": "electron-builder --win --publish never", - "build:mac": "electron-builder --mac --publish never", + "build:mac": "electron-builder --mac --x64 --arm64 --publish never", + "build:mac:x64": "electron-builder --mac --x64 --publish never", + "build:mac:arm64": "electron-builder --mac --arm64 --publish never", "build:linux": "electron-builder --linux --publish never", "build:all": "electron-builder --win --mac --linux --publish never", "dist": "electron-builder --publish always" diff --git a/packages/launcher/src/main/agent-manager.js b/packages/launcher/src/main/agent-manager.js index af0c34c0..9e9cc659 100644 --- a/packages/launcher/src/main/agent-manager.js +++ b/packages/launcher/src/main/agent-manager.js @@ -38,6 +38,13 @@ class AgentManager { } } + getSupportedAgentTypes() { + const supported = core?.adapters?.ADAPTER_MAP + ? Object.keys(core.adapters.ADAPTER_MAP) + : []; + return supported.sort(); + } + /** Reload core library after install/update */ reloadCore() { // Clear require cache for global path @@ -92,6 +99,11 @@ class AgentManager { async addAgent(agentConfig) { const name = agentConfig.name; const type = agentConfig.type || 'openclaw'; + const supportedTypes = this.getSupportedAgentTypes(); + + if (supportedTypes.length > 0 && !supportedTypes.includes(type)) { + throw new Error(`Agent type '${type}' is not supported in Launcher yet. Supported: ${supportedTypes.join(', ')}`); + } this._connector.addAgent({ name, type, role: 'worker' }); diff --git a/packages/launcher/src/main/main.js b/packages/launcher/src/main/main.js index dea14a8a..42823806 100644 --- a/packages/launcher/src/main/main.js +++ b/packages/launcher/src/main/main.js @@ -495,6 +495,7 @@ function setupIPC() { // Agent CRUD ipcMain.handle('agents:list', () => agentManager.getAgents()); + ipcMain.handle('agents:supported-types', () => agentManager.getSupportedAgentTypes()); ipcMain.handle('agents:add', (_e, config) => agentManager.addAgent(config)); ipcMain.handle('agents:remove', (_e, name) => agentManager.removeAgent(name)); ipcMain.handle('agents:update', (_e, name, config) => agentManager.updateAgent(name, config)); diff --git a/packages/launcher/src/main/preload.js b/packages/launcher/src/main/preload.js index 3b624bd3..bfbd5647 100644 --- a/packages/launcher/src/main/preload.js +++ b/packages/launcher/src/main/preload.js @@ -8,6 +8,7 @@ contextBridge.exposeInMainWorld('api', { // Agents listAgents: () => ipcRenderer.invoke('agents:list'), + getSupportedAgentTypes: () => ipcRenderer.invoke('agents:supported-types'), addAgent: (config) => ipcRenderer.invoke('agents:add', config), removeAgent: (name) => ipcRenderer.invoke('agents:remove', name), updateAgent: (name, config) => ipcRenderer.invoke('agents:update', name, config), diff --git a/packages/launcher/src/renderer/renderer.js b/packages/launcher/src/renderer/renderer.js index aee9f198..1e94750b 100644 --- a/packages/launcher/src/renderer/renderer.js +++ b/packages/launcher/src/renderer/renderer.js @@ -126,14 +126,17 @@ async function refreshDashboard() { const loginBasedTypes = ['claude', 'copilot', 'gemini', 'amp']; const isLoginBased = loginBasedTypes.includes(a.type); const isConnected = !!a.network; + const isUnsupported = isUnsupportedAgent(a); const wsLabel = a.network ? (a.networkName && a.networkName !== a.network ? `${a.network} (${a.networkName})` : a.network) : ''; // Status indicators - const configStatus = (hasApiKey || isLoginBased) - ? 'LLM configured' - : 'LLM not configured'; + const configStatus = isUnsupported + ? 'Launcher unsupported' + : ((hasApiKey || isLoginBased) + ? 'LLM configured' + : 'LLM not configured'); const connectStatus = isConnected ? `Connected: ${esc(wsLabel)}` : 'Not connected'; @@ -591,13 +594,23 @@ async function showNewAgentDialog() { showModal(`
Installed but not supported in Launcher yet: ${esc(unsupportedInstalled.map((c) => c.label || c.name).join(', '))}
` + : ''; showModal(`No agent runtimes installed. Install one first.
+No Launcher-supported agent runtimes installed. Install one first.
+ ${extraHint} ${esc(a.type)} - ${hasKey ? '🔑 API key set' : isLoginBased ? '🔑 Configured (login)' : '⚠ No API key'} + ${unsupported ? 'Launcher unsupported' : hasKey ? '🔑 API key set' : isLoginBased ? '🔑 Configured (login)' : '⚠ No API key'} ${envDisplay.length ? ' · ' + envDisplay.map(esc).join(' · ') : ''} ${a.lastError ? `${esc(a.lastError)}` : ''} @@ -1145,6 +1159,11 @@ function statusClass(state) { return 'offline'; } +function isUnsupportedAgent(agent) { + const msg = String(agent?.lastError || ''); + return msg.includes('Unknown agent type:'); +} + // ---- D25: Activity log ---- const activityEntries = []; diff --git a/packages/launcher/src/renderer/styles.css b/packages/launcher/src/renderer/styles.css index aa33606d..18ed3618 100644 --- a/packages/launcher/src/renderer/styles.css +++ b/packages/launcher/src/renderer/styles.css @@ -617,6 +617,10 @@ h1 { color: var(--warning-text); } +.text-danger { + color: var(--danger-text); +} + .agent-list-actions { display: flex; gap: 6px; @@ -827,7 +831,7 @@ h1 { opacity: 0.2; } -.badge-success-sm, .badge-warning-sm, .badge-muted-sm { +.badge-success-sm, .badge-warning-sm, .badge-muted-sm, .badge-danger-sm { font-size: 10px; padding: 2px 6px; border-radius: 4px; @@ -836,6 +840,7 @@ h1 { .badge-success-sm { background: var(--success-bg); color: var(--success-text); } .badge-warning-sm { background: var(--warning-bg); color: var(--warning-text); } .badge-muted-sm { background: #f0f0f0; color: #888; } +.badge-danger-sm { background: var(--danger-bg); color: var(--danger-text); } .agent-card-info { display: flex; diff --git a/src/openagents/adapters/cursor.py b/src/openagents/adapters/cursor.py index 3773c7ac..76052f40 100644 --- a/src/openagents/adapters/cursor.py +++ b/src/openagents/adapters/cursor.py @@ -1,35 +1,37 @@ """ -Cursor adapter for OpenAgents workspace. +Cursor CLI adapter for OpenAgents workspace. -Bridges Cursor to an OpenAgents workspace via: +Bridges the official ``cursor-agent`` CLI to an OpenAgents workspace via: - Polling loop for incoming messages -- Direct HTTP mode for OpenAI-compatible LLM APIs (primary) -- Workspace context injected via system prompt - -In direct mode (when OPENAI_API_KEY and OPENAI_BASE_URL are set), -the adapter calls the chat completions API directly — no Cursor -CLI binary or subscription needed. +- Cursor CLI subprocess execution with JSON output +- OpenAgents MCP server exposed through Cursor's global ``mcp.json`` +- Per-channel session resume for conversation continuity """ +import asyncio import json import logging import os -from typing import Optional - -import aiohttp +from pathlib import Path +from typing import Any, Optional from openagents.adapters.base import BaseAdapter +from openagents.adapters.utils import ( + ensure_runtime_env_home, + find_executable, + first_text, + format_attachments_for_prompt, + write_json_file, + build_workspace_mcp_server, +) from openagents.adapters.workspace_prompt import build_openclaw_system_prompt from openagents.workspace_client import DEFAULT_ENDPOINT logger = logging.getLogger(__name__) -# Max conversation history entries to keep in memory -MAX_HISTORY_ENTRIES = 50 - class CursorAdapter(BaseAdapter): - """Connects Cursor to an OpenAgents workspace.""" + """Connects Cursor CLI to an OpenAgents workspace.""" def __init__( self, @@ -43,32 +45,44 @@ def __init__( ): super().__init__(workspace_id, channel_name, token, agent_name, endpoint) self.disabled_modules = disabled_modules or set() - - # Direct LLM API mode - self._direct_api_key = os.environ.get("OPENAI_API_KEY", "") - self._direct_base_url = os.environ.get("OPENAI_BASE_URL", "").rstrip("/") - self._direct_model = ( - os.environ.get("CURSOR_MODEL", "") - or os.environ.get("OPENCLAW_MODEL", "") + self.working_dir = working_dir + self._cursor_binary = find_executable("cursor-agent", "cursor") + self._cursor_model = os.environ.get("CURSOR_MODEL", "").strip() + self._sessions_file = ( + Path.home() / ".openagents" / "sessions" / f"{workspace_id}_{agent_name}_cursor.json" ) - self._direct_mode = bool(self._direct_api_key and self._direct_base_url) + self._runtime_home = ( + Path.home() / ".openagents" / "runtime" / "cursor" / f"{workspace_id}_{agent_name}" + ) + self._channel_sessions: dict[str, str] = {} + self._load_sessions() - if self._direct_mode: - logger.info( - f"Direct LLM mode: {self._direct_base_url} " - f"model={self._direct_model or 'gpt-4o'}" - ) - else: - logger.warning( - "Cursor adapter started without direct API config. " - "Set OPENAI_API_KEY + OPENAI_BASE_URL for direct mode." - ) + def _load_sessions(self): + try: + if self._sessions_file.exists(): + data = json.loads(self._sessions_file.read_text(encoding="utf-8")) + if isinstance(data, dict): + self._channel_sessions.update( + { + str(channel): str(session_id) + for channel, session_id in data.items() + if session_id + } + ) + except Exception: + logger.debug("Could not load Cursor session state", exc_info=True) - # Conversation history for multi-turn context - self._conversation_history: list[dict] = [] + def _save_sessions(self): + try: + self._sessions_file.parent.mkdir(parents=True, exist_ok=True) + self._sessions_file.write_text( + json.dumps(self._channel_sessions, indent=2), + encoding="utf-8", + ) + except Exception: + logger.debug("Could not save Cursor session state", exc_info=True) def _build_system_prompt(self, channel_name: str) -> str: - """Build workspace context system prompt.""" return build_openclaw_system_prompt( agent_name=self.agent_name, workspace_id=self.workspace_id, @@ -79,117 +93,195 @@ def _build_system_prompt(self, channel_name: str) -> str: disabled_modules=self.disabled_modules, ) + def _cursor_env(self, channel_name: str) -> dict[str, str]: + runtime_home = self._runtime_home + env = ensure_runtime_env_home(os.environ, runtime_home) + env["CURSOR_API_KEY"] = os.environ.get("CURSOR_API_KEY", "") + mcp_config = { + "mcpServers": build_workspace_mcp_server( + self.workspace_id, + channel_name, + self.agent_name, + self.endpoint, + self.token, + disable_files="files" in self.disabled_modules, + disable_browser="browser" in self.disabled_modules, + ) + } + write_json_file(runtime_home / ".cursor" / "mcp.json", mcp_config) + return env + + def _extract_tool_name(self, event: dict[str, Any]) -> str: + for key in ("tool_name", "toolName", "name"): + if isinstance(event.get(key), str) and event[key]: + return event[key] + for container_key in ("tool_call", "tool", "call"): + container = event.get(container_key) + if isinstance(container, dict): + for key in ("name", "tool", "toolName"): + if isinstance(container.get(key), str) and container[key]: + return container[key] + return "" + + def _extract_session_id(self, event: dict[str, Any]) -> str: + for key in ("session_id", "sessionId", "conversation_id", "conversationId"): + value = event.get(key) + if isinstance(value, str) and value: + return value + result = event.get("result") + if isinstance(result, dict): + for key in ("session_id", "sessionId", "conversation_id", "conversationId"): + value = result.get(key) + if isinstance(value, str) and value: + return value + return "" + + def _extract_result_text(self, event: dict[str, Any]) -> str: + candidates = [ + event.get("result"), + event.get("message"), + event.get("content"), + event.get("text"), + event.get("output"), + event.get("assistant_message"), + ] + for candidate in candidates: + text = first_text(candidate).strip() + if text: + return text + return "" + + def _build_cursor_cmd(self, prompt: str, channel_name: str) -> list[str]: + if not self._cursor_binary: + raise FileNotFoundError( + "cursor-agent CLI not found. Install Cursor CLI and ensure 'cursor-agent' is on PATH." + ) + + full_prompt = f"{self._build_system_prompt(channel_name)}\n\n---\n\n{prompt}" + + cmd = [ + self._cursor_binary, + "-p", + full_prompt, + "--print", + "--output-format", + "stream-json", + ] + if self._mode != "plan": + cmd.append("--force") + if self._cursor_model: + cmd.extend(["-m", self._cursor_model]) + + session_id = self._channel_sessions.get(channel_name) + if session_id: + cmd.extend(["--resume", session_id]) + + return cmd + async def _handle_message(self, msg: dict): - """Process a single incoming message.""" content = msg.get("content", "").strip() + attachments = msg.get("attachments", []) + + att_text = format_attachments_for_prompt(attachments) + if att_text: + content = (content + att_text) if content else att_text.strip() if not content: return - msg_channel = msg.get("sessionId") or self.channel_name + if not os.environ.get("CURSOR_API_KEY"): + await self._send_error( + msg.get("sessionId") or self.channel_name, + "Cursor is not configured. Set CURSOR_API_KEY and restart the agent.", + ) + return + msg_channel = msg.get("sessionId") or self.channel_name sender = msg.get("senderName") or msg.get("senderType", "user") logger.info( - f"Processing message from {sender} in channel " - f"{msg_channel}: {content[:80]}..." + "Processing Cursor message from %s in channel %s: %s...", + sender, + msg_channel, + content[:80], ) await self._auto_title_channel(msg_channel, content) await self._send_status(msg_channel, "thinking...") try: - if self._direct_mode: - response_text = await self._call_completion_api( - content, msg_channel - ) - else: - response_text = "" - await self._send_error( - msg_channel, - "Cursor direct API mode not configured. " - "Set OPENAI_API_KEY + OPENAI_BASE_URL.", - ) - return - - if response_text: - self._conversation_history.append( - {"role": "user", "content": content} - ) - self._conversation_history.append( - {"role": "assistant", "content": response_text} - ) - if len(self._conversation_history) > MAX_HISTORY_ENTRIES * 2: - self._conversation_history = ( - self._conversation_history[-MAX_HISTORY_ENTRIES * 2 :] - ) - await self._send_response(msg_channel, response_text) - else: - await self._send_response( - msg_channel, "No response generated. Please try again." - ) - - except Exception as e: - logger.exception(f"Error handling message: {e}") - await self._send_error( - msg_channel, f"Error processing message: {e}" - ) - - async def _call_completion_api( - self, user_message: str, channel: str - ) -> str: - """Call OpenAI-compatible chat completions API directly.""" - system_prompt = self._build_system_prompt(channel) - - messages = [{"role": "system", "content": system_prompt}] - messages.extend(self._conversation_history) - messages.append({"role": "user", "content": user_message}) - - url = f"{self._direct_base_url}/chat/completions" - headers = { - "Content-Type": "application/json", - "Authorization": f"Bearer {self._direct_api_key}", - } - payload = { - "model": self._direct_model or "gpt-4o", - "messages": messages, - "stream": True, - } - - full_text = "" - - async with aiohttp.ClientSession() as session: - async with session.post( - url, - headers=headers, - json=payload, - timeout=aiohttp.ClientTimeout(total=300), - ) as resp: - if resp.status != 200: - body = await resp.text() - raise RuntimeError( - f"LLM API returned {resp.status}: {body[:300]}" - ) - - async for line in resp.content: - line = line.decode("utf-8", errors="replace").strip() - if not line or not line.startswith("data: "): - continue + cmd = self._build_cursor_cmd(content, msg_channel) + except FileNotFoundError as exc: + await self._send_error(msg_channel, str(exc)) + return - data = line[6:] - if data == "[DONE]": - break + env = self._cursor_env(msg_channel) + process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=env, + cwd=self.working_dir, + ) - try: - chunk = json.loads(data) - except json.JSONDecodeError: - continue + final_chunks: list[str] = [] + last_text = "" - choices = chunk.get("choices", []) - if not choices: - continue + try: + assert process.stdout is not None + while True: + line = await process.stdout.readline() + if not line: + break + + raw = line.decode("utf-8", errors="replace").strip() + if not raw: + continue + + try: + event = json.loads(raw) + except json.JSONDecodeError: + logger.debug("Cursor non-JSON line: %s", raw[:200]) + continue + + session_id = self._extract_session_id(event) + if session_id: + self._channel_sessions[msg_channel] = session_id + self._save_sessions() + + event_type = event.get("type", "") + if event_type == "tool_call_started": + tool_name = self._extract_tool_name(event) or "tool" + await self._send_status(msg_channel, f"Using tool: `{tool_name}`") + continue + if event_type in {"tool_call_completed", "tool_call_finished"}: + continue + + text = self._extract_result_text(event) + if text and text != last_text: + last_text = text + if event_type == "result": + final_chunks = [text] + else: + final_chunks.append(text) + + returncode = await process.wait() + stderr_text = "" + if process.stderr is not None: + stderr_text = ( + (await process.stderr.read()).decode("utf-8", errors="replace").strip() + ) - delta = choices[0].get("delta", {}) - text = delta.get("content") - if text: - full_text += text + if returncode != 0: + detail = stderr_text or "cursor-agent exited with an error" + await self._send_error(msg_channel, detail[:1200]) + return - return full_text.strip() + response = "\n".join(chunk for chunk in final_chunks if chunk).strip() + if not response: + response = stderr_text.strip() + if not response: + response = "No response generated. Please try again." + await self._send_response(msg_channel, response) + finally: + if process.returncode is None: + process.kill() + await process.wait() diff --git a/src/openagents/adapters/opencode.py b/src/openagents/adapters/opencode.py new file mode 100644 index 00000000..14b16785 --- /dev/null +++ b/src/openagents/adapters/opencode.py @@ -0,0 +1,419 @@ +""" +OpenCode CLI adapter for OpenAgents workspace. + +Bridges the official ``opencode`` CLI to an OpenAgents workspace via: +- Polling loop for incoming messages +- ``opencode run --format json`` subprocess execution +- Runtime-injected OpenCode config for model/provider/MCP settings +- Per-channel session resume for conversation continuity +""" + +import asyncio +import json +import logging +import os +from pathlib import Path +from typing import Any + +from openagents.adapters.base import BaseAdapter +from openagents.adapters.utils import ( + ensure_runtime_env_home, + find_executable, + first_text, + format_attachments_for_prompt, + resolve_openagents_binary, +) +from openagents.adapters.workspace_prompt import build_openclaw_system_prompt +from openagents.workspace_client import DEFAULT_ENDPOINT + +logger = logging.getLogger(__name__) + + +class OpenCodeAdapter(BaseAdapter): + """Connects OpenCode CLI to an OpenAgents workspace.""" + + def __init__( + self, + workspace_id: str, + channel_name: str, + token: str, + agent_name: str, + endpoint: str = DEFAULT_ENDPOINT, + disabled_modules: set | None = None, + working_dir: str | None = None, + ): + super().__init__(workspace_id, channel_name, token, agent_name, endpoint) + self.disabled_modules = disabled_modules or set() + self.working_dir = working_dir + self._opencode_binary = find_executable("opencode") + self._runtime_root = ( + Path.home() / ".openagents" / "runtime" / "opencode" / f"{workspace_id}_{agent_name}" + ) + self._sessions_file = ( + Path.home() / ".openagents" / "sessions" / f"{workspace_id}_{agent_name}_opencode.json" + ) + self._channel_sessions: dict[str, str] = {} + self._load_sessions() + + def _load_sessions(self): + try: + if self._sessions_file.exists(): + data = json.loads(self._sessions_file.read_text(encoding="utf-8")) + if isinstance(data, dict): + self._channel_sessions.update( + { + str(channel): str(session_id) + for channel, session_id in data.items() + if session_id + } + ) + except Exception: + logger.debug("Could not load OpenCode session state", exc_info=True) + + def _save_sessions(self): + try: + self._sessions_file.parent.mkdir(parents=True, exist_ok=True) + self._sessions_file.write_text( + json.dumps(self._channel_sessions, indent=2), + encoding="utf-8", + ) + except Exception: + logger.debug("Could not save OpenCode session state", exc_info=True) + + def _build_system_prompt(self, channel_name: str) -> str: + return build_openclaw_system_prompt( + agent_name=self.agent_name, + workspace_id=self.workspace_id, + channel_name=channel_name, + endpoint=self.endpoint, + token=self.token, + mode=self._mode, + disabled_modules=self.disabled_modules, + ) + + def _resolve_provider_config(self) -> tuple[str, dict[str, Any]]: + model_name = ( + os.environ.get("OPENCODE_MODEL") + or os.environ.get("LLM_MODEL") + or os.environ.get("OPENCLAW_MODEL") + or "" + ).strip() + llm_base_url = (os.environ.get("LLM_BASE_URL") or os.environ.get("OPENAI_BASE_URL") or "").strip() + llm_api_key = ( + os.environ.get("LLM_API_KEY") + or os.environ.get("OPENAI_API_KEY") + or os.environ.get("ANTHROPIC_API_KEY") + or "" + ).strip() + + if llm_base_url: + model_name = model_name or "gpt-5.4" + provider_id = "openagents-openai-compatible" + return ( + f"{provider_id}/{model_name}", + { + provider_id: { + "npm": "@ai-sdk/openai-compatible", + "name": "OpenAgents OpenAI-compatible", + "options": { + "baseURL": llm_base_url, + "apiKey": llm_api_key, + }, + "models": { + model_name: { + "name": model_name, + } + }, + } + }, + ) + + if os.environ.get("ANTHROPIC_API_KEY") and (model_name.startswith("claude") or not model_name): + model_name = model_name or "claude-sonnet-4-5" + return ( + f"anthropic/{model_name}", + { + "anthropic": { + "options": { + "apiKey": os.environ.get("ANTHROPIC_API_KEY", ""), + } + } + }, + ) + + model_name = model_name or "gpt-5.4" + api_key = os.environ.get("OPENAI_API_KEY") or llm_api_key + return ( + f"openai/{model_name}", + { + "openai": { + "options": { + "apiKey": api_key, + } + } + }, + ) + + def _runtime_env(self, channel_name: str) -> tuple[dict[str, str], str]: + model_ref, provider_cfg = self._resolve_provider_config() + runtime_root = self._runtime_root + runtime_root.mkdir(parents=True, exist_ok=True) + + config = { + "$schema": "https://opencode.ai/config.json", + "model": model_ref, + "provider": provider_cfg, + "permission": ( + { + "*": "allow", + } + if self._mode != "plan" + else { + "*": "allow", + "edit": "deny", + "bash": "deny", + "task": "deny", + } + ), + "mcp": { + "openagents-workspace": { + "type": "local", + "command": [ + resolve_openagents_binary(), + "mcp-server", + "--workspace-id", + self.workspace_id, + "--channel-name", + channel_name, + "--agent-name", + self.agent_name, + "--endpoint", + self.endpoint, + *(["--disable-files"] if "files" in self.disabled_modules else []), + *(["--disable-browser"] if "browser" in self.disabled_modules else []), + ], + "enabled": True, + "environment": { + "OA_WORKSPACE_TOKEN": self.token, + }, + } + }, + } + + env = dict(os.environ) + env["OPENCODE_CONFIG_CONTENT"] = json.dumps(config) + env["XDG_CONFIG_HOME"] = str(runtime_root / "config") + env["XDG_DATA_HOME"] = str(runtime_root / "data") + env["XDG_STATE_HOME"] = str(runtime_root / "state") + env = ensure_runtime_env_home(env, runtime_root / "home") + return env, model_ref + + def _extract_session_id(self, event: dict[str, Any]) -> str: + properties = event.get("properties") + if isinstance(properties, dict): + if isinstance(properties.get("sessionID"), str) and properties["sessionID"]: + return properties["sessionID"] + info = properties.get("info") + if isinstance(info, dict): + session_id = info.get("id") + if isinstance(session_id, str) and session_id: + return session_id + return "" + + def _tool_status_text(self, part: dict[str, Any]) -> str: + tool_name = part.get("tool", "tool") + state = part.get("state") or {} + status = state.get("status") + title = first_text(state.get("title") or state.get("metadata") or "").strip() + if status == "running": + return title or f"Using tool: `{tool_name}`" + if status == "completed": + return title or f"Completed tool: `{tool_name}`" + if status == "error": + error = first_text(state.get("error")).strip() + return error or f"Tool failed: `{tool_name}`" + return f"Using tool: `{tool_name}`" + + def _build_opencode_cmd(self, prompt: str, channel_name: str, model_ref: str) -> list[str]: + if not self._opencode_binary: + raise FileNotFoundError( + "opencode CLI not found. Install it with 'openagents install opencode' or ensure 'opencode' is on PATH." + ) + + full_prompt = f"{self._build_system_prompt(channel_name)}\n\n---\n\n{prompt}" + cmd = [ + self._opencode_binary, + "run", + "--format", + "json", + "--model", + model_ref, + ] + session_id = self._channel_sessions.get(channel_name) + if session_id: + cmd.extend(["--session", session_id]) + cmd.append(full_prompt) + return cmd + + async def _handle_message(self, msg: dict): + content = msg.get("content", "").strip() + attachments = msg.get("attachments", []) + + att_text = format_attachments_for_prompt(attachments) + if att_text: + content = (content + att_text) if content else att_text.strip() + if not content: + return + + if not ( + os.environ.get("LLM_API_KEY") + or os.environ.get("OPENAI_API_KEY") + or os.environ.get("ANTHROPIC_API_KEY") + ): + await self._send_error( + msg.get("sessionId") or self.channel_name, + "OpenCode is not configured. Set LLM_API_KEY, OPENAI_API_KEY, or ANTHROPIC_API_KEY and restart the agent.", + ) + return + + msg_channel = msg.get("sessionId") or self.channel_name + sender = msg.get("senderName") or msg.get("senderType", "user") + logger.info( + "Processing OpenCode message from %s in channel %s: %s...", + sender, + msg_channel, + content[:80], + ) + + await self._auto_title_channel(msg_channel, content) + await self._send_status(msg_channel, "thinking...") + + env, model_ref = self._runtime_env(msg_channel) + try: + cmd = self._build_opencode_cmd(content, msg_channel, model_ref) + except FileNotFoundError as exc: + await self._send_error(msg_channel, str(exc)) + return + + process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=env, + cwd=self.working_dir, + ) + + assistant_message_ids: set[str] = set() + assistant_parts: dict[str, str] = {} + last_status = "" + + try: + assert process.stdout is not None + while True: + line = await process.stdout.readline() + if not line: + break + + raw = line.decode("utf-8", errors="replace").strip() + if not raw: + continue + + try: + event = json.loads(raw) + except json.JSONDecodeError: + logger.debug("OpenCode non-JSON line: %s", raw[:200]) + continue + + session_id = self._extract_session_id(event) + if session_id: + self._channel_sessions[msg_channel] = session_id + self._save_sessions() + + event_type = event.get("type", "") + properties = event.get("properties") or {} + + if event_type == "message.updated": + info = properties.get("info") or {} + if info.get("role") == "assistant" and info.get("id"): + assistant_message_ids.add(info["id"]) + continue + + if event_type == "message.part.updated": + part = properties.get("part") or {} + part_type = part.get("type") + message_id = part.get("messageID") + + if part_type == "tool": + status_text = self._tool_status_text(part) + if status_text and status_text != last_status: + last_status = status_text + await self._send_status(msg_channel, status_text) + continue + + if part_type == "patch": + files = part.get("files") or [] + if files: + await self._send_status( + msg_channel, + "Editing: " + ", ".join(f"`{name}`" for name in files[:5]), + ) + continue + + if part_type == "text" and (message_id in assistant_message_ids or not assistant_message_ids): + text = properties.get("delta") or part.get("text") or "" + if not text and part.get("id") in assistant_parts: + text = assistant_parts[part["id"]] + if text: + if properties.get("delta") and part.get("id") in assistant_parts: + assistant_parts[part["id"]] += str(text) + else: + assistant_parts[part["id"]] = str(text) + continue + + if event_type == "command.executed": + name = properties.get("name", "command") + arguments = properties.get("arguments", "").strip() + summary = f"`{name} {arguments}`".strip() + await self._send_status(msg_channel, f"Running: {summary}") + continue + + if event_type == "file.edited": + file_name = properties.get("file", "") + if file_name: + await self._send_status(msg_channel, f"Edited: `{file_name}`") + continue + + if event_type == "session.compacted": + await self._send_status(msg_channel, "Compacting conversation...") + continue + + if event_type == "session.error": + error = first_text(properties.get("error")).strip() + if error: + await self._send_error(msg_channel, error[:1200]) + continue + + returncode = await process.wait() + stderr_text = "" + if process.stderr is not None: + stderr_text = ( + (await process.stderr.read()).decode("utf-8", errors="replace").strip() + ) + + if returncode != 0: + detail = stderr_text or "opencode exited with an error" + await self._send_error(msg_channel, detail[:1200]) + return + + response = "\n".join( + text.strip() for text in assistant_parts.values() if text.strip() + ).strip() + if not response: + response = stderr_text.strip() + if not response: + response = "No response generated. Please try again." + await self._send_response(msg_channel, response) + finally: + if process.returncode is None: + process.kill() + await process.wait() diff --git a/src/openagents/adapters/utils.py b/src/openagents/adapters/utils.py index 65ee00ff..0bef6493 100644 --- a/src/openagents/adapters/utils.py +++ b/src/openagents/adapters/utils.py @@ -1,6 +1,12 @@ """Shared utilities for adapter implementations.""" +import json +import os +import platform import re -from typing import Optional +import shutil +import sys +from pathlib import Path +from typing import Any, Optional SESSION_DEFAULT_RE = re.compile(r"^(Session \d+|session-[0-9a-f]+|channel-[0-9a-f]+)$") @@ -79,3 +85,128 @@ def format_attachments_for_prompt(attachments: list[dict]) -> Optional[str]: f"use workspace_read_file to read this file" ) return "\n".join(lines) + + +def find_executable(*names: str) -> Optional[str]: + """Find an executable on PATH, preferring Windows wrappers when needed.""" + if not names: + return None + + if platform.system() == "Windows": + for name in names: + for candidate in (f"{name}.cmd", f"{name}.exe", name): + found = shutil.which(candidate) + if found: + return found + return None + + for name in names: + found = shutil.which(name) + if found: + return found + return None + + +def resolve_openagents_binary() -> str: + """Resolve the openagents CLI path for launching the MCP server.""" + candidate = find_executable("openagents") + if candidate: + return candidate + + local_candidate = Path(sys.executable).parent / "openagents" + if local_candidate.exists(): + return str(local_candidate) + + user_bin = Path.home() / ".local" / "bin" / "openagents" + if user_bin.exists(): + return str(user_bin) + + homebrew_bin = Path("/opt/homebrew/bin/openagents") + if homebrew_bin.exists(): + return str(homebrew_bin) + + return "openagents" + + +def build_workspace_mcp_server( + workspace_id: str, + channel_name: str, + agent_name: str, + endpoint: str, + token: str, + *, + server_name: str = "openagents-workspace", + disable_files: bool = False, + disable_browser: bool = False, +) -> dict[str, Any]: + """Build an MCP server config entry for the OpenAgents workspace server.""" + args = [ + "mcp-server", + "--workspace-id", + workspace_id, + "--channel-name", + channel_name, + "--agent-name", + agent_name, + "--endpoint", + endpoint, + ] + if disable_files: + args.append("--disable-files") + if disable_browser: + args.append("--disable-browser") + + return { + server_name: { + "type": "stdio", + "command": resolve_openagents_binary(), + "args": args, + "env": { + "OA_WORKSPACE_TOKEN": token, + }, + }, + } + + +def write_json_file(path: Path, payload: Any) -> Path: + """Create parent directories and write JSON payload to disk.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2), encoding="utf-8") + return path + + +def ensure_runtime_env_home(env: dict[str, str], home_dir: Path) -> dict[str, str]: + """Return an env dict with HOME-style variables pointed at a runtime dir.""" + updated = dict(env) + home = str(home_dir) + updated["HOME"] = home + if platform.system() == "Windows": + updated["USERPROFILE"] = home + return updated + + +def first_text(value: Any) -> str: + """Best-effort extraction of human-readable text from nested event payloads.""" + if value is None: + return "" + if isinstance(value, str): + return value + if isinstance(value, list): + parts = [first_text(item) for item in value] + return "\n".join(part for part in parts if part).strip() + if isinstance(value, dict): + for key in ( + "text", + "delta", + "content", + "message", + "result", + "output", + "title", + "arguments", + ): + extracted = first_text(value.get(key)) + if extracted: + return extracted + return "" + return str(value) diff --git a/src/openagents/registry/cursor.yaml b/src/openagents/registry/cursor.yaml index 975630bc..4ba2d91c 100644 --- a/src/openagents/registry/cursor.yaml +++ b/src/openagents/registry/cursor.yaml @@ -1,12 +1,12 @@ name: cursor label: Cursor -description: AI-powered code editor with agent mode CLI +description: Cursor official CLI agent homepage: https://cursor.com tags: [coding, editor, cli, ai] builtin: true install: - binary: cursor + binary: cursor-agent requires: [] macos: "curl https://cursor.com/install -fsSL | bash" linux: "curl https://cursor.com/install -fsSL | bash" @@ -20,21 +20,16 @@ launch: args: [] env_config: - - name: OPENAI_API_KEY - description: API key for LLM inference + - name: CURSOR_API_KEY + description: Cursor API key required: true password: true + - name: CURSOR_MODEL + description: Optional Cursor model override + required: false + placeholder: "claude-4-sonnet, gpt-5, auto, etc." check_ready: - env_vars: [OPENAI_API_KEY] - saved_env_key: OPENAI_API_KEY - not_ready_message: "No API key — press e to configure" - -resolve_env: - rules: - - from: LLM_API_KEY - to: OPENAI_API_KEY - - from: LLM_BASE_URL - to: OPENAI_BASE_URL - - from: LLM_MODEL - to: CURSOR_MODEL + env_vars: [CURSOR_API_KEY] + saved_env_key: CURSOR_API_KEY + not_ready_message: "Cursor API key not configured — press e to configure" diff --git a/src/openagents/registry/opencode.yaml b/src/openagents/registry/opencode.yaml index 2c37d4d4..e3c6f232 100644 --- a/src/openagents/registry/opencode.yaml +++ b/src/openagents/registry/opencode.yaml @@ -3,6 +3,7 @@ label: OpenCode description: Open-source terminal-native AI coding agent homepage: https://opencode.ai tags: [coding, open-source, cli, terminal] +builtin: true install: binary: opencode @@ -10,3 +11,41 @@ install: macos: "npm install -g opencode-ai@latest" linux: "npm install -g opencode-ai@latest" windows: "npm install -g opencode-ai@latest" + +adapter: + module: openagents.adapters.opencode + class: OpenCodeAdapter + +launch: + args: [] + +env_config: + - name: LLM_API_KEY + description: LLM provider API key + required: false + password: true + - name: OPENAI_API_KEY + description: OpenAI API key + required: false + password: true + - name: ANTHROPIC_API_KEY + description: Anthropic API key + required: false + password: true + - name: LLM_BASE_URL + description: OpenAI-compatible base URL + required: false + placeholder: "https://api.openai.com/v1" + - name: LLM_MODEL + description: Model name + required: false + placeholder: "gpt-5.4, claude-sonnet-4-5, deepseek-chat, etc." + - name: OPENCODE_MODEL + description: Optional OpenCode model override + required: false + placeholder: "openai/gpt-5.4 or anthropic/claude-sonnet-4-5" + +check_ready: + env_vars: [LLM_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY] + saved_env_key: LLM_API_KEY + not_ready_message: "No model provider credentials configured — press e to configure" diff --git a/tests/platform/config.yaml b/tests/platform/config.yaml index 20aeec8c..eb3742c7 100644 --- a/tests/platform/config.yaml +++ b/tests/platform/config.yaml @@ -34,8 +34,15 @@ agents: - OPENAI_API_KEY cursor: - binary: cursor - model: claude-sonnet-4-6 + binary: cursor-agent + model: auto + credential_env: + - CURSOR_API_KEY + + opencode: + binary: opencode + model: gpt-5.4 credential_env: - LLM_API_KEY - OPENAI_API_KEY + - ANTHROPIC_API_KEY diff --git a/tests/platform/install/test_cursor.py b/tests/platform/install/test_cursor.py index e5ac98c0..4baa5dc9 100644 --- a/tests/platform/install/test_cursor.py +++ b/tests/platform/install/test_cursor.py @@ -1,17 +1,15 @@ """ Platform install tests for Cursor agent. -Cursor uses direct API mode — it calls the OpenAI-compatible chat -completions API directly without needing the Cursor CLI binary. -The install step verifies the registry entry loads and that the SDK -recognises the agent type. +Tests the real user experience: `openagents install cursor` +and verifies the official `cursor-agent` binary is available. Run: pytest tests/platform/install/test_cursor.py -v """ -import shutil import subprocess +import shutil import pytest @@ -34,15 +32,19 @@ def test_openagents_cli_available(self, has_openagents): ) def test_openagents_install_cursor(self): - """`openagents install cursor --yes` should succeed. - - Cursor uses direct API mode so the install command may not - install a binary, but should still succeed. - """ + """`openagents install cursor --yes` should succeed.""" try: - result = run_openagents("install", AGENT_TYPE, "--yes", timeout=60) + result = run_openagents("install", AGENT_TYPE, "--yes", timeout=300) except subprocess.TimeoutExpired: - pytest.skip("Install timed out — Cursor uses direct API mode, no binary expected.") + if shutil.which(BINARY_NAME) is not None: + pytest.skip( + f"Install timed out at 300s but '{BINARY_NAME}' " + f"is on PATH — likely succeeded." + ) + pytest.fail( + f"`openagents install {AGENT_TYPE}` timed out " + f"after 300s and binary not found on PATH." + ) return assert result.returncode == 0, ( @@ -52,15 +54,26 @@ def test_openagents_install_cursor(self): f"stderr:\n{result.stderr[-1000:]}" ) - def test_direct_api_mode_note(self): - """Cursor uses direct API mode — binary is optional.""" - binary_path = shutil.which(BINARY_NAME) - if binary_path: - safe_print(f" Cursor binary found at: {binary_path}") - else: - safe_print( - f" Cursor binary not found (expected — uses direct API mode)" - ) + def test_binary_on_path(self): + """After install, `cursor-agent` should be on PATH.""" + path = shutil.which(BINARY_NAME) + assert path is not None, ( + f"'{BINARY_NAME}' not found on PATH after " + f"`openagents install {AGENT_TYPE}`." + ) + + def test_binary_version(self): + """`cursor-agent --version` should return a version string.""" + if shutil.which(BINARY_NAME) is None: + pytest.skip(f"'{BINARY_NAME}' not on PATH") + + result = run_cmd([BINARY_NAME, "--version"], timeout=30) + assert result.returncode == 0, ( + f"'{BINARY_NAME} --version' failed " + f"(exit {result.returncode}).\n" + f"stderr: {result.stderr[-500:]}" + ) + assert len(result.stdout.strip()) > 0, "Version output is empty" class TestCursorInstallReport: @@ -72,7 +85,7 @@ def test_report_environment(self, os_platform, openagents_version): report = { "platform": os_platform, "openagents_version": openagents_version, - "agent_binary": binary_path or "(direct API mode)", + "agent_binary": binary_path, } for k, v in report.items(): safe_print(f" {k}: {v}") diff --git a/tests/platform/install/test_opencode.py b/tests/platform/install/test_opencode.py new file mode 100644 index 00000000..9e302e1e --- /dev/null +++ b/tests/platform/install/test_opencode.py @@ -0,0 +1,86 @@ +""" +Platform install tests for OpenCode agent. + +Tests the real user experience: `openagents install opencode` +across Linux, macOS, and Windows. + +Run: + pytest tests/platform/install/test_opencode.py -v +""" + +import shutil +import subprocess + +import pytest + +from tests.platform.conftest import run_cmd, run_openagents, safe_print, agent_config + + +AGENT_TYPE = "opencode" +_cfg = agent_config(AGENT_TYPE) +BINARY_NAME = _cfg.get("binary", AGENT_TYPE) + + +class TestOpenCodeInstall: + """Test installing OpenCode via `openagents install opencode`.""" + + def test_openagents_cli_available(self, has_openagents): + assert has_openagents, ( + "openagents CLI is not installed. " + "Run: pip install openagents" + ) + + def test_openagents_install_opencode(self): + try: + result = run_openagents("install", AGENT_TYPE, "--yes", timeout=300) + except subprocess.TimeoutExpired: + if shutil.which(BINARY_NAME) is not None: + pytest.skip( + f"Install timed out at 300s but '{BINARY_NAME}' " + f"is on PATH — likely succeeded." + ) + pytest.fail( + f"`openagents install {AGENT_TYPE}` timed out " + f"after 300s and binary not found on PATH." + ) + return + + assert result.returncode == 0, ( + f"`openagents install {AGENT_TYPE}` failed " + f"(exit {result.returncode}).\n" + f"stdout:\n{result.stdout[-1000:]}\n" + f"stderr:\n{result.stderr[-1000:]}" + ) + + def test_binary_on_path(self): + path = shutil.which(BINARY_NAME) + assert path is not None, ( + f"'{BINARY_NAME}' not found on PATH after " + f"`openagents install {AGENT_TYPE}`." + ) + + def test_binary_version(self): + if shutil.which(BINARY_NAME) is None: + pytest.skip(f"'{BINARY_NAME}' not on PATH") + + result = run_cmd([BINARY_NAME, "--version"], timeout=30) + assert result.returncode == 0, ( + f"'{BINARY_NAME} --version' failed " + f"(exit {result.returncode}).\n" + f"stderr: {result.stderr[-500:]}" + ) + assert len(result.stdout.strip()) > 0, "Version output is empty" + + +class TestOpenCodeInstallReport: + """Collect environment info for the test report.""" + + def test_report_environment(self, os_platform, openagents_version): + binary_path = shutil.which(BINARY_NAME) + report = { + "platform": os_platform, + "openagents_version": openagents_version, + "agent_binary": binary_path, + } + for k, v in report.items(): + safe_print(f" {k}: {v}") diff --git a/tests/platform/start/test_cursor.py b/tests/platform/start/test_cursor.py index 5b9b789b..e92cf9f4 100644 --- a/tests/platform/start/test_cursor.py +++ b/tests/platform/start/test_cursor.py @@ -1,16 +1,15 @@ """ Platform start tests for Cursor agent. -Tests that `openagents create cursor` can launch the agent daemon. -Cursor uses direct API mode so no binary is needed — the adapter -calls the chat completions API directly. +Tests that `openagents create cursor` can launch the agent daemon +using the official Cursor CLI runtime. Run: pytest tests/platform/start/test_cursor.py -v """ -import shutil import time +import shutil import pytest @@ -40,6 +39,13 @@ def cleanup_agent(): class TestCursorStart: """Test starting Cursor via `openagents create cursor`.""" + def test_agent_installed(self): + """Cursor CLI must be installed before start tests run.""" + assert shutil.which(BINARY_NAME) is not None, ( + f"'{BINARY_NAME}' not on PATH. " + f"Run install tests first: pytest tests/platform/install/test_cursor.py" + ) + def test_openagents_start(self): """`openagents create cursor` should launch the daemon.""" result = run_openagents( @@ -95,7 +101,7 @@ def test_report_environment(self, os_platform, openagents_version): report = { "platform": os_platform, "openagents_version": openagents_version, - "agent_binary": binary_path or "(direct API mode)", + "agent_binary": binary_path, } for k, v in report.items(): safe_print(f" {k}: {v}") diff --git a/tests/platform/start/test_opencode.py b/tests/platform/start/test_opencode.py new file mode 100644 index 00000000..900bdf6b --- /dev/null +++ b/tests/platform/start/test_opencode.py @@ -0,0 +1,102 @@ +""" +Platform start tests for OpenCode agent. + +Tests that `openagents create opencode` can launch the agent daemon. + +Run: + pytest tests/platform/start/test_opencode.py -v +""" + +import shutil +import time + +import pytest + +from tests.platform.conftest import ( + run_openagents, + safe_print, + is_daemon_running_with_agents, + agent_config, +) + + +AGENT_NAME = "opencode" +_cfg = agent_config(AGENT_NAME) +BINARY_NAME = _cfg.get("binary", AGENT_NAME) + +pytestmark = pytest.mark.skipif( + is_daemon_running_with_agents(), + reason="Skipped: daemon is running with active agents — these tests would kill it", +) + + +@pytest.fixture(autouse=True) +def cleanup_agent(): + yield + run_openagents("remove", AGENT_NAME, timeout=10, stdin_text="y\n") + + +class TestOpenCodeStart: + """Test starting OpenCode via `openagents create opencode`.""" + + def test_agent_installed(self): + assert shutil.which(BINARY_NAME) is not None, ( + f"'{BINARY_NAME}' not on PATH. " + f"Run install tests first: pytest tests/platform/install/test_opencode.py" + ) + + def test_openagents_start(self): + result = run_openagents( + "create", AGENT_NAME, "--name", AGENT_NAME, "--no-browser", + timeout=30, + stdin_text="y\n\n", + ) + assert result.returncode == 0, ( + f"`openagents create {AGENT_NAME}` failed " + f"(exit {result.returncode}).\n" + f"stdout:\n{result.stdout[-1000:]}\n" + f"stderr:\n{result.stderr[-1000:]}" + ) + + def test_daemon_running(self): + run_openagents("create", AGENT_NAME, "--name", AGENT_NAME, "--no-browser", timeout=30, stdin_text="y\n\n") + time.sleep(2) + + result = run_openagents("status", timeout=10) + output = result.stdout.lower() + assert "running" in output or "pid" in output or AGENT_NAME in output, ( + f"`openagents status` does not show daemon running.\n" + f"stdout:\n{result.stdout}\n" + f"stderr:\n{result.stderr}" + ) + + def test_agent_remove(self): + run_openagents("create", AGENT_NAME, "--name", AGENT_NAME, "--no-browser", timeout=30, stdin_text="y\n\n") + time.sleep(2) + + result = run_openagents("remove", AGENT_NAME, timeout=10, stdin_text="y\n") + combined = (result.stdout + result.stderr).lower() + ok = ( + result.returncode == 0 + or "not found" in combined + or "sighup" in combined + ) + assert ok, ( + f"`openagents remove` failed (exit {result.returncode}).\n" + f"stdout: {result.stdout[-500:]}\n" + f"stderr: {result.stderr[-500:]}" + ) + + +class TestOpenCodeStartReport: + """Collect environment info for the test report.""" + + def test_report_environment(self, os_platform, openagents_version): + binary_path = shutil.which(BINARY_NAME) + report = { + "platform": os_platform, + "openagents_version": openagents_version, + "agent_binary": binary_path, + } + for k, v in report.items(): + safe_print(f" {k}: {v}") diff --git a/tests/platform/tools/test_cursor.py b/tests/platform/tools/test_cursor.py index 57842da6..ff3de117 100644 --- a/tests/platform/tools/test_cursor.py +++ b/tests/platform/tools/test_cursor.py @@ -171,4 +171,4 @@ def test_report_environment(self, os_platform, openagents_version): """Log environment details.""" safe_print(f" platform: {os_platform}") safe_print(f" openagents_version: {openagents_version}") - safe_print(f" agent_binary: {shutil.which(BINARY_NAME) or '(direct API mode)'}") + safe_print(f" agent_binary: {shutil.which(BINARY_NAME)}") diff --git a/tests/platform/tools/test_opencode.py b/tests/platform/tools/test_opencode.py new file mode 100644 index 00000000..1068cee8 --- /dev/null +++ b/tests/platform/tools/test_opencode.py @@ -0,0 +1,111 @@ +""" +Platform tool execution tests for OpenCode agent. + +Tests that workspace file operations via the API behave correctly in an +OpenCode-backed workspace. + +Run: + pytest tests/platform/tools/test_opencode.py -v +""" + +import asyncio +import shutil +import uuid + +import pytest + +from tests.platform.conftest import ( + safe_print, + agent_config, + workspace_endpoint, +) + + +AGENT_TYPE = "opencode" +_cfg = agent_config(AGENT_TYPE) +BINARY_NAME = _cfg.get("binary", AGENT_TYPE) +ENDPOINT = workspace_endpoint() + + +@pytest.fixture() +def workspace_env(): + from openagents.client.workspace_client import WorkspaceClient + + agent_name = f"ci-opencode-{uuid.uuid4().hex[:8]}" + ws_name = f"ws-{agent_name}" + client = WorkspaceClient(endpoint=ENDPOINT) + + ws = asyncio.run( + client.create_workspace(name=ws_name, agent_name=agent_name, agent_type=AGENT_TYPE) + ) + + yield { + "agent_name": agent_name, + "workspace_id": ws.workspace_id, + "token": ws.token, + "channel_name": ws.channel_name, + "client": client, + } + + +class TestOpenCodeTools: + """Test workspace tool operations for OpenCode.""" + + def test_upload_file(self, workspace_env): + env = workspace_env + client = env["client"] + content = b"Hello from CI test" + filename = f"test-{uuid.uuid4().hex[:8]}.txt" + + result = asyncio.run( + client.upload_file( + workspace_id=env["workspace_id"], + token=env["token"], + filename=filename, + content=content, + content_type="text/plain", + source=f"openagents:{env['agent_name']}", + channel_name=env["channel_name"], + ) + ) + + assert result is not None, "upload_file returned None" + assert result.get("file_id") or result.get("id") + + def test_upload_and_read_file(self, workspace_env): + env = workspace_env + client = env["client"] + original_content = f"Read test {uuid.uuid4().hex[:8]}" + + upload_result = asyncio.run( + client.upload_file( + workspace_id=env["workspace_id"], + token=env["token"], + filename="read-test.txt", + content=original_content.encode("utf-8"), + content_type="text/plain", + source=f"openagents:{env['agent_name']}", + ) + ) + + file_id = upload_result.get("file_id") or upload_result.get("id") + assert file_id + + content = asyncio.run( + client.read_file( + workspace_id=env["workspace_id"], + token=env["token"], + file_id=file_id, + ) + ) + content_text = content.decode("utf-8", errors="replace") if isinstance(content, bytes) else str(content) + assert original_content in content_text + + +class TestOpenCodeToolsReport: + """Collect environment info for the test report.""" + + def test_report_environment(self, os_platform, openagents_version): + safe_print(f" platform: {os_platform}") + safe_print(f" openagents_version: {openagents_version}") + safe_print(f" agent_binary: {shutil.which(BINARY_NAME)}") diff --git a/tests/platform/workspace_tools/test_cursor.py b/tests/platform/workspace_tools/test_cursor.py index 8eb3d67d..a69c34cc 100644 --- a/tests/platform/workspace_tools/test_cursor.py +++ b/tests/platform/workspace_tools/test_cursor.py @@ -66,6 +66,10 @@ def mcp_tools(): class TestCursorWorkspaceTools: """Test workspace tool availability for Cursor.""" + def test_agent_installed(self): + """Cursor CLI must be installed.""" + assert shutil.which(BINARY_NAME) is not None + def test_core_tools_available(self, mcp_tools): """Core workspace tools must be present.""" tool_names, _ = mcp_tools @@ -107,4 +111,4 @@ class TestCursorWorkspaceToolsReport: def test_report_environment(self, os_platform, openagents_version): safe_print(f" platform: {os_platform}") safe_print(f" openagents_version: {openagents_version}") - safe_print(f" agent_binary: {shutil.which(BINARY_NAME) or '(direct API mode)'}") + safe_print(f" agent_binary: {shutil.which(BINARY_NAME)}") diff --git a/tests/platform/workspace_tools/test_opencode.py b/tests/platform/workspace_tools/test_opencode.py new file mode 100644 index 00000000..a27ef284 --- /dev/null +++ b/tests/platform/workspace_tools/test_opencode.py @@ -0,0 +1,102 @@ +""" +Platform workspace tools tests for OpenCode agent. + +Tests that the MCP server exposes the shared workspace tools OpenCode uses. + +Run: + pytest tests/platform/workspace_tools/test_opencode.py -v +""" + +import asyncio +import shutil + +import pytest + +from tests.platform.conftest import safe_print, agent_config + + +AGENT_TYPE = "opencode" +_cfg = agent_config(AGENT_TYPE) +BINARY_NAME = _cfg.get("binary", AGENT_TYPE) + +CORE_TOOLS = { + "workspace_get_history", + "workspace_get_agents", + "workspace_status", +} + +FILE_TOOLS = { + "workspace_write_file", + "workspace_read_file", + "workspace_list_files", + "workspace_delete_file", +} + + +def _get_tools_from_server(server): + from mcp import types as mcp_types + + handler = server.request_handlers[mcp_types.ListToolsRequest] + req = mcp_types.ListToolsRequest(method="tools/list") + result = asyncio.run(handler(req)) + return result.root.tools + + +@pytest.fixture() +def mcp_tools(): + from openagents.mcp_server import create_mcp_server + + server = create_mcp_server( + workspace_id="test-workspace", + channel_name="test-channel", + token="test-token", + agent_name="test-opencode", + ) + + tools = _get_tools_from_server(server) + tool_names = {t.name for t in tools} + return tool_names, tools + + +class TestOpenCodeWorkspaceTools: + """Test workspace tool availability for OpenCode.""" + + def test_agent_installed(self): + assert shutil.which(BINARY_NAME) is not None + + def test_core_tools_available(self, mcp_tools): + tool_names, _ = mcp_tools + for tool in CORE_TOOLS: + assert tool in tool_names, ( + f"Core tool '{tool}' not found. " + f"Available: {sorted(tool_names)}" + ) + + def test_file_tools_available(self, mcp_tools): + tool_names, _ = mcp_tools + for tool in FILE_TOOLS: + assert tool in tool_names, ( + f"File tool '{tool}' not found. " + f"Available: {sorted(tool_names)}" + ) + + def test_tools_have_descriptions(self, mcp_tools): + _, tools = mcp_tools + for tool in tools: + assert tool.description, f"Tool '{tool.name}' has no description" + + def test_total_tool_count(self, mcp_tools): + tool_names, _ = mcp_tools + assert len(tool_names) >= 15, ( + f"Only {len(tool_names)} tools registered, expected >= 15. " + f"Tools: {sorted(tool_names)}" + ) + + +class TestOpenCodeWorkspaceToolsReport: + """Collect environment info.""" + + def test_report_environment(self, os_platform, openagents_version): + safe_print(f" platform: {os_platform}") + safe_print(f" openagents_version: {openagents_version}") + safe_print(f" agent_binary: {shutil.which(BINARY_NAME)}")