diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 00000000..87d269cc --- /dev/null +++ b/PLAN.md @@ -0,0 +1,28 @@ +# STDIO Transition Plan + +## Goal +Finish migrating the MEW CLI, space lifecycle, and test suite to a pure STDIO transport so developers can start, inspect, and interact with local spaces without WebSocket or PM2 dependencies. + +## โœ… Completed +- Transport abstraction and FIFO-based gateway (`cli/src/gateway/*`, `cli/src/commands/gateway.js`). +- Adapter shim for STDIO participants (`cli/bin/mew-stdio-adapter.js`). +- `mew space up/down/status/clean` rewritten to spawn/manage gateway + adapters directly (`cli/src/commands/space.js`). +- All regression scenarios (1โ€“12) converted to STDIO drivers and agents; `tests/run-all-tests.sh` passes end-to-end. +- `mew` command defaults updated to auto-run `space up` and reuse `.mew/space.yaml` in new spaces. +- Human interactive CLI wired via `mew space connect` and `mew --interactive` defaults, using an Ink-powered terminal UI (with `/simple` fallback) on top of STDIO FIFOs. +- Configurable transports so spaces can mix local STDIO adapters with WebSocket participants (`space.transport`, per-participant overrides, optional gateway listener). +- TypeScript SDK `@mew-protocol/client` refactored with pluggable transports (STDIO + WebSocket) enabling agents to run under the new CLI adapter. + +## ๐Ÿšง In Progress / Next +1. **Interactive UX polish** โ€“ Upgrade the terminal experience (slash commands, better message formatting, optional TUI/Ink front-end) and capture transcripts/logging options. +2. **Template polish** โ€“ Ensure `mew init` templates install cleanly (package name templating, dependency bootstrap) and document the STDIO workflow in generated READMEs. +3. **Gateway lifecycle UX** โ€“ Surface `space status` summaries and logs (e.g., `mew space status --follow`) to make it easy to observe running adapters. +4. **Remote participant docs/tooling** โ€“ Document WebSocket setup, token distribution, ship helper scripts for remote participants, and update agent scaffolding to nudge remote configuration. + +## ๐Ÿ“Œ Stretch Ideas +- Space inspector TUI driven by the new STDIO channel. +- Scriptable hooks for adapters (auto-tail logs, health summaries). +- Capability-grant UX built on the STDIO approval flow once the human client exists. + +--- +*Last updated: 2025-09-19* diff --git a/PROGRESS.md b/PROGRESS.md index 29290d87..2a39bcf0 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1,148 +1,32 @@ -# MEW Protocol Development Progress +# MEW Protocol Progress Log -## Current Status (2025-01-15) +_Last updated: 2025-09-19_ -### ๐ŸŽ‰ Recent Achievements +## Current Status +The CLI, gateway, and regression suite now run entirely over the new STDIO transport. All scenarios (1โ€“12) succeed under `tests/run-all-tests.sh`, the `mew` command boots a fresh space using `.mew/space.yaml` without PM2 or WebSockets, `mew space connect` launches an Ink-based interactive terminal (with `/simple` fallback), spaces can optionally expose a WebSocket listener for remote participants via `space.transport` overrides, and the TypeScript SDK/agent now speak STDIO by default while retaining optional WebSocket support. -#### v0.2.0 Released to npm -- Successfully published `@mew-protocol/cli` version 0.2.0 -- Package available at: https://www.npmjs.com/package/@mew-protocol/cli -- Install with: `npm install -g @mew-protocol/cli` +## Recent Highlights +- **STDIO Gateway & CLI lifecycle**: Gateway core + FIFO transport landed, with adapters spawned by `mew space up`. State is persisted via `.mew/run/state.json`. +- **Scenario conversions**: Every regression scenario now uses STDIO drivers (`multi-*`, `streaming-*`, `batch-*`, etc.), eliminating HTTP hacks and PM2 wrappers. +- **`mew` UX fixes**: `mew` auto-starts a stopped space, warns if already running, and now launches the interactive session automatically (or via `mew space connect`). Template package names are templated correctly, avoiding npm errors. +- **Interactive CLI**: Human participants attach with `mew space connect`, defaulting to the Ink experience (history, proposals, commands) with a `/simple` readline fallback for debugging. +- **Hybrid transports**: `space.yaml` now supports per-participant `transport` selection, spinning up STDIO adapters locally while offering optional WebSocket endpoints for remote processes with the same token model. +- **SDK parity**: `@mew-protocol/client` exposes pluggable STDIO/WebSocket transports; `@mew-protocol/agent` defaults to STDIO so template agents run under the new adapter out of the box. -### โœ… Completed Features +## Remaining Focus Areas +1. **Interactive UX polish** โ€“ refine the Ink interface (history search, transcript export, richer capability dialogs) and keep `/simple` parity. +2. **Template bootstrap polish** โ€“ smooth out `npm install` failures by templating names earlier or bundling dependencies; document the STDIO workflow in generated projects. +3. **Observability** โ€“ richer `space status` output (pid/log summary, follow mode) plus remote participant visibility. -#### Seamless Init-to-Connect Flow (Fixed) -- **Problem Solved**: After `mew init`, the command now automatically continues to start and connect -- **Implementation**: Spawns new process to run `mew space up -i` after initialization -- **Flow**: `mew` โ†’ init โ†’ start โ†’ connect all in one command +## Key Metrics +- Regression scenarios passing: **12 / 12** +- Templates supported: `coder-agent`, `note-taker` (both STDIO-ready) +- STDIO adapters provided: **17** shared drivers/participants under `tests/agents/` -#### MCP Operation Approval Dialog (Phase 1 Complete) -- **Problem Solved**: Fixed input focus issues where keystrokes appeared in input field during approval -- **Implementation**: Option 2 from ADR-009 (Simple Numbered List MVP) -- **Features Added**: - - Arrow key navigation (โ†‘โ†“) with visual selection indicator - - Enter key to confirm selection - - Number key shortcuts (1/2) for quick approval/denial - - Escape key to cancel - - Proper focus management with input composer disabled during dialogs - - Generic template that works for all operation types - -#### Enhanced `mew` Command Behavior -- **Smart Default Actions**: - - First run (no space): `mew` โ†’ init โ†’ start & connect - - Space exists but stopped: `mew` โ†’ start & connect - - Space running: `mew` โ†’ connect -- **Port Conflict Resolution**: - - Automatically finds next available port when default is in use - - Prevents duplicate gateways for the same space - - Updates all references to use the selected port - -#### Template System for `mew init` -- Built-in templates: `coder-agent` and `note-taker` -- Interactive template selection -- Isolated dependencies in `.mew/` directory -- Keeps project root clean - -#### Improved Interactive UI -- Better formatting for reasoning messages -- Visual reasoning status with spinner animation -- Context-aware message display -- Enhanced message formatting for all types - -### ๐Ÿšง Known Issues to Address - -1. **UI Layout Issue**: After approval dialog, input box position is incorrect with whitespace below -2. **Race Condition**: When MCP filesystem joins before coder agent, tools aren't discovered properly - - Coder agent should request tools before reasoning - - Need to clear tool cache when participants rejoin - -### ๐Ÿ“‹ Upcoming Work - -#### Phase 2: Tool-Specific Templates (Next Priority) -- Detect operation type from method and tool name -- Create optimized templates for: - - File operations (read/write/delete with previews) - - Command execution (npm/shell/git with risk assessment) - - Network requests (URL/method/headers display) - - Database operations (query preview) -- Maintain consistent interaction pattern across all templates - -#### Phase 3: Capability Grants (Future) -- Add "Yes, allow X to Y" option (3rd choice in dialog) -- Send MEW Protocol `capability/grant` messages -- Track granted capabilities per participant -- Skip approval prompts for granted operations -- Session-scoped permissions (not persistent) - -### ๐Ÿ“ฆ Repository Structure - -``` -mew-protocol/ -โ”œโ”€โ”€ cli/ # CLI package (v0.2.0 published) -โ”‚ โ”œโ”€โ”€ src/ -โ”‚ โ”‚ โ”œโ”€โ”€ commands/ # Command implementations -โ”‚ โ”‚ โ””โ”€โ”€ utils/ # Including advanced-interactive-ui.js -โ”‚ โ”œโ”€โ”€ templates/ # Space templates -โ”‚ โ””โ”€โ”€ spec/ -โ”‚ โ””โ”€โ”€ draft/ -โ”‚ โ””โ”€โ”€ decisions/ -โ”‚ โ””โ”€โ”€ accepted/ -โ”‚ โ””โ”€โ”€ 009-aud-approval-dialog-ux.md -โ”œโ”€โ”€ sdk/ # SDK packages -โ”œโ”€โ”€ bridge/ # Bridge implementation -โ”œโ”€โ”€ gateway/ # Gateway server -โ””โ”€โ”€ TODO.md # Task tracking - -``` - -### ๐Ÿ”ง Development Setup - -```bash -# Install latest CLI globally -npm install -g @mew-protocol/cli@0.2.0 - -# Test the new features -mkdir test-space && cd test-space -mew # Will trigger init โ†’ start โ†’ connect flow - -# For development -cd mew-protocol/cli -npm install -npm run lint -``` - -### ๐Ÿ“ˆ Metrics - -- **Package Size**: 45.2 KB compressed -- **Files**: 27 files in npm package -- **Dependencies**: Managed locally in workspaces -- **Test Coverage**: Basic functional testing in place - -### ๐ŸŽฏ Success Indicators - -โœ… Approval dialog no longer has input focus issues -โœ… Users can navigate intuitively with arrows or numbers -โœ… `mew` command provides smart defaults -โœ… Port conflicts handled automatically -โœ… Templates make initialization easy - -### ๐Ÿ”ฎ Vision - -The MEW Protocol is evolving toward a sophisticated multi-agent coordination system where: -1. **Transparency**: All agent interactions visible in shared workspace -2. **Control**: Humans approve operations through intuitive dialogs -3. **Progressive Trust**: Capabilities expand as agents prove reliable -4. **Tool Interoperability**: Agents discover and use each other's tools dynamically - -### ๐Ÿ“ Notes for Contributors - -- Lint errors exist in legacy files but don't affect new features -- Focus on Phase 2 (tool-specific templates) for next iteration -- Consider ADR for handling the race condition with tool discovery -- UI layout issue needs investigation in Ink components +## Next Steps +- Layer richer CLI affordances (history search, transcript export, capability approvals) on top of the new Ink default. +- Tidy template package metadata and retry logic for dependency install. +- Capture the STDIO design decisions in finalized ADRs and document remote/WebSocket configuration patterns. --- - -*Last Updated: 2025-01-15* -*Version: 0.2.0* -*Status: Active Development* \ No newline at end of file +Contributions welcome! Prioritize the human-interaction tooling and template polish to round out the STDIO experience. diff --git a/bridge/bin/mew-bridge.js b/bridge/bin/mew-bridge.js index 07096829..d5e5a81a 100755 --- a/bridge/bin/mew-bridge.js +++ b/bridge/bin/mew-bridge.js @@ -5,7 +5,7 @@ const { program } = require('commander'); program .description('MEW-MCP Bridge - Connect MCP servers to MEW spaces') - .requiredOption('--gateway ', 'Gateway WebSocket URL') + .option('--gateway ', 'Gateway WebSocket URL (required for websocket transport)') .requiredOption('--space ', 'Space ID to join') .requiredOption('--participant-id ', 'Participant ID for this bridge') .requiredOption('--token ', 'Authentication token') @@ -16,10 +16,22 @@ program .option('--init-timeout ', 'Initialization timeout in ms', '30000') .option('--reconnect ', 'Auto-reconnect on disconnect', 'true') .option('--max-reconnects ', 'Maximum reconnect attempts', '3') + .option('--transport ', 'Transport to use: stdio | websocket', 'stdio') .parse(process.argv); const options = program.opts(); +const transport = (options.transport || 'stdio').toLowerCase(); +if (transport !== 'stdio' && transport !== 'websocket') { + console.error(`Unsupported transport: ${options.transport}`); + process.exit(1); +} + +if (transport === 'websocket' && !options.gateway) { + console.error('Gateway URL is required when transport is websocket'); + process.exit(1); +} + // Parse MCP args const mcpArgs = options.mcpArgs ? options.mcpArgs.split(',') : []; @@ -50,13 +62,18 @@ const bridge = new MCPBridge({ token: options.token, initTimeout: parseInt(options.initTimeout), mcpServer: mcpConfig, + transport: transport, }); // Start the bridge async function start() { try { console.log(`Starting MCP bridge for ${options.participantId}...`); - console.log(`Connecting to gateway: ${options.gateway}`); + if (transport === 'websocket') { + console.log(`Connecting to gateway: ${options.gateway}`); + } else { + console.log('Connecting via STDIO transport'); + } console.log(`Space: ${options.space}`); console.log(`MCP server: ${options.mcpCommand} ${mcpArgs.join(' ')}`); @@ -97,4 +114,3 @@ async function start() { // Start the bridge start(); - diff --git a/bridge/src/mcp-bridge.ts b/bridge/src/mcp-bridge.ts index 8fecfaac..69bf243c 100644 --- a/bridge/src/mcp-bridge.ts +++ b/bridge/src/mcp-bridge.ts @@ -1,16 +1,18 @@ import Debug from 'debug'; import { MEWParticipant } from '@mew-protocol/participant'; +import { TransportKind } from '@mew-protocol/client'; import { MCPClient, MCPServerConfig } from './mcp-client'; const debug = Debug('mew:bridge'); export interface MCPBridgeOptions { - gateway: string; + gateway?: string; space: string; participantId?: string; token: string; mcpServer: MCPServerConfig; initTimeout?: number; + transport?: TransportKind; } /** @@ -28,10 +30,11 @@ export class MCPBridge extends MEWParticipant { constructor(options: MCPBridgeOptions) { // Initialize parent with connection options super({ - gateway: options.gateway, space: options.space, participant_id: options.participantId || 'mcp-bridge', token: options.token, + transport: options.transport, + gateway: options.transport === 'websocket' ? options.gateway : undefined, }); this.initTimeout = options.initTimeout || 30000; diff --git a/cli/bin/mew-stdio-adapter.js b/cli/bin/mew-stdio-adapter.js new file mode 100755 index 00000000..e4f0fb19 --- /dev/null +++ b/cli/bin/mew-stdio-adapter.js @@ -0,0 +1,224 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); +const { spawn } = require('child_process'); +const { program } = require('commander'); +const { encodeEnvelope, FrameParser } = require('../src/stdio/utils'); + +program + .requiredOption('--fifo-in ', 'Path to FIFO receiving data from gateway') + .requiredOption('--fifo-out ', 'Path to FIFO sending data to gateway') + .requiredOption('--space ', 'Space identifier') + .requiredOption('--participant-id ', 'Participant identifier') + .requiredOption('--token ', 'Participant token') + .option('--command ', 'Command to spawn as participant process') + .option('--args ', 'Arguments for participant command') + .option('--cwd ', 'Working directory for participant command') + .option('--env ', 'Environment variable overrides for participant command (KEY=VALUE)') + .option('--log-file ', 'Write adapter logs to the specified file') + .allowUnknownOption(true) + .parse(process.argv); + +const options = program.opts(); + +const logTarget = options.logFile ? fs.createWriteStream(options.logFile, { flags: 'a' }) : null; +function log(...args) { + const line = `[adapter:${options.participantId}] ${args.join(' ')}`; + if (logTarget) { + logTarget.write(`${line}\n`); + } else { + console.log(line); + } +} + +log('Adapter parsed args', JSON.stringify(options.args || [])); +log('Adapter parsed token', options.token); + +function logError(...args) { + const line = `[adapter:${options.participantId}] ${args.join(' ')}`; + if (logTarget) { + logTarget.write(`${line}\n`); + } else { + console.error(line); + } +} + +function parseEnv(pairs) { + if (!pairs || pairs.length === 0) { + return process.env; + } + const env = { ...process.env }; + for (const pair of pairs) { + const index = pair.indexOf('='); + if (index === -1) { + throw new Error(`Invalid env pair: ${pair}`); + } + const key = pair.slice(0, index); + const value = pair.slice(index + 1); + env[key] = value; + } + return env; +} + +const fifoInPath = path.resolve(options.fifoIn); +const fifoOutPath = path.resolve(options.fifoOut); + +const gatewayRead = fs.createReadStream(fifoInPath); +const gatewayWrite = fs.createWriteStream(fifoOutPath); + +// Keep track of whether we have announced join +let joined = false; + +function sendToGateway(envelope) { + try { + const payload = encodeEnvelope(envelope); + gatewayWrite.write(payload); + } catch (error) { + logError('Failed to write to gateway FIFO:', error.message); + } +} + +function sendJoin() { + if (joined) return; + const joinEnvelope = { + protocol: 'mew/v0.3', + kind: 'system/join', + payload: { + space: options.space, + participantId: options.participantId, + token: options.token, + }, + }; + log('Join envelope token', options.token); + sendToGateway(joinEnvelope); + joined = true; + log('Sent join handshake to gateway'); +} + +let participantProcess = null; +let participantIn = process.stdin; +let participantOut = process.stdout; +let participantErr = process.stderr; + +if (options.command) { + const spawnOptions = { + stdio: ['pipe', 'pipe', 'pipe'], + env: parseEnv(options.env), + }; + if (options.cwd) { + spawnOptions.cwd = path.resolve(options.cwd); + } + + const childArgs = options.args && options.args[0] === '--' ? options.args.slice(1) : options.args || []; + log(`Spawning participant process: ${options.command} ${(childArgs || []).join(' ')}`); + participantProcess = spawn(options.command, childArgs, spawnOptions); + participantIn = participantProcess.stdin; + participantOut = participantProcess.stdout; + participantErr = participantProcess.stderr; + + participantProcess.on('exit', (code, signal) => { + log(`Participant exited (code=${code}, signal=${signal || 'null'})`); + sendToGateway({ + protocol: 'mew/v0.3', + kind: 'system/participant-left', + payload: { + participantId: options.participantId, + }, + }); + process.exit(code ?? 0); + }); + + participantProcess.on('error', (error) => { + logError('Participant process error:', error.message); + process.exit(1); + }); +} + +const gatewayParser = new FrameParser((envelope) => { + if (participantIn.writable) { + participantIn.write(encodeEnvelope(envelope)); + } +}); + +gatewayRead.on('data', (chunk) => { + try { + gatewayParser.push(chunk); + } catch (error) { + logError('Failed to parse data from gateway:', error.message); + } +}); + +gatewayRead.on('error', (error) => { + logError('Error reading from gateway FIFO:', error.message); + process.exit(1); +}); + +gatewayRead.on('close', () => { + log('Gateway FIFO closed, shutting down'); + if (participantProcess) { + participantProcess.kill('SIGTERM'); + } + process.exit(0); +}); + +const participantParser = new FrameParser((envelope) => { + sendToGateway(envelope); +}); + +participantOut.on('data', (chunk) => { + try { + participantParser.push(chunk); + } catch (error) { + logError('Failed to parse participant output:', error.message); + } +}); + +participantOut.on('error', (error) => { + logError('Participant stdout error:', error.message); +}); + +participantOut.on('close', () => { + log('Participant stdout closed'); +}); + +if (participantErr && participantErr !== process.stderr) { + participantErr.on('data', (chunk) => { + logError(`Participant stderr: ${chunk.toString()}`.trim()); + }); +} + +gatewayWrite.on('open', () => { + sendJoin(); +}); + +if (!options.command) { + // Bridge current process stdio if no command is provided + process.stdin.on('data', (chunk) => { + try { + participantParser.push(chunk); + } catch (error) { + logError('Failed to parse stdin data:', error.message); + } + }); + + process.stdin.on('close', () => { + log('STDIN closed, exiting'); + process.exit(0); + }); +} + +process.on('SIGINT', () => { + log('Adapter received SIGINT'); + if (participantProcess) { + participantProcess.kill('SIGINT'); + } + process.exit(0); +}); + +process.on('SIGTERM', () => { + log('Adapter received SIGTERM'); + if (participantProcess) { + participantProcess.kill('SIGTERM'); + } + process.exit(0); +}); diff --git a/cli/spec/draft/decisions/proposed/013-ssc-stdio-shim-connector.md b/cli/spec/draft/decisions/proposed/013-ssc-stdio-shim-connector.md new file mode 100644 index 00000000..264a2e99 --- /dev/null +++ b/cli/spec/draft/decisions/proposed/013-ssc-stdio-shim-connector.md @@ -0,0 +1,99 @@ +# ADR-SSC: CLI-Managed STDIO Shim Connector + +**Status:** Proposed +**Date:** 2025-09-17 +**Context:** MEW CLI Draft +**Incorporation:** Not Incorporated + +## Context + +Several MEW participants (legacy MCP servers, lightweight tools, local agents) expose only stdin/stdout transports. The gateway currently expects each participant to connect directly over WebSocket (or another network transport) and does not manage external process lifecycles. Recent protocol work added stream lifecycle support, and we now need a straightforward way to connect stdio-only processes without expanding the gateway's responsibilities. + +The CLI already orchestrates spaces via PM2: it launches the gateway, auto-start agents, and manages their lifecycles. Aligning with that pattern, we want the CLI to launch stdio-based participants and bridge them into the space. Today users must write ad-hoc scripts to proxy these processes over WebSocket, which is error-prone and inconsistent. + +## Options Considered + +### Option 1: Teach the Gateway to Manage STDIO Processes + +**Pros:** +- Direct integration; fewer moving pieces for users. +- Gateway can supervise child processes and monitor health. + +**Cons:** +- Bloats gateway scope (process management, logging, restarts). +- Harder to run in hosted environments where gateway and tools live on different machines. +- Increases security surface (gateway would spawn arbitrary binaries). + +### Option 2: Require Users to Implement Their Own Shims + +**Pros:** +- No new code in CLI or gateway. +- Maximum flexibility for integrators. + +**Cons:** +- Every project rebuilds the same plumbing. +- Inconsistent behavior (capability enforcement, logging, retries). +- Higher barrier for onboarding stdio-only participants. + +### Option 3: Provide a CLI-Managed STDIO Shim (Chosen) + +**Pros:** +- Keeps gateway focused on routing/capabilities. +- Reuses existing PM2 orchestration in the CLI. +- Gives users a simple, consistent workflow for stdio-based participants. +- Works alongside WebSocket participants without change. + +**Cons:** +- CLI becomes responsible for supervising shim processes. +- Additional component to monitor/debug (shim failures must be surfaced). + +## Decision + +Adopt Option 3. The CLI will ship a small โ€œstdio shimโ€ utility that: + +- Spawns a target process (or connects to an already running one) and treats its stdin/stdout as a JSON-RPC transport. +- Maintains a persistent WebSocket connection to the gateway, forwarding envelopes between the child process and the space. +- Integrates with existing CLI space management (tokens, capabilities, logging, PM2 restart policies). + +This keeps the gateway simple while enabling stdio-only tools to participate in MEW spaces transparently. + +## Implementation Details + +1. **Shim executable** + - Command (e.g., `mew shim stdio-bridge`) that accepts parameters for gateway URL, space ID, participant ID, token, and the child command/args/environment. + - Reads from the childโ€™s stdout, frames messages via `Content-Length`, and forwards them over the WebSocket connection using standard MEW envelopes. + - Delivers inbound MEW envelopes from the gateway to the childโ€™s stdin. + - Emits structured logs for lifecycle events (child exit, reconnects, malformed messages). + +2. **Space configuration support** + - Extend `space.yaml` to declare participants with `io: stdio` (or similar), command definition, env, and restart policy. + - CLI `space up` auto-generates shim PM2 processes for these participants, mirroring the current auto_start logic. + +3. **Lifecycle & reliability** + - Shim detects child exit codes and reports them via gateway/system logs. + - Respects PM2 restart policies defined in `space.yaml`. + - Handles gateway reconnects (e.g., exponential backoff) without losing child connection if possible. + +4. **Security/isolation** + - CLI continues to control which binaries are executed via `space.yaml` (no gatewayโ€‘level spawning). + - Tokens/capabilities for each participant are issued through existing CLI mechanisms. + +## Consequences + +### Positive +- Enables stdio-only MCP servers and local tools to join spaces without custom scripts. +- Gateway remains transport-agnostic and lightweight. +- Simplifies onboarding: users configure a participant once and let the CLI manage the bridge. +- Provides consistent logging and restart behavior via PM2. + +### Negative +- Adds a new CLI component to maintain and test. +- Shim failures introduce another failure mode (must surface clearly to operators). +- Requires careful handling of stdout/stderr to avoid blocking or message framing issues. + +## Follow-Up / Open Questions + +- Define CLI UX (new flags, error messages, diagnostics) for shim processes. +- Decide how to surface shim health in `mew space status`. +- Evaluate whether the shim should support additional transports (named pipes, TCP sockets) for advanced scenarios. + diff --git a/cli/src/commands/gateway.js b/cli/src/commands/gateway.js index a9259d4b..ba8931e9 100644 --- a/cli/src/commands/gateway.js +++ b/cli/src/commands/gateway.js @@ -1,970 +1,225 @@ const { Command } = require('commander'); -const WebSocket = require('ws'); -const express = require('express'); -const http = require('http'); const fs = require('fs'); -const yaml = require('js-yaml'); const path = require('path'); -const { spawn } = require('child_process'); +const yaml = require('js-yaml'); const crypto = require('crypto'); - -const gateway = new Command('gateway').description('Gateway server management'); - -gateway - .command('start') - .description('Start a MEW gateway server') - .option('-p, --port ', 'Port to listen on', '8080') - .option('-l, --log-level ', 'Log level (debug|info|warn|error)', 'info') - .option('-s, --space-config ', 'Path to space.yaml configuration', './space.yaml') - .action(async (options) => { - const port = parseInt(options.port); - - // If we're running as a detached process, redirect console output to a log file - if (process.env.GATEWAY_LOG_FILE) { - const fs = require('fs'); - const logStream = fs.createWriteStream(process.env.GATEWAY_LOG_FILE, { flags: 'a' }); - const originalLog = console.log; - const originalError = console.error; - - console.log = function (...args) { - const message = args.join(' '); - logStream.write(`[${new Date().toISOString()}] ${message}\n`); - }; - - console.error = function (...args) { - const message = args.join(' '); - logStream.write(`[${new Date().toISOString()}] ERROR: ${message}\n`); - }; +const { GatewayCore } = require('../gateway/core'); +const { FIFOTransport } = require('../gateway/transports/fifo'); +const { WebSocketTransport } = require('../gateway/transports/websocket'); + +function createLogger(level) { + const levels = ['error', 'warn', 'info', 'debug']; + const normalized = levels.includes(level) ? level : 'info'; + const shouldLog = (target) => levels.indexOf(target) <= levels.indexOf(normalized); + + return { + error: (...args) => shouldLog('error') && console.error('[gateway]', ...args), + warn: (...args) => shouldLog('warn') && console.warn('[gateway]', ...args), + log: (...args) => shouldLog('info') && console.log('[gateway]', ...args), + debug: (...args) => shouldLog('debug') && console.debug('[gateway]', ...args), + }; +} + +function loadSpaceConfig(configPath) { + const resolved = path.resolve(configPath); + const content = fs.readFileSync(resolved, 'utf8'); + const config = yaml.load(content); + return { config, configPath: resolved }; +} + +function ensureDirectory(dirPath) { + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } +} + +function resolveToken(spaceDir, participantId, participantConfig, logger) { + const envVar = `MEW_TOKEN_${participantId.toUpperCase().replace(/-/g, '_')}`; + if (process.env[envVar]) { + logger.debug(`Using token from env ${envVar}`); + return process.env[envVar]; + } + + const tokensDir = path.join(spaceDir, '.mew', 'tokens'); + const tokenPath = path.join(tokensDir, `${participantId}.token`); + if (fs.existsSync(tokenPath)) { + const token = fs.readFileSync(tokenPath, 'utf8').trim(); + if (token) { + return token; } - - console.log(`Starting MEW gateway on port ${port}...`); - - // Load space configuration (CLI responsibility, not gateway) - let spaceConfig = null; - let tokenMap = new Map(); // Map of participantId -> token - - try { - const configPath = path.resolve(options.spaceConfig); - const configContent = fs.readFileSync(configPath, 'utf8'); - spaceConfig = yaml.load(configContent); - console.log(`Loaded space configuration from ${configPath}`); - console.log(`Space ID: ${spaceConfig.space.id}`); - console.log(`Participants configured: ${Object.keys(spaceConfig.participants).length}`); - - // Load tokens from secure storage - const spaceDir = path.dirname(configPath); - const mewDir = fs.existsSync(path.join(spaceDir, '.mew')) - ? path.join(spaceDir, '.mew') - : spaceDir; - const tokensDir = path.join(mewDir, 'tokens'); - - // Load tokens for all participants - for (const participantId of Object.keys(spaceConfig.participants)) { - const tokenPath = path.join(tokensDir, `${participantId}.token`); - - // Check environment variable override first - const envVarName = `MEW_TOKEN_${participantId.toUpperCase().replace(/-/g, '_')}`; - if (process.env[envVarName]) { - tokenMap.set(participantId, process.env[envVarName]); - console.log(`Loaded token for ${participantId} from environment variable`); - } else if (fs.existsSync(tokenPath)) { - // Load from file - const token = fs.readFileSync(tokenPath, 'utf8').trim(); - tokenMap.set(participantId, token); - console.log(`Loaded token for ${participantId} from secure storage`); - } else { - // Generate new token if not found - const token = crypto.randomBytes(32).toString('base64url'); - - // Ensure tokens directory exists - if (!fs.existsSync(tokensDir)) { - fs.mkdirSync(tokensDir, { recursive: true, mode: 0o700 }); - // Create .gitignore in tokens directory - const tokenGitignore = path.join(tokensDir, '.gitignore'); - fs.writeFileSync(tokenGitignore, '*\n!.gitignore\n', { mode: 0o600 }); - } - - fs.writeFileSync(tokenPath, token, { mode: 0o600 }); - tokenMap.set(participantId, token); - console.log(`Generated new token for ${participantId}`); - } - } - } catch (error) { - console.error(`Failed to load space configuration: ${error.message}`); - console.log('Continuing with default configuration...'); - } - - // Create Express app for health endpoint - const app = express(); - app.use(express.json()); - - // Health endpoint - app.get('/health', (req, res) => { - res.json({ - status: 'ok', - spaces: spaces.size, - clients: Array.from(spaces.values()).reduce( - (sum, space) => sum + space.participants.size, - 0, - ), - uptime: process.uptime(), - features: ['capabilities', 'context', 'validation', 'http-io'], - }); - }); - - // HTTP API for message injection - app.post('/participants/:participantId/messages', (req, res) => { - const { participantId } = req.params; - const authHeader = req.headers.authorization; - const spaceName = req.query.space || 'default'; - - // Extract token from Authorization header - if (!authHeader || !authHeader.startsWith('Bearer ')) { - return res.status(401).json({ error: 'Missing or invalid authorization header' }); - } - - const token = authHeader.substring(7); - - // Verify token matches participant - const expectedToken = participantTokens.get(participantId); - if (!expectedToken || expectedToken !== token) { - return res.status(403).json({ error: 'Invalid token for participant' }); - } - - // Use configured space ID as default, or fallback to query param - const actualSpaceName = spaceName === 'default' && spaceConfig?.space?.id - ? spaceConfig.space.id - : spaceName; - - // Get or create space - let space = spaces.get(actualSpaceName); - if (!space) { - space = { participants: new Map() }; - spaces.set(actualSpaceName, space); - } - - // Build complete envelope - const envelope = { - protocol: 'mew/v0.3', - id: `http-${Date.now()}-${Math.random().toString(36).substring(7)}`, - ts: new Date().toISOString(), - from: participantId, - ...req.body - }; - - // Validate capabilities (skip for now - participant is already authenticated) - // TODO: Implement capability check for HTTP-injected messages - - // Broadcast message to space - const envelopeStr = JSON.stringify(envelope); - for (const [pid, ws] of space.participants) { - if (ws.readyState === WebSocket.OPEN) { - ws.send(envelopeStr); - } - } - - res.json({ - id: envelope.id, - status: 'accepted', - timestamp: envelope.ts - }); - }); - - // List participants endpoint - app.get('/participants', (req, res) => { - const spaceName = req.query.space || 'default'; - const space = spaces.get(spaceName); - - if (!space) { - return res.json({ participants: [] }); - } - - const participants = Array.from(space.participants.keys()).map(id => ({ - id, - connected: space.participants.get(id).readyState === WebSocket.OPEN, - capabilities: participantCapabilities.get(id) || [] - })); - - res.json({ participants }); - }); - - // Create HTTP server - const server = http.createServer(app); - - // Create WebSocket server - const wss = new WebSocket.Server({ server }); - - // Track spaces and participants - const spaces = new Map(); // spaceId -> { participants: Map(participantId -> ws) } - - // Track participant info - const participantTokens = new Map(); // participantId -> token - const participantCapabilities = new Map(); // participantId -> capabilities array - const runtimeCapabilities = new Map(); // participantId -> Map(grantId -> capabilities) - - // Track spawned processes - const spawnedProcesses = new Map(); // participantId -> ChildProcess - - // Gateway hooks for external configuration - let capabilityResolver = null; - let participantJoinedCallback = null; - let authorizationHook = null; - - // Set capability resolver hook - gateway.setCapabilityResolver = function (resolver) { - capabilityResolver = resolver; - }; - - // Set participant joined callback - gateway.onParticipantJoined = function (callback) { - participantJoinedCallback = callback; - }; - - // Set authorization hook - gateway.setAuthorizationHook = function (hook) { - authorizationHook = hook; + } + + if (participantConfig.tokens && participantConfig.tokens.length > 0) { + return participantConfig.tokens[0]; + } + + const token = crypto.randomBytes(24).toString('base64url'); + if (!fs.existsSync(tokensDir)) { + fs.mkdirSync(tokensDir, { recursive: true, mode: 0o700 }); + } + const gitignorePath = path.join(tokensDir, '.gitignore'); + if (!fs.existsSync(gitignorePath)) { + fs.writeFileSync(gitignorePath, '*\n!.gitignore\n', { mode: 0o600 }); + } + fs.writeFileSync(tokenPath, token, { mode: 0o600 }); + logger.warn(`Generated new token for participant ${participantId}`); + return token; +} + +async function startGateway(options) { + const logger = createLogger(options.logLevel || 'info'); + const { config, configPath } = loadSpaceConfig(options.spaceConfig || './space.yaml'); + + if (!config?.space?.id) { + throw new Error('space.id missing from configuration'); + } + + const spaceId = config.space.id; + let spaceDir = path.dirname(configPath); + if (path.basename(spaceDir) === '.mew') { + spaceDir = path.dirname(spaceDir); + } + const transportConfig = config.space?.transport || {}; + const defaultTransport = transportConfig.default || 'stdio'; + const transportOverrides = transportConfig.overrides || {}; + + const fifoDir = options.fifoDir + ? path.resolve(options.fifoDir) + : path.join(spaceDir, '.mew', 'fifos'); + + const participantEntries = Object.entries(config.participants || {}); + const participantConfigs = new Map(); + const tokensByParticipant = new Map(); + const stdioParticipants = []; + const websocketParticipants = []; + + ensureDirectory(fifoDir); + + for (const [participantId, participantConfigRaw] of participantEntries) { + const resolvedTransport = + participantConfigRaw.transport || transportOverrides[participantId] || defaultTransport; + const participantConfig = { + ...participantConfigRaw, + transport: resolvedTransport, }; + participantConfigs.set(participantId, participantConfig); - // Default capability resolver using space.yaml - async function defaultCapabilityResolver(token, participantId, messageKind) { - if (!spaceConfig) { - // Fallback to basic defaults - return [{ kind: 'chat' }]; - } - - // Find participant by token (using tokenMap from secure storage) - for (const [pid, storedToken] of tokenMap.entries()) { - if (storedToken === token) { - const config = spaceConfig.participants[pid]; - return config?.capabilities || spaceConfig.defaults?.capabilities || []; - } - } - - // Legacy support: check tokens field in config if it exists (backward compatibility) - for (const [pid, config] of Object.entries(spaceConfig.participants)) { - if (config.tokens && config.tokens.includes(token)) { - return config.capabilities || spaceConfig.defaults?.capabilities || []; - } - } - - // Return default capabilities if no match - return spaceConfig.defaults?.capabilities || [{ kind: 'chat' }]; - } + const token = resolveToken(spaceDir, participantId, participantConfig, logger); + tokensByParticipant.set(participantId, token); - // Use custom resolver if set, otherwise use default - async function resolveCapabilities(token, participantId, messageKind) { - if (capabilityResolver) { - return await capabilityResolver(token, participantId, messageKind); - } - return await defaultCapabilityResolver(token, participantId, messageKind); + if (resolvedTransport === 'websocket') { + websocketParticipants.push(participantId); + } else { + stdioParticipants.push(participantId); } + } - // Check if message matches capability pattern - function matchesCapability(message, capability) { - // Simple kind matching first - if (capability.kind === '*') return true; - - // Wildcard pattern matching - if (capability.kind && capability.kind.endsWith('/*')) { - const prefix = capability.kind.slice(0, -2); - if (message.kind && message.kind.startsWith(prefix + '/')) { - // Check payload patterns if specified - if (capability.payload) { - return matchesPayloadPattern(message.payload, capability.payload); - } - return true; - } - } - - // Exact kind match - if (capability.kind === message.kind) { - // If capability has payload pattern, it must match - if (capability.payload) { - return matchesPayloadPattern(message.payload, capability.payload); - } - // No payload pattern means any payload is allowed - return true; - } - - return false; - } - - // Match payload patterns (simplified version) - function matchesPayloadPattern(payload, pattern) { - if (!pattern) return true; // No pattern means any payload is allowed - if (!payload) return false; // Pattern exists but no payload - - for (const [key, value] of Object.entries(pattern)) { - if (typeof value === 'string') { - // Handle negative patterns - if (value.startsWith('!')) { - const negativePattern = value.slice(1); - if (payload[key] === negativePattern) { - return false; // Explicitly excluded value - } - continue; // Pattern matches anything except the negated value - } - - // Handle wildcards in strings - if (value.endsWith('*')) { - const prefix = value.slice(0, -1); - if (!payload[key] || !payload[key].startsWith(prefix)) { - return false; - } - } else if (payload[key] !== value) { - return false; - } - } else if (typeof value === 'object') { - // Recursive matching for nested objects - if (!matchesPayloadPattern(payload[key], value)) { - return false; - } - } - } - - return true; - } - - // Check if participant has capability for message - async function hasCapabilityForMessage(participantId, message) { - // Always allow heartbeat messages - if (message.kind === 'system/heartbeat') { - return true; - } - - // Get static capabilities from config - const staticCapabilities = participantCapabilities.get(participantId) || []; - - // Get runtime capabilities (granted dynamically) - const runtimeCaps = runtimeCapabilities.get(participantId); - const dynamicCapabilities = runtimeCaps ? Array.from(runtimeCaps.values()).flat() : []; - - // Merge static and dynamic capabilities - const allCapabilities = [...staticCapabilities, ...dynamicCapabilities]; - - if (options.logLevel === 'debug' && dynamicCapabilities.length > 0) { - console.log(`Checking capabilities for ${participantId}:`, { - static: staticCapabilities, - dynamic: dynamicCapabilities, - message: { kind: message.kind, payload: message.payload }, - }); - } - - // Check each capability pattern - for (const cap of allCapabilities) { - if (matchesCapability(message, cap)) { - return true; - } - } - - return false; - } - - // Register HTTP-only participants from config - function registerHttpParticipants() { - if (!spaceConfig || !spaceConfig.participants) return; - - for (const [participantId, config] of Object.entries(spaceConfig.participants)) { - // Register tokens and capabilities for HTTP participants - if (config.tokens && config.tokens.length > 0) { - const token = config.tokens[0]; // Use first token - participantTokens.set(participantId, token); - participantCapabilities.set(participantId, config.capabilities || []); - - if (config.io === 'http') { - console.log(`Registered HTTP participant: ${participantId}`); - } - - // Auto-connect participants with output_log - if (config.output_log && config.auto_connect) { - console.log(`Auto-connecting participant with output log: ${participantId}`); - autoConnectOutputLogParticipant(participantId, config); - } - } - } - } - - // Auto-connect a participant that writes to output_log - function autoConnectOutputLogParticipant(participantId, config) { - const fs = require('fs'); - const path = require('path'); - - // Resolve output log path - const outputPath = path.resolve(process.cwd(), config.output_log); - const outputDir = path.dirname(outputPath); - - // Ensure directory exists - if (!fs.existsSync(outputDir)) { - fs.mkdirSync(outputDir, { recursive: true }); - } - - // Create a virtual WebSocket connection for this participant - const virtualWs = { - send: (data) => { - // Write received messages to output log - try { - const message = typeof data === 'string' ? JSON.parse(data) : data; - // Append to output log - fs.appendFileSync(outputPath, JSON.stringify(message) + '\n'); - } catch (error) { - console.error(`Error writing to output log for ${participantId}:`, error); - } - }, - readyState: WebSocket.OPEN, - close: () => {}, - }; - - // Get or create space - use the configured space ID - const spaceId = spaceConfig.space.id || 'default'; - if (!spaces.has(spaceId)) { - spaces.set(spaceId, { participants: new Map() }); - } - - // Register this virtual connection in the space - const space = spaces.get(spaceId); - space.participants.set(participantId, virtualWs); - - console.log(`${participantId} auto-connected with output to ${config.output_log}`); - } - - // Auto-start agents with auto_start: true - function autoStartAgents() { - if (!spaceConfig || !spaceConfig.participants) return; - - for (const [participantId, config] of Object.entries(spaceConfig.participants)) { - if (config.auto_start && config.command) { - console.log(`Auto-starting agent: ${participantId}`); - - // Substitute ${PORT} in args - const args = (config.args || []).map(arg => - arg.replace('${PORT}', port.toString()) - ); - - const child = spawn(config.command, args, { - env: { ...process.env, PORT: port.toString(), ...config.env }, - stdio: 'inherit', - }); + const gatewayCore = new GatewayCore({ + spaceId, + participants: participantConfigs, + tokensByParticipant, + logger, + }); - spawnedProcesses.set(participantId, child); + logger.debug?.( + 'Participant tokens:', + Array.from(tokensByParticipant.entries()).map(([pid, token]) => [pid, token.slice(0, 8)]), + ); - child.on('error', (error) => { - console.error(`Failed to start ${participantId}:`, error); - }); + const transports = []; - child.on('exit', (code, signal) => { - console.log(`${participantId} exited with code ${code}, signal ${signal}`); - spawnedProcesses.delete(participantId); + if (stdioParticipants.length > 0) { + logger.log(`Starting STDIO gateway for space ${spaceId}`); + logger.log(`Using FIFO directory ${fifoDir}`); - // Handle restart policy - if (config.restart_policy === 'on-failure' && code !== 0) { - console.log(`Restarting ${participantId} due to failure...`); - setTimeout(() => autoStartAgents(), 5000); - } - }); - } + const fifoTransport = new FIFOTransport({ + fifoDir, + participantIds: stdioParticipants, + logger, + }); + transports.push(fifoTransport); + gatewayCore.attachTransport(fifoTransport); + await fifoTransport.start(); + logger.log('STDIO transport ready'); + } + + const shouldStartWebSocket = websocketParticipants.length > 0 || transportConfig.default === 'websocket'; + let websocketTransport = null; + + if (shouldStartWebSocket) { + const listenValue = config.gateway?.websocket?.listen || '127.0.0.1:4700'; + let host = '127.0.0.1'; + let port = 4700; + + if (typeof listenValue === 'number') { + port = listenValue; + } else if (typeof listenValue === 'string') { + if (listenValue.includes(':')) { + const [hostPart, portPart] = listenValue.split(':'); + if (hostPart) host = hostPart; + if (portPart) port = Number(portPart); + } else { + port = Number(listenValue); } } - // Validate message structure - function validateMessage(message) { - if (!message || typeof message !== 'object') { - return 'Message must be an object'; - } - - // Protocol version check - if (message.protocol && message.protocol !== 'mew/v0.3') { - return `Invalid protocol version: ${message.protocol}`; - } - - // Check required fields based on kind - if (message.kind === 'chat' && !message.payload?.text) { - return 'Chat message requires payload.text'; - } - - if (message.kind === 'mcp/request' && !message.payload?.method) { - return 'MCP request requires payload.method'; - } - - return null; // Valid + if (Number.isNaN(port) || port <= 0) { + throw new Error(`Invalid websocket listen port: ${listenValue}`); } - // Handle WebSocket connections - wss.on('connection', (ws, req) => { - let participantId = null; - let spaceId = null; - - if (options.logLevel === 'debug') { - console.log('New WebSocket connection'); - } - - ws.on('message', async (data) => { - try { - const message = JSON.parse(data.toString()); - - // Validate message - const validationError = validateMessage(message); - if (validationError) { - ws.send( - JSON.stringify({ - protocol: 'mew/v0.3', - kind: 'system/error', - payload: { - error: validationError, - code: 'VALIDATION_ERROR', - }, - }), - ); - return; - } - - // Handle join (special case - before capability check) - if (message.kind === 'system/join' || message.type === 'join') { - participantId = - message.participantId || - message.payload?.participantId || - `participant-${Date.now()}`; - spaceId = - message.space || message.payload?.space || spaceConfig?.space?.id || 'default'; - const token = message.token || message.payload?.token; - - // Create space if it doesn't exist - if (!spaces.has(spaceId)) { - spaces.set(spaceId, { participants: new Map() }); - } - - // Add participant to space - const space = spaces.get(spaceId); - space.participants.set(participantId, ws); - ws.participantId = participantId; - ws.spaceId = spaceId; - - // Store token and resolve capabilities - participantTokens.set(participantId, token); - const capabilities = await resolveCapabilities(token, participantId, null); - participantCapabilities.set(participantId, capabilities); - - // Send welcome message per MEW v0.2 spec - const welcomeMessage = { - protocol: 'mew/v0.3', - id: `welcome-${Date.now()}`, - ts: new Date().toISOString(), - from: 'system:gateway', - to: [participantId], - kind: 'system/welcome', - payload: { - you: { - id: participantId, - capabilities: capabilities, - }, - participants: Array.from(space.participants.keys()) - .filter((pid) => pid !== participantId) - .map((pid) => ({ - id: pid, - capabilities: participantCapabilities.get(pid) || [], - })), - }, - }; - - ws.send(JSON.stringify(welcomeMessage)); - - // Broadcast presence to others - const presenceMessage = { - protocol: 'mew/v0.3', - id: `presence-${Date.now()}`, - ts: new Date().toISOString(), - from: 'system:gateway', - kind: 'system/presence', - payload: { - event: 'join', - participant: { - id: participantId, - capabilities: capabilities, - }, - }, - }; - - for (const [pid, pws] of space.participants.entries()) { - if (pid !== participantId && pws.readyState === WebSocket.OPEN) { - pws.send(JSON.stringify(presenceMessage)); - } - } - - // Call participant joined callback if set - if (participantJoinedCallback) { - await participantJoinedCallback(participantId, token, { - space: spaceId, - capabilities: capabilities, - }); - } - - console.log(`${participantId} joined space ${spaceId} with token ${token || 'none'}`); - return; - } - - // Check capabilities for non-join messages - if (!(await hasCapabilityForMessage(participantId, message))) { - const errorMessage = { - protocol: 'mew/v0.3', - id: `error-${Date.now()}`, - ts: new Date().toISOString(), - from: 'system:gateway', - to: [participantId], - kind: 'system/error', - correlation_id: message.id ? [message.id] : undefined, - payload: { - error: 'capability_violation', - attempted_kind: message.kind, - your_capabilities: participantCapabilities.get(participantId) || [], - }, - }; - - ws.send(JSON.stringify(errorMessage)); - - if (options.logLevel === 'debug') { - console.log(`Capability denied for ${participantId}: ${message.kind}`); - } - return; - } - - // Handle capability management messages - if (message.kind === 'capability/grant') { - // Check if sender has capability to grant capabilities - const canGrant = await hasCapabilityForMessage(participantId, { - kind: 'capability/grant', - }); - if (!canGrant) { - const errorMessage = { - protocol: 'mew/v0.3', - id: `error-${Date.now()}`, - ts: new Date().toISOString(), - from: 'system:gateway', - to: [participantId], - kind: 'system/error', - correlation_id: message.id ? [message.id] : undefined, - payload: { - error: 'capability_violation', - message: 'You do not have permission to grant capabilities', - attempted_kind: message.kind, - }, - }; - ws.send(JSON.stringify(errorMessage)); - return; - } - - const recipient = message.payload?.recipient; - const grantCapabilities = message.payload?.capabilities || []; - const grantId = message.id || `grant-${Date.now()}`; - - if (recipient && grantCapabilities.length > 0) { - // Initialize runtime capabilities for recipient if needed - if (!runtimeCapabilities.has(recipient)) { - runtimeCapabilities.set(recipient, new Map()); - } - - // Store the granted capabilities - const recipientCaps = runtimeCapabilities.get(recipient); - recipientCaps.set(grantId, grantCapabilities); - - console.log( - `Granted capabilities to ${recipient}: ${JSON.stringify(grantCapabilities)}`, - ); - console.log( - `Runtime capabilities for ${recipient}:`, - Array.from(recipientCaps.entries()), - ); - - // Send acknowledgment to recipient - const space = spaces.get(spaceId); - const recipientWs = space?.participants.get(recipient); - if (recipientWs && recipientWs.readyState === WebSocket.OPEN) { - const ackMessage = { - protocol: 'mew/v0.3', - id: `ack-${Date.now()}`, - ts: new Date().toISOString(), - from: 'system:gateway', - to: [recipient], - kind: 'capability/grant-ack', - correlation_id: [grantId], - payload: { - status: 'accepted', - grant_id: grantId, - capabilities: grantCapabilities, - }, - }; - recipientWs.send(JSON.stringify(ackMessage)); - - // Send updated welcome message with new capabilities - // This allows the participant to update their internal capability tracking - // Get both static capabilities and runtime capabilities - const staticCapabilities = participantCapabilities.get(recipient) || []; - const runtimeCaps = runtimeCapabilities.get(recipient); - const dynamicCapabilities = runtimeCaps ? Array.from(runtimeCaps.values()).flat() : []; - - // Combine static and dynamic capabilities - const updatedCapabilities = [...staticCapabilities, ...dynamicCapabilities]; - - const updatedWelcomeMessage = { - protocol: 'mew/v0.3', - id: `welcome-update-${Date.now()}`, - ts: new Date().toISOString(), - from: 'system:gateway', - to: [recipient], - kind: 'system/welcome', - payload: { - you: { - id: recipient, - capabilities: updatedCapabilities, - }, - participants: Array.from(space.participants.keys()) - .filter((pid) => pid !== recipient) - .map((pid) => { - // Also include runtime capabilities for other participants - const otherStatic = participantCapabilities.get(pid) || []; - const otherRuntime = runtimeCapabilities.get(pid); - const otherDynamic = otherRuntime ? Array.from(otherRuntime.values()).flat() : []; - return { - id: pid, - capabilities: [...otherStatic, ...otherDynamic], - }; - }), - }, - }; - recipientWs.send(JSON.stringify(updatedWelcomeMessage)); - console.log(`Sent updated welcome message to ${recipient} with ${updatedCapabilities.length} total capabilities`); - console.log(' Static capabilities:', staticCapabilities.length); - console.log(' Granted capabilities:', dynamicCapabilities.length); - } - } - } else if (message.kind === 'capability/revoke') { - // Check if sender has capability to revoke capabilities - const canRevoke = await hasCapabilityForMessage(participantId, { - kind: 'capability/revoke', - }); - if (!canRevoke) { - const errorMessage = { - protocol: 'mew/v0.3', - id: `error-${Date.now()}`, - ts: new Date().toISOString(), - from: 'system:gateway', - to: [participantId], - kind: 'system/error', - correlation_id: message.id ? [message.id] : undefined, - payload: { - error: 'capability_violation', - message: 'You do not have permission to revoke capabilities', - attempted_kind: message.kind, - }, - }; - ws.send(JSON.stringify(errorMessage)); - return; - } - - const recipient = message.payload?.recipient; - const grantIdToRevoke = message.payload?.grant_id; - const capabilitiesToRevoke = message.payload?.capabilities; - - if (recipient) { - const recipientCaps = runtimeCapabilities.get(recipient); - - if (recipientCaps) { - if (grantIdToRevoke) { - // Revoke by grant ID - if (recipientCaps.has(grantIdToRevoke)) { - recipientCaps.delete(grantIdToRevoke); - console.log(`Revoked grant ${grantIdToRevoke} from ${recipient}`); - } - } else if (capabilitiesToRevoke) { - // Revoke by capability patterns - remove all matching grants - for (const [grantId, caps] of recipientCaps.entries()) { - const remainingCaps = caps.filter((cap) => { - // Check if this capability should be revoked - for (const revokePattern of capabilitiesToRevoke) { - if (JSON.stringify(cap) === JSON.stringify(revokePattern)) { - return false; // Remove this capability - } - } - return true; // Keep this capability - }); - - if (remainingCaps.length === 0) { - recipientCaps.delete(grantId); - } else { - recipientCaps.set(grantId, remainingCaps); - } - } - console.log( - `Revoked capabilities from ${recipient}: ${JSON.stringify(capabilitiesToRevoke)}`, - ); - } - - // Clean up empty entries - if (recipientCaps.size === 0) { - runtimeCapabilities.delete(recipient); - } - } - } - } - - // Add protocol envelope fields if missing - const envelope = { - protocol: message.protocol || 'mew/v0.3', - id: message.id || `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, - ts: message.ts || new Date().toISOString(), - from: participantId, - ...message, - }; - - // Ensure correlation_id is always an array if present - if (envelope.correlation_id && !Array.isArray(envelope.correlation_id)) { - envelope.correlation_id = [envelope.correlation_id]; - } - - // ALWAYS broadcast to ALL participants - MEW Protocol requires all messages visible to all - if (spaceId && spaces.has(spaceId)) { - const space = spaces.get(spaceId); - - // Log if message has specific addressing - if (envelope.to && Array.isArray(envelope.to)) { - console.log(`[GATEWAY DEBUG] Message from ${envelope.from} addressed to: ${envelope.to.join(', ')}, kind: ${envelope.kind}`); - } - - // Broadcast to ALL participants (everyone sees everything in MEW Protocol) - console.log(`[GATEWAY DEBUG] Broadcasting ${envelope.kind} from ${envelope.from} to all ${space.participants.size} participants`); - for (const [pid, pws] of space.participants.entries()) { - if (pws.readyState === WebSocket.OPEN) { - pws.send(JSON.stringify(envelope)); - if (options.logLevel === 'debug') { - console.log(`[GATEWAY DEBUG] Sent to ${pid}`); - } - } - } - - if (options.logLevel === 'debug') { - console.log(`Message from ${participantId} in ${spaceId}:`, message.kind); - } - } - } catch (error) { - console.error('Error handling message:', error); - ws.send( - JSON.stringify({ - protocol: 'mew/v0.3', - kind: 'system/error', - payload: { - error: error.message, - code: 'PROCESSING_ERROR', - }, - }), - ); + websocketTransport = new WebSocketTransport({ host, port, logger }); + transports.push(websocketTransport); + gatewayCore.attachTransport(websocketTransport); + await websocketTransport.start(); + logger.log(`WebSocket transport ready on ws://${host}:${port}`); + } + + if (transports.length === 0) { + logger.warn('No transports configured; gateway will not accept connections'); + } + + logger.log('Gateway ready'); + + return new Promise((resolve, reject) => { + const shutdown = (signal) => { + logger.log(`Received ${signal}, shutting down gateway`); + try { + for (const participantId of tokensByParticipant.keys()) { + const connection = gatewayCore.connections.get(participantId); + connection?.channel?.close?.(); } - }); - - ws.on('error', (error) => { - console.error('WebSocket error:', error); - }); - - ws.on('close', () => { - if (participantId && spaceId && spaces.has(spaceId)) { - const space = spaces.get(spaceId); - space.participants.delete(participantId); - participantTokens.delete(participantId); - participantCapabilities.delete(participantId); - runtimeCapabilities.delete(participantId); - - // Broadcast leave presence - const presenceMessage = { - protocol: 'mew/v0.3', - id: `presence-${Date.now()}`, - ts: new Date().toISOString(), - from: 'system:gateway', - kind: 'system/presence', - payload: { - event: 'leave', - participant: { - id: participantId, - }, - }, - }; - - for (const [pid, pws] of space.participants.entries()) { - if (pws.readyState === WebSocket.OPEN) { - pws.send(JSON.stringify(presenceMessage)); - } - } - - if (options.logLevel === 'debug') { - console.log(`${participantId} disconnected from ${spaceId}`); - } - } - }); - }); - - // Start server - server.listen(port, () => { - console.log(`Gateway listening on port ${port}`); - console.log(`Health endpoint: http://localhost:${port}/health`); - console.log(`HTTP API: http://localhost:${port}/participants/{id}/messages`); - console.log(`WebSocket endpoint: ws://localhost:${port}`); - if (spaceConfig) { - console.log(`Space configuration loaded: ${spaceConfig.space.name}`); - } - - // Register HTTP participants and auto-start agents - registerHttpParticipants(); - autoStartAgents(); - }); - - // Graceful shutdown - process.on('SIGTERM', () => { - console.log('Shutting down gateway...'); - - // Stop spawned processes - for (const [pid, child] of spawnedProcesses.entries()) { - console.log(`Stopping ${pid}...`); - child.kill('SIGTERM'); - } - - // Close WebSocket server - wss.close(() => { - server.close(() => { - process.exit(0); + Promise.all(transports.map((t) => t.stop?.().catch(() => {}))).finally(() => { + resolve(); }); - }); - }); - - // Handle SIGINT (Ctrl+C) as well - process.on('SIGINT', () => { - console.log('\nShutting down gateway...'); - - // Stop spawned processes - for (const [pid, child] of spawnedProcesses.entries()) { - console.log(`Stopping ${pid}...`); - child.kill('SIGTERM'); + } finally { + // resolved above } + }; - // Close WebSocket server - wss.close(() => { - server.close(() => { - process.exit(0); - }); - }); + process.on('SIGINT', () => shutdown('SIGINT')); + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('uncaughtException', (error) => { + logger.error('Uncaught exception in gateway', error); + reject(error); }); + }); +} - // Keep the process alive - // The server.listen() callback doesn't prevent Node from exiting - // When spawned with detached and stdio: ['ignore'], stdin is closed - // Create an interval that won't be garbage collected - const keepAlive = setInterval(() => { - // This empty function keeps the event loop active - }, 2147483647); // Maximum 32-bit signed integer (~24.8 days) - - // Make sure the interval is not optimized away - keepAlive.unref = () => {}; - - // Return a promise that never resolves to keep the process alive - // This is needed when the gateway is started as a detached process - return new Promise(() => { - // This promise intentionally never resolves - }); +const gateway = new Command('gateway').description('Gateway server management'); + +gateway + .command('start') + .description('Start the MEW gateway (STDIO/WebSocket based on configuration)') + .option('-s, --space-config ', 'Path to space.yaml configuration', './space.yaml') + .option('-f, --fifo-dir ', 'Directory containing participant FIFO pairs') + .option('-l, --log-level ', 'Log level (error|warn|info|debug)', 'info') + .action(async (options) => { + try { + await startGateway(options); + } catch (error) { + console.error('[gateway] Failed to start:', error.message); + process.exit(1); + } }); module.exports = gateway; diff --git a/cli/src/commands/space.js b/cli/src/commands/space.js index 70a82922..072a411d 100644 --- a/cli/src/commands/space.js +++ b/cli/src/commands/space.js @@ -2,178 +2,86 @@ const { Command } = require('commander'); const fs = require('fs'); const path = require('path'); const yaml = require('js-yaml'); -const { spawn, execSync } = require('child_process'); -const net = require('net'); -const http = require('http'); -const pm2 = require('pm2'); +const { spawn } = require('child_process'); const crypto = require('crypto'); +const readline = require('readline'); +const chalk = require('chalk'); +const EventEmitter = require('events'); -const space = new Command('space').description('Manage MEW spaces'); - -// PM2 connection helper -function connectPM2(spaceDir) { - return new Promise((resolve, reject) => { - // For now, use default PM2 home to avoid issues - // TODO: Investigate why custom PM2_HOME causes hanging - - console.log('Connecting to PM2 (using default PM2_HOME)...'); - - // Connect to PM2 daemon (will start if not running) - pm2.connect((err) => { - if (err) { - console.error('PM2 connect error:', err); - reject(err); - } else { - console.log('PM2 connected successfully'); - resolve(); - } - }); - }); -} - -// PM2 start helper -function startPM2Process(config) { - return new Promise((resolve, reject) => { - pm2.start(config, (err, apps) => { - if (err) { - reject(err); - } else { - resolve(apps[0]); - } - }); - }); -} - -// PM2 list helper -function listPM2Processes() { - return new Promise((resolve, reject) => { - pm2.list((err, list) => { - if (err) { - reject(err); - } else { - resolve(list); - } - }); - }); -} +const { encodeEnvelope, FrameParser } = require('../stdio/utils'); +const { + resolveParticipant, + getInteractiveOverrides, +} = require('../utils/participant-resolver'); +const { startAdvancedInteractiveUI } = require('../utils/advanced-interactive-ui'); -// PM2 delete helper -function deletePM2Process(name) { - return new Promise((resolve, reject) => { - pm2.delete(name, (err) => { - if (err && !err.message.includes('not found')) { - reject(err); - } else { - resolve(); - } - }); - }); -} +const space = new Command('space').description('Manage MEW spaces'); -// PM2 disconnect helper -function disconnectPM2() { - pm2.disconnect(); +function ensureDir(dirPath) { + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } } -/** - * Generate a secure random token - */ -function generateSecureToken() { - return crypto.randomBytes(32).toString('base64url'); +function loadSpaceConfig(configPath) { + const resolved = path.resolve(configPath); + const content = fs.readFileSync(resolved, 'utf8'); + const config = yaml.load(content); + return { config, configPath: resolved }; } -/** - * Ensure a token exists for a participant, generating if needed - * @param {string} spaceDir - Directory containing the space - * @param {string} participantId - Participant ID to get/generate token for - * @returns {Promise} The token - */ -async function ensureTokenExists(spaceDir, participantId) { - const tokensDir = path.join(spaceDir, '.mew', 'tokens'); - const tokenPath = path.join(tokensDir, `${participantId}.token`); - - // Check environment variable override first - const envVarName = `MEW_TOKEN_${participantId.toUpperCase().replace(/-/g, '_')}`; - if (process.env[envVarName]) { - console.log(`Using token from environment variable ${envVarName}`); - return process.env[envVarName]; +function ensureToken(spaceDir, participantId, participantConfig) { + const envVar = `MEW_TOKEN_${participantId.toUpperCase().replace(/-/g, '_')}`; + if (process.env[envVar]) { + return process.env[envVar]; } - // Ensure tokens directory exists - if (!fs.existsSync(tokensDir)) { - fs.mkdirSync(tokensDir, { recursive: true, mode: 0o700 }); + const tokensDir = path.join(spaceDir, '.mew', 'tokens'); + ensureDir(tokensDir); + const tokenFile = path.join(tokensDir, `${participantId}.token`); - // Create .gitignore in tokens directory - const tokenGitignore = path.join(tokensDir, '.gitignore'); - fs.writeFileSync(tokenGitignore, '*\n!.gitignore\n', { mode: 0o600 }); + if (fs.existsSync(tokenFile)) { + const token = fs.readFileSync(tokenFile, 'utf8').trim(); + if (token) { + return token; + } } - // Check if token file exists - if (fs.existsSync(tokenPath)) { - const token = fs.readFileSync(tokenPath, 'utf8').trim(); - console.log(`Loaded existing token for ${participantId}`); + if (participantConfig.tokens && participantConfig.tokens.length > 0) { + const token = participantConfig.tokens[0]; + fs.writeFileSync(tokenFile, token, { mode: 0o600 }); return token; } - // Generate new token - const token = generateSecureToken(); - fs.writeFileSync(tokenPath, token, { mode: 0o600 }); - console.log(`Generated new token for ${participantId}`); - - return token; -} - -/** - * Load participant tokens from secure storage or generate them - * @param {string} spaceDir - Directory containing the space - * @param {object} config - Space configuration - * @returns {Promise} Map of participant IDs to tokens - */ -async function loadParticipantTokens(spaceDir, config) { - const tokenMap = new Map(); - - for (const [participantId, participantConfig] of Object.entries(config.participants || {})) { - // Generate or load token - const token = await ensureTokenExists(spaceDir, participantId); - tokenMap.set(participantId, token); + const token = crypto.randomBytes(24).toString('base64url'); + if (!fs.existsSync(path.join(tokensDir, '.gitignore'))) { + fs.writeFileSync(path.join(tokensDir, '.gitignore'), '*\n!.gitignore\n'); } - - return tokenMap; -} - -// Get path to store running spaces info -function getSpacesFilePath() { - const homeDir = process.env.HOME || process.env.USERPROFILE; - const mewDir = path.join(homeDir, '.mew'); - if (!fs.existsSync(mewDir)) { - fs.mkdirSync(mewDir, { recursive: true }); - } - return path.join(mewDir, 'running-spaces.json'); + fs.writeFileSync(tokenFile, token, { mode: 0o600 }); + return token; } -// Load running spaces from file -function loadRunningSpaces() { - const filePath = getSpacesFilePath(); - if (fs.existsSync(filePath)) { - try { - const content = fs.readFileSync(filePath, 'utf8'); - return new Map(JSON.parse(content)); - } catch (error) { - return new Map(); +async function ensureFifo(fifoPath) { + if (fs.existsSync(fifoPath)) { + const stats = fs.statSync(fifoPath); + if (!stats.isFIFO()) { + throw new Error(`${fifoPath} exists but is not a FIFO`); } + return; } - return new Map(); -} -// Save running spaces to file -function saveRunningSpaces(spaces) { - const filePath = getSpacesFilePath(); - const data = Array.from(spaces.entries()); - fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); + const mkfifo = spawn('mkfifo', [fifoPath]); + return new Promise((resolve, reject) => { + mkfifo.on('exit', (code) => { + if (code === 0) resolve(); + else reject(new Error(`mkfifo exited with code ${code}`)); + }); + mkfifo.on('error', reject); + }); } -// Check if process is running -function isProcessRunning(pid) { +function isPidRunning(pid) { + if (!pid) return false; try { process.kill(pid, 0); return true; @@ -182,1329 +90,943 @@ function isProcessRunning(pid) { } } -// Check if port is available -function isPortAvailable(port) { - return new Promise((resolve) => { - const tester = net - .createServer() - .once('error', () => resolve(false)) - .once('listening', () => { - tester.once('close', () => resolve(true)).close(); - }) - .listen(port); - }); -} - -// Find next available port starting from the given port -async function findAvailablePort(startPort, maxTries = 10) { - let port = parseInt(startPort); - for (let i = 0; i < maxTries; i++) { - if (await isPortAvailable(port)) { - return port; - } - port++; - } - return null; -} - -// Wait for gateway to be ready -async function waitForGateway(port, maxRetries = 30) { - for (let i = 0; i < maxRetries; i++) { - try { - const response = await new Promise((resolve, reject) => { - http - .get(`http://localhost:${port}/health`, (res) => { - let data = ''; - res.on('data', (chunk) => (data += chunk)); - res.on('end', () => resolve(data)); - }) - .on('error', reject); - }); - - const health = JSON.parse(response); - if (health.status === 'ok') { - return true; - } - } catch (error) { - // Gateway not ready yet - } - await new Promise((resolve) => setTimeout(resolve, 1000)); +function readState(spaceDir) { + const runDir = path.join(spaceDir, '.mew', 'run'); + const statePath = path.join(runDir, 'state.json'); + if (!fs.existsSync(statePath)) { + return null; } - return false; -} - -// Check if mkfifo is available -function hasMkfifo() { try { - execSync('which mkfifo', { stdio: 'ignore' }); - return true; + const data = fs.readFileSync(statePath, 'utf8'); + return JSON.parse(data); } catch (error) { - return false; + return null; } } -/** - * Load space configuration from yaml file - * Checks .mew/space.yaml first, then falls back to provided path - */ -function loadSpaceConfig(configPath) { - try { - let resolvedPath; - - // Check if the path ends with space.yaml (common default) - if (configPath.endsWith('space.yaml') && !configPath.includes('.mew')) { - // Extract the directory from the path - const dir = path.dirname(configPath); - const mewConfigPath = path.join(dir, '.mew/space.yaml'); - - // Check .mew/space.yaml first - if (fs.existsSync(mewConfigPath)) { - resolvedPath = mewConfigPath; - } else if (fs.existsSync(configPath)) { - // Fall back to the original path - resolvedPath = configPath; - } else { - // Neither exists, try to give a helpful error - throw new Error(`No space configuration found. Checked:\n - ${mewConfigPath}\n - ${configPath}`); - } - } else { - // Use the explicit path provided (e.g., already includes .mew or is absolute) - resolvedPath = path.resolve(configPath); - } +function writeState(spaceDir, state) { + const runDir = path.join(spaceDir, '.mew', 'run'); + ensureDir(runDir); + const statePath = path.join(runDir, 'state.json'); + fs.writeFileSync(statePath, JSON.stringify(state, null, 2)); + return statePath; +} - const configContent = fs.readFileSync(resolvedPath, 'utf8'); - const config = yaml.load(configContent); - return { config, configPath: resolvedPath }; - } catch (error) { - console.error(`Failed to load space configuration: ${error.message}`); - process.exit(1); +function removeState(spaceDir) { + const runDir = path.join(spaceDir, '.mew', 'run'); + const statePath = path.join(runDir, 'state.json'); + if (fs.existsSync(statePath)) { + fs.unlinkSync(statePath); } } -/** - * Create PID file for space - */ -function savePidFile(spaceDir, pids) { - const pidDir = path.join(spaceDir, '.mew'); - if (!fs.existsSync(pidDir)) { - fs.mkdirSync(pidDir, { recursive: true }); - } +function spawnDetached(command, args, { cwd, env, stdout, stderr }) { + ensureDir(path.dirname(stdout)); + ensureDir(path.dirname(stderr)); + const outFd = fs.openSync(stdout, 'a'); + const errFd = fs.openSync(stderr, 'a'); + + const child = spawn(command, args, { + cwd, + env, + detached: true, + stdio: ['ignore', outFd, errFd], + }); - const pidFile = path.join(pidDir, 'pids.json'); - fs.writeFileSync(pidFile, JSON.stringify(pids, null, 2)); - return pidFile; -} + child.unref(); -/** - * Load PID file for space - */ -function loadPidFile(spaceDir) { - const pidFile = path.join(spaceDir, '.mew', 'pids.json'); - if (!fs.existsSync(pidFile)) { - return null; - } + fs.closeSync(outFd); + fs.closeSync(errFd); - try { - const content = fs.readFileSync(pidFile, 'utf8'); - return JSON.parse(content); - } catch (error) { - console.error(`Failed to load PID file: ${error.message}`); - return null; - } + return child.pid; } -/** - * Remove PID file - */ -function removePidFile(spaceDir) { - const pidFile = path.join(spaceDir, '.mew', 'pids.json'); - if (fs.existsSync(pidFile)) { - fs.unlinkSync(pidFile); - } +async function createFifoPair(fifoDir, participantId) { + const inboundPath = path.join(fifoDir, `${participantId}.in`); + const outboundPath = path.join(fifoDir, `${participantId}.out`); + + await ensureFifo(inboundPath); + await ensureFifo(outboundPath); + + return { inboundPath, outboundPath }; } -/** - * Resolve environment variables in an object - * Replaces ${VAR_NAME} with process.env.VAR_NAME - */ -function resolveEnvVariables(envObj) { - const resolved = {}; - for (const [key, value] of Object.entries(envObj)) { - if (typeof value === 'string') { - // Replace ${VAR_NAME} with actual environment variable value - resolved[key] = value.replace(/\$\{([^}]+)\}/g, (match, varName) => { - return process.env[varName] || match; // Keep original if env var doesn't exist - }); - } else { - resolved[key] = value; - } - } - return resolved; +function formatParticipantList(participants = []) { + if (!participants.length) return 'none'; + return participants + .map((p) => (typeof p === 'string' ? p : p.id || p.participantId || 'unknown')) + .join(', '); } -/** - * Create FIFOs for participant if configured - * @param {boolean} createOutputFifo - Whether to create output FIFO (false when output_log is set) - */ -function createFifos(spaceDir, participantId, createOutputFifo = true) { - const fifoDir = path.join(spaceDir, 'fifos'); - if (!fs.existsSync(fifoDir)) { - fs.mkdirSync(fifoDir, { recursive: true }); - } +function describeEnvelope(envelope, { spaceId, participantId, useColor, debug }) { + const kind = envelope.kind || 'unknown'; + const from = envelope.from || envelope.participant || ''; + const ts = envelope.ts ? ` ${envelope.ts}` : ''; - const inFifo = path.join(fifoDir, `${participantId}-in`); - const outFifo = path.join(fifoDir, `${participantId}-out`); + const colorize = (value, color) => (useColor ? chalk[color](value) : value); - // Create FIFOs if they don't exist - if (!hasMkfifo()) { - throw new Error('mkfifo command not found. FIFOs are required for this participant.'); + if (debug) { + const raw = JSON.stringify(envelope, null, 2); + console.log(colorize(`[${kind}]${ts} ${from}`, 'gray')); + console.log(raw); + return; } - try { - // Always create input FIFO - if (!fs.existsSync(inFifo)) { - execSync(`mkfifo "${inFifo}"`); + switch (kind) { + case 'system/welcome': { + const participants = envelope.payload?.participants || []; + console.log( + colorize( + `โœ” Joined space ${spaceId} as ${participantId}. Participants: ${formatParticipantList(participants)}`, + 'green', + ), + ); + return; } - // Only create output FIFO if requested - if (createOutputFifo && !fs.existsSync(outFifo)) { - execSync(`mkfifo "${outFifo}"`); + case 'system/presence': { + const event = envelope.payload?.event; + const participant = envelope.payload?.participant?.id || envelope.payload?.participantId; + if (event && participant) { + const prefix = event === 'join' ? 'โž•' : event === 'leave' ? 'โž–' : 'โ„น'; + console.log( + colorize( + `${prefix} ${participant} ${event === 'join' ? 'joined' : event === 'leave' ? 'left' : event}`, + event === 'join' ? 'green' : event === 'leave' ? 'yellow' : 'gray', + ), + ); + } + return; } - } catch (error) { - console.error(`Failed to create FIFOs: ${error.message}`); - throw error; - } - - return { inFifo, outFifo: createOutputFifo ? outFifo : null }; -} - -/** - * Clean up FIFOs - */ -function cleanupFifos(spaceDir) { - const fifoDir = path.join(spaceDir, 'fifos'); - if (fs.existsSync(fifoDir)) { - const files = fs.readdirSync(fifoDir); - for (const file of files) { - const filePath = path.join(fifoDir, file); - try { - fs.unlinkSync(filePath); - } catch (error) { - // Ignore errors during cleanup + case 'chat': { + const text = envelope.payload?.text || ''; + const target = envelope.to && envelope.to.length ? ` โ†’ ${envelope.to.join(', ')}` : ''; + console.log(`${colorize(from, 'cyan')}${target}: ${text}`); + return; + } + case 'mcp/proposal': + case 'mcp/request': + case 'mcp/response': { + const method = envelope.payload?.method || ''; + console.log( + `${colorize(kind, 'magenta')} from ${colorize(from, 'cyan')}${method ? ` (${method})` : ''}`, + ); + if (envelope.payload) { + console.log(JSON.stringify(envelope.payload, null, 2)); + } + return; + } + default: { + console.log(colorize(`[${kind}] from ${from}`, 'gray')); + if (envelope.payload) { + console.log(JSON.stringify(envelope.payload, null, 2)); } } } } -// Action handler for space up -async function spaceUpAction(options) { - // Check for incompatible flags - if (options.interactive && options.detach) { - console.error('Error: --interactive and --detach flags are mutually exclusive'); - process.exit(1); - } - const spaceDir = path.resolve(options.spaceDir); - - // Construct config path - if options.config is relative, make it relative to spaceDir - let configPath; - if (path.isAbsolute(options.config)) { - configPath = options.config; +function resolveWebsocketListen(value) { + let host = '127.0.0.1'; + let port = 4700; + + if (typeof value === 'number') { + port = value; + } else if (typeof value === 'string') { + if (value.includes(':')) { + const [hostPart, portPart] = value.split(':'); + if (hostPart) { + host = hostPart; + } + if (portPart) { + const parsed = Number(portPart); + if (!Number.isNaN(parsed) && parsed > 0) { + port = parsed; + } + } } else { - configPath = path.join(spaceDir, options.config); + const parsed = Number(value); + if (!Number.isNaN(parsed) && parsed > 0) { + port = parsed; + } } + } - console.log(`Starting space in ${spaceDir}...`); - - // Load space configuration (will check .mew/space.yaml first) - const { config, configPath: actualConfigPath } = loadSpaceConfig(configPath); - // Update configPath to the actual resolved path - configPath = actualConfigPath; - const spaceName = config.space?.name || 'unnamed-space'; - const spaceId = config.space?.id || 'space-' + Date.now(); + return `${host}:${port}`; +} - console.log(`Space: ${spaceName} (${spaceId})`); +async function spaceConnectAction(options = {}) { + const spaceDir = path.resolve(options.spaceDir || '.'); + const state = readState(spaceDir); - // Check if space is already running - const existingPids = loadPidFile(spaceDir); - if (existingPids && existingPids.gateway && isProcessRunning(existingPids.gateway)) { - console.error('Space is already running. Run "mew space down" first.'); - process.exit(1); - } + if (!state) { + throw new Error('No running space found. Start the space with "mew space up" first.'); + } - // Find available port if the requested one is in use - let selectedPort = parseInt(options.port); - const portAvailable = await isPortAvailable(selectedPort); - if (!portAvailable) { - console.log(`Port ${selectedPort} is already in use. Finding available port...`); - selectedPort = await findAvailablePort(selectedPort); - if (!selectedPort) { - console.error('Could not find an available port. Please specify a different port with --port'); - process.exit(1); - } - console.log(`Using port ${selectedPort}`); - } + if (!isPidRunning(state.gateway?.pid)) { + throw new Error('Gateway process not running. Start the space again with "mew space up".'); + } - const pids = { - spaceId, - spaceName, - spaceDir, - port: selectedPort, - gateway: null, - agents: {}, - clients: {}, - }; + const { config: spaceConfig } = loadSpaceConfig(state.configPath); - // Create logs directory - const logsDir = path.join(spaceDir, 'logs'); - if (!fs.existsSync(logsDir)) { - fs.mkdirSync(logsDir, { recursive: true }); - } + const participantSelection = await resolveParticipant({ + participantId: options.participant, + spaceConfig, + interactive: options.interactiveSelection !== false, + }); - // Connect to PM2 with space-local daemon - console.log('Initializing PM2 daemon...'); - try { - await connectPM2(spaceDir); - } catch (error) { - console.error(`Failed to connect to PM2: ${error.message}`); - process.exit(1); - } + const participantId = participantSelection.id; + const participantTransportConfig = spaceConfig.space?.transport || {}; + const participantTransport = + spaceConfig.participants?.[participantId]?.transport || + participantTransportConfig.overrides?.[participantId] || + participantTransportConfig.default || + 'stdio'; + + if (participantTransport !== 'stdio') { + throw new Error( + `Participant ${participantId} uses transport '${participantTransport}'. Use a compatible remote client to connect.`, + ); + } - // Start gateway using PM2 - console.log(`Starting gateway on port ${selectedPort}...`); - const gatewayLogPath = path.join(logsDir, 'gateway.log'); + const baseParticipantConfig = spaceConfig.participants[participantId] || participantSelection; + const participantConfig = getInteractiveOverrides(baseParticipantConfig); - try { - const gatewayApp = await startPM2Process({ - name: `${spaceId}-gateway`, - script: path.join(__dirname, '../../bin/mew.js'), - args: [ - 'gateway', - 'start', - '--port', - selectedPort, - '--log-level', - options.logLevel, - '--space-config', - configPath, - ], - cwd: spaceDir, - autorestart: false, - max_memory_restart: '500M', - error_file: path.join(logsDir, 'gateway-error.log'), - out_file: gatewayLogPath, - merge_logs: true, - time: true, - }); + const token = ensureToken(spaceDir, participantId, baseParticipantConfig); + const fifoDir = state.fifoDir || path.join(spaceDir, '.mew', 'fifos'); - pids.gateway = gatewayApp.pid || gatewayApp.pm2_env?.pm_id || 'unknown'; - console.log(`โœ“ Gateway started via PM2 (PID: ${pids.gateway})`); - } catch (error) { - console.error(`Failed to start gateway: ${error.message}`); - disconnectPM2(); - process.exit(1); - } + const { inboundPath, outboundPath } = await createFifoPair(fifoDir, participantId); - // Wait for gateway to be ready - console.log('Waiting for gateway to be ready...'); - const gatewayReady = await waitForGateway(selectedPort); - if (!gatewayReady) { - console.error('Gateway failed to become ready. Check logs/gateway.log for details.'); - await deletePM2Process(`${spaceId}-gateway`); - disconnectPM2(); - process.exit(1); - } - console.log('โœ“ Gateway is ready'); - - // Load participant tokens from secure storage - console.log('Loading participant tokens...'); - const tokenMap = await loadParticipantTokens(spaceDir, config); - console.log('โœ“ Tokens loaded/generated for all participants'); - - // Start agents and bridges with auto_start: true - for (const [participantId, participant] of Object.entries(config.participants || {})) { - // Handle MCP bridge participants - if (participant.type === 'mcp-bridge' && participant.auto_start && participant.mcp_server) { - console.log(`Starting MCP bridge: ${participantId}...`); - - const bridgeLogPath = path.join(logsDir, `${participantId}-bridge.log`); - const mcpServer = participant.mcp_server; - - // Build bridge arguments - const bridgeArgs = [ - '--gateway', - `ws://localhost:${selectedPort}`, - '--space', - spaceId, - '--participant-id', - participantId, - '--token', - tokenMap.get(participantId), - '--mcp-command', - mcpServer.command, - ]; - - // Add MCP args if present - if (mcpServer.args && mcpServer.args.length > 0) { - bridgeArgs.push('--mcp-args', mcpServer.args.join(',')); - } + const preferAdvancedUi = + !options.noUi && !options.debug && !options.simple && process.stdout.isTTY; + const useAdvancedUi = preferAdvancedUi; + const debug = Boolean(options.debug || options.simple); + const useColor = !options.noColor && !options.noUi && process.stdout.isTTY; - // Add MCP env if present - if (mcpServer.env) { - const envPairs = Object.entries(mcpServer.env) - .map(([k, v]) => `${k}=${v}`) - .join(','); - bridgeArgs.push('--mcp-env', envPairs); - } + if (!useAdvancedUi) { + console.log( + `${useColor ? chalk.blue('โ„น') : 'โ„น'} Connecting to ${state.spaceId} as ${participantId}โ€ฆ`, + ); + } - // Add MCP cwd if present (resolve relative paths) - if (mcpServer.cwd) { - // If the cwd is relative, resolve it relative to spaceDir - const resolvedCwd = path.isAbsolute(mcpServer.cwd) - ? mcpServer.cwd - : path.resolve(spaceDir, mcpServer.cwd); - bridgeArgs.push('--mcp-cwd', resolvedCwd); - } + const gatewayRead = fs.createReadStream(inboundPath); + const gatewayWrite = fs.createWriteStream(outboundPath); - // Add bridge config options if present - if (participant.bridge_config) { - if (participant.bridge_config.init_timeout) { - bridgeArgs.push('--init-timeout', participant.bridge_config.init_timeout.toString()); - } - if (participant.bridge_config.reconnect !== undefined) { - bridgeArgs.push('--reconnect', participant.bridge_config.reconnect.toString()); - } - if (participant.bridge_config.max_reconnects) { - bridgeArgs.push( - '--max-reconnects', - participant.bridge_config.max_reconnects.toString(), - ); - } - } + let closed = false; + let resolveSession; + const sessionDone = new Promise((resolve) => { + resolveSession = resolve; + }); - try { - // Find the bridge executable - const bridgePath = path.resolve( - __dirname, - '..', - '..', - '..', - 'bridge', - 'bin', - 'mew-bridge.js', - ); - - const bridgeApp = await startPM2Process({ - name: `mcp_bridge_${participantId}`, - script: bridgePath, - args: bridgeArgs, - cwd: spaceDir, - autorestart: false, - max_memory_restart: '200M', - error_file: path.join(logsDir, `${participantId}-bridge-error.log`), - out_file: bridgeLogPath, - merge_logs: true, - time: true, - env: { - ...process.env, - NODE_ENV: process.env.NODE_ENV || 'production', - ...resolveEnvVariables(participant.env || {}) - }, - }); - - pids.agents[participantId] = bridgeApp.pid || bridgeApp.pm2_env?.pm_id || 'unknown'; - console.log( - `โœ“ MCP bridge ${participantId} started via PM2 (PID: ${pids.agents[participantId]})`, - ); - } catch (error) { - console.error(`Failed to start MCP bridge ${participantId}: ${error.message}`); - } - } - // Handle regular agent participants - else if (participant.auto_start && participant.command) { - console.log(`Starting agent: ${participantId}...`); - - const agentLogPath = path.join(logsDir, `${participantId}.log`); - const agentArgs = participant.args || []; - - // Replace placeholders in args - const processedArgs = agentArgs.map((arg) => - arg - .replace('${PORT}', selectedPort) - .replace('${SPACE}', spaceId) - .replace('${TOKEN}', tokenMap.get(participantId)), - ); + let writeReady = false; + let canWrite = true; + const pendingEnvelopes = []; - try { - const agentApp = await startPM2Process({ - name: `${spaceId}-${participantId}`, - script: participant.command, - args: processedArgs, - cwd: spaceDir, - autorestart: false, - max_memory_restart: '200M', - error_file: path.join(logsDir, `${participantId}-error.log`), - out_file: agentLogPath, - merge_logs: true, - time: true, - env: { - ...process.env, // Inherit all environment variables from current shell - ...resolveEnvVariables(participant.env || {}), // Override with any specific env from config - // Set token as environment variable for participants that read from env - [`MEW_TOKEN_${participantId.toUpperCase().replace(/-/g, '_')}`]: tokenMap.get(participantId) - }, - }); - - pids.agents[participantId] = agentApp.pid || agentApp.pm2_env?.pm_id || 'unknown'; - console.log(`โœ“ ${participantId} started via PM2 (PID: ${pids.agents[participantId]})`); - } catch (error) { - console.error(`Failed to start ${participantId}: ${error.message}`); - } + const sendRawEnvelope = (envelope) => { + if (!canWrite) return; + try { + const payload = encodeEnvelope(envelope); + gatewayWrite.write(payload); + } catch (error) { + if (!useAdvancedUi) { + console.error('Failed to send envelope to gateway:', error.message); } + } + }; - // Create FIFOs for participants with fifo: true - if (participant.fifo === true) { - // Determine if we need output FIFO (not needed if output_log is set) - const createOutputFifo = !participant.output_log; - - console.log(`Creating FIFOs for ${participantId}...`); - const { inFifo, outFifo } = createFifos(spaceDir, participantId, createOutputFifo); - - if (createOutputFifo) { - console.log(`โœ“ FIFOs created: ${participantId}-in, ${participantId}-out`); - } else { - console.log( - `โœ“ FIFO created: ${participantId}-in (output goes to ${participant.output_log})`, - ); - } - - // If auto_connect is true, connect the participant - if (participant.auto_connect === true) { - console.log(`Connecting ${participantId}...`); - - const clientLogPath = path.join(logsDir, `${participantId}-client.log`); - - // Build client arguments - const clientArgs = [ - 'client', - 'connect', - '--gateway', - `ws://localhost:${selectedPort}`, - '--space', - spaceId, - '--participant-id', - participantId, - '--token', - tokenMap.get(participantId), - '--fifo-in', - inFifo, - ]; - - // Add output configuration - if (participant.output_log) { - // Ensure logs directory exists - const outputLogPath = path.join(spaceDir, participant.output_log); - const outputLogDir = path.dirname(outputLogPath); - if (!fs.existsSync(outputLogDir)) { - fs.mkdirSync(outputLogDir, { recursive: true }); - } - clientArgs.push('--output-file', outputLogPath); - } else { - clientArgs.push('--fifo-out', outFifo); - } + const flushPending = () => { + while (pendingEnvelopes.length) { + sendRawEnvelope(pendingEnvelopes.shift()); + } + }; + const queueEnvelope = (envelope) => { + if (!canWrite) return; + if (!writeReady) { + pendingEnvelopes.push(envelope); + return; + } + sendRawEnvelope(envelope); + }; + + const eventBus = useAdvancedUi ? new EventEmitter() : null; + const socket = useAdvancedUi + ? { + readyState: 0, + send(data) { + if (!canWrite) return; + let envelope = data; try { - const clientApp = await startPM2Process({ - name: `${spaceId}-${participantId}-client`, - script: path.join(__dirname, '../../bin/mew.js'), - args: clientArgs, - cwd: spaceDir, - autorestart: false, - error_file: path.join(logsDir, `${participantId}-client-error.log`), - out_file: clientLogPath, - merge_logs: true, - time: true, - }); - - pids.clients[participantId] = clientApp.pid || clientApp.pm2_env?.pm_id || 'unknown'; - console.log( - `โœ“ ${participantId} connected via PM2 (PID: ${pids.clients[participantId]})`, - ); + if (typeof data === 'string') { + envelope = JSON.parse(data); + } + queueEnvelope(envelope); } catch (error) { - console.error(`Failed to connect ${participantId}: ${error.message}`); + eventBus.emit('error', error); } - } - } - } - - // Save PID file - const pidFile = savePidFile(spaceDir, pids); - console.log(`\nโœ“ Space is up! (PID file: ${pidFile})`); - console.log(`\nGateway: ws://localhost:${selectedPort}`); - console.log(`Space ID: ${spaceId}`); - console.log(`\nTo stop: mew down`); - - // Store running space info - const runningSpaces = loadRunningSpaces(); - runningSpaces.set(spaceDir, pids); - saveRunningSpaces(runningSpaces); - - // Disconnect from PM2 daemon (it continues running) - disconnectPM2(); - - // If interactive flag is set, connect interactively - if (options.interactive) { - console.log('\nConnecting interactively...\n'); - - // Import required modules for interactive connection - const WebSocket = require('ws'); - const { - resolveParticipant, - getInteractiveOverrides, - } = require('../utils/participant-resolver'); - const { printBanner } = require('../utils/banner'); - - // Determine UI mode - const useDebugUI = options.debug || options.simple || options.noUi; - - // Import appropriate UI module - const InteractiveUI = useDebugUI ? - require('../utils/interactive-ui') : - null; - const { startAdvancedInteractiveUI } = useDebugUI ? - { startAdvancedInteractiveUI: null } : - require('../utils/advanced-interactive-ui'); - - try { - // Resolve participant - const participant = await resolveParticipant({ - participantId: options.participant, - spaceConfig: config, - interactive: true, - }); - - console.log(`Connecting as participant: ${participant.id}`); - - // Get interactive overrides - const participantConfig = getInteractiveOverrides(participant); - - // Get token for this participant - const token = await ensureTokenExists(spaceDir, participant.id); - - // Connect to gateway - const ws = new WebSocket(`ws://localhost:${selectedPort}`); - - ws.on('open', () => { - // Send join message - const joinMessage = { - protocol: 'mew/v0.3', - id: `join-${Date.now()}`, - ts: new Date().toISOString(), - kind: 'system/join', - payload: { - space: spaceId, - participant: participant.id, - token: token, - capabilities: participantConfig.capabilities || [], - }, - }; - - ws.send(JSON.stringify(joinMessage)); - - // Display banner before starting UI - if (!useDebugUI) { - printBanner({ - spaceName: spaceName, - spaceId: spaceId, - participantId: participant.id, - gateway: `ws://localhost:${selectedPort}`, - color: process.env.NO_COLOR !== '1' - }); + }, + close() { + if (this.readyState === 3) { + eventBus.emit('close'); + return; } - - // Start interactive UI - if (useDebugUI) { - const ui = new InteractiveUI(ws, participant.id, spaceId); - ui.start(); + this.readyState = 3; + eventBus.emit('close'); + }, + on(event, handler) { + eventBus.on(event, handler); + }, + once(event, handler) { + eventBus.once(event, handler); + }, + off(event, handler) { + if (eventBus.off) { + eventBus.off(event, handler); } else { - startAdvancedInteractiveUI(ws, participant.id, spaceId); + eventBus.removeListener(event, handler); } - }); - - ws.on('error', (err) => { - console.error('Failed to connect:', err.message); - process.exit(1); - }); - } catch (error) { - console.error('Failed to resolve participant:', error.message); - process.exit(1); + }, } - } -} + : null; -// Command: mew space up -space - .command('up') - .description('Start a space with gateway and configured participants') - .option('-c, --config ', 'Path to space.yaml (default: auto-detect)', './space.yaml') - .option('-d, --space-dir ', 'Space directory', '.') - .option('-p, --port ', 'Gateway port', '8080') - .option('-l, --log-level ', 'Log level', 'info') - .option('-i, --interactive', 'Connect interactively after starting space') - .option('--detach', 'Run in background (default if not interactive)') - .option('--participant ', 'Connect as this participant (with --interactive)') - .option('--debug', 'Use simple debug interface instead of advanced UI') - .option('--simple', 'Alias for --debug') - .option('--no-ui', 'Disable UI enhancements, use plain interface') - .action(spaceUpAction); + let sigintHandler = null; + let rl = null; + let rlIsActive = false; + let rlClosedByShutdown = false; -// Action handler for space down -async function spaceDownAction(options) { - const spaceDir = path.resolve(options.spaceDir); + const shutdown = (code = 0) => { + if (closed) return; + closed = true; - console.log(`Stopping space in ${spaceDir}...`); + canWrite = false; + pendingEnvelopes.length = 0; - // Load PID file - const pids = loadPidFile(spaceDir); - if (!pids) { - console.error('No running space found in this directory.'); - process.exit(1); + if (socket && socket.readyState !== 3) { + socket.close(); } - const spaceId = pids.spaceId; - console.log(`Stopping ${pids.spaceName} (${spaceId})...`); - - // Connect to PM2 try { - await connectPM2(spaceDir); + gatewayWrite.end(); } catch (error) { - console.error(`Failed to connect to PM2: ${error.message}`); - console.log('Space may have been stopped manually.'); + // ignore } - // Stop all PM2 processes for this space try { - // Stop clients first - for (const participantId of Object.keys(pids.clients || {})) { - await deletePM2Process(`${spaceId}-${participantId}-client`); - console.log(`โœ“ Stopped client: ${participantId}`); - } - - // Stop agents - for (const participantId of Object.keys(pids.agents || {})) { - await deletePM2Process(`${spaceId}-${participantId}`); - console.log(`โœ“ Stopped agent: ${participantId}`); - } - - // Stop gateway - if (pids.gateway) { - await deletePM2Process(`${spaceId}-gateway`); - console.log(`โœ“ Stopped gateway`); - } - - // Kill PM2 daemon for this space - try { - await new Promise((resolve, reject) => { - pm2.killDaemon((err) => { - if (err) reject(err); - else resolve(); - }); - }); - console.log('โœ“ Stopped PM2 daemon'); - } catch (error) { - // Daemon might already be dead - } + gatewayRead.destroy(); } catch (error) { - console.error(`Error stopping processes: ${error.message}`); + // ignore } - // Disconnect from PM2 - disconnectPM2(); - - // Clean up PM2 directory - const pm2Dir = path.join(spaceDir, '.mew', 'pm2'); - if (fs.existsSync(pm2Dir)) { - try { - fs.rmSync(pm2Dir, { recursive: true, force: true }); - console.log('โœ“ Cleaned up PM2 directory'); - } catch (error) { - console.error(`Failed to clean PM2 directory: ${error.message}`); - } + if (rl && rlIsActive) { + rlClosedByShutdown = true; + rl.close(); } - // Clean up FIFOs - cleanupFifos(spaceDir); - console.log('โœ“ Cleaned up FIFOs'); - - // Remove PID file - removePidFile(spaceDir); - console.log('โœ“ Removed PID file'); + if (sigintHandler) { + process.off('SIGINT', sigintHandler); + sigintHandler = null; + } - // Remove from running spaces - const runningSpaces = loadRunningSpaces(); - runningSpaces.delete(spaceDir); - saveRunningSpaces(runningSpaces); + process.exitCode = process.exitCode ?? code; - console.log('\nโœ“ Space stopped successfully!'); - process.exit(0); -} + if (resolveSession) { + resolveSession(); + resolveSession = null; + } + }; -// Command: mew space down -space - .command('down') - .description('Stop a running space') - .option('-d, --space-dir ', 'Space directory', '.') - .action(spaceDownAction); + let handleEnvelope; -// Command: mew space status -space - .command('status') - .description('Show status of running spaces') - .option('-d, --space-dir ', 'Space directory (optional)', null) - .action(async (options) => { - if (options.spaceDir) { - // Show status for specific space - const spaceDir = path.resolve(options.spaceDir); - const pids = loadPidFile(spaceDir); - - if (!pids) { - console.log('No running space found in this directory.'); - return; - } + if (useAdvancedUi) { + handleEnvelope = (envelope) => { + eventBus.emit('message', JSON.stringify(envelope)); + }; - const spaceId = pids.spaceId; - console.log(`Space: ${pids.spaceName} (${spaceId})`); - console.log(`Directory: ${pids.spaceDir}`); - console.log(`Gateway: ws://localhost:${pids.port}`); + const uiInstance = startAdvancedInteractiveUI(socket, participantId, state.spaceId); + if (uiInstance.waitUntilExit) { + uiInstance.waitUntilExit().finally(() => { + shutdown(0); + }); + } else { + eventBus.once('close', () => shutdown(0)); + } + } else { + const sendChat = (text) => { + if (!text.trim()) return; + queueEnvelope({ + protocol: 'mew/v0.3', + kind: 'chat', + payload: { + text, + format: 'plain', + }, + }); + }; - // Connect to PM2 to get process status - try { - await connectPM2(spaceDir); - const processes = await listPM2Processes(); - - // Filter processes for this space - const spaceProcesses = processes.filter((p) => p.name && p.name.startsWith(spaceId)); - - if (spaceProcesses.length > 0) { - console.log('\nProcesses (via PM2):'); - for (const proc of spaceProcesses) { - const status = proc.pm2_env.status === 'online' ? 'running' : proc.pm2_env.status; - const memory = proc.monit ? `${Math.round(proc.monit.memory / 1024 / 1024)}MB` : 'N/A'; - console.log(` - ${proc.name}: ${status} (PID: ${proc.pid}, Memory: ${memory})`); - } + const handleCommand = (line) => { + const [command, ...rest] = line.trim().split(' '); + const arg = rest.join(' '); + + switch (command) { + case '/help': + console.log('Commands:'); + console.log(' /help Show this help'); + console.log(' /json Send raw JSON envelope (merged with defaults)'); + console.log(' /quit Disconnect'); + console.log(' /participants List participants from configuration'); + break; + case '/quit': + if (rl) rl.close(); + break; + case '/participants': { + const participants = Object.keys(spaceConfig.participants || {}); + console.log(`Participants: ${participants.join(', ') || 'none'}`); + break; } - - disconnectPM2(); - } catch (error) { - // Fall back to PID checking if PM2 connection fails - console.log('\nProcesses (PID check):'); - - if (pids.gateway) { - try { - process.kill(pids.gateway, 0); - console.log(` - Gateway: running (PID: ${pids.gateway})`); - } catch (error) { - console.log(` - Gateway: stopped (PID: ${pids.gateway})`); + case '/json': { + if (!arg) { + console.log('Usage: /json {"kind":"chat","payload":{...}}'); + break; } - } - - for (const [id, pid] of Object.entries(pids.agents || {})) { try { - process.kill(pid, 0); - console.log(` - ${id}: running (PID: ${pid})`); + const envelope = JSON.parse(arg); + if (!envelope.protocol) { + envelope.protocol = 'mew/v0.3'; + } + queueEnvelope(envelope); } catch (error) { - console.log(` - ${id}: stopped (PID: ${pid})`); + console.error('Invalid JSON:', error.message); } + break; } + default: + console.log(`Unknown command ${command}. Type /help for help.`); + } + }; - for (const [id, pid] of Object.entries(pids.clients || {})) { - try { - process.kill(pid, 0); - console.log(` - ${id}: connected (PID: ${pid})`); - } catch (error) { - console.log(` - ${id}: disconnected (PID: ${pid})`); - } - } + rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + prompt: useColor ? chalk.green('mew> ') : 'mew> ', + }); + rlIsActive = true; + + rl.on('line', (line) => { + if (!line.trim()) { + rl.prompt(); + return; } - // Check for FIFOs - const fifoDir = path.join(spaceDir, 'fifos'); - if (fs.existsSync(fifoDir)) { - const fifos = fs.readdirSync(fifoDir); - if (fifos.length > 0) { - console.log('\nFIFOs:'); - const participants = new Set(); - for (const fifo of fifos) { - const match = fifo.match(/^(.+)-(in|out)$/); - if (match) { - participants.add(match[1]); - } - } - for (const participant of participants) { - console.log(` - ${participant}: ${path.join(fifoDir, participant + '-in')}`); - console.log(` ${path.join(fifoDir, participant + '-out')}`); - } - } + if (line.startsWith('/')) { + handleCommand(line); + } else { + sendChat(line); } - } else { - // Show all running spaces - console.log('Running spaces:\n'); - - let foundAny = false; - - // Check current directory - const currentPids = loadPidFile('.'); - if (currentPids) { - console.log(`Current directory:`); - console.log(` ${currentPids.spaceName} (${currentPids.spaceId})`); - console.log(` Gateway: ws://localhost:${currentPids.port}`); - foundAny = true; + + rl.prompt(); + }); + + rl.on('close', () => { + rlIsActive = false; + if (!rlClosedByShutdown) { + console.log('Disconnectingโ€ฆ'); } + shutdown(0); + }); - // Check saved running spaces - const runningSpaces = loadRunningSpaces(); - if (runningSpaces.size > 0) { - for (const [dir, pids] of runningSpaces.entries()) { - // Validate that the space is actually running - if (pids.gateway && isProcessRunning(pids.gateway)) { - console.log(`\n${dir}:`); - console.log(` ${pids.spaceName} (${pids.spaceId})`); - console.log(` Gateway: ws://localhost:${pids.port}`); - foundAny = true; - } - } + rl.on('SIGINT', () => { + rl.close(); + }); + + sigintHandler = () => { + rl.close(); + }; + process.on('SIGINT', sigintHandler); + + handleEnvelope = (envelope) => { + describeEnvelope(envelope, { + spaceId: state.spaceId, + participantId, + useColor, + debug, + }); + if (!debug && rl) { + rl.prompt(true); } + }; + + rl.prompt(); + } - if (!foundAny) { - console.log('No running spaces found.'); + const parser = new FrameParser((envelope) => { + if (!handleEnvelope) return; + try { + handleEnvelope(envelope); + } catch (error) { + if (!useAdvancedUi) { + console.error('Failed to handle envelope:', error.message); } } }); -// Command: mew space clean -space - .command('clean') - .description('Clean up space artifacts (logs, fifos, temporary files)') - .option('--all', 'Clean everything including .mew directory') - .option('--logs', 'Clean only log files') - .option('--fifos', 'Clean only FIFO pipes') - .option('--force', 'Skip confirmation prompts') - .option('--dry-run', 'Show what would be cleaned without doing it') - .action(async (options) => { - const spaceDir = process.cwd(); - - // Check for space configuration (.mew/space.yaml first, then space.yaml) - let spaceConfigPath = path.join(spaceDir, '.mew/space.yaml'); - if (!fs.existsSync(spaceConfigPath)) { - spaceConfigPath = path.join(spaceDir, 'space.yaml'); - if (!fs.existsSync(spaceConfigPath)) { - console.error('Error: No space configuration found.'); - console.error('Checked: .mew/space.yaml and space.yaml'); - console.error('Run "mew init" to create a new space.'); - process.exit(1); + gatewayRead.on('data', (chunk) => { + try { + parser.push(chunk); + } catch (error) { + if (useAdvancedUi) { + eventBus.emit('error', error); + } else { + console.error('Failed to parse data from gateway:', error.message); } } + }); - // Check if space is running - const pids = loadPidFile(spaceDir); - const isRunning = pids && pids.gateway && isProcessRunning(pids.gateway); - - // Collect items to clean - const itemsToClean = { - logs: [], - fifos: [], - mew: false, - pm2: false, - }; + gatewayRead.on('error', (error) => { + if (useAdvancedUi) { + eventBus.emit('error', error); + } else { + console.error('Gateway read error:', error.message); + } + shutdown(1); + }); - // Determine what to clean based on options - const cleanLogs = options.logs || (!options.fifos && !options.all) || options.all; - const cleanFifos = options.fifos || (!options.logs && !options.all) || options.all; - const cleanMew = options.all; - - // Collect log files - if (cleanLogs) { - const logsDir = path.join(spaceDir, 'logs'); - if (fs.existsSync(logsDir)) { - const files = fs.readdirSync(logsDir); - for (const file of files) { - const filePath = path.join(logsDir, file); - const stats = fs.statSync(filePath); - if (stats.isFile()) { - itemsToClean.logs.push({ - path: filePath, - size: stats.size, - name: file, - }); - } - } - } + gatewayRead.on('close', () => { + if (!useAdvancedUi) { + console.log('Gateway closed the connection.'); } + shutdown(0); + }); - // Collect FIFO pipes - if (cleanFifos) { - const fifosDir = path.join(spaceDir, 'fifos'); - if (fs.existsSync(fifosDir)) { - const files = fs.readdirSync(fifosDir); - for (const file of files) { - const filePath = path.join(fifosDir, file); - const stats = fs.statSync(filePath); - if (stats.isFIFO()) { - // Check if FIFO is in use - let inUse = false; - if (isRunning && pids.clients) { - // Check if any client is using this FIFO - for (const clientId of Object.keys(pids.clients)) { - if (file === `${clientId}-in` || file === `${clientId}-out`) { - inUse = true; - break; - } - } - } - itemsToClean.fifos.push({ - path: filePath, - name: file, - inUse, - }); - } - } - } + gatewayWrite.on('error', (error) => { + if (useAdvancedUi) { + eventBus.emit('error', error); + } else { + console.error('Gateway write error:', error.message); } + shutdown(1); + }); - // Check .mew directory - if (cleanMew) { - const mewDir = path.join(spaceDir, '.mew'); - if (fs.existsSync(mewDir)) { - itemsToClean.mew = true; - // Check for PM2 directory - const pm2Dir = path.join(mewDir, 'pm2'); - if (fs.existsSync(pm2Dir)) { - itemsToClean.pm2 = true; - } - } + gatewayWrite.on('open', () => { + writeReady = true; + if (useAdvancedUi) { + socket.readyState = 1; + eventBus.emit('open'); + } + const joinEnvelope = { + protocol: 'mew/v0.3', + kind: 'system/join', + id: `join-${Date.now()}`, + payload: { + space: state.spaceId, + participantId, + token, + }, + ts: new Date().toISOString(), + }; + if (debug) { + console.log('Sending join envelope:', JSON.stringify(joinEnvelope)); } + sendRawEnvelope(joinEnvelope); + flushPending(); + }); - // Calculate total size - let totalSize = 0; - let totalFiles = 0; + await sessionDone; +} - for (const log of itemsToClean.logs) { - totalSize += log.size; - totalFiles++; +async function spaceUpAction(options) { + const spaceDir = path.resolve(options.spaceDir || '.'); + let configPath = options.config + ? (path.isAbsolute(options.config) + ? options.config + : path.join(spaceDir, options.config)) + : path.join(spaceDir, 'space.yaml'); + + if (!fs.existsSync(configPath)) { + const altConfig = path.join(spaceDir, '.mew', 'space.yaml'); + if (fs.existsSync(altConfig)) { + configPath = altConfig; } + } - // Show what will be cleaned - if (options.dryRun) { - console.log('Would clean:\n'); + const { config, configPath: resolvedConfigPath } = loadSpaceConfig(configPath); + const spaceId = config?.space?.id; + if (!spaceId) { + console.error('space.id missing from configuration'); + process.exit(1); + } - if (itemsToClean.logs.length > 0) { - console.log(` - ${itemsToClean.logs.length} log files (${formatBytes(totalSize)})`); - if (options.verbose) { - for (const log of itemsToClean.logs) { - console.log(` - ${log.name} (${formatBytes(log.size)})`); - } - } - } + const transportConfig = config.space?.transport || {}; + const defaultTransport = transportConfig.default || 'stdio'; + const transportOverrides = transportConfig.overrides || {}; - if (itemsToClean.fifos.length > 0) { - const activeFifos = itemsToClean.fifos.filter((f) => f.inUse); - const inactiveFifos = itemsToClean.fifos.filter((f) => !f.inUse); - if (inactiveFifos.length > 0) { - console.log(` - ${inactiveFifos.length} FIFO pipes (inactive)`); - } - if (activeFifos.length > 0) { - console.log(` - ${activeFifos.length} FIFO pipes (ACTIVE - will be skipped)`); - } - } + const existingState = readState(spaceDir); + if (existingState && isPidRunning(existingState.gateway?.pid)) { + console.error('Space already appears to be running. Run "mew space down" first.'); + process.exit(1); + } - if (itemsToClean.mew) { - console.log(' - .mew directory (including process state)'); - if (itemsToClean.pm2) { - console.log(' - PM2 daemon and logs'); - } - } + console.log(`Starting space ${spaceId} in ${spaceDir}`); + + const logsDir = path.join(spaceDir, 'logs'); + ensureDir(logsDir); + const fifoDir = path.join(spaceDir, '.mew', 'fifos'); + ensureDir(fifoDir); + + const participantEntries = Object.entries(config.participants || {}); + const participantsState = {}; + const tokens = new Map(); + const participantTransports = new Map(); + + const substituteValue = (value, { token, space, port }) => { + if (typeof value !== 'string') return value; + return value + .replace(/\$\{TOKEN\}/g, token || '') + .replace(/\$\{SPACE\}/g, space || '') + .replace(/\$\{SPACE_NAME\}/g, space || '') + .replace(/\$\{PORT\}/g, port || ''); + }; + + for (const [participantId, participantConfig] of participantEntries) { + const resolvedTransport = + participantConfig.transport || transportOverrides[participantId] || defaultTransport; + participantTransports.set(participantId, resolvedTransport); + + const token = ensureToken(spaceDir, participantId, participantConfig); + tokens.set(participantId, token); + + if (resolvedTransport === 'stdio') { + await createFifoPair(fifoDir, participantId); + } + } - if (totalFiles > 0) { - console.log(`\nTotal: ${formatBytes(totalSize)} would be freed`); - } + const gatewayLog = path.join(logsDir, 'gateway.log'); + const gatewayErrLog = path.join(logsDir, 'gateway-error.log'); - return; - } + const gatewayPid = spawnDetached( + process.execPath, + [path.join(__dirname, '../../bin/mew.js'), 'gateway', 'start', '--space-config', resolvedConfigPath, '--fifo-dir', fifoDir, '--log-level', options.logLevel || 'info'], + { + cwd: spaceDir, + env: process.env, + stdout: gatewayLog, + stderr: gatewayErrLog, + }, + ); - // Warn if space is running - if (isRunning && !options.force) { - console.log(`Space "${pids.spaceName}" is currently running.`); + console.log(`Gateway started (PID ${gatewayPid})`); - if (cleanMew) { - console.error('Error: Cannot clean .mew directory while space is running.'); - console.error('Use "mew space down" first, or remove --all flag.'); - process.exit(1); - } + const tokenDir = path.join(spaceDir, '.mew', 'tokens'); - console.log('Warning: This will clean artifacts while space is active.'); - console.log('Use "mew space down" first, or use --force to proceed anyway.'); + const remoteParticipants = []; - const readline = require('readline'); - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); + for (const [participantId, participantConfig] of participantEntries) { + const transport = participantTransports.get(participantId) || 'stdio'; - const answer = await new Promise((resolve) => { - rl.question('Continue? (y/N): ', resolve); - }); - rl.close(); + if (transport !== 'stdio') { + participantsState[participantId] = { + transport, + tokenPath: path.join(tokenDir, `${participantId}.token`), + }; - if (answer.toLowerCase() !== 'y') { - console.log('Aborted.'); - process.exit(0); + if (participantConfig.auto_start) { + console.warn( + `Participant ${participantId} has transport '${transport}' so auto_start is ignored.`, + ); } + remoteParticipants.push(participantId); + continue; } - // Confirm destructive operations - if (cleanMew && !options.force) { - console.log('This will remove ALL space artifacts including configuration.'); - - const readline = require('readline'); - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); + if (!participantConfig.auto_start) { + participantsState[participantId] = { + transport, + fifoIn: path.join(fifoDir, `${participantId}.in`), + fifoOut: path.join(fifoDir, `${participantId}.out`), + tokenPath: path.join(tokenDir, `${participantId}.token`), + }; + continue; + } - const answer = await new Promise((resolve) => { - rl.question('Are you sure? (y/N): ', resolve); + const fifoIn = path.join(fifoDir, `${participantId}.in`); + const fifoOut = path.join(fifoDir, `${participantId}.out`); + const adapterLog = path.join(logsDir, `${participantId}-adapter.log`); + const adapterErrLog = path.join(logsDir, `${participantId}-adapter-error.log`); + + const args = [ + path.join(__dirname, '../../bin/mew-stdio-adapter.js'), + '--fifo-in', + fifoIn, + '--fifo-out', + fifoOut, + '--space', + spaceId, + '--participant-id', + participantId, + '--token', + tokens.get(participantId), + '--log-file', + adapterLog, + ]; + console.log(`Launching adapter ${participantId} with token ${tokens.get(participantId)}`); + + if (participantConfig.command) { + args.push('--command', participantConfig.command); + } + if (participantConfig.args && participantConfig.args.length) { + const substituted = participantConfig.args.map((value) => + substituteValue(value, { + token: tokens.get(participantId), + space: spaceId, + port: options.port || '0', + }), + ); + args.push('--args', '--', ...substituted); + } + if (participantConfig.cwd) { + args.push('--cwd', substituteValue(participantConfig.cwd, { + token: tokens.get(participantId), + space: spaceId, + port: options.port || '0', + })); + } + if (participantConfig.env) { + const envPairs = Object.entries(participantConfig.env).map(([key, value]) => { + const resolved = substituteValue(value, { + token: tokens.get(participantId), + space: spaceId, + port: options.port || '0', + }); + return `${key}=${resolved}`; }); - rl.close(); - - if (answer.toLowerCase() !== 'y') { - console.log('Aborted.'); - process.exit(0); + if (envPairs.length) { + args.push('--env', ...envPairs); } } - // Perform cleaning - let cleanedCount = 0; - let errors = []; - - // Clean logs - if (itemsToClean.logs.length > 0) { - console.log('Cleaning logs...'); - for (const log of itemsToClean.logs) { - try { - fs.unlinkSync(log.path); - cleanedCount++; - } catch (error) { - errors.push(`Failed to delete ${log.name}: ${error.message}`); - } - } - console.log(`โœ“ Cleaned ${itemsToClean.logs.length} log files`); - } + const adapterPid = spawnDetached(process.execPath, args, { + cwd: spaceDir, + env: process.env, + stdout: adapterLog, + stderr: adapterErrLog, + }); - // Clean FIFOs (skip active ones) - if (itemsToClean.fifos.length > 0) { - const inactiveFifos = itemsToClean.fifos.filter((f) => !f.inUse); - if (inactiveFifos.length > 0) { - console.log('Cleaning FIFOs...'); - for (const fifo of inactiveFifos) { - try { - fs.unlinkSync(fifo.path); - cleanedCount++; - } catch (error) { - errors.push(`Failed to delete ${fifo.name}: ${error.message}`); - } - } - console.log(`โœ“ Cleaned ${inactiveFifos.length} FIFO pipes`); - } + participantsState[participantId] = { + transport, + adapterPid, + fifoIn, + fifoOut, + logs: { + adapter: adapterLog, + error: adapterErrLog, + }, + tokenPath: path.join(tokenDir, `${participantId}.token`), + }; - const activeFifos = itemsToClean.fifos.filter((f) => f.inUse); - if (activeFifos.length > 0) { - console.log(`โš  Skipped ${activeFifos.length} active FIFO pipes`); - } - } + console.log(`Adapter started for ${participantId} (PID ${adapterPid})`); + } - // Clean .mew directory - if (itemsToClean.mew) { - console.log('Cleaning .mew directory...'); - const mewDir = path.join(spaceDir, '.mew'); - - // If PM2 daemon is running, try to kill it first - if (itemsToClean.pm2 && !isRunning) { - try { - await connectPM2(spaceDir); - await new Promise((resolve, reject) => { - pm2.killDaemon((err) => { - if (err) reject(err); - else resolve(); - }); - }); - console.log('โœ“ Stopped PM2 daemon'); - } catch (error) { - // Daemon might already be dead - } - } + const websocketListenValueRaw = config.gateway?.websocket?.listen; + const websocketListenResolved = resolveWebsocketListen(websocketListenValueRaw); + + const websocketRequired = + remoteParticipants.length > 0 || defaultTransport === 'websocket'; + + if (remoteParticipants.length > 0) { + console.log( + `Awaiting WebSocket participants (${remoteParticipants.join(', ')}). Gateway listening at ws://${websocketListenResolved}.`, + ); + console.log('Tokens are stored under .mew/tokens/.token.'); + } + + const statePath = writeState(spaceDir, { + spaceId, + configPath: resolvedConfigPath, + fifoDir, + gateway: { + pid: gatewayPid, + logs: { + out: gatewayLog, + err: gatewayErrLog, + }, + }, + participants: participantsState, + transports: { + default: defaultTransport, + overrides: transportOverrides, + websocket: websocketRequired ? websocketListenResolved : null, + }, + }); - // Remove the directory + console.log(`Space state written to ${statePath}`); + + if (options.interactive) { + await spaceConnectAction({ + spaceDir, + participant: options.participant, + debug: options.debug || options.simple, + noUi: options.noUi, + noColor: options.noColor, + interactiveSelection: options.interactiveSelection, + }).catch((error) => { + console.error(error.message); + process.exitCode = 1; + }); + } +} + +async function spaceDownAction(options) { + const spaceDir = path.resolve(options.spaceDir || '.'); + const state = readState(spaceDir); + if (!state) { + console.error('No running space found.'); + process.exit(1); + } + + console.log(`Stopping space ${state.spaceId}...`); + + for (const [participantId, participantState] of Object.entries(state.participants || {})) { + if (participantState.adapterPid && isPidRunning(participantState.adapterPid)) { try { - fs.rmSync(mewDir, { recursive: true, force: true }); - console.log('โœ“ Cleaned .mew directory'); + process.kill(participantState.adapterPid, 'SIGTERM'); + console.log(`Sent SIGTERM to adapter ${participantId}`); } catch (error) { - errors.push(`Failed to clean .mew directory: ${error.message}`); + console.error(`Failed to stop adapter ${participantId}: ${error.message}`); } } + } - // Report results - if (errors.length > 0) { - console.log('\nโš  Some items could not be cleaned:'); - for (const error of errors) { - console.log(` - ${error}`); - } + if (state.gateway?.pid && isPidRunning(state.gateway.pid)) { + try { + process.kill(state.gateway.pid, 'SIGTERM'); + console.log('Sent SIGTERM to gateway'); + } catch (error) { + console.error(`Failed to stop gateway: ${error.message}`); } + } + + removeState(spaceDir); + console.log('Space stopped'); +} + +function spaceStatusAction(options) { + const spaceDir = path.resolve(options.spaceDir || '.'); + const state = readState(spaceDir); + if (!state) { + console.log('No running space found.'); + return; + } - if (cleanedCount > 0 || itemsToClean.mew) { + const gatewayRunning = isPidRunning(state.gateway?.pid); + console.log(`Space ${state.spaceId}`); + console.log(` Gateway PID: ${state.gateway?.pid || 'n/a'} (${gatewayRunning ? 'running' : 'stopped'})`); + if (state.transports?.websocket) { + console.log(` WebSocket listen: ws://${state.transports.websocket}`); + } + + for (const [participantId, participantState] of Object.entries(state.participants || {})) { + const transport = participantState.transport || state.transports?.default || 'stdio'; + if (transport === 'stdio') { + const running = participantState.adapterPid && isPidRunning(participantState.adapterPid); console.log( - `\nโœ“ Cleanup complete! ${totalSize > 0 ? `Freed ${formatBytes(totalSize)}` : ''}`, + ` Adapter ${participantId}: PID ${participantState.adapterPid || 'n/a'} (${running ? 'running' : 'stopped'})`, ); } else { - console.log('\nNothing to clean.'); + console.log( + ` Remote participant ${participantId}: transport=${transport} (token: ${ + participantState.tokenPath || 'see .mew/tokens' + })`, + ); } + } +} - // Disconnect from PM2 if we connected - if (itemsToClean.pm2) { - disconnectPM2(); +function removeFifos(spaceDir) { + const fifoDir = path.join(spaceDir, '.mew', 'fifos'); + if (!fs.existsSync(fifoDir)) return; + for (const entry of fs.readdirSync(fifoDir)) { + const target = path.join(fifoDir, entry); + try { + fs.unlinkSync(target); + } catch (error) { + // ignore cleanup error } - }); + } +} -// Helper function to format bytes -function formatBytes(bytes) { - if (bytes === 0) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +function removeLogs(spaceDir) { + const logsDir = path.join(spaceDir, 'logs'); + if (!fs.existsSync(logsDir)) return; + for (const entry of fs.readdirSync(logsDir)) { + const target = path.join(logsDir, entry); + try { + fs.unlinkSync(target); + } catch (error) { + // ignore cleanup error + } + } +} + +function spaceCleanAction() { + const spaceDir = process.cwd(); + removeLogs(spaceDir); + removeFifos(spaceDir); + console.log('Cleaned logs and FIFOs'); } -// Command: mew space connect space - .command('connect') - .description('Connect interactively to a running space') - .option('-c, --config ', 'Path to space.yaml (default: auto-detect)', './space.yaml') - .option('-d, --space-dir ', 'Directory of space to connect to', '.') - .option('--participant ', 'Connect as this participant') - .option('--gateway ', 'Override gateway URL (default: from running space)') - .option('--debug', 'Use simple debug interface instead of advanced UI') + .command('up') + .description('Start a space with gateway and adapters') + .option('-c, --config ', 'Path to space.yaml', './space.yaml') + .option('-d, --space-dir ', 'Space directory', '.') + .option('-l, --log-level ', 'Gateway log level', 'info') + .option('-i, --interactive', 'Connect interactively after starting the space') + .option('-p, --participant ', 'Participant ID to connect as') + .option('--debug', 'Show raw envelopes while connected') .option('--simple', 'Alias for --debug') - .option('--no-ui', 'Disable UI enhancements, use plain interface') - .action(async (options) => { - const spaceDir = path.resolve(options.spaceDir); - const configPath = path.join(spaceDir, path.basename(options.config)); - - console.log(`Connecting to space in ${spaceDir}...`); - - // Check if space is running - const pids = loadPidFile(spaceDir); - if (!pids) { - console.error('No running space found. Use "mew space up" first.'); + .option('--no-ui', 'Disable fancy formatting for interactive mode') + .option('--no-color', 'Disable colored output') + .action((options) => { + spaceUpAction(options).catch((error) => { + console.error(error.message); process.exit(1); - } - - // Load space configuration - const { config } = loadSpaceConfig(configPath); - const spaceId = pids.spaceId; - const gatewayUrl = options.gateway || `ws://localhost:${pids.port}`; - - console.log(`Space: ${pids.spaceName} (${spaceId})`); - console.log(`Gateway: ${gatewayUrl}`); - - // Import required modules - const WebSocket = require('ws'); - const { - resolveParticipant, - getInteractiveOverrides, - } = require('../utils/participant-resolver'); - const { printBanner } = require('../utils/banner'); - - // Determine UI mode - const useDebugUI = options.debug || options.simple || options.noUi; - - // Import appropriate UI module - const InteractiveUI = useDebugUI ? - require('../utils/interactive-ui') : - null; - const { startAdvancedInteractiveUI } = useDebugUI ? - { startAdvancedInteractiveUI: null } : - require('../utils/advanced-interactive-ui'); + }); + }); - try { - // Resolve participant - const participant = await resolveParticipant({ - participantId: options.participant, - spaceConfig: config, - interactive: true, - }); +space + .command('connect') + .description('Attach an interactive terminal to a running space') + .option('-d, --space-dir ', 'Space directory', '.') + .option('-p, --participant ', 'Participant ID to connect as') + .option('--debug', 'Show raw envelopes while connected') + .option('--simple', 'Alias for --debug') + .option('--no-ui', 'Disable fancy formatting') + .option('--no-color', 'Disable colored output') + .action((options) => { + spaceConnectAction(options).catch((error) => { + console.error(error.message); + process.exit(1); + }); + }); - console.log(`Connecting as participant: ${participant.id}\n`); - - // Get interactive overrides - const participantConfig = getInteractiveOverrides(participant); - - // Get token for this participant - const token = await ensureTokenExists(spaceDir, participant.id); - - // Connect to gateway - const ws = new WebSocket(gatewayUrl); - - ws.on('open', () => { - // Send join message - const joinMessage = { - protocol: 'mew/v0.3', - id: `join-${Date.now()}`, - ts: new Date().toISOString(), - kind: 'system/join', - payload: { - space: spaceId, - participant: participant.id, - token: token, - capabilities: participantConfig.capabilities || [], - }, - }; - - ws.send(JSON.stringify(joinMessage)); - - // Display banner before starting UI - if (!useDebugUI) { - printBanner({ - spaceName: pids.spaceName, - spaceId: spaceId, - participantId: participant.id, - gateway: gatewayUrl, - color: process.env.NO_COLOR !== '1' - }); - } +space + .command('down') + .description('Stop a running space') + .option('-d, --space-dir ', 'Space directory', '.') + .action(spaceDownAction); - // Start interactive UI - if (useDebugUI) { - const ui = new InteractiveUI(ws, participant.id, spaceId); - ui.start(); - } else { - startAdvancedInteractiveUI(ws, participant.id, spaceId); - } - }); +space + .command('status') + .description('Show basic space status') + .option('-d, --space-dir ', 'Space directory', '.') + .action(spaceStatusAction); - ws.on('error', (err) => { - console.error('Failed to connect:', err.message); - console.error('Make sure the space is running with "mew space up"'); - process.exit(1); - }); +space + .command('clean') + .description('Remove space logs and FIFOs') + .action(spaceCleanAction); - ws.on('close', () => { - console.log('\nConnection closed'); - process.exit(0); - }); - } catch (error) { - console.error('Failed to resolve participant:', error.message); - process.exit(1); - } - }); +space.spaceUpAction = spaceUpAction; +space.spaceDownAction = spaceDownAction; +space.spaceStatusAction = spaceStatusAction; +space.spaceCleanAction = spaceCleanAction; +space.spaceConnectAction = spaceConnectAction; -// Export both the command and the action handlers module.exports = space; -module.exports.spaceUpAction = spaceUpAction; -module.exports.spaceDownAction = spaceDownAction; diff --git a/cli/src/gateway/core.js b/cli/src/gateway/core.js new file mode 100644 index 00000000..e134931a --- /dev/null +++ b/cli/src/gateway/core.js @@ -0,0 +1,243 @@ +const EventEmitter = require('events'); + +/** + * GatewayCore manages space state, participant tracking, and message routing while + * remaining agnostic of the underlying transport. + */ +class GatewayCore extends EventEmitter { + /** + * @param {object} options + * @param {string} options.spaceId + * @param {Map} options.participants - participantId -> config + * @param {Map} options.tokensByParticipant - participantId -> token + * @param {object} [options.logger] + */ + constructor({ spaceId, participants, tokensByParticipant, logger }) { + super(); + this.spaceId = spaceId; + this.participantConfigs = participants; // Map of participantId -> config from space.yaml + this.tokensByParticipant = tokensByParticipant; // Map participantId -> token string + + // Reverse lookup: token -> participantId for authentication + this.participantByToken = new Map(); + for (const [participantId, token] of tokensByParticipant.entries()) { + if (token) { + this.participantByToken.set(token, participantId); + } + } + + this.logger = logger || console; + this.transports = new Set(); + this.connections = new Map(); // participantId -> connection state + } + + /** + * Registers a transport and wires up event handlers. + * Transport must expose on(event, handler) and support events: message, disconnect, error. + */ + attachTransport(transport) { + this.transports.add(transport); + + transport.on('message', ({ participantId, envelope, channel }) => { + this._handleIncomingEnvelope(participantId, envelope, channel); + }); + + transport.on('disconnect', ({ participantId, channel }) => { + this._handleDisconnect(participantId, channel); + }); + + transport.on('error', (error) => { + this.logger.error('Transport error:', error); + }); + } + + /** + * Core handles envelope routing, performing join authentication on first message. + */ + _handleIncomingEnvelope(participantId, envelope, channel) { + let effectiveParticipantId = participantId; + + if (!effectiveParticipantId) { + const joinPayload = envelope?.payload || envelope; + const token = joinPayload?.token || envelope?.token; + if (token && this.participantByToken.has(token)) { + effectiveParticipantId = this.participantByToken.get(token); + } + } + + if (!effectiveParticipantId) { + this.logger.warn('Received envelope without participant resolution'); + channel.send?.({ + protocol: 'mew/v0.3', + kind: 'system/error', + payload: { message: 'Unable to resolve participant for connection' }, + }); + channel.close?.(); + return; + } + + const connection = this.connections.get(effectiveParticipantId); + + if (!connection) { + this._handleJoin(effectiveParticipantId, envelope, channel); + return; + } + + // Subsequent messages: enforce space and from fields before routing + const enrichedEnvelope = { + ...envelope, + space: this.spaceId, + from: effectiveParticipantId, + protocol: envelope.protocol || 'mew/v0.3', + ts: envelope.ts || new Date().toISOString(), + }; + + this._broadcast(enrichedEnvelope, { exclude: effectiveParticipantId }); + } + + _handleJoin(participantId, envelope, channel) { + const joinPayload = envelope?.payload || envelope; + const token = joinPayload?.token || envelope?.token; + const requestedParticipantId = joinPayload?.participantId || envelope?.participantId; + const requestedSpace = joinPayload?.space || envelope?.space; + + if (requestedSpace && requestedSpace !== this.spaceId) { + this.logger.warn( + `Participant ${participantId} attempted to join unexpected space ${requestedSpace}`, + ); + channel.send({ + protocol: 'mew/v0.3', + kind: 'system/error', + payload: { message: 'Invalid space for this gateway' }, + }); + channel.close?.(); + return; + } + + if (!token) { + this.logger.warn(`Participant ${participantId} missing token in join handshake`); + channel.send({ + protocol: 'mew/v0.3', + kind: 'system/error', + payload: { message: 'Authentication required' }, + }); + channel.close?.(); + return; + } + + const expectedParticipant = this.participantByToken.get(token); + if (!expectedParticipant || expectedParticipant !== participantId) { + this.logger.warn( + `Authentication failed for participant ${participantId} (token=${token?.slice(0, 8) || 'none'})`, + ); + channel.send({ + protocol: 'mew/v0.3', + kind: 'system/error', + payload: { message: 'Authentication failed' }, + }); + channel.close?.(); + return; + } + + if (requestedParticipantId && requestedParticipantId !== participantId) { + this.logger.warn( + `Join mismatch: FIFO participant ${participantId} requested id ${requestedParticipantId}`, + ); + channel.send({ + protocol: 'mew/v0.3', + kind: 'system/error', + payload: { message: 'Participant mismatch' }, + }); + channel.close?.(); + return; + } + + const config = this.participantConfigs.get(participantId) || {}; + + const connectionState = { + participantId, + channel, + capabilities: config.capabilities || [], + }; + this.connections.set(participantId, connectionState); + + if (typeof channel.setParticipantId === 'function') { + try { + channel.setParticipantId(participantId); + } catch (error) { + this.logger.warn('Failed to propagate participantId to transport channel:', error.message); + } + } + + this.logger.log(`Participant ${participantId} joined space ${this.spaceId}`); + + // Send welcome to participant + channel.send({ + protocol: 'mew/v0.3', + kind: 'system/welcome', + payload: { + space: this.spaceId, + participant: participantId, + you: { + id: participantId, + capabilities: connectionState.capabilities, + }, + participants: Array.from(this.connections.keys()).map((id) => ({ + id, + capabilities: this.connections.get(id)?.capabilities || this.participantConfigs.get(id)?.capabilities || [], + })), + }, + }); + + // Notify other participants + this._broadcast( + { + protocol: 'mew/v0.3', + kind: 'system/participant-joined', + from: participantId, + space: this.spaceId, + ts: new Date().toISOString(), + }, + { exclude: participantId }, + ); + + this.emit('participant:joined', { participantId }); + } + + _handleDisconnect(participantId) { + if (!this.connections.has(participantId)) { + return; + } + + this.logger.log(`Participant ${participantId} disconnected`); + this.connections.delete(participantId); + + this._broadcast( + { + protocol: 'mew/v0.3', + kind: 'system/participant-left', + from: participantId, + space: this.spaceId, + ts: new Date().toISOString(), + }, + { exclude: participantId }, + ); + + this.emit('participant:left', { participantId }); + } + + _broadcast(envelope, { exclude } = {}) { + for (const [participantId, connection] of this.connections.entries()) { + if (participantId === exclude) continue; + try { + connection.channel.send(envelope); + } catch (error) { + this.logger.error('Failed to deliver envelope', error); + } + } + } +} + +module.exports = { + GatewayCore, +}; diff --git a/cli/src/gateway/transports/fifo.js b/cli/src/gateway/transports/fifo.js new file mode 100644 index 00000000..1dd40a41 --- /dev/null +++ b/cli/src/gateway/transports/fifo.js @@ -0,0 +1,131 @@ +const EventEmitter = require('events'); +const fs = require('fs'); +const path = require('path'); +const { encodeEnvelope, FrameParser } = require('../../stdio/utils'); + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function waitForPath(targetPath, { timeoutMs = 5000, pollMs = 100 } = {}) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + await fs.promises.access(targetPath, fs.constants.F_OK); + return; + } catch (error) { + if (error.code !== 'ENOENT') { + throw error; + } + await sleep(pollMs); + } + } + throw new Error(`Timed out waiting for FIFO at ${targetPath}`); +} + +class FIFOTransport extends EventEmitter { + /** + * @param {object} options + * @param {string} options.fifoDir - Directory containing FIFO pairs + * @param {Iterable} options.participantIds + * @param {object} [options.logger] + */ + constructor({ fifoDir, participantIds, logger }) { + super(); + this.fifoDir = fifoDir; + this.participantIds = [...participantIds]; + this.logger = logger || console; + this.channels = new Map(); + this.started = false; + } + + async start() { + if (this.started) return; + this.started = true; + + await Promise.all( + this.participantIds.map((participantId) => this.#setupChannel(participantId)), + ); + } + + async #setupChannel(participantId) { + const toParticipantPath = path.join(this.fifoDir, `${participantId}.in`); + const fromParticipantPath = path.join(this.fifoDir, `${participantId}.out`); + + await waitForPath(toParticipantPath); + await waitForPath(fromParticipantPath); + + this.logger.log( + `FIFO transport attaching to ${participantId} using ${fromParticipantPath} -> ${toParticipantPath}`, + ); + + const readStream = fs.createReadStream(fromParticipantPath); + const writeStream = fs.createWriteStream(toParticipantPath); + + const channel = { + participantId, + send: (envelope) => { + const payload = encodeEnvelope(envelope); + if (!writeStream.write(payload)) { + this.logger.warn(`Backpressure writing to participant ${participantId}`); + } + }, + close: () => { + closed = true; + writeStream.end(); + readStream.destroy(); + }, + }; + + this.channels.set(participantId, channel); + + const parser = new FrameParser((envelope) => { + this.emit('message', { participantId, envelope, channel }); + }); + + let closed = false; + const emitDisconnect = () => { + if (closed) return; + closed = true; + this.emit('disconnect', { participantId, channel }); + }; + + readStream.on('data', (chunk) => { + try { + parser.push(chunk); + } catch (error) { + this.emit('error', error); + } + }); + readStream.on('end', emitDisconnect); + readStream.on('close', emitDisconnect); + readStream.on('error', (error) => { + if (error.code === 'EPIPE') { + emitDisconnect(); + return; + } + this.emit('error', error); + }); + + writeStream.on('error', (error) => { + if (error.code === 'EPIPE') { + emitDisconnect(); + return; + } + this.emit('error', error); + }); + } + + /** + * Sends an envelope to a participant. + */ + send(participantId, envelope) { + const channel = this.channels.get(participantId); + if (!channel) { + throw new Error(`Unknown participant channel ${participantId}`); + } + channel.send(envelope); + } +} + +module.exports = { + FIFOTransport, +}; diff --git a/cli/src/gateway/transports/websocket.js b/cli/src/gateway/transports/websocket.js new file mode 100644 index 00000000..e68b8a44 --- /dev/null +++ b/cli/src/gateway/transports/websocket.js @@ -0,0 +1,98 @@ +const EventEmitter = require('events'); +const WebSocket = require('ws'); + +class WebSocketTransport extends EventEmitter { + constructor({ host = '127.0.0.1', port = 4700, logger }) { + super(); + this.host = host; + this.port = port; + this.logger = logger || console; + this.server = null; + this.connections = new Set(); + } + + async start() { + if (this.server) return; + + this.logger.log(`Starting WebSocket transport on ws://${this.host}:${this.port}`); + + this.server = new WebSocket.Server({ host: this.host, port: this.port }); + + this.server.on('connection', (socket) => { + const connection = { + socket, + participantId: null, + }; + this.connections.add(connection); + + const channel = { + send: (envelope) => { + if (socket.readyState !== WebSocket.OPEN) return; + try { + socket.send(JSON.stringify(envelope)); + } catch (error) { + this.logger.warn('Failed to send envelope over WebSocket:', error.message); + } + }, + close: () => { + try { + socket.close(); + } catch (error) { + // ignore + } + }, + setParticipantId: (participantId) => { + connection.participantId = participantId; + }, + }; + + socket.on('message', (data) => { + try { + const text = data.toString(); + const envelope = JSON.parse(text); + this.emit('message', { + participantId: connection.participantId, + envelope, + channel, + }); + } catch (error) { + this.logger.warn('Failed to parse WebSocket message:', error.message); + } + }); + + socket.on('close', () => { + this.connections.delete(connection); + this.emit('disconnect', { + participantId: connection.participantId, + channel, + }); + }); + + socket.on('error', (error) => { + this.emit('error', error); + }); + }); + + this.server.on('error', (error) => { + this.emit('error', error); + }); + + await new Promise((resolve, reject) => { + this.server.once('listening', resolve); + this.server.once('error', reject); + }); + } + + async stop() { + if (!this.server) return; + await new Promise((resolve) => { + this.server.close(() => resolve()); + }); + this.server = null; + this.connections.clear(); + } +} + +module.exports = { + WebSocketTransport, +}; diff --git a/cli/src/index.js b/cli/src/index.js index dc2ec517..220d48d2 100644 --- a/cli/src/index.js +++ b/cli/src/index.js @@ -70,6 +70,7 @@ program .option('--debug', 'Use simple debug interface instead of advanced UI') .option('--simple', 'Alias for --debug') .option('--no-ui', 'Disable UI enhancements, use plain interface') + .option('--no-color', 'Disable colored output in interactive mode') .action(spaceCommand.spaceUpAction); program @@ -80,62 +81,53 @@ program // Helper function to check if space is running function isSpaceRunning() { - const pidFile = path.join(process.cwd(), '.mew', 'pids.json'); - if (!fs.existsSync(pidFile)) { + const runStatePath = path.join(process.cwd(), '.mew', 'run', 'state.json'); + if (!fs.existsSync(runStatePath)) { return false; } try { - const pids = JSON.parse(fs.readFileSync(pidFile, 'utf8')); - // Check if gateway process is still running - if (pids.gateway) { - try { - process.kill(pids.gateway, 0); // Check if process exists - return true; - } catch { - return false; - } + const state = JSON.parse(fs.readFileSync(runStatePath, 'utf8')); + const pid = state?.gateway?.pid; + if (!pid) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + return false; } - } catch { + } catch (error) { return false; } - return false; } // Default behavior when no command is provided if (process.argv.length === 2) { - // No arguments provided - intelligent default behavior - if (checkSpaceExists()) { - // Space exists - check if it's running - if (isSpaceRunning()) { - // Space is running - connect to it - console.log('Connecting to running space...'); - process.argv.push('space', 'connect'); - } else { - // Space exists but not running - start it interactively + (async () => { + try { + if (!checkSpaceExists()) { + console.log('Welcome to MEW Protocol! Let\'s set up your space.'); + const initCommand = new InitCommand(); + await initCommand.execute({}); + console.log('\nSpace initialized! Starting interactive session...'); + await spaceCommand.spaceUpAction({ interactive: true }); + return; + } + + if (isSpaceRunning()) { + console.log('Space is running. Connecting interactively...'); + await spaceCommand.spaceConnectAction({}); + return; + } + console.log('Starting space and connecting interactively...'); - process.argv.push('space', 'up', '-i'); - } - } else { - // No space - run init, then connect - console.log('Welcome to MEW Protocol! Let\'s set up your space.'); - const initCommand = new InitCommand(); - initCommand.execute({}).then(() => { - console.log('\nSpace initialized! Starting and connecting...'); - // After init, start the space interactively by spawning a new process - const { spawn } = require('child_process'); - const child = spawn(process.argv[0], [process.argv[1], 'space', 'up', '-i'], { - stdio: 'inherit' - }); - child.on('exit', (code) => { - process.exit(code || 0); - }); - }).catch(error => { + await spaceCommand.spaceUpAction({ interactive: true }); + } catch (error) { console.error('Error:', error.message); process.exit(1); - }); - return; // Don't parse args since we're handling it - } + } + })(); + return; } // Parse arguments diff --git a/cli/src/stdio/utils.js b/cli/src/stdio/utils.js new file mode 100644 index 00000000..85a7c737 --- /dev/null +++ b/cli/src/stdio/utils.js @@ -0,0 +1,60 @@ +const { StringDecoder } = require('string_decoder'); + +function encodeEnvelope(envelope) { + const content = JSON.stringify(envelope); + const length = Buffer.byteLength(content, 'utf8'); + return `Content-Length: ${length}\r\n\r\n${content}`; +} + +class FrameParser { + constructor(onMessage) { + this.onMessage = onMessage; + this.buffer = Buffer.alloc(0); + this.decoder = new StringDecoder('utf8'); + this.expectedLength = null; + } + + push(chunk) { + this.buffer = Buffer.concat([this.buffer, chunk]); + this._process(); + } + + end() { + if (this.buffer.length > 0) { + this._process(); + } + } + + _process() { + while (true) { + if (this.expectedLength === null) { + const headerEnd = this.buffer.indexOf('\r\n\r\n'); + if (headerEnd === -1) return; + const header = this.buffer.slice(0, headerEnd).toString('utf8'); + const match = header.match(/Content-Length: (\d+)/i); + if (!match) { + throw new Error('Invalid frame header'); + } + this.expectedLength = Number(match[1]); + this.buffer = this.buffer.slice(headerEnd + 4); + } + + if (this.buffer.length < this.expectedLength) { + return; + } + + const messageBuffer = this.buffer.slice(0, this.expectedLength); + this.buffer = this.buffer.slice(this.expectedLength); + this.expectedLength = null; + + const json = this.decoder.write(messageBuffer); + const data = JSON.parse(json); + this.onMessage(data); + } + } +} + +module.exports = { + encodeEnvelope, + FrameParser, +}; diff --git a/cli/src/utils/advanced-interactive-ui.js b/cli/src/utils/advanced-interactive-ui.js index b2cf4a83..7550395f 100644 --- a/cli/src/utils/advanced-interactive-ui.js +++ b/cli/src/utils/advanced-interactive-ui.js @@ -947,20 +947,33 @@ function getPayloadPreview(payload, kind) { * Starts the advanced interactive UI */ function startAdvancedInteractiveUI(ws, participantId, spaceId) { - const { rerender, unmount } = render( + const instance = render( React.createElement(AdvancedInteractiveUI, { ws, participantId, spaceId }) ); - // Handle cleanup - process.on('SIGINT', () => { - unmount(); + const handleSigint = () => { + instance.unmount(); process.exit(0); + }; + + process.on('SIGINT', handleSigint); + + const exitPromise = instance.waitUntilExit(); + exitPromise.finally(() => { + process.off('SIGINT', handleSigint); }); - return { rerender, unmount }; + return { + rerender: instance.rerender, + unmount: () => { + process.off('SIGINT', handleSigint); + instance.unmount(); + }, + waitUntilExit: () => exitPromise, + }; } module.exports = { startAdvancedInteractiveUI, AdvancedInteractiveUI, -}; \ No newline at end of file +}; diff --git a/cli/src/utils/participant-resolver.js b/cli/src/utils/participant-resolver.js index 963e990d..39ce792f 100644 --- a/cli/src/utils/participant-resolver.js +++ b/cli/src/utils/participant-resolver.js @@ -149,12 +149,6 @@ function getInteractiveOverrides(participantConfig) { delete config.output_log; delete config.auto_connect; - // Ensure we have tokens - if (!config.tokens || config.tokens.length === 0) { - // Generate a default token if none provided - config.tokens = [`${config.id}-token-${Date.now()}`]; - } - return config; } diff --git a/cli/templates/coder-agent/space.yaml b/cli/templates/coder-agent/space.yaml index a1273862..afae2446 100644 --- a/cli/templates/coder-agent/space.yaml +++ b/cli/templates/coder-agent/space.yaml @@ -4,6 +4,14 @@ space: name: "{{SPACE_NAME}}" description: "Coding assistant that requires human approval for file modifications" default_participant: human + # transport: + # default: stdio + # overrides: + # remote-tester: websocket + +gateway: + # websocket: + # listen: 0.0.0.0:4700 participants: # Human participant with full capabilities (can approve proposals) @@ -15,9 +23,14 @@ participants: # Coder agent with limited capabilities - creates proposals for file modifications mew: type: local - command: "npx" - args: ["--yes", "@mew-protocol/agent", "--gateway", "ws://localhost:${PORT}", "--space", "{{SPACE_NAME}}", "--token", "${TOKEN}", "--id", "mew"] + command: "node" + args: ["../../sdk/typescript-sdk/agent/dist/index.js"] auto_start: true + env: + MEW_TRANSPORT: "stdio" + MEW_SPACE: "{{SPACE_NAME}}" + MEW_TOKEN: "${TOKEN}" + MEW_PARTICIPANT_ID: "mew" # No tokens field - generated at runtime in .mew/tokens/ capabilities: - kind: "chat" @@ -86,50 +99,34 @@ participants: method: "tools/call" params: name: "get_file_info" - env: - # API configuration - these read from environment at runtime - OPENAI_API_KEY: "${OPENAI_API_KEY}" - OPENAI_BASE_URL: "{{AGENT_BASE_URL}}" - OPENAI_MODEL: "{{AGENT_MODEL}}" - # Configure the agent with 100 max iterations for complex multi-step tasks - # and 1 hour timeout for proposals to allow human review time - # and 50 message conversation history for context across interactions - MEW_AGENT_CONFIG: '{"maxIterations": 100, "requestTimeout": 3600000, "conversationHistoryLength": 50, "reasoningEnabled": true, "systemPrompt": "{{AGENT_PROMPT}}"}' - # MCP Bridge for file system access - executes approved proposals mcp-fs-bridge: type: local - command: "npx" - args: ["--yes", "@mew-protocol/bridge", "--gateway", "ws://localhost:${PORT}", "--space", "{{SPACE_NAME}}", "--token", "${TOKEN}", "--participant-id", "mcp-fs-bridge", "--mcp-command", "npx", "--mcp-args", "@modelcontextprotocol/server-filesystem,./", "--mcp-cwd", "./"] + command: "node" + args: + [ + "../../bridge/dist/mcp-bridge.js", + "--transport", + "stdio", + "--space", + "{{SPACE_NAME}}", + "--token", + "${TOKEN}", + "--participant-id", + "mcp-fs-bridge", + "--mcp-command", + "node", + "--mcp-args", + ".mew/node_modules/@modelcontextprotocol/server-filesystem/dist/index.js,./", + "--mcp-cwd", + "./" + ] auto_start: true - # No tokens field - generated at runtime in .mew/tokens/ capabilities: - kind: "mcp/request" - kind: "mcp/response" - kind: "mcp/notification" - # Auto-fulfiller for proposals (disabled by default for manual testing) - auto-fulfiller: - type: local - command: "node" - args: ["./.mew/agents/auto-fulfiller.js"] - auto_start: false # Disabled for manual testing - # No tokens field - generated at runtime in .mew/tokens/ - capabilities: - - kind: "mcp/proposal" # Can see proposals - - kind: "mcp/request" # Can execute approved operations - - kind: "mcp/response" # Can respond to requests - - kind: "chat" - env: - NODE_PATH: "./.mew/node_modules" - MEW_GATEWAY: "ws://localhost:${PORT}" - MEW_SPACE: "{{SPACE_NAME}}" - MEW_TOKEN: "${TOKEN}" # Token will be loaded from .mew/tokens/auto-fulfiller.token - MEW_PARTICIPANT_ID: "auto-fulfiller" - AUTO_APPROVE: "false" # Set to "true" to enable auto-approval - APPROVAL_DELAY: "2000" # 2 second delay before auto-approval - TARGET_PARTICIPANTS: "mcp-fs-bridge" - defaults: capabilities: - - kind: "chat" # Everyone can chat by default \ No newline at end of file + - kind: "chat" # Everyone can chat by default diff --git a/sdk/typescript-sdk/agent/src/MEWAgent.ts b/sdk/typescript-sdk/agent/src/MEWAgent.ts index 138d8b75..5c81d8ac 100644 --- a/sdk/typescript-sdk/agent/src/MEWAgent.ts +++ b/sdk/typescript-sdk/agent/src/MEWAgent.ts @@ -1,4 +1,5 @@ import { MEWParticipant, ParticipantOptions, Tool, Resource } from '@mew-protocol/participant'; +import { TransportKind } from '@mew-protocol/client'; import { Envelope } from '@mew-protocol/types'; import { v4 as uuidv4 } from 'uuid'; import OpenAI from 'openai'; @@ -17,6 +18,7 @@ export interface AgentConfig extends ParticipantOptions { logLevel?: 'debug' | 'info' | 'warn' | 'error'; reasoningFormat?: 'native' | 'scratchpad'; // How to format previous thoughts for LLM (default: 'native') conversationHistoryLength?: number; // Number of previous messages to include in context (default: 0 = only current) + transport?: TransportKind; // Chat response configuration chatResponse?: { @@ -927,4 +929,4 @@ Return a JSON object: this.send(envelope); } } -} \ No newline at end of file +} diff --git a/sdk/typescript-sdk/agent/src/index.ts b/sdk/typescript-sdk/agent/src/index.ts index d45922ce..0fcf7ee9 100644 --- a/sdk/typescript-sdk/agent/src/index.ts +++ b/sdk/typescript-sdk/agent/src/index.ts @@ -9,7 +9,8 @@ import * as yaml from 'js-yaml'; function parseArgs(): { options: any; configFile?: string } { const args = process.argv.slice(2); const options: any = { - gateway: process.env.MEW_GATEWAY || 'ws://localhost:8080', + transport: (process.env.MEW_TRANSPORT as string) || 'stdio', + gateway: process.env.MEW_GATEWAY, space: process.env.MEW_SPACE || 'playground', token: process.env.MEW_TOKEN || 'agent-token', participantId: process.env.MEW_PARTICIPANT_ID || 'typescript-agent', @@ -25,6 +26,10 @@ function parseArgs(): { options: any; configFile?: string } { case '--gateway': case '-g': options.gateway = args[++i]; + options.transport = 'websocket'; + break; + case '--transport': + options.transport = args[++i]; break; case '--space': case '-s': @@ -75,7 +80,8 @@ MEW TypeScript Agent Example Usage: mew-agent [options] Options: - -g, --gateway Gateway WebSocket URL (default: ws://localhost:8080) + -g, --gateway Gateway WebSocket URL (implies --transport websocket) + --transport Transport to use: stdio | websocket (default: stdio) -s, --space Space name to join (default: playground) -t, --token Authentication token (default: agent-token) -i, --id Participant ID (default: typescript-agent) @@ -88,6 +94,7 @@ Options: Environment Variables: MEW_GATEWAY Gateway URL + MEW_TRANSPORT stdio | websocket MEW_SPACE Space name MEW_TOKEN Authentication token MEW_PARTICIPANT_ID Participant ID @@ -97,7 +104,8 @@ Environment Variables: MEW_AGENT_CONFIG JSON configuration (overrides file config) Example: - mew-agent --gateway ws://localhost:8080 --space dev --id my-agent + mew-agent --space dev --id my-agent # STDIO via parent process + mew-agent --gateway ws://localhost:8080 --transport websocket mew-agent --config agent-config.yaml mew-agent --model gpt-3.5-turbo --api-key sk-... mew-agent --openai-url http://localhost:11434/v1 --model llama2 # For Ollama @@ -128,7 +136,16 @@ async function main(): Promise { const { options, configFile } = parseArgs(); // Build agent configuration + const transport = (options.transport as string | undefined)?.toLowerCase() === 'websocket' + ? 'websocket' + : 'stdio'; + + if (transport === 'websocket' && !options.gateway) { + options.gateway = 'ws://localhost:8080'; + } + let agentConfig: AgentConfig = { + transport, gateway: options.gateway, space: options.space, token: options.token, @@ -165,7 +182,11 @@ async function main(): Promise { // Create the agent console.log(`Starting MEW TypeScript Agent: ${agentConfig.participant_id}`); - console.log(`Connecting to ${agentConfig.gateway} (space: ${agentConfig.space})`); + if (agentConfig.transport === 'websocket') { + console.log(`Connecting via WebSocket ${agentConfig.gateway} (space: ${agentConfig.space})`); + } else { + console.log(`Connecting via STDIO (space: ${agentConfig.space})`); + } const agent = new MEWAgent(agentConfig); diff --git a/sdk/typescript-sdk/client/src/MEWClient.ts b/sdk/typescript-sdk/client/src/MEWClient.ts index a886516f..6906618b 100644 --- a/sdk/typescript-sdk/client/src/MEWClient.ts +++ b/sdk/typescript-sdk/client/src/MEWClient.ts @@ -1,31 +1,38 @@ -import { EventEmitter } from 'events'; -import WebSocket from 'ws'; +import { EventEmitter } from 'node:events'; import { Envelope, Capability } from '@mew-protocol/types'; +import { Transport } from './transports/Transport'; +import { StdioTransport } from './transports/StdioTransport'; +import { WebSocketTransport } from './transports/WebSocketTransport'; const PROTOCOL_VERSION = 'mew/v0.3'; +type ClientState = 'disconnected' | 'connecting' | 'connected' | 'joined' | 'ready'; +export type TransportKind = 'stdio' | 'websocket'; + export interface ClientOptions { - gateway: string; // WebSocket URL - space: string; // Space to join - token: string; // Authentication token - participant_id?: string; // Unique identifier - reconnect?: boolean; // Auto-reconnect on disconnect + space: string; + token: string; + participant_id?: string; + reconnect?: boolean; heartbeatInterval?: number; maxReconnectAttempts?: number; reconnectDelay?: number; - capabilities?: Capability[]; // Initial capabilities + capabilities?: Capability[]; + transport?: TransportKind; + gateway?: string; // Back-compat alias for websocketUrl + websocketUrl?: string; + forceJoin?: boolean; } -type ClientState = 'disconnected' | 'connecting' | 'connected' | 'joined' | 'ready'; - export class MEWClient extends EventEmitter { protected options: ClientOptions; - protected ws?: WebSocket; protected state: ClientState = 'disconnected'; protected reconnectAttempts = 0; protected heartbeatTimer?: NodeJS.Timeout; protected reconnectTimer?: NodeJS.Timeout; protected messageCounter = 0; + protected transport: Transport; + private readonly shouldSendJoin: boolean; constructor(options: ClientOptions) { super(); @@ -34,108 +41,81 @@ export class MEWClient extends EventEmitter { heartbeatInterval: 30000, maxReconnectAttempts: 5, reconnectDelay: 1000, - ...options + ...options, }; - - // Generate participant_id if not provided + if (!this.options.participant_id) { - this.options.participant_id = `client-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + this.options.participant_id = `client-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; } - } - /** - * Connect to the gateway - */ - async connect(): Promise { - return new Promise((resolve, reject) => { - if (this.state !== 'disconnected') { - return resolve(); + const transportKind = this.resolveTransportKind(); + if (transportKind === 'websocket') { + const url = this.options.gateway ?? this.options.websocketUrl; + if (!url) { + throw new Error('WebSocket transport selected but no gateway URL provided'); } + this.transport = new WebSocketTransport({ url }); + } else { + this.transport = new StdioTransport(); + } - this.state = 'connecting'; - - try { - this.ws = new WebSocket(this.options.gateway); - - this.ws.on('open', () => { - this.state = 'connected'; - this.reconnectAttempts = 0; - this.emit('connected'); - - // Send join message - this.sendJoin(); - - // Start heartbeat - this.startHeartbeat(); - - resolve(); - }); - - this.ws.on('message', (data: WebSocket.Data) => { - try { - const message = JSON.parse(data.toString()); - this.handleMessage(message); - } catch (error) { - console.error('Failed to parse message:', error); - } - }); - - this.ws.on('close', () => { - this.handleDisconnect(); - }); - - this.ws.on('error', (error: Error) => { - this.emit('error', error); - if (this.state === 'connecting') { - reject(error); - } - }); - } catch (error) { - this.state = 'disconnected'; - reject(error); + this.shouldSendJoin = this.options.forceJoin ?? this.transport.kind !== 'stdio'; + } + + protected resolveTransportKind(): TransportKind { + if (this.options.transport) return this.options.transport; + if (this.options.gateway || this.options.websocketUrl) return 'websocket'; + return 'stdio'; + } + + async connect(): Promise { + if (this.state !== 'disconnected') return; + this.state = 'connecting'; + + this.transport.onMessage((envelope) => this.handleMessage(envelope)); + this.transport.onError((error) => this.emit('error', error)); + this.transport.onClose(() => this.handleDisconnect()); + + try { + await this.transport.start(); + this.state = 'connected'; + this.reconnectAttempts = 0; + this.emit('connected'); + + await this.sendJoin(); + if (this.transport.kind === 'websocket') { + this.startHeartbeat(); } - }); + } catch (error) { + this.state = 'disconnected'; + throw error; + } } - /** - * Disconnect from the gateway - */ disconnect(): void { this.stopHeartbeat(); this.clearReconnectTimer(); this.state = 'disconnected'; - - if (this.ws) { - this.ws.close(); - this.ws = undefined; - } - + this.transport.close().catch(() => {}); this.emit('disconnected'); } - /** - * Send an envelope - */ - send(envelope: Envelope | Partial): void { - if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { - throw new Error('Not connected to gateway'); + async send(envelope: Envelope | Partial): Promise { + if (this.state === 'disconnected' || this.state === 'connecting') { + throw new Error('Client is not connected'); } - // Fill in missing fields const fullEnvelope: Envelope = { protocol: PROTOCOL_VERSION, id: `${this.options.participant_id}-${++this.messageCounter}`, ts: new Date().toISOString(), from: this.options.participant_id!, - ...envelope + ...envelope, } as Envelope; - this.ws.send(JSON.stringify(fullEnvelope)); + await this.transport.send(fullEnvelope); } - /** - * Event handler helpers for subclasses - */ onConnected(handler: () => void): void { this.on('connected', handler); } @@ -148,7 +128,7 @@ export class MEWClient extends EventEmitter { this.on('message', handler); } - onWelcome(handler: (data: any) => void): void { + onWelcome(handler: (payload: any) => void): void { this.on('welcome', handler); } @@ -156,92 +136,81 @@ export class MEWClient extends EventEmitter { this.on('error', handler); } - /** - * Send join message - */ - private sendJoin(): void { - if (!this.ws) return; - - const joinMessage = { - type: 'join', - space: this.options.space, - token: this.options.token, - participantId: this.options.participant_id, - capabilities: this.options.capabilities || [] - }; + protected async sendJoin(): Promise { + if (!this.shouldSendJoin) { + this.state = 'joined'; + return; + } - this.ws.send(JSON.stringify(joinMessage)); + const joinEnvelope: Envelope = { + protocol: PROTOCOL_VERSION, + id: `join-${Date.now()}`, + ts: new Date().toISOString(), + from: this.options.participant_id!, + kind: 'system/join', + payload: { + space: this.options.space, + participantId: this.options.participant_id, + token: this.options.token, + capabilities: this.options.capabilities || [], + }, + } as Envelope; + + await this.transport.send(joinEnvelope); this.state = 'joined'; } - /** - * Handle incoming messages - */ - private handleMessage(message: any): void { - // Handle system welcome message + protected handleMessage(message: Envelope): void { if (message.kind === 'system/welcome') { this.state = 'ready'; this.emit('welcome', message.payload); return; } - // Handle error messages - if (message.type === 'error' || message.kind === 'error') { - this.emit('error', new Error(message.message || message.payload?.message || 'Unknown error')); + if (message.kind === 'system/error') { + const err = new Error((message.payload as any)?.message || 'Unknown error'); + this.emit('error', err); return; } - // Handle envelopes - if (message.protocol && message.kind) { - this.emit('message', message as Envelope); - - // Emit specific events for different kinds - if (message.kind) { - this.emit(message.kind, message); - } + this.emit('message', message); + if (message.kind) { + this.emit(message.kind, message); } } - /** - * Handle disconnection - */ - private handleDisconnect(): void { + protected handleDisconnect(): void { this.stopHeartbeat(); - const wasReady = this.state === 'ready'; + const shouldReconnect = + this.options.reconnect && + this.transport.kind === 'websocket' && + this.reconnectAttempts < (this.options.maxReconnectAttempts ?? 5); + this.state = 'disconnected'; - this.ws = undefined; - this.emit('disconnected'); - // Attempt reconnection if enabled - if (this.options.reconnect && wasReady && - this.reconnectAttempts < this.options.maxReconnectAttempts!) { + if (shouldReconnect) { this.scheduleReconnect(); } } - /** - * Schedule reconnection attempt - */ private scheduleReconnect(): void { - const delay = this.options.reconnectDelay! * Math.pow(2, this.reconnectAttempts); - this.reconnectAttempts++; - + const baseDelay = this.options.reconnectDelay ?? 1000; + const delay = baseDelay * Math.pow(2, this.reconnectAttempts); + this.reconnectAttempts += 1; + this.emit('reconnecting', { attempt: this.reconnectAttempts, delay }); - + this.reconnectTimer = setTimeout(() => { - this.connect().catch(error => { - console.error('Reconnection failed:', error); - if (this.reconnectAttempts < this.options.maxReconnectAttempts!) { + this.connect().catch((error) => { + this.emit('error', error); + if (this.reconnectAttempts < (this.options.maxReconnectAttempts ?? 5)) { this.scheduleReconnect(); } }); }, delay); } - /** - * Clear reconnection timer - */ private clearReconnectTimer(): void { if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); @@ -249,40 +218,29 @@ export class MEWClient extends EventEmitter { } } - /** - * Start heartbeat - */ private startHeartbeat(): void { this.stopHeartbeat(); - this.heartbeatTimer = setInterval(() => { - if (this.ws && this.ws.readyState === WebSocket.OPEN) { - this.ws.ping(); - } + const heartbeat: Envelope = { + protocol: PROTOCOL_VERSION, + id: `heartbeat-${Date.now()}`, + ts: new Date().toISOString(), + from: this.options.participant_id!, + kind: 'system/heartbeat', + space: this.options.space, + payload: {}, + } as Envelope; + + this.transport.send(heartbeat).catch(() => { + // ignore heartbeat failures + }); }, this.options.heartbeatInterval); } - /** - * Stop heartbeat - */ private stopHeartbeat(): void { if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = undefined; } } - - /** - * Get current state - */ - getState(): ClientState { - return this.state; - } - - /** - * Check if connected and ready - */ - isReady(): boolean { - return this.state === 'ready'; - } -} \ No newline at end of file +} diff --git a/sdk/typescript-sdk/client/src/index.ts b/sdk/typescript-sdk/client/src/index.ts index e9d1e83f..7147a5b6 100644 --- a/sdk/typescript-sdk/client/src/index.ts +++ b/sdk/typescript-sdk/client/src/index.ts @@ -5,7 +5,8 @@ */ // Main MEW client export -export { MEWClient, ClientOptions } from './MEWClient'; +export { MEWClient, ClientOptions, TransportKind } from './MEWClient'; +export * from './transports'; // Type exports export * from './types'; diff --git a/sdk/typescript-sdk/client/src/transports/StdioTransport.ts b/sdk/typescript-sdk/client/src/transports/StdioTransport.ts new file mode 100644 index 00000000..0f6031ec --- /dev/null +++ b/sdk/typescript-sdk/client/src/transports/StdioTransport.ts @@ -0,0 +1,137 @@ +import { Readable, Writable } from 'node:stream'; +import { EventEmitter } from 'node:events'; +import { Envelope } from '../types'; +import { Transport, MessageHandler, CloseHandler, ErrorHandler } from './Transport'; + +interface StdioTransportOptions { + input?: Readable; + output?: Writable; +} + +class FrameParser { + private buffer: Buffer = Buffer.alloc(0); + private expectedLength: number | null = null; + private readonly onMessage: (envelope: Envelope) => void; + + constructor(onMessage: (envelope: Envelope) => void) { + this.onMessage = onMessage; + } + + push(chunk: Buffer) { + this.buffer = Buffer.concat([this.buffer, chunk]); + this.process(); + } + + private process() { + while (true) { + if (this.expectedLength === null) { + const headerEnd = this.buffer.indexOf('\r\n\r\n'); + if (headerEnd === -1) return; + const header = this.buffer.slice(0, headerEnd).toString('utf8'); + const match = header.match(/Content-Length: (\d+)/i); + if (!match) { + throw new Error('Invalid frame header'); + } + this.expectedLength = Number(match[1]); + this.buffer = this.buffer.slice(headerEnd + 4); + } + + if (this.expectedLength === null) continue; + if (this.buffer.length < this.expectedLength) return; + + const messageBuffer = this.buffer.slice(0, this.expectedLength); + this.buffer = this.buffer.slice(this.expectedLength); + this.expectedLength = null; + + const json = messageBuffer.toString('utf8'); + const envelope = JSON.parse(json) as Envelope; + this.onMessage(envelope); + } + } +} + +function encodeEnvelope(envelope: Envelope): string { + const json = JSON.stringify(envelope); + const length = Buffer.byteLength(json, 'utf8'); + return `Content-Length: ${length}\r\n\r\n${json}`; +} + +export class StdioTransport implements Transport { + public readonly kind = 'stdio' as const; + private readonly input: Readable; + private readonly output: Writable; + private started = false; + private readonly emitter = new EventEmitter(); + private parser?: FrameParser; + private boundData?: (chunk: Buffer) => void; + private boundError?: (error: Error) => void; + private boundEnd?: () => void; + + constructor(options: StdioTransportOptions = {}) { + this.input = options.input ?? process.stdin; + this.output = options.output ?? process.stdout; + } + + async start(): Promise { + if (this.started) return; + this.started = true; + + this.parser = new FrameParser((envelope) => { + this.emitter.emit('message', envelope); + }); + + this.boundData = (chunk: Buffer) => { + try { + this.parser?.push(chunk); + } catch (error) { + this.emitter.emit('error', error); + } + }; + this.boundError = (error: Error) => { + this.emitter.emit('error', error); + }; + this.boundEnd = () => { + this.emitter.emit('close'); + }; + + this.input.on('data', this.boundData); + this.input.on('error', this.boundError); + this.input.on('end', this.boundEnd); + } + + async send(envelope: Envelope): Promise { + if (!this.started) { + throw new Error('STDIO transport not started'); + } + const payload = encodeEnvelope(envelope); + return new Promise((resolve, reject) => { + this.output.write(payload, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + } + + async close(): Promise { + if (!this.started) return; + this.started = false; + + if (this.boundData) this.input.off('data', this.boundData); + if (this.boundError) this.input.off('error', this.boundError); + if (this.boundEnd) this.input.off('end', this.boundEnd); + + this.emitter.emit('close'); + } + + onMessage(handler: MessageHandler): void { + this.emitter.on('message', handler); + } + + onClose(handler: CloseHandler): void { + this.emitter.on('close', handler); + } + + onError(handler: ErrorHandler): void { + this.emitter.on('error', handler); + } +} diff --git a/sdk/typescript-sdk/client/src/transports/Transport.ts b/sdk/typescript-sdk/client/src/transports/Transport.ts new file mode 100644 index 00000000..a34a81d8 --- /dev/null +++ b/sdk/typescript-sdk/client/src/transports/Transport.ts @@ -0,0 +1,15 @@ +import { Envelope } from '../types'; + +export type MessageHandler = (envelope: Envelope) => void; +export type CloseHandler = (reason?: Error) => void; +export type ErrorHandler = (error: Error) => void; + +export interface Transport { + readonly kind: 'stdio' | 'websocket'; + start(): Promise; + send(envelope: Envelope): Promise; + close(): Promise; + onMessage(handler: MessageHandler): void; + onClose(handler: CloseHandler): void; + onError(handler: ErrorHandler): void; +} diff --git a/sdk/typescript-sdk/client/src/transports/WebSocketTransport.ts b/sdk/typescript-sdk/client/src/transports/WebSocketTransport.ts new file mode 100644 index 00000000..78bd390e --- /dev/null +++ b/sdk/typescript-sdk/client/src/transports/WebSocketTransport.ts @@ -0,0 +1,92 @@ +import { EventEmitter } from 'node:events'; +import WebSocket from 'ws'; +import { Envelope } from '../types'; +import { Transport, MessageHandler, CloseHandler, ErrorHandler } from './Transport'; + +interface WebSocketTransportOptions { + url: string; + headers?: Record; +} + +export class WebSocketTransport implements Transport { + public readonly kind = 'websocket' as const; + private readonly url: string; + private readonly headers?: Record; + private readonly emitter = new EventEmitter(); + private socket?: WebSocket; + + constructor(options: WebSocketTransportOptions) { + this.url = options.url; + this.headers = options.headers; + } + + async start(): Promise { + if (this.socket && this.socket.readyState === WebSocket.OPEN) return; + + await new Promise((resolve, reject) => { + this.socket = new WebSocket(this.url, { headers: this.headers }); + + this.socket.once('open', () => { + this.socket?.on('message', (data: WebSocket.RawData) => { + try { + const envelope = JSON.parse(data.toString()) as Envelope; + this.emitter.emit('message', envelope); + } catch (error) { + this.emitter.emit('error', error); + } + }); + + this.socket?.on('close', () => { + this.emitter.emit('close'); + }); + + this.socket?.on('error', (error: Error) => { + this.emitter.emit('error', error); + }); + + resolve(); + }); + + this.socket?.once('error', reject); + }); + } + + async send(envelope: Envelope): Promise { + if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { + throw new Error('WebSocket is not connected'); + } + + await new Promise((resolve, reject) => { + this.socket?.send(JSON.stringify(envelope), (error) => { + if (error) reject(error); + else resolve(); + }); + }); + } + + async close(): Promise { + if (!this.socket) return; + + await new Promise((resolve) => { + if (!this.socket || this.socket.readyState === WebSocket.CLOSED) { + resolve(); + return; + } + + this.socket.once('close', () => resolve()); + this.socket.close(); + }); + } + + onMessage(handler: MessageHandler): void { + this.emitter.on('message', handler); + } + + onClose(handler: CloseHandler): void { + this.emitter.on('close', handler); + } + + onError(handler: ErrorHandler): void { + this.emitter.on('error', handler); + } +} diff --git a/sdk/typescript-sdk/client/src/transports/index.ts b/sdk/typescript-sdk/client/src/transports/index.ts new file mode 100644 index 00000000..63e11d1a --- /dev/null +++ b/sdk/typescript-sdk/client/src/transports/index.ts @@ -0,0 +1,3 @@ +export * from './Transport'; +export * from './StdioTransport'; +export * from './WebSocketTransport'; diff --git a/sdk/typescript-sdk/client/tsconfig.json b/sdk/typescript-sdk/client/tsconfig.json index 29e4d1a6..18b01f4e 100644 --- a/sdk/typescript-sdk/client/tsconfig.json +++ b/sdk/typescript-sdk/client/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "target": "ES2022", - "module": "ESNext", + "module": "CommonJS", "lib": ["ES2022"], "moduleResolution": "node", "declaration": true, @@ -22,4 +22,4 @@ }, "include": ["src/**/*"], "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"] -} \ No newline at end of file +} diff --git a/sdk/typescript-sdk/participant/src/MEWParticipant.ts b/sdk/typescript-sdk/participant/src/MEWParticipant.ts index 5fd41513..43044c69 100644 --- a/sdk/typescript-sdk/participant/src/MEWParticipant.ts +++ b/sdk/typescript-sdk/participant/src/MEWParticipant.ts @@ -141,7 +141,11 @@ export class MEWParticipant extends MEWClient { // Debug: Sending MCP request // Send the request - this.send(envelope); + this.send(envelope).catch((error) => { + if (timer) clearTimeout(timer); + this.pendingRequests.delete(id); + reject(error); + }); return; } @@ -174,8 +178,8 @@ export class MEWParticipant extends MEWClient { reason: 'timeout' } }; - this.send(withdrawEnvelope); - + this.send(withdrawEnvelope).catch(() => {}); + reject(new Error(`Proposal ${id} not fulfilled after ${timeout}ms`)); }, timeout); @@ -189,7 +193,11 @@ export class MEWParticipant extends MEWClient { // Debug: Sending MCP proposal // Send the proposal - this.send(envelope); + this.send(envelope).catch((error) => { + if (timer) clearTimeout(timer); + this.pendingRequests.delete(id); + reject(error); + }); return; } @@ -220,7 +228,7 @@ export class MEWParticipant extends MEWClient { correlation_id: [proposalId], payload: { reason } }; - this.send(withdrawEnvelope); + this.send(withdrawEnvelope).catch(() => {}); // Reject the promise pending.reject(new Error(`Proposal withdrawn: ${reason}`)); @@ -243,7 +251,7 @@ export class MEWParticipant extends MEWClient { envelope.to = Array.isArray(to) ? to : [to]; } - this.send(envelope); + this.send(envelope).catch((error) => this.emit('error', error)); } /** @@ -731,7 +739,7 @@ export class MEWParticipant extends MEWClient { }; // Debug: Sending MCP response - this.send(response); + this.send(response).catch((error) => this.emit('error', error)); } /** @@ -880,4 +888,4 @@ export class MEWParticipant extends MEWClient { this.participantDiscoveryStatus.delete(participant.id); } } -} \ No newline at end of file +} diff --git a/tests/README.md b/tests/README.md index d22eee94..1d5237c1 100644 --- a/tests/README.md +++ b/tests/README.md @@ -20,7 +20,7 @@ cd scenario-1-basic All test scenarios are located in this directory. Each scenario is self-contained with: ### Required Files -- `space.yaml` - Space configuration defining participants, capabilities, and tokens +- `space.yaml` - Space configuration defining participants and capabilities - `test.sh` - Main test orchestrator that runs setup โ†’ test logic โ†’ check โ†’ teardown ### Supporting Scripts @@ -46,20 +46,26 @@ All test scenarios are located in this directory. Each scenario is self-containe - **scenario-1-basic** - Basic message flow between agents - **scenario-2-mcp** - MCP tool execution and responses -- **scenario-3-proposals** - Proposal system with capability blocking -- **scenario-4-capabilities** - Dynamic capability granting -- **scenario-5-reasoning** - Reasoning with context field -- **scenario-6-errors** - Error recovery and edge cases -- **scenario-7-mcp-bridge** - MCP server integration via bridge +- **scenario-3-proposals** - Proposal system fulfilled via STDIO agents +- **scenario-4-capabilities** - Simulated capability grant/revoke signaling +- **scenario-5-reasoning** - Reasoning sequence with context preservation +- **scenario-6-errors** - Error handling and recovery behaviors +- **scenario-7-mcp-bridge** - MCP bridge simulation over STDIO +- **scenario-8-grant** - Capability grant workflow with CLI-managed adapters +- **scenario-8-typescript-agent** - STDIO TypeScript agent exercising MCP tools +- **scenario-9-typescript-proposals** - Proposal-only TypeScript agent fulfilled via STDIO driver +- **scenario-10-multi-agent** - Coordinator/worker/driver collaboration over STDIO +- **scenario-11-streams** - Streaming reasoning events delivered via framed STDIO +- **scenario-12-sdk-streams** - Batched data streaming between STDIO participants - **scenario-8-grant** - Capability grant workflow (proposal โ†’ grant โ†’ direct request) ## Test Agents -The `/agents/` directory contains reusable test agents used across scenarios: -- `calculator-participant.js` - Simple calculator agent for MCP testing using MEWParticipant -- `fulfiller.js` - Agent that fulfills proposals -- `proposer.js` - Agent that creates proposals -- `requester.js` - Agent that makes various requests +The `/agents/` directory contains reusable STDIO agents used across scenarios: +- `calculator-participant.js` - FIFO/STDIO calculator that responds to MCP tool calls +- `basic-driver.js`, `mcp-driver.js`, `proposal-driver.js`, etc. - Scenario-specific drivers that validate behavior +- `fulfiller-participant.js` - STDIO fulfiller that reacts to proposals and forwards results +- `bridge-agent.js` - Stubbed MCP bridge responder for scenario 7 ## Adding New Tests @@ -81,10 +87,10 @@ To debug a failing test: ## Architecture -Tests use PM2 for process management and the MEW CLI to start spaces. Each test: -1. Sets up a space with a gateway and participants -2. Runs test agents that interact via MEW messages -3. Verifies expected outcomes +Tests use the MEW CLI's STDIO transport to start the gateway and connect lightweight agents. Each test: +1. Sets up a space with FIFO-backed participants managed directly by the CLI +2. Runs test agents that communicate via framed STDIO messages +3. Verifies expected outcomes from agent log output 4. Cleans up all processes and artifacts -For protocol details, see the main MEUP documentation. \ No newline at end of file +For protocol details, see the main MEUP documentation. diff --git a/tests/agents/basic-driver.js b/tests/agents/basic-driver.js new file mode 100755 index 00000000..65cef9cc --- /dev/null +++ b/tests/agents/basic-driver.js @@ -0,0 +1,163 @@ +#!/usr/bin/env node +const fs = require('fs'); +const { StringDecoder } = require('string_decoder'); + +const participantId = process.env.MEW_PARTICIPANT_ID || 'driver'; +const logPath = process.env.DRIVER_LOG || './driver.log'; +fs.mkdirSync(require('path').dirname(logPath), { recursive: true }); + +function append(line) { + fs.appendFileSync(logPath, `${line}\n`); +} + +function encodeEnvelope(envelope) { + const json = JSON.stringify(envelope); + const length = Buffer.byteLength(json, 'utf8'); + return `Content-Length: ${length}\r\n\r\n${json}`; +} + +class FrameParser { + constructor(onMessage) { + this.onMessage = onMessage; + this.decoder = new StringDecoder('utf8'); + this.buffer = Buffer.alloc(0); + this.expectedLength = null; + } + + push(chunk) { + this.buffer = Buffer.concat([this.buffer, chunk]); + this.process(); + } + + process() { + while (true) { + if (this.expectedLength === null) { + const headerEnd = this.buffer.indexOf('\r\n\r\n'); + if (headerEnd === -1) return; + const header = this.buffer.slice(0, headerEnd).toString('utf8'); + const match = header.match(/Content-Length: (\d+)/i); + if (!match) throw new Error('Invalid frame header'); + this.expectedLength = Number(match[1]); + this.buffer = this.buffer.slice(headerEnd + 4); + } + if (this.buffer.length < this.expectedLength) return; + const payload = this.buffer.slice(0, this.expectedLength); + this.buffer = this.buffer.slice(this.expectedLength); + this.expectedLength = null; + const json = this.decoder.write(payload); + const envelope = JSON.parse(json); + this.onMessage(envelope); + } + } +} + +const parser = new FrameParser(handleEnvelope); +process.stdin.on('data', (chunk) => parser.push(chunk)); +process.stdin.on('end', () => process.exit(0)); + +const state = { + currentIndex: -1, + awaiting: null, +}; + +const largeText = 'A'.repeat(1000); + +const tests = [ + { + name: 'simple', + start() { + sendChat('Hello, echo!'); + state.awaiting = (env) => env.payload?.text === 'Echo: Hello, echo!'; + }, + }, + { + name: 'correlation', + start() { + sendChat('Test with ID', 'msg-123'); + state.awaiting = (env) => + env.correlation_id && env.correlation_id.includes('msg-123'); + }, + }, + { + name: 'multiple', + start() { + state.expected = new Set(['Echo: Message 1', 'Echo: Message 2', 'Echo: Message 3']); + ['Message 1', 'Message 2', 'Message 3'].forEach((text) => sendChat(text)); + state.awaiting = (env) => { + const msg = env.payload?.text; + if (state.expected.delete(msg)) { + return state.expected.size === 0; + } + return false; + }; + }, + }, + { + name: 'large', + start() { + sendChat(largeText); + state.awaiting = (env) => env.payload?.text === `Echo: ${largeText}`; + }, + }, + { + name: 'rapid', + start() { + state.expectedRapid = 5; + for (let i = 1; i <= 5; i++) { + sendChat(`Rapid ${i}`); + } + state.awaiting = (env) => { + const msg = env.payload?.text; + if (msg && msg.startsWith('Echo: Rapid')) { + state.expectedRapid -= 1; + return state.expectedRapid === 0; + } + return false; + }; + }, + }, +]; + +function nextTest() { + state.currentIndex += 1; + if (state.currentIndex >= tests.length) { + append('DONE'); + process.exit(0); + } + const test = tests[state.currentIndex]; + state.awaiting = null; + test.start(); +} + +function handleEnvelope(envelope) { + if (envelope.kind === 'system/welcome') { + append('WELCOME'); + nextTest(); + return; + } + + if (envelope.kind !== 'chat') return; + if (envelope.from === participantId) return; + + if (state.awaiting && state.awaiting(envelope)) { + append(`OK ${tests[state.currentIndex].name}`); + nextTest(); + } +} + +function sendChat(text, id) { + const envelope = { + protocol: 'mew/v0.3', + kind: 'chat', + payload: { + text, + format: 'plain', + }, + }; + if (id) { + envelope.id = id; + } + process.stdout.write(encodeEnvelope(envelope)); +} + +process.on('SIGINT', () => process.exit(0)); diff --git a/tests/agents/batch-agent.js b/tests/agents/batch-agent.js new file mode 100644 index 00000000..5b571a4e --- /dev/null +++ b/tests/agents/batch-agent.js @@ -0,0 +1,72 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); +const { FrameParser, encodeEnvelope } = require('../../cli/src/stdio/utils'); + +const participantId = process.env.MEW_PARTICIPANT_ID || 'batch-agent'; +const driverId = process.env.DRIVER_ID || 'batch-driver'; +const logPath = process.env.BATCH_AGENT_LOG ? path.resolve(process.env.BATCH_AGENT_LOG) : null; + +function append(line) { + if (!logPath) return; + try { + fs.mkdirSync(path.dirname(logPath), { recursive: true }); + fs.appendFileSync(logPath, `${line}\n`); + } catch (_) {} +} + +function send(envelope) { + process.stdout.write( + encodeEnvelope({ + protocol: 'mew/v0.3', + ...envelope, + }), + ); +} + +function emitChunks() { + const chunks = ['chunk-1', 'chunk-2', 'chunk-3']; + chunks.forEach((chunk, index) => { + send({ + kind: 'chat', + to: [driverId], + payload: { + text: `DATA ${chunk}`, + format: 'plain', + }, + }); + append(`EMIT ${chunk}`); + }); + send({ + kind: 'chat', + to: [driverId], + payload: { + text: 'COMPLETE all chunks delivered', + format: 'plain', + }, + }); + append('EMIT COMPLETE'); +} + +const parser = new FrameParser((envelope) => { + if (envelope.kind === 'system/welcome') { + append('WELCOME'); + return; + } + if (envelope.kind === 'chat' && envelope.from === driverId) { + append(`REQUEST ${envelope.payload?.text || ''}`); + emitChunks(); + } +}); + +process.stdin.on('data', (chunk) => { + try { + parser.push(chunk); + } catch (error) { + append(`ERROR ${error.message}`); + } +}); + +process.stdin.on('close', () => process.exit(0)); +process.on('SIGINT', () => process.exit(0)); +process.on('SIGTERM', () => process.exit(0)); diff --git a/tests/agents/batch-driver.js b/tests/agents/batch-driver.js new file mode 100644 index 00000000..29c06ded --- /dev/null +++ b/tests/agents/batch-driver.js @@ -0,0 +1,75 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); +const { FrameParser, encodeEnvelope } = require('../../cli/src/stdio/utils'); + +const participantId = process.env.MEW_PARTICIPANT_ID || 'batch-driver'; +const agentId = process.env.AGENT_ID || 'batch-agent'; +const logPath = process.env.BATCH_DRIVER_LOG ? path.resolve(process.env.BATCH_DRIVER_LOG) : null; + +function append(line) { + if (!logPath) return; + try { + fs.mkdirSync(path.dirname(logPath), { recursive: true }); + fs.appendFileSync(logPath, `${line}\n`); + } catch (_) {} +} + +function send(envelope) { + process.stdout.write( + encodeEnvelope({ + protocol: 'mew/v0.3', + ...envelope, + }), + ); +} + +const chunks = []; +let started = false; + +const parser = new FrameParser((envelope) => { + if (envelope.kind === 'system/welcome' && !started) { + append('WELCOME'); + started = true; + send({ + kind: 'chat', + to: [agentId], + payload: { + text: 'request data batch', + format: 'plain', + }, + }); + return; + } + + if (envelope.kind === 'chat' && envelope.from === agentId) { + const text = envelope.payload?.text || ''; + append(`RECV ${text}`); + if (text.startsWith('DATA ')) { + chunks.push(text.slice(5)); + return; + } + if (text.startsWith('COMPLETE')) { + if (chunks.length === 3 && chunks.every((v, i) => v === `chunk-${i + 1}`)) { + append('OK batch-sequence'); + append('DONE'); + process.exit(0); + } else { + append('FAIL batch-sequence'); + process.exit(1); + } + } + } +}); + +process.stdin.on('data', (chunk) => { + try { + parser.push(chunk); + } catch (error) { + append(`ERROR ${error.message}`); + } +}); + +process.stdin.on('close', () => process.exit(1)); +process.on('SIGINT', () => process.exit(0)); +process.on('SIGTERM', () => process.exit(0)); diff --git a/tests/agents/bridge-agent.js b/tests/agents/bridge-agent.js new file mode 100755 index 00000000..93637c02 --- /dev/null +++ b/tests/agents/bridge-agent.js @@ -0,0 +1,117 @@ +#!/usr/bin/env node +const { FrameParser, encodeEnvelope } = require('../../cli/src/stdio/utils'); + +const participantId = process.env.MEW_PARTICIPANT_ID || 'bridge-agent'; + +const tools = [ + { + name: 'read_file', + description: 'Read a file from the virtual filesystem', + inputSchema: { + type: 'object', + properties: { + path: { type: 'string' }, + }, + required: ['path'], + }, + }, + { + name: 'list_directory', + description: 'List files in the virtual filesystem', + inputSchema: { + type: 'object', + properties: { + path: { type: 'string' }, + }, + required: ['path'], + }, + }, +]; + +function send(envelope) { + process.stdout.write(encodeEnvelope({ protocol: 'mew/v0.3', ...envelope })); +} + +function handleRequest(envelope) { + const id = envelope.id || `req-${Date.now()}`; + const method = envelope.payload?.method; + const params = envelope.payload?.params || {}; + + if (method === 'tools/list') { + send({ + kind: 'mcp/response', + correlation_id: id, + payload: { + success: true, + result: { + tools, + }, + }, + }); + return; + } + + if (method === 'tools/call') { + const name = params.name; + const args = params.arguments || {}; + switch (name) { + case 'read_file': + send({ + kind: 'mcp/response', + correlation_id: id, + payload: { + success: true, + result: `Contents of ${args.path || 'unknown'}`, + }, + }); + return; + case 'list_directory': + send({ + kind: 'mcp/response', + correlation_id: id, + payload: { + success: true, + result: ['file1.txt', 'file2.txt'], + }, + }); + return; + default: + send({ + kind: 'mcp/response', + correlation_id: id, + payload: { + success: false, + error: `Bridge: unknown tool ${name}`, + }, + }); + return; + } + } + + send({ + kind: 'mcp/response', + correlation_id: id, + payload: { + success: false, + error: `Bridge: unsupported method ${method}`, + }, + }); +} + +const parser = new FrameParser((envelope) => { + if (envelope.kind === 'mcp/request') { + handleRequest(envelope); + } +}); + +process.stdin.on('data', (chunk) => { + try { + parser.push(chunk); + } catch (error) { + // ignore parse errors in bridge stub + } +}); + +process.stdin.on('close', () => process.exit(0)); +process.on('SIGINT', () => process.exit(0)); +process.on('SIGTERM', () => process.exit(0)); diff --git a/tests/agents/bridge-driver.js b/tests/agents/bridge-driver.js new file mode 100755 index 00000000..5e90b969 --- /dev/null +++ b/tests/agents/bridge-driver.js @@ -0,0 +1,113 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); +const { FrameParser, encodeEnvelope } = require('../../cli/src/stdio/utils'); + +const participantId = process.env.MEW_PARTICIPANT_ID || 'bridge-driver'; +const logPath = process.env.DRIVER_LOG || './bridge-driver.log'; +fs.mkdirSync(path.dirname(logPath), { recursive: true }); + +function append(line) { + fs.appendFileSync(logPath, `${line}\n`); +} + +const pending = new Map(); +let stepIndex = -1; + +const steps = [ + { + name: 'tools-list', + action() { + sendRequest('tools/list', {}, (payload) => Array.isArray(payload.result?.tools)); + }, + }, + { + name: 'read-file', + action() { + sendRequest('tools/call', { name: 'read_file', arguments: { path: '/tmp/file.txt' } }, (payload) => { + return typeof payload.result === 'string' && payload.result.includes('/tmp/file.txt'); + }); + }, + }, + { + name: 'invalid-tool', + action() { + sendRequest('tools/call', { name: 'unknown', arguments: {} }, (payload) => { + return payload.success === false && /unknown/.test(payload.error || ''); + }); + }, + }, + { + name: 'done', + action() { + append('DONE'); + process.exit(0); + }, + }, +]; + +function nextStep() { + stepIndex += 1; + if (stepIndex >= steps.length) { + append('DONE'); + process.exit(0); + } + steps[stepIndex].action(); +} + +function sendRequest(method, params, validate) { + const id = `req-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + pending.set(id, { name: steps[stepIndex].name, validate }); + process.stdout.write( + encodeEnvelope({ + protocol: 'mew/v0.3', + id, + kind: 'mcp/request', + to: ['bridge-agent'], + payload: { + method, + params, + }, + }), + ); +} + +const parser = new FrameParser((envelope) => { + if (envelope.kind === 'system/welcome') { + append('WELCOME'); + nextStep(); + return; + } + + if (envelope.kind !== 'mcp/response' || envelope.from !== 'bridge-agent') { + return; + } + + const pendingEntry = pending.get(envelope.correlation_id); + if (!pendingEntry) { + return; + } + pending.delete(envelope.correlation_id); + + const valid = pendingEntry.validate(envelope.payload || {}); + if (valid) { + append(`OK ${pendingEntry.name}`); + nextStep(); + } else { + append(`FAIL ${pendingEntry.name}`); + append(`DEBUG payload=${JSON.stringify(envelope.payload)}`); + process.exit(1); + } +}); + +process.stdin.on('data', (chunk) => { + try { + parser.push(chunk); + } catch (error) { + append(`ERROR ${error.message}`); + } +}); + +process.stdin.on('close', () => process.exit(0)); +process.on('SIGINT', () => process.exit(0)); +process.on('SIGTERM', () => process.exit(0)); diff --git a/tests/agents/calculator-participant.js b/tests/agents/calculator-participant.js index 0fa4988a..e7620936 100644 --- a/tests/agents/calculator-participant.js +++ b/tests/agents/calculator-participant.js @@ -1,137 +1,189 @@ #!/usr/bin/env node -/** - * Calculator Agent - Provides MCP math tools (add, multiply, evaluate) - * Using MEWParticipant base class for cleaner, promise-based code - */ - +const { FrameParser, encodeEnvelope } = require('../../cli/src/stdio/utils'); +const fs = require('fs'); const path = require('path'); -// Import MEWParticipant from the SDK -const participantPath = path.resolve(__dirname, '../../sdk/typescript-sdk/participant/dist/index.js'); -const { MEWParticipant } = require(participantPath); - -class CalculatorAgent extends MEWParticipant { - constructor(options) { - super(options); - - // Register calculator tools - they'll be automatically handled - this.registerTool({ - name: 'add', - description: 'Add two numbers', - inputSchema: { - type: 'object', - properties: { - a: { type: 'number', description: 'First number' }, - b: { type: 'number', description: 'Second number' } - }, - required: ['a', 'b'] +const participantId = process.env.MEW_PARTICIPANT_ID || 'calculator-agent'; +const logPath = process.env.CALCULATOR_LOG || null; + +function appendLog(line) { + if (!logPath) return; + try { + fs.mkdirSync(path.dirname(logPath), { recursive: true }); + fs.appendFileSync(logPath, `${line}\n`); + } catch (error) { + // Ignore logging errors in tests + } +} + +const tools = [ + { + name: 'add', + description: 'Add two numbers', + inputSchema: { + type: 'object', + properties: { + a: { type: 'number' }, + b: { type: 'number' }, }, - execute: async (args) => { - return args.a + args.b; - } - }); - - this.registerTool({ - name: 'multiply', - description: 'Multiply two numbers', - inputSchema: { - type: 'object', - properties: { - a: { type: 'number', description: 'First number' }, - b: { type: 'number', description: 'Second number' } + required: ['a', 'b'], + }, + }, + { + name: 'multiply', + description: 'Multiply two numbers', + inputSchema: { + type: 'object', + properties: { + a: { type: 'number' }, + b: { type: 'number' }, + }, + required: ['a', 'b'], + }, + }, + { + name: 'evaluate', + description: 'Evaluate a mathematical expression', + inputSchema: { + type: 'object', + properties: { + expression: { type: 'string' }, + }, + required: ['expression'], + }, + }, +]; + +function send(envelope) { + process.stdout.write(encodeEnvelope({ protocol: 'mew/v0.3', ...envelope })); +} + +function isTargeted(envelope) { + if (!envelope.to || envelope.to.length === 0) { + return true; + } + return envelope.to.includes(participantId); +} + +function handleRequest(envelope) { + const requestId = envelope.id || envelope.correlation_id || `req-${Date.now()}`; + const context = envelope.context; + const responseBase = { + kind: 'mcp/response', + correlation_id: requestId ? [requestId] : undefined, + context, + }; + + if (!isTargeted(envelope)) { + return; + } + + const method = envelope.payload?.method; + const params = envelope.payload?.params || {}; + appendLog(`REQUEST ${method}`); + + switch (method) { + case 'tools/list': + send({ + ...responseBase, + payload: { + success: true, + result: { + tools, + }, + }, + }); + break; + case 'tools/call': { + const toolName = params.name; + const args = params.arguments || {}; + handleToolCall(toolName, args, responseBase); + break; + } + default: + send({ + ...responseBase, + payload: { + success: false, + error: `Unsupported method: ${method}`, }, - required: ['a', 'b'] + }); + } +} + +function handleToolCall(toolName, args, responseBase) { + try { + let result; + switch (toolName) { + case 'add': + result = (Number(args.a) || 0) + (Number(args.b) || 0); + break; + case 'multiply': + result = (Number(args.a) || 0) * (Number(args.b) || 0); + break; + case 'evaluate': + result = evaluateExpression(args.expression); + break; + default: + send({ + ...responseBase, + payload: { + success: false, + error: `Tool not found: ${toolName}`, + }, + }); + return; + } + + send({ + ...responseBase, + payload: { + success: true, + result, }, - execute: async (args) => { - return args.a * args.b; - } }); - - this.registerTool({ - name: 'evaluate', - description: 'Evaluate a mathematical expression', - inputSchema: { - type: 'object', - properties: { - expression: { type: 'string', description: 'Mathematical expression to evaluate' } - }, - required: ['expression'] + } catch (error) { + send({ + ...responseBase, + payload: { + success: false, + error: error.message, }, - execute: async (args) => { - try { - // Simple safe eval for basic math expressions - const result = Function('"use strict"; return (' + args.expression + ')')(); - // Convert Infinity to a string for JSON serialization - if (!isFinite(result)) { - return result.toString(); // Returns "Infinity" or "-Infinity" - } - return result; - } catch (error) { - throw new Error(`Invalid expression: ${error.message}`); - } - } }); } - - async onReady() { - console.log('Calculator agent ready!'); - console.log('Registered 3 tools: add, multiply, evaluate'); - - // Add debug logging for all incoming messages - this.onMessage((envelope) => { - console.log(`Calculator received ${envelope.kind} from ${envelope.from}:`, JSON.stringify(envelope.payload)); - }); +} + +function evaluateExpression(expression) { + if (typeof expression !== 'string' || !expression.trim()) { + throw new Error('Invalid expression'); } - - async onShutdown() { - console.log('Calculator agent shutting down...'); + + // Basic validation: allow numbers, operators, parentheses, whitespace + if (!/^[-+*/()0-9.\s]+$/.test(expression)) { + throw new Error('Expression contains unsupported characters'); } -} -// Parse command line arguments -const args = process.argv.slice(2); -const options = { - gateway: 'ws://localhost:8080', - space: 'test-space', - token: 'calculator-token', - participant_id: 'calculator-agent' -}; - -for (let i = 0; i < args.length; i++) { - if (args[i] === '--gateway' || args[i] === '-g') { - options.gateway = args[i + 1]; - i++; - } else if (args[i] === '--space' || args[i] === '-s') { - options.space = args[i + 1]; - i++; - } else if (args[i] === '--token' || args[i] === '-t') { - options.token = args[i + 1]; - i++; - } else if (args[i] === '--id') { - options.participant_id = args[i + 1]; - i++; + const result = Function(`"use strict"; return (${expression})`)(); + if (Number.isFinite(result)) { + return result; } + return result.toString(); } -// Create and start the agent -const agent = new CalculatorAgent(options); - -console.log(`Starting calculator agent with MEWParticipant...`); -console.log(`Gateway: ${options.gateway}`); -console.log(`Space: ${options.space}`); - -agent.connect() - .then(() => { - console.log('Calculator agent connected successfully'); - }) - .catch(error => { - console.error('Failed to connect:', error); - process.exit(1); - }); - -// Handle shutdown -process.on('SIGINT', () => { - console.log('\nReceived SIGINT, shutting down...'); - agent.disconnect(); - process.exit(0); -}); \ No newline at end of file +const parser = new FrameParser((envelope) => { + if (envelope.kind === 'mcp/request') { + handleRequest(envelope); + } +}); + +process.stdin.on('data', (chunk) => { + try { + parser.push(chunk); + } catch (error) { + appendLog(`ERROR ${error.message}`); + } +}); + +process.stdin.on('close', () => process.exit(0)); +process.on('SIGINT', () => process.exit(0)); +process.on('SIGTERM', () => process.exit(0)); diff --git a/tests/agents/coordinator-agent.js b/tests/agents/coordinator-agent.js new file mode 100755 index 00000000..0fa464e1 --- /dev/null +++ b/tests/agents/coordinator-agent.js @@ -0,0 +1,63 @@ +#!/usr/bin/env node +const { encodeEnvelope, FrameParser } = require('../../cli/src/stdio/utils'); + +const participantId = process.env.MEW_PARTICIPANT_ID || 'coordinator'; + +function send(envelope) { + process.stdout.write(encodeEnvelope({ protocol: 'mew/v0.3', ...envelope })); +} + +function sendGrant() { + send({ + kind: 'capability/grant', + to: ['limited-agent'], + payload: { + recipient: 'limited-agent', + capabilities: [ + { + kind: 'mcp/request', + payload: { + method: 'tools/*', + }, + }, + ], + }, + }); +} + +function sendRevoke() { + send({ + kind: 'capability/revoke', + to: ['limited-agent'], + payload: { + recipient: 'limited-agent', + capabilities: [ + { + kind: 'mcp/request', + payload: { + method: 'tools/*', + }, + }, + ], + }, + }); +} + +const parser = new FrameParser((envelope) => { + if (envelope.kind === 'system/welcome') { + setTimeout(sendGrant, 500); + setTimeout(sendRevoke, 3000); + } +}); + +process.stdin.on('data', (chunk) => { + try { + parser.push(chunk); + } catch (error) { + // ignore + } +}); + +process.stdin.on('close', () => process.exit(0)); +process.on('SIGINT', () => process.exit(0)); +process.on('SIGTERM', () => process.exit(0)); diff --git a/tests/agents/echo.js b/tests/agents/echo.js old mode 100644 new mode 100755 index 63862ff2..46d01ae8 --- a/tests/agents/echo.js +++ b/tests/agents/echo.js @@ -1,118 +1,50 @@ #!/usr/bin/env node -/** - * Echo Agent - Echoes chat messages back with "Echo: " prefix - * For MEW v0.2 test scenarios - * - * Uses MEW SDK client for WebSocket communication - */ +const { FrameParser, encodeEnvelope } = require('../../cli/src/stdio/utils'); -// Import the MEW SDK client -const path = require('path'); -const clientPath = path.resolve(__dirname, '../../sdk/typescript-sdk/client/dist/index.js'); -const { MEWClient, ClientEvents } = require(clientPath); +const participantId = process.env.MEW_PARTICIPANT_ID || 'echo-agent'; -// Parse command line arguments -const args = process.argv.slice(2); -const options = { - gateway: 'ws://localhost:8080', - space: 'test-space', - token: 'echo-token' -}; - -for (let i = 0; i < args.length; i++) { - if (args[i] === '--gateway' || args[i] === '-g') { - options.gateway = args[i + 1]; - i++; - } else if (args[i] === '--space' || args[i] === '-s') { - options.space = args[i + 1]; - i++; - } else if (args[i] === '--token' || args[i] === '-t') { - options.token = args[i + 1]; - i++; - } +function send(envelope) { + const enriched = { + protocol: 'mew/v0.3', + ...envelope, + }; + process.stdout.write(encodeEnvelope(enriched)); } -const participantId = 'echo-agent'; - -console.log(`Echo agent connecting to ${options.gateway}...`); - -// Create MEW client instance -const client = new MEWClient({ - gateway: options.gateway, - space: options.space, - token: options.token, - participant_id: participantId, - capabilities: [], - reconnect: true -}); - -// Track if we've sent the initial join message -let joined = false; - -// Handle connection events -client.onConnected(() => { - console.log('Echo agent connected to gateway'); - - // For compatibility with the gateway, send a join message - if (!joined) { - // Send a join envelope using the client - client.send({ - kind: 'system/join', - payload: { - participantId, - space: options.space, - token: options.token - } - }); - joined = true; +const parser = new FrameParser((envelope) => { + if (envelope.kind === 'system/welcome') { + return; + } + if (envelope.from === participantId) { + return; + } + if (envelope.kind !== 'chat') { + return; } -}); -// Handle welcome message -client.onWelcome((data) => { - console.log(`Echo agent welcomed to space. Participants: ${data.participants.length}`); + const messageText = envelope.payload?.text || ''; + const response = { + kind: 'chat', + correlation_id: envelope.id || envelope.correlation_id, + payload: { + text: `Echo: ${messageText}`, + format: envelope.payload?.format || 'plain', + }, + }; + send(response); }); -// Handle raw messages to preserve correlation_id functionality -// Using onMessage instead of onChat to get access to the full envelope with ID -client.onMessage((envelope) => { - // If it's a chat message not from us, echo it with correlation_id - if (envelope.kind === 'chat' && envelope.from !== participantId) { - const text = envelope.payload?.text || ''; - - // Send echo with correlation_id if the original had an id - client.send({ - kind: 'chat', - correlation_id: envelope.id ? envelope.id : undefined, - payload: { - text: `Echo: ${text}`, - format: 'plain' - } - }); - console.log(`Echoed: "${text}"`); +process.stdin.on('data', (chunk) => { + try { + parser.push(chunk); + } catch (error) { + console.error('Echo agent failed to parse input:', error.message); } }); -// Handle errors -client.onError((error) => { - console.error('WebSocket error:', error); -}); - -// Handle disconnection -client.onDisconnected(() => { - console.log('Echo agent disconnected from gateway'); +process.stdin.on('close', () => { process.exit(0); }); -// Connect to the gateway -client.connect().catch((error) => { - console.error('Failed to connect:', error); - process.exit(1); -}); - -// Handle shutdown -process.on('SIGINT', () => { - console.log('\nShutting down echo agent...'); - client.disconnect(); - process.exit(0); -}); \ No newline at end of file +process.on('SIGINT', () => process.exit(0)); +process.on('SIGTERM', () => process.exit(0)); diff --git a/tests/agents/error-driver.js b/tests/agents/error-driver.js new file mode 100755 index 00000000..8df34aaa --- /dev/null +++ b/tests/agents/error-driver.js @@ -0,0 +1,174 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); +const { FrameParser, encodeEnvelope } = require('../../cli/src/stdio/utils'); + +const participantId = process.env.MEW_PARTICIPANT_ID || 'error-driver'; +const logPath = process.env.DRIVER_LOG || './error-driver.log'; +fs.mkdirSync(path.dirname(logPath), { recursive: true }); + +function append(line) { + fs.appendFileSync(logPath, `${line}\n`); +} + +let currentStep = null; +const pending = new Map(); + +const steps = [ + { + name: 'invalid-tool', + action() { + sendCalc('invalid', {}, false, 'Tool not found'); + }, + }, + { + name: 'large-message', + action() { + const largeText = 'A'.repeat(8192); + sendEnvelope({ + kind: 'chat', + payload: { + text: largeText, + }, + }); + append('OK large-message'); + nextStep(); + }, + }, + { + name: 'rapid-fire', + action() { + let remaining = 5; + for (let i = 0; i < 5; i++) { + const id = `rapid-${Date.now()}-${i}`; + pending.set(id, { + step: 'rapid-fire', + expected: i, + onSuccess: () => { + remaining -= 1; + if (remaining === 0) { + append('OK rapid-fire'); + nextStep(); + } + }, + }); + sendEnvelope({ + id, + kind: 'mcp/request', + to: ['calculator-agent'], + payload: { + method: 'tools/call', + params: { + name: 'add', + arguments: { a: i, b: 1 }, + }, + }, + }); + } + }, + }, + { + name: 'done', + action() { + append('DONE'); + process.exit(0); + }, + }, +]; + +function nextStep() { + currentStep = steps.shift(); + if (!currentStep) { + append('DONE'); + process.exit(0); + } + currentStep.action(); +} + +function sendEnvelope(envelope) { + process.stdout.write(encodeEnvelope({ protocol: 'mew/v0.3', ...envelope })); +} + +function sendCalc(toolName, args, expectSuccess, errorIncludes) { + const id = `err-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + pending.set(id, { + step: currentStep.name, + expectSuccess, + errorIncludes, + }); + sendEnvelope({ + id, + kind: 'mcp/request', + to: ['calculator-agent'], + payload: { + method: 'tools/call', + params: { + name: toolName, + arguments: args, + }, + }, + }); +} + +const parser = new FrameParser((envelope) => { + if (envelope.kind === 'system/welcome') { + append('WELCOME'); + nextStep(); + return; + } + + if (envelope.kind === 'mcp/response' && envelope.from === 'calculator-agent') { + const correlation = Array.isArray(envelope.correlation_id) + ? envelope.correlation_id[0] + : envelope.correlation_id; + const pendingEntry = pending.get(correlation); + if (!pendingEntry) { + return; + } + pending.delete(correlation); + + if (pendingEntry.step === 'rapid-fire') { + if (envelope.payload?.success === true) { + pendingEntry.onSuccess(); + } else { + append('FAIL rapid-fire'); + process.exit(1); + } + return; + } + + const { expectSuccess, errorIncludes, step } = pendingEntry; + const success = envelope.payload?.success !== false; + + if (expectSuccess === success) { + if (expectSuccess) { + append(`OK ${step}`); + } else { + const errorMsg = envelope.payload?.error || ''; + if (errorIncludes && !errorMsg.includes(errorIncludes)) { + append(`FAIL ${step}`); + append(`DEBUG error=${errorMsg}`); + process.exit(1); + } + append(`OK ${step}`); + } + nextStep(); + } else { + append(`FAIL ${step}`); + append(`DEBUG payload=${JSON.stringify(envelope.payload)}`); + process.exit(1); + } + } +}); + +process.stdin.on('data', (chunk) => { + try { + parser.push(chunk); + } catch (error) { + append(`ERROR ${error.message}`); + } +}); + +process.stdin.on('close', () => process.exit(0)); +process.on('SIGINT', () => process.exit(0)); +process.on('SIGTERM', () => process.exit(0)); diff --git a/tests/agents/filesystem-server.js b/tests/agents/filesystem-server.js new file mode 100644 index 00000000..4033617f --- /dev/null +++ b/tests/agents/filesystem-server.js @@ -0,0 +1,166 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); +const { FrameParser, encodeEnvelope } = require('../../cli/src/stdio/utils'); + +const participantId = process.env.MEW_PARTICIPANT_ID || 'file-server'; +const rootDir = path.resolve(process.env.FS_ROOT || process.cwd()); +const logPath = process.env.FS_LOG ? path.resolve(process.env.FS_LOG) : null; + +function append(line) { + if (!logPath) return; + try { + fs.mkdirSync(path.dirname(logPath), { recursive: true }); + fs.appendFileSync(logPath, `${new Date().toISOString()} ${line}\n`); + } catch (_) { + // ignore logging errors in tests + } +} + +function send(envelope) { + process.stdout.write( + encodeEnvelope({ + protocol: 'mew/v0.3', + ...envelope, + }), + ); +} + +function safeJoin(relativePath) { + const target = path.resolve(rootDir, relativePath || ''); + if (!target.startsWith(rootDir)) { + throw new Error('Path escapes root directory'); + } + return target; +} + +const tools = [ + { + name: 'write_file', + description: 'Write text content to a file path relative to the configured root directory', + inputSchema: { + type: 'object', + properties: { + path: { type: 'string' }, + content: { type: 'string' }, + }, + required: ['path', 'content'], + }, + }, + { + name: 'list_directory', + description: 'List files in a directory relative to the configured root directory', + inputSchema: { + type: 'object', + properties: { + path: { type: 'string', default: '.' }, + }, + }, + }, +]; + +function sendSuccess({ correlationId, to, result }) { + send({ + kind: 'mcp/response', + correlation_id: correlationId ? [correlationId] : undefined, + to, + payload: { + success: true, + result, + }, + }); +} + +function sendError({ correlationId, to, message }) { + send({ + kind: 'mcp/response', + correlation_id: correlationId ? [correlationId] : undefined, + to, + payload: { + success: false, + error: message, + }, + }); +} + +function handleRequest(envelope) { + if (envelope.to && !envelope.to.includes(participantId)) { + return; + } + + const { method, params = {} } = envelope.payload || {}; + const correlationId = envelope.id || envelope.correlation_id?.[0]; + const replyTo = envelope.from ? [envelope.from] : undefined; + + append(`REQUEST ${method} from ${envelope.from || 'unknown'}`); + + try { + if (method === 'tools/list') { + sendSuccess({ + correlationId, + to: replyTo, + result: { tools }, + }); + return; + } + + if (method === 'tools/call') { + const { name, arguments: args = {} } = params; + if (name === 'write_file') { + const target = safeJoin(args.path || ''); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, args.content || ''); + append(`WRITE ${target}`); + sendSuccess({ + correlationId, + to: replyTo, + result: { + message: `Wrote ${args.content?.length || 0} bytes to ${args.path}`, + }, + }); + return; + } + + if (name === 'list_directory') { + const target = safeJoin(args.path || '.'); + const listing = fs.existsSync(target) ? fs.readdirSync(target) : []; + append(`LIST ${target}`); + sendSuccess({ + correlationId, + to: replyTo, + result: listing, + }); + return; + } + + throw new Error(`Unsupported tool: ${name}`); + } + + throw new Error(`Unsupported method: ${method}`); + } catch (error) { + append(`ERROR ${error.message}`); + sendError({ + correlationId, + to: replyTo, + message: error.message, + }); + } +} + +const parser = new FrameParser((envelope) => { + if (envelope.kind === 'mcp/request') { + handleRequest(envelope); + } +}); + +process.stdin.on('data', (chunk) => { + try { + parser.push(chunk); + } catch (error) { + append(`PARSE_ERROR ${error.message}`); + } +}); + +process.stdin.on('close', () => process.exit(0)); +process.on('SIGINT', () => process.exit(0)); +process.on('SIGTERM', () => process.exit(0)); diff --git a/tests/agents/fulfiller-participant.js b/tests/agents/fulfiller-participant.js index b8a0efa3..c4c51021 100755 --- a/tests/agents/fulfiller-participant.js +++ b/tests/agents/fulfiller-participant.js @@ -1,144 +1,120 @@ #!/usr/bin/env node -/** - * Fulfiller Agent - Auto-fulfills MCP proposals using MEWParticipant - * For MEW v0.2 test scenarios - */ - +const fs = require('fs'); const path = require('path'); +const { FrameParser, encodeEnvelope } = require('../../cli/src/stdio/utils'); -// Import MEWParticipant from the SDK -const participantPath = path.resolve(__dirname, '../../sdk/typescript-sdk/participant/dist/index.js'); -const { MEWParticipant } = require(participantPath); - -// Parse command line arguments -const args = process.argv.slice(2); -const options = { - gateway: 'ws://localhost:8080', - space: 'test-space', - token: 'fulfiller-token' -}; - -for (let i = 0; i < args.length; i++) { - if (args[i] === '--gateway' || args[i] === '-g') { - options.gateway = args[i + 1]; - i++; - } else if (args[i] === '--space' || args[i] === '-s') { - options.space = args[i + 1]; - i++; - } else if (args[i] === '--token' || args[i] === '-t') { - options.token = args[i + 1]; - i++; - } +const participantId = process.env.MEW_PARTICIPANT_ID || 'fulfiller-agent'; +const logPath = process.env.FULFILLER_LOG || './fulfiller.log'; +fs.mkdirSync(path.dirname(logPath), { recursive: true }); + +function append(line) { + fs.appendFileSync(logPath, `${line}\n`); +} + +const pending = new Map(); // correlationId -> { proposer, requestName } + +function send(envelope) { + process.stdout.write(encodeEnvelope({ protocol: 'mew/v0.3', ...envelope })); } -class FulfillerAgent extends MEWParticipant { - constructor(options) { - super({ - gateway: options.gateway, - space: options.space, - token: options.token, - participant_id: 'fulfiller-agent', - capabilities: [ - { kind: 'mcp/request', payload: { method: 'tools/*' } }, - { kind: 'mcp/proposal' }, - { kind: 'chat' }, - { kind: 'system/*' } - ], - reconnect: true - }); - - console.log(`Fulfiller agent connecting to ${options.gateway}...`); - - // Register proposal handler using the MEWParticipant system - this.onMCPProposal(async (envelope) => { - await this.handleProposal(envelope); - }); +function sendMcpRequest(method, params, proposer) { + const id = `req-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + pending.set(id, { proposer, method }); + send({ + id, + kind: 'mcp/request', + to: ['calculator-agent'], + payload: { + method, + params, + }, + }); +} + +function handleProposal(envelope) { + const proposer = envelope.from; + const method = envelope.payload?.method; + const params = envelope.payload?.params || {}; + + append(`PROPOSAL ${proposer} ${method}`); + + if (method !== 'tools/call') { + sendError(proposer, `Unsupported proposal method: ${method}`); + return; } - async onReady() { - console.log('Fulfiller agent ready!'); - - // Debug: Log all incoming messages - this.onMessage((msg) => { - console.log(`Fulfiller received message kind: ${msg.kind} from: ${msg.from}`); - if (msg.kind === 'mcp/response') { - console.log(`Fulfiller received MCP response:`, JSON.stringify(msg)); - } - }); + const toolName = params.name; + const argumentsObj = params.arguments || {}; + sendMcpRequest('tools/call', { name: toolName, arguments: argumentsObj }, proposer); +} + +function handleMcpResponse(envelope) { + const correlation = Array.isArray(envelope.correlation_id) + ? envelope.correlation_id[0] + : envelope.correlation_id; + if (!correlation) { + return; + } + const pendingRequest = pending.get(correlation); + if (!pendingRequest) { + return; } + pending.delete(correlation); + + const { proposer } = pendingRequest; + const payload = envelope.payload || {}; - async handleProposal(envelope) { - console.log(`Saw proposal from ${envelope.from}, auto-fulfilling...`); - console.log(`Proposal details: ${JSON.stringify(envelope)}`); - - // In MEW v0.3, the proposal payload IS the MCP request payload - const proposal = envelope.payload; - if (!proposal || !proposal.method) { - console.log('No valid MCP proposal data found in message'); - return; - } - - // Extract tool call parameters from MCP payload - let toolName, toolArgs; - - if (proposal.method === 'tools/call' && proposal.params) { - toolName = proposal.params.name; - toolArgs = proposal.params.arguments || {}; - } else { - console.log(`Unsupported proposal method: ${proposal.method}`); - return; - } - - // Wait a moment to simulate review, then fulfill using promise-based request - setTimeout(async () => { - try { - console.log(`Fulfilling proposal ${envelope.id} with tool ${toolName}`); - console.log(`Sending MCP request to calculator-agent: tools/call with params:`, { name: toolName, arguments: toolArgs }); - - // Use promise-based mcpRequest to call the tool - const result = await this.mcpRequest( - 'calculator-agent', - { - method: 'tools/call', - params: { name: toolName, arguments: toolArgs } - }, - 10000 // 10 second timeout - ); - - // Extract result text from MCP response - let resultText = 'Error: Unknown result'; - if (result?.content?.[0]?.text) { - resultText = result.content[0].text; - } else if (typeof result === 'string' || typeof result === 'number') { - resultText = String(result); - } - - // Forward the result as a chat message to the original proposer - await this.chat(resultText, envelope.from); - - console.log(`Forwarded result to ${envelope.from}: ${resultText}`); - - } catch (error) { - console.error(`Failed to fulfill proposal ${envelope.id}:`, error); - - // Send error message to proposer - await this.chat(`Error fulfilling proposal: ${error.message}`, envelope.from); - } - }, 500); + if (payload.success === false) { + const message = payload.error || 'Unknown error'; + sendError(proposer, message); + return; } + + const result = payload.result; + const text = typeof result === 'string' ? result : String(result); + append(`RESULT ${proposer} ${text}`); + send({ + kind: 'chat', + to: [proposer], + payload: { + text, + format: 'plain', + }, + }); } -// Create and start the fulfiller agent -const agent = new FulfillerAgent(options); +function sendError(proposer, message) { + append(`ERROR ${proposer} ${message}`); + send({ + kind: 'chat', + to: [proposer], + payload: { + text: `Error fulfilling proposal: ${message}`, + format: 'plain', + }, + }); +} -agent.connect().catch((error) => { - console.error('Failed to connect:', error); - process.exit(1); +const parser = new FrameParser((envelope) => { + append(`RECV ${envelope.kind || 'unknown'} from ${envelope.from || 'unknown'}`); + if (envelope.kind === 'mcp/proposal') { + handleProposal(envelope); + return; + } + + if (envelope.kind === 'mcp/response' && envelope.from === 'calculator-agent') { + handleMcpResponse(envelope); + } +}); + +process.stdin.on('data', (chunk) => { + try { + parser.push(chunk); + } catch (error) { + append(`PARSE_ERROR ${error.message}`); + } }); -// Handle shutdown -process.on('SIGINT', () => { - console.log('\nShutting down fulfiller agent...'); - agent.disconnect(); - process.exit(0); -}); \ No newline at end of file +process.stdin.on('close', () => process.exit(0)); +process.on('SIGINT', () => process.exit(0)); +process.on('SIGTERM', () => process.exit(0)); diff --git a/tests/agents/grant-agent.js b/tests/agents/grant-agent.js new file mode 100644 index 00000000..42194468 --- /dev/null +++ b/tests/agents/grant-agent.js @@ -0,0 +1,116 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); +const { FrameParser, encodeEnvelope } = require('../../cli/src/stdio/utils'); + +const participantId = process.env.MEW_PARTICIPANT_ID || 'grant-agent'; +const logPath = process.env.DRIVER_LOG ? path.resolve(process.env.DRIVER_LOG) : null; +const fileServerId = process.env.FILE_SERVER_ID || 'file-server'; +const coordinatorId = process.env.COORDINATOR_ID || 'grant-coordinator'; + +function append(line) { + if (!logPath) return; + try { + fs.mkdirSync(path.dirname(logPath), { recursive: true }); + fs.appendFileSync(logPath, `${line}\n`); + } catch (_) { + // ignore logging errors in tests + } +} + +function send(envelope) { + process.stdout.write( + encodeEnvelope({ + protocol: 'mew/v0.3', + ...envelope, + }), + ); +} + +let proposalId = null; +let directRequestId = null; +let receivedGrant = false; + +function sendProposal() { + proposalId = `proposal-${Date.now()}`; + append('SEND proposal foo.txt'); + send({ + id: proposalId, + kind: 'mcp/proposal', + to: [fileServerId], + payload: { + method: 'tools/call', + params: { + name: 'write_file', + arguments: { + path: 'foo.txt', + content: 'foo', + }, + }, + }, + }); +} + +function sendDirectRequest() { + if (directRequestId) return; + directRequestId = `request-${Date.now()}`; + append('SEND direct bar.txt'); + send({ + id: directRequestId, + kind: 'mcp/request', + to: [fileServerId], + payload: { + method: 'tools/call', + params: { + name: 'write_file', + arguments: { + path: 'bar.txt', + content: 'bar', + }, + }, + }, + }); +} + +const parser = new FrameParser((envelope) => { + if (envelope.kind === 'system/welcome') { + append('WELCOME'); + setTimeout(sendProposal, 250); + return; + } + + if (envelope.kind === 'capability/grant' && envelope.from === coordinatorId) { + receivedGrant = true; + append('RECEIVED grant'); + setTimeout(sendDirectRequest, 250); + return; + } + + if (envelope.kind === 'mcp/response' && envelope.from === fileServerId) { + if (envelope.correlation_id?.includes(directRequestId)) { + const success = envelope.payload?.success !== false; + if (success) { + append('OK direct-request'); + append('DONE'); + setTimeout(() => process.exit(0), 250); + } else { + append('FAIL direct-request'); + append(`DEBUG ${JSON.stringify(envelope.payload)}`); + process.exit(1); + } + } + return; + } +}); + +process.stdin.on('data', (chunk) => { + try { + parser.push(chunk); + } catch (error) { + append(`ERROR ${error.message}`); + } +}); + +process.stdin.on('close', () => process.exit(receivedGrant ? 0 : 1)); +process.on('SIGINT', () => process.exit(0)); +process.on('SIGTERM', () => process.exit(0)); diff --git a/tests/agents/grant-coordinator.js b/tests/agents/grant-coordinator.js new file mode 100644 index 00000000..8de516be --- /dev/null +++ b/tests/agents/grant-coordinator.js @@ -0,0 +1,104 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); +const { FrameParser, encodeEnvelope } = require('../../cli/src/stdio/utils'); + +const participantId = process.env.MEW_PARTICIPANT_ID || 'grant-coordinator'; +const logPath = process.env.GRANT_LOG ? path.resolve(process.env.GRANT_LOG) : null; +const fileServerId = process.env.FILE_SERVER_ID || 'file-server'; +const agentId = process.env.AGENT_ID || 'grant-agent'; + +function append(line) { + if (!logPath) return; + try { + fs.mkdirSync(path.dirname(logPath), { recursive: true }); + fs.appendFileSync(logPath, `${line}\n`); + } catch (_) { + // ignore logging errors in tests + } +} + +function send(envelope) { + process.stdout.write( + encodeEnvelope({ + protocol: 'mew/v0.3', + ...envelope, + }), + ); +} + +const pending = new Map(); + +function forwardProposal(envelope) { + const { params } = envelope.payload || {}; + if (!params) return; + const forwardId = `forward-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + pending.set(forwardId, { originalProposalId: envelope.id, agentId: envelope.from }); + append('FORWARD proposal to file-server'); + send({ + id: forwardId, + kind: 'mcp/request', + to: [fileServerId], + payload: { + method: params.method || 'tools/call', + params, + }, + }); +} + +function grantCapability(agent, correlationId) { + append('GRANT capability to agent'); + send({ + kind: 'capability/grant', + to: [agent], + correlation_id: correlationId ? [correlationId] : undefined, + payload: { + recipient: agent, + capabilities: [ + { + kind: 'mcp/request', + payload: { + method: 'tools/*', + }, + }, + ], + }, + }); +} + +const parser = new FrameParser((envelope) => { + if (envelope.kind === 'mcp/proposal' && envelope.to?.includes(fileServerId)) { + append('RECEIVED proposal'); + forwardProposal(envelope); + return; + } + + if (envelope.kind === 'mcp/response' && envelope.from === fileServerId) { + const pendingEntry = pending.get(envelope.correlation_id?.[0] || envelope.correlation_id); + if (!pendingEntry) { + return; + } + pending.delete(envelope.correlation_id?.[0] || envelope.correlation_id); + + const success = envelope.payload?.success !== false; + if (success) { + append('FORWARD_SUCCESS'); + grantCapability(pendingEntry.agentId || agentId, pendingEntry.originalProposalId); + } else { + append('FORWARD_FAILED'); + } + return; + } +}); + +process.stdin.on('data', (chunk) => { + try { + parser.push(chunk); + } catch (error) { + append(`ERROR ${error.message}`); + } +}); + +process.stdin.on('close', () => process.exit(0)); +process.on('SIGINT', () => process.exit(0)); +process.on('SIGTERM', () => process.exit(0)); diff --git a/tests/agents/limited-driver.js b/tests/agents/limited-driver.js new file mode 100755 index 00000000..98e2c7f3 --- /dev/null +++ b/tests/agents/limited-driver.js @@ -0,0 +1,93 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); +const { FrameParser, encodeEnvelope } = require('../../cli/src/stdio/utils'); + +const participantId = process.env.MEW_PARTICIPANT_ID || 'limited-agent'; +const logPath = process.env.DRIVER_LOG || './limited-driver.log'; +fs.mkdirSync(path.dirname(logPath), { recursive: true }); + +function append(line) { + fs.appendFileSync(logPath, `${line}\n`); +} + +let grantReceived = false; +let revokeReceived = false; +let awaitingResponse = false; + +function sendRequest() { + const id = `req-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + awaitingResponse = id; + const envelope = { + protocol: 'mew/v0.3', + id, + kind: 'mcp/request', + to: ['calculator-agent'], + payload: { + method: 'tools/call', + params: { + name: 'add', + arguments: { a: 2, b: 3 }, + }, + }, + }; + process.stdout.write(encodeEnvelope(envelope)); +} + +function maybeFinish() { + if (grantReceived && revokeReceived && !awaitingResponse) { + append('DONE'); + process.exit(0); + } +} + +const parser = new FrameParser((envelope) => { + if (envelope.kind === 'system/welcome') { + append('WELCOME'); + return; + } + + if (envelope.kind === 'capability/grant' && envelope.from === 'coordinator') { + grantReceived = true; + append('GRANT'); + sendRequest(); + return; + } + + if (envelope.kind === 'capability/revoke' && envelope.from === 'coordinator') { + revokeReceived = true; + append('REVOKE'); + maybeFinish(); + return; + } + + if ( + envelope.kind === 'mcp/response' && + envelope.from === 'calculator-agent' && + awaitingResponse && + (envelope.correlation_id === awaitingResponse || + (Array.isArray(envelope.correlation_id) && envelope.correlation_id.includes(awaitingResponse))) + ) { + awaitingResponse = false; + const result = envelope.payload?.result; + if (result === 5) { + append('OK tool-call'); + } else { + append('FAIL tool-call'); + process.exit(1); + } + maybeFinish(); + } +}); + +process.stdin.on('data', (chunk) => { + try { + parser.push(chunk); + } catch (error) { + append(`ERROR ${error.message}`); + } +}); + +process.stdin.on('close', () => process.exit(0)); +process.on('SIGINT', () => process.exit(0)); +process.on('SIGTERM', () => process.exit(0)); diff --git a/tests/agents/mcp-driver.js b/tests/agents/mcp-driver.js new file mode 100755 index 00000000..970078cb --- /dev/null +++ b/tests/agents/mcp-driver.js @@ -0,0 +1,148 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); +const { FrameParser, encodeEnvelope } = require('../../cli/src/stdio/utils'); + +const participantId = process.env.MEW_PARTICIPANT_ID || 'mcp-driver'; +const logPath = process.env.DRIVER_LOG || './driver.log'; +fs.mkdirSync(path.dirname(logPath), { recursive: true }); + +function append(line) { + fs.appendFileSync(logPath, `${line}\n`); +} + +const pending = new Map(); +let currentTest = null; +let testIndex = -1; + +const tests = [ + { + name: 'tools-list', + send() { + sendRequest('tools/list', {}); + }, + assert(response) { + const tools = response.payload?.result?.tools; + return Array.isArray(tools) && tools.length >= 3; + }, + }, + { + name: 'add', + send() { + sendRequest('tools/call', { name: 'add', arguments: { a: 5, b: 3 } }); + }, + assert(response) { + return response.payload?.result === 8; + }, + }, + { + name: 'multiply', + send() { + sendRequest('tools/call', { name: 'multiply', arguments: { a: 7, b: 9 } }); + }, + assert(response) { + return response.payload?.result === 63; + }, + }, + { + name: 'evaluate', + send() { + sendRequest('tools/call', { name: 'evaluate', arguments: { expression: '20 / 4' } }); + }, + assert(response) { + return response.payload?.result === 5; + }, + }, + { + name: 'divide-zero', + send() { + sendRequest('tools/call', { name: 'evaluate', arguments: { expression: '10 / 0' } }); + }, + assert(response) { + const result = response.payload?.result; + return result === 'Infinity' || result === Infinity || result === null; + }, + }, + { + name: 'invalid-tool', + send() { + sendRequest('tools/call', { name: 'invalid', arguments: {} }); + }, + assert(response) { + return response.payload?.success === false && typeof response.payload?.error === 'string'; + }, + }, +]; + +function nextTest() { + testIndex += 1; + if (testIndex >= tests.length) { + append('DONE'); + process.exit(0); + } + + currentTest = tests[testIndex]; + currentTest.send(); +} + +function sendRequest(method, params) { + const id = `req-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + pending.set(id, currentTest); + const envelope = { + protocol: 'mew/v0.3', + id, + kind: 'mcp/request', + to: ['calculator-agent'], + payload: { + method, + params, + }, + }; + process.stdout.write(encodeEnvelope(envelope)); +} + +const parser = new FrameParser((envelope) => { + if (envelope.kind === 'system/welcome') { + append('WELCOME'); + nextTest(); + return; + } + + if (envelope.kind !== 'mcp/response') { + return; + } + + const correlation = Array.isArray(envelope.correlation_id) + ? envelope.correlation_id[0] + : envelope.correlation_id; + if (!correlation) { + return; + } + + const test = pending.get(correlation); + if (!test) { + return; + } + pending.delete(correlation); + + const success = Boolean(test.assert(envelope)); + if (success) { + append(`OK ${test.name}`); + nextTest(); + } else { + append(`FAIL ${test.name}`); + process.exit(1); + } +}); + +process.stdin.on('data', (chunk) => { + try { + parser.push(chunk); + } catch (error) { + append(`ERROR ${error.message}`); + } +}); + +process.stdin.on('close', () => process.exit(0)); +process.on('SIGINT', () => process.exit(0)); +process.on('SIGTERM', () => process.exit(0)); diff --git a/tests/agents/multi-coordinator.js b/tests/agents/multi-coordinator.js new file mode 100644 index 00000000..40143818 --- /dev/null +++ b/tests/agents/multi-coordinator.js @@ -0,0 +1,87 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); +const { FrameParser, encodeEnvelope } = require('../../cli/src/stdio/utils'); + +const participantId = process.env.MEW_PARTICIPANT_ID || 'coordinator-agent'; +const workerId = process.env.WORKER_ID || 'worker-agent'; +const driverId = process.env.DRIVER_ID || 'multi-driver'; +const logPath = process.env.COORDINATOR_LOG ? path.resolve(process.env.COORDINATOR_LOG) : null; +let awaitingWorker = false; + +function append(line) { + if (!logPath) return; + try { + fs.mkdirSync(path.dirname(logPath), { recursive: true }); + fs.appendFileSync(logPath, `${line}\n`); + } catch (_) {} +} + +function send(envelope) { + process.stdout.write( + encodeEnvelope({ + protocol: 'mew/v0.3', + ...envelope, + }), + ); +} + +function handleChat(envelope) { + const text = envelope.payload?.text || ''; + append(`CHAT from ${envelope.from}: ${text}`); + + if (envelope.from === driverId && /coordinate/i.test(text)) { + const match = text.match(/(\d+)\s*([+*])\s*(\d+)/); + if (match) { + const a = Number(match[1]); + const op = match[2] === '*' ? 'multiply' : 'add'; + const b = Number(match[3]); + awaitingWorker = true; + send({ + kind: 'chat', + to: [workerId], + payload: { + text: `Compute ${a} ${op} ${b}`, + format: 'plain', + }, + }); + append(`REQUEST worker ${a} ${op} ${b}`); + } + return; + } + + if (envelope.from === workerId && awaitingWorker) { + awaitingWorker = false; + send({ + kind: 'chat', + to: [driverId], + payload: { + text, + format: 'plain', + }, + }); + append(`FORWARD result ${text}`); + } +} + +const parser = new FrameParser((envelope) => { + if (envelope.kind === 'system/welcome') { + append('WELCOME'); + return; + } + if (envelope.kind === 'chat') { + handleChat(envelope); + } +}); + +process.stdin.on('data', (chunk) => { + try { + parser.push(chunk); + } catch (error) { + append(`ERROR ${error.message}`); + } +}); + +process.stdin.on('close', () => process.exit(0)); +process.on('SIGINT', () => process.exit(0)); +process.on('SIGTERM', () => process.exit(0)); diff --git a/tests/agents/multi-driver.js b/tests/agents/multi-driver.js new file mode 100644 index 00000000..713b1033 --- /dev/null +++ b/tests/agents/multi-driver.js @@ -0,0 +1,67 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); +const { FrameParser, encodeEnvelope } = require('../../cli/src/stdio/utils'); + +const participantId = process.env.MEW_PARTICIPANT_ID || 'multi-driver'; +const coordinatorId = process.env.COORDINATOR_ID || 'coordinator-agent'; +const logPath = process.env.DRIVER_LOG ? path.resolve(process.env.DRIVER_LOG) : null; + +function append(line) { + if (!logPath) return; + try { + fs.mkdirSync(path.dirname(logPath), { recursive: true }); + fs.appendFileSync(logPath, `${line}\n`); + } catch (_) {} +} + +function send(envelope) { + process.stdout.write( + encodeEnvelope({ + protocol: 'mew/v0.3', + ...envelope, + }), + ); +} + +let started = false; +let done = false; + +const parser = new FrameParser((envelope) => { + if (envelope.kind === 'system/welcome' && !started) { + append('WELCOME'); + started = true; + send({ + kind: 'chat', + to: [coordinatorId], + payload: { + text: 'Please coordinate with worker-agent to compute 15 + 25', + format: 'plain', + }, + }); + append('REQUEST coordination 15 + 25'); + return; + } + + if (envelope.kind === 'chat' && envelope.from === coordinatorId) { + append(`COORDINATOR_RESPONSE ${envelope.payload?.text || ''}`); + if (!done && /40/.test(envelope.payload?.text || '')) { + done = true; + append('OK coordination'); + append('DONE'); + setTimeout(() => process.exit(0), 200); + } + } +}); + +process.stdin.on('data', (chunk) => { + try { + parser.push(chunk); + } catch (error) { + append(`ERROR ${error.message}`); + } +}); + +process.stdin.on('close', () => process.exit(done ? 0 : 1)); +process.on('SIGINT', () => process.exit(0)); +process.on('SIGTERM', () => process.exit(0)); diff --git a/tests/agents/multi-worker.js b/tests/agents/multi-worker.js new file mode 100644 index 00000000..c0e8d336 --- /dev/null +++ b/tests/agents/multi-worker.js @@ -0,0 +1,66 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); +const { FrameParser, encodeEnvelope } = require('../../cli/src/stdio/utils'); + +const participantId = process.env.MEW_PARTICIPANT_ID || 'worker-agent'; +const logPath = process.env.WORKER_LOG ? path.resolve(process.env.WORKER_LOG) : null; + +function append(line) { + if (!logPath) return; + try { + fs.mkdirSync(path.dirname(logPath), { recursive: true }); + fs.appendFileSync(logPath, `${line}\n`); + } catch (_) {} +} + +function send(envelope) { + process.stdout.write( + encodeEnvelope({ + protocol: 'mew/v0.3', + ...envelope, + }), + ); +} + +function handleChat(envelope) { + const text = envelope.payload?.text || ''; + append(`CHAT ${text}`); + const match = text.match(/(-?\d+)\s*(add|multiply)\s*(-?\d+)/i); + if (!match) return; + const a = Number(match[1]); + const op = match[2].toLowerCase(); + const b = Number(match[3]); + const result = op === 'multiply' ? a * b : a + b; + send({ + kind: 'chat', + to: [envelope.from || 'coordinator-agent'], + payload: { + text: `Result: ${result}`, + format: 'plain', + }, + }); + append(`RESULT ${result}`); +} + +const parser = new FrameParser((envelope) => { + if (envelope.kind === 'system/welcome') { + append('WELCOME'); + return; + } + if (envelope.kind === 'chat') { + handleChat(envelope); + } +}); + +process.stdin.on('data', (chunk) => { + try { + parser.push(chunk); + } catch (error) { + append(`ERROR ${error.message}`); + } +}); + +process.stdin.on('close', () => process.exit(0)); +process.on('SIGINT', () => process.exit(0)); +process.on('SIGTERM', () => process.exit(0)); diff --git a/tests/agents/proposal-driver.js b/tests/agents/proposal-driver.js new file mode 100755 index 00000000..813c4e7d --- /dev/null +++ b/tests/agents/proposal-driver.js @@ -0,0 +1,95 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); +const { FrameParser, encodeEnvelope } = require('../../cli/src/stdio/utils'); + +const participantId = process.env.MEW_PARTICIPANT_ID || 'proposal-driver'; +const logPath = process.env.DRIVER_LOG || './proposal-driver.log'; +fs.mkdirSync(path.dirname(logPath), { recursive: true }); + +function append(line) { + fs.appendFileSync(logPath, `${line}\n`); +} + +let testIndex = -1; +let awaiting = null; + +const tests = [ + { + name: 'add-proposal', + send() { + sendProposal({ name: 'add', arguments: { a: 10, b: 5 } }); + awaiting = (envelope) => envelope.payload?.text?.includes('15'); + }, + }, + { + name: 'multiply-proposal', + send() { + sendProposal({ name: 'multiply', arguments: { a: 7, b: 8 } }); + awaiting = (envelope) => envelope.payload?.text?.includes('56'); + }, + }, + { + name: 'invalid-proposal', + send() { + sendProposal({ name: 'invalid_tool', arguments: {} }); + awaiting = (envelope) => /error/i.test(envelope.payload?.text || ''); + }, + }, +]; + +function nextTest() { + testIndex += 1; + if (testIndex >= tests.length) { + append('DONE'); + process.exit(0); + } + + const test = tests[testIndex]; + awaiting = null; + test.send(); +} + +function sendProposal({ name, arguments: args }) { + const envelope = { + protocol: 'mew/v0.3', + id: `proposal-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + kind: 'mcp/proposal', + to: ['fulfiller-agent'], + payload: { + method: 'tools/call', + params: { + name, + arguments: args, + }, + }, + }; + process.stdout.write(encodeEnvelope(envelope)); +} + +const parser = new FrameParser((envelope) => { + if (envelope.kind === 'system/welcome') { + append('WELCOME'); + nextTest(); + return; + } + + if (envelope.kind === 'chat' && envelope.from === 'fulfiller-agent') { + if (awaiting && awaiting(envelope)) { + append(`OK ${tests[testIndex].name}`); + nextTest(); + } + } +}); + +process.stdin.on('data', (chunk) => { + try { + parser.push(chunk); + } catch (error) { + append(`ERROR ${error.message}`); + } +}); + +process.stdin.on('close', () => process.exit(0)); +process.on('SIGINT', () => process.exit(0)); +process.on('SIGTERM', () => process.exit(0)); diff --git a/tests/agents/reasoning-driver.js b/tests/agents/reasoning-driver.js new file mode 100644 index 00000000..335dd77c --- /dev/null +++ b/tests/agents/reasoning-driver.js @@ -0,0 +1,221 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); +const { FrameParser, encodeEnvelope } = require('../../cli/src/stdio/utils'); + +const participantId = process.env.MEW_PARTICIPANT_ID || 'research-agent'; +const logPath = process.env.DRIVER_LOG || './reasoning-driver.log'; +fs.mkdirSync(path.dirname(logPath), { recursive: true }); + +function append(line) { + fs.appendFileSync(logPath, `${line}\n`); +} + +const requestId = `req-${Date.now()}`; +const reasoningId = `reason-${Date.now()}`; +const pending = new Map(); +let stepIndex = -1; + +const steps = [ + { + name: 'chat-request', + action() { + sendEnvelope({ + id: requestId, + kind: 'chat', + context: undefined, + payload: { + text: 'Calculate the total cost with tax', + }, + }); + append('OK chat-request'); + nextStep(); + }, + }, + { + name: 'reason-start', + action() { + sendEnvelope({ + id: `start-${Date.now()}`, + kind: 'reasoning/start', + correlation_id: [requestId], + context: reasoningId, + payload: { + message: 'Starting calculation for total cost', + }, + }); + append('OK reason-start'); + nextStep(); + }, + }, + { + name: 'thought-1', + action() { + sendEnvelope({ + kind: 'reasoning/thought', + context: reasoningId, + payload: { + message: 'Compute base cost: 5 x 12', + }, + }); + append('OK thought-1'); + nextStep(); + }, + }, + { + name: 'calc-base', + action() { + sendCalc('multiply', { a: 5, b: 12 }, 60); + }, + }, + { + name: 'thought-2', + action() { + sendEnvelope({ + kind: 'reasoning/thought', + context: reasoningId, + payload: { + message: 'Compute tax: base x 0.08', + }, + }); + append('OK thought-2'); + nextStep(); + }, + }, + { + name: 'calc-tax', + action() { + sendCalc('multiply', { a: 60, b: 0.08 }, 4.8); + }, + }, + { + name: 'thought-3', + action() { + sendEnvelope({ + kind: 'reasoning/thought', + context: reasoningId, + payload: { + message: 'Add base and tax', + }, + }); + append('OK thought-3'); + nextStep(); + }, + }, + { + name: 'calc-total', + action() { + sendCalc('add', { a: 60, b: 4.8 }, 64.8); + }, + }, + { + name: 'reason-conclusion', + action() { + sendEnvelope({ + kind: 'reasoning/conclusion', + context: reasoningId, + payload: { + message: 'Total cost is $64.80', + }, + }); + append('OK reason-conclusion'); + nextStep(); + }, + }, + { + name: 'final-chat', + action() { + sendEnvelope({ + kind: 'chat', + correlation_id: [requestId], + payload: { + text: 'Final total: $64.80', + }, + }); + append('OK final-chat'); + append('DONE'); + process.exit(0); + }, + }, +]; + +function sendEnvelope(envelope) { + const message = { protocol: 'mew/v0.3', ...envelope }; + if (message.context === undefined) { + message.context = reasoningId; + } + process.stdout.write(encodeEnvelope(message)); +} + +function sendCalc(name, args, expected) { + const id = `calc-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + pending.set(id, { name: steps[stepIndex].name, expected }); + const envelope = { + id, + kind: 'mcp/request', + to: ['calculator-agent'], + context: reasoningId, + payload: { + method: 'tools/call', + params: { + name, + arguments: args, + }, + }, + }; + process.stdout.write(encodeEnvelope({ protocol: 'mew/v0.3', ...envelope })); +} + +function nextStep() { + stepIndex += 1; + if (stepIndex >= steps.length) { + append('DONE'); + process.exit(0); + } + steps[stepIndex].action(); +} + +const parser = new FrameParser((envelope) => { + if (envelope.kind === 'system/welcome') { + append('WELCOME'); + nextStep(); + return; + } + + if (envelope.kind === 'mcp/response' && envelope.from === 'calculator-agent') { + const correlation = Array.isArray(envelope.correlation_id) + ? envelope.correlation_id[0] + : envelope.correlation_id; + const pendingEntry = pending.get(correlation); + if (!pendingEntry) { + return; + } + pending.delete(correlation); + + const { expected, name } = pendingEntry; + const result = envelope.payload?.result; + const contextMatches = envelope.context === reasoningId; + const numericMatches = Math.abs(Number(result) - expected) < 1e-6; + + if (contextMatches && numericMatches) { + append(`OK ${name}`); + nextStep(); + } else { + append(`FAIL ${name}`); + append(`DEBUG result=${result} expected=${expected} context=${envelope.context}`); + process.exit(1); + } + } +}); + +process.stdin.on('data', (chunk) => { + try { + parser.push(chunk); + } catch (error) { + append(`ERROR ${error.message}`); + } +}); + +process.stdin.on('close', () => process.exit(0)); +process.on('SIGINT', () => process.exit(0)); +process.on('SIGTERM', () => process.exit(0)); diff --git a/tests/agents/streaming-agent.js b/tests/agents/streaming-agent.js new file mode 100644 index 00000000..da3d1454 --- /dev/null +++ b/tests/agents/streaming-agent.js @@ -0,0 +1,96 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); +const { FrameParser, encodeEnvelope } = require('../../cli/src/stdio/utils'); + +const participantId = process.env.MEW_PARTICIPANT_ID || 'streaming-agent'; +const logPath = process.env.STREAM_AGENT_LOG ? path.resolve(process.env.STREAM_AGENT_LOG) : null; +const driverId = process.env.DRIVER_ID || 'stream-driver'; + +function append(line) { + if (!logPath) return; + try { + fs.mkdirSync(path.dirname(logPath), { recursive: true }); + fs.appendFileSync(logPath, `${line}\n`); + } catch (_) {} +} + +function send(envelope) { + process.stdout.write( + encodeEnvelope({ + protocol: 'mew/v0.3', + ...envelope, + }), + ); +} + +function emitStream() { + const baseId = `stream-${Date.now()}`; + send({ + id: `${baseId}-start`, + kind: 'reasoning/start', + to: [driverId], + payload: { + message: 'Starting streamed reasoning', + }, + }); + append('EMIT start'); + send({ + id: `${baseId}-thought1`, + kind: 'reasoning/thought', + to: [driverId], + payload: { + message: 'Thinking step 1', + }, + }); + append('EMIT thought1'); + send({ + id: `${baseId}-thought2`, + kind: 'reasoning/thought', + to: [driverId], + payload: { + message: 'Thinking step 2', + }, + }); + append('EMIT thought2'); + send({ + id: `${baseId}-end`, + kind: 'reasoning/conclusion', + to: [driverId], + payload: { + message: 'Finished with answer 123', + }, + }); + append('EMIT conclusion'); + send({ + kind: 'chat', + to: [driverId], + payload: { + text: 'Final answer: 123', + format: 'plain', + }, + }); +} + +const parser = new FrameParser((envelope) => { + if (envelope.kind === 'system/welcome') { + append('WELCOME'); + return; + } + if (envelope.kind === 'chat' && envelope.from === driverId) { + append(`CHAT ${envelope.payload?.text || ''}`); + emitStream(); + } +}); + +process.stdin.on('data', (chunk) => { + try { + parser.push(chunk); + } catch (error) { + append(`ERROR ${error.message}`); + } +}); + +process.stdin.on('close', () => process.exit(0)); +process.on('SIGINT', () => process.exit(0)); +process.on('SIGTERM', () => process.exit(0)); diff --git a/tests/agents/streaming-driver.js b/tests/agents/streaming-driver.js new file mode 100644 index 00000000..4ea16d06 --- /dev/null +++ b/tests/agents/streaming-driver.js @@ -0,0 +1,85 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); +const { FrameParser, encodeEnvelope } = require('../../cli/src/stdio/utils'); + +const participantId = process.env.MEW_PARTICIPANT_ID || 'stream-driver'; +const agentId = process.env.AGENT_ID || 'streaming-agent'; +const logPath = process.env.STREAM_DRIVER_LOG ? path.resolve(process.env.STREAM_DRIVER_LOG) : null; + +function append(line) { + if (!logPath) return; + try { + fs.mkdirSync(path.dirname(logPath), { recursive: true }); + fs.appendFileSync(logPath, `${line}\n`); + } catch (_) {} +} + +function send(envelope) { + process.stdout.write( + encodeEnvelope({ + protocol: 'mew/v0.3', + ...envelope, + }), + ); +} + +let events = []; +let requestSent = false; + +function handleEvent(kind, payload) { + events.push(kind); + append(`${kind} ${payload?.message || payload?.text || ''}`); + if (!requestSent) return; + if (kind === 'chat') { + const hasStart = events.includes('reasoning/start'); + const thoughts = events.filter((k) => k === 'reasoning/thought').length; + const hasConclusion = events.includes('reasoning/conclusion'); + if (hasStart && thoughts >= 2 && hasConclusion && /123/.test(payload?.text || '')) { + append('OK streaming-sequence'); + append('DONE'); + process.exit(0); + } + } +} + +const parser = new FrameParser((envelope) => { + if (envelope.kind === 'system/welcome' && !requestSent) { + append('WELCOME'); + requestSent = true; + send({ + kind: 'chat', + to: [agentId], + payload: { + text: 'Please stream your reasoning for 123', + format: 'plain', + }, + }); + return; + } + + if (envelope.from !== agentId) return; + + switch (envelope.kind) { + case 'reasoning/start': + case 'reasoning/thought': + case 'reasoning/conclusion': + handleEvent(envelope.kind, envelope.payload); + break; + case 'chat': + handleEvent('chat', envelope.payload); + break; + } +}); + +process.stdin.on('data', (chunk) => { + try { + parser.push(chunk); + } catch (error) { + append(`ERROR ${error.message}`); + } +}); + +process.stdin.on('close', () => process.exit(1)); +process.on('SIGINT', () => process.exit(0)); +process.on('SIGTERM', () => process.exit(0)); diff --git a/tests/agents/ts-proposal-driver.js b/tests/agents/ts-proposal-driver.js new file mode 100644 index 00000000..d88fb33b --- /dev/null +++ b/tests/agents/ts-proposal-driver.js @@ -0,0 +1,87 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); +const { FrameParser, encodeEnvelope } = require('../../cli/src/stdio/utils'); + +const participantId = process.env.MEW_PARTICIPANT_ID || 'ts-proposal-driver'; +const logPath = process.env.DRIVER_LOG ? path.resolve(process.env.DRIVER_LOG) : null; +const agentId = process.env.AGENT_ID || 'typescript-proposal-agent'; + +function append(line) { + if (!logPath) return; + try { + fs.mkdirSync(path.dirname(logPath), { recursive: true }); + fs.appendFileSync(logPath, `${line}\n`); + } catch (_) { + // ignore logging errors + } +} + +function send(envelope) { + process.stdout.write( + encodeEnvelope({ + protocol: 'mew/v0.3', + ...envelope, + }), + ); +} + +let awaitingResult = false; + +function fulfillProposal(envelope) { + const params = envelope.payload?.params || {}; + const args = params.arguments || {}; + const operation = args.operation || 'add'; + const a = Number(args.a || 0); + const b = Number(args.b || 0); + const result = operation === 'multiply' ? a * b : a + b; + append(`FULFILL ${operation} ${a} ${b} -> ${result}`); + awaitingResult = true; + send({ + kind: 'chat', + to: [agentId], + payload: { + text: `Result: ${result}`, + format: 'plain', + }, + }); +} + +function handleChat(envelope) { + if (!awaitingResult) return; + const text = envelope.payload?.text || ''; + if (text.includes('Result')) { + append('OK chat-result'); + append('DONE'); + process.exit(0); + } +} + +const parser = new FrameParser((envelope) => { + if (envelope.kind === 'system/welcome') { + append('WELCOME'); + return; + } + + if (envelope.kind === 'mcp/proposal' && envelope.from === agentId) { + append('RECEIVED proposal'); + fulfillProposal(envelope); + return; + } + + if (envelope.kind === 'chat' && envelope.from === agentId) { + handleChat(envelope); + } +}); + +process.stdin.on('data', (chunk) => { + try { + parser.push(chunk); + } catch (error) { + append(`ERROR ${error.message}`); + } +}); + +process.stdin.on('close', () => process.exit(0)); +process.on('SIGINT', () => process.exit(0)); +process.on('SIGTERM', () => process.exit(0)); diff --git a/tests/agents/typescript-agent.js b/tests/agents/typescript-agent.js new file mode 100644 index 00000000..3ad5963b --- /dev/null +++ b/tests/agents/typescript-agent.js @@ -0,0 +1,187 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); +const { FrameParser, encodeEnvelope } = require('../../cli/src/stdio/utils'); + +const participantId = process.env.MEW_PARTICIPANT_ID || 'typescript-agent'; +const logPath = process.env.TS_AGENT_LOG ? path.resolve(process.env.TS_AGENT_LOG) : null; + +function append(line) { + if (!logPath) return; + try { + fs.mkdirSync(path.dirname(logPath), { recursive: true }); + fs.appendFileSync(logPath, `${line}\n`); + } catch (_) { + // ignore logging errors + } +} + +function send(envelope) { + process.stdout.write( + encodeEnvelope({ + protocol: 'mew/v0.3', + ...envelope, + }), + ); +} + +const tools = [ + { + name: 'calculate', + description: 'Perform a math operation (add or multiply)', + inputSchema: { + type: 'object', + properties: { + operation: { type: 'string', enum: ['add', 'multiply'] }, + a: { type: 'number' }, + b: { type: 'number' }, + }, + required: ['operation', 'a', 'b'], + }, + }, + { + name: 'echo', + description: 'Echo a message back', + inputSchema: { + type: 'object', + properties: { + message: { type: 'string' }, + }, + required: ['message'], + }, + }, +]; + +function respondWithTools(envelope) { + append('LIST tools'); + send({ + kind: 'mcp/response', + correlation_id: envelope.id ? [envelope.id] : undefined, + to: [envelope.from], + payload: { + success: true, + result: { + tools, + }, + }, + }); +} + +function handleCalculate(envelope, args) { + const { operation, a, b } = args; + let result; + if (operation === 'add') { + result = a + b; + } else if (operation === 'multiply') { + result = a * b; + } else { + send({ + kind: 'mcp/response', + correlation_id: envelope.id ? [envelope.id] : undefined, + to: [envelope.from], + payload: { + success: false, + error: `Unsupported operation: ${operation}`, + }, + }); + return; + } + + append(`CALC ${a} ${operation} ${b} = ${result}`); + send({ + kind: 'mcp/response', + correlation_id: envelope.id ? [envelope.id] : undefined, + to: [envelope.from], + payload: { + success: true, + result: `Result: ${a} ${operation} ${b} = ${result}`, + }, + }); +} + +function handleEcho(envelope, args) { + const message = `Echo: ${args.message || ''}`; + append(`ECHO ${args.message || ''}`); + send({ + kind: 'mcp/response', + correlation_id: envelope.id ? [envelope.id] : undefined, + to: [envelope.from], + payload: { + success: true, + result: message, + }, + }); +} + +function handleMcpRequest(envelope) { + if (envelope.to && !envelope.to.includes(participantId)) { + return; + } + + const { method, params = {} } = envelope.payload || {}; + if (method === 'tools/list') { + respondWithTools(envelope); + return; + } + + if (method === 'tools/call') { + const { name, arguments: args = {} } = params; + if (name === 'calculate') { + handleCalculate(envelope, args); + return; + } + if (name === 'echo') { + handleEcho(envelope, args); + return; + } + send({ + kind: 'mcp/response', + correlation_id: envelope.id ? [envelope.id] : undefined, + to: [envelope.from], + payload: { + success: false, + error: `Unknown tool: ${name}`, + }, + }); + } +} + +function handleChat(envelope) { + if (envelope.to && !envelope.to.includes(participantId)) return; + const incoming = envelope.payload?.text || ''; + append(`CHAT ${incoming}`); + send({ + kind: 'chat', + to: [envelope.from], + payload: { + text: `Hello! I received: ${incoming}`, + format: 'plain', + }, + }); +} + +const parser = new FrameParser((envelope) => { + if (envelope.kind === 'system/welcome') { + append('WELCOME'); + return; + } + if (envelope.kind === 'mcp/request') { + handleMcpRequest(envelope); + return; + } + if (envelope.kind === 'chat') { + handleChat(envelope); + } +}); + +process.stdin.on('data', (chunk) => { + try { + parser.push(chunk); + } catch (error) { + append(`ERROR ${error.message}`); + } +}); + +process.stdin.on('close', () => process.exit(0)); +process.on('SIGINT', () => process.exit(0)); +process.on('SIGTERM', () => process.exit(0)); diff --git a/tests/agents/typescript-driver.js b/tests/agents/typescript-driver.js new file mode 100644 index 00000000..d6bd99d1 --- /dev/null +++ b/tests/agents/typescript-driver.js @@ -0,0 +1,179 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); +const { FrameParser, encodeEnvelope } = require('../../cli/src/stdio/utils'); + +const participantId = process.env.MEW_PARTICIPANT_ID || 'ts-driver'; +const logPath = process.env.DRIVER_LOG ? path.resolve(process.env.DRIVER_LOG) : null; +const agentId = process.env.AGENT_ID || 'typescript-agent'; + +function append(line) { + if (!logPath) return; + try { + fs.mkdirSync(path.dirname(logPath), { recursive: true }); + fs.appendFileSync(logPath, `${line}\n`); + } catch (_) { + // ignore logging errors + } +} + +function send(envelope) { + process.stdout.write( + encodeEnvelope({ + protocol: 'mew/v0.3', + ...envelope, + }), + ); +} + +const pending = new Map(); +let chatPending = false; + +function sendToolsList() { + const id = `list-${Date.now()}`; + pending.set(id, { + name: 'tools-list', + validate: (payload) => Array.isArray(payload.result?.tools) && payload.result.tools.length >= 2, + }); + send({ + id, + kind: 'mcp/request', + to: [agentId], + payload: { + method: 'tools/list', + params: {}, + }, + }); +} + +function sendCalculate(op, a, b) { + const id = `calc-${op}-${Date.now()}`; + const expected = op === 'add' ? a + b : a * b; + pending.set(id, { + name: `calc-${op}`, + validate: (payload) => typeof payload.result === 'string' && payload.result.includes(`${expected}`), + }); + send({ + id, + kind: 'mcp/request', + to: [agentId], + payload: { + method: 'tools/call', + params: { + name: 'calculate', + arguments: { operation: op, a, b }, + }, + }, + }); +} + +function sendEcho(message) { + const id = `echo-${Date.now()}`; + pending.set(id, { + name: 'echo', + validate: (payload) => payload.result === `Echo: ${message}`, + }); + send({ + id, + kind: 'mcp/request', + to: [agentId], + payload: { + method: 'tools/call', + params: { + name: 'echo', + arguments: { message }, + }, + }, + }); +} + +function sendChat(message) { + chatPending = message; + send({ + kind: 'chat', + to: [agentId], + payload: { + text: message, + format: 'plain', + }, + }); +} + +function runSequence() { + append('RUN sequence'); + sendToolsList(); + sendCalculate('add', 5, 3); + sendCalculate('multiply', 7, 6); + sendEcho('Hello from driver'); + sendChat('How are you?'); +} + +function handleResponse(envelope) { + const correlation = Array.isArray(envelope.correlation_id) + ? envelope.correlation_id[0] + : envelope.correlation_id; + if (!correlation) return; + const entry = pending.get(correlation); + if (!entry) return; + pending.delete(correlation); + + const ok = entry.validate(envelope.payload || {}); + if (ok) { + append(`OK ${entry.name}`); + } else { + append(`FAIL ${entry.name}`); + append(`DEBUG ${JSON.stringify(envelope.payload)}`); + process.exit(1); + } + + if (pending.size === 0 && !chatPending) { + append('DONE'); + process.exit(0); + } +} + +function handleChatResponse(envelope) { + if (!chatPending) return; + const text = envelope.payload?.text || ''; + if (text.includes(chatPending)) { + append('OK chat-response'); + chatPending = false; + } else { + append('FAIL chat-response'); + append(`DEBUG ${text}`); + process.exit(1); + } + if (pending.size === 0) { + append('DONE'); + process.exit(0); + } +} + +const parser = new FrameParser((envelope) => { + if (envelope.kind === 'system/welcome') { + append('WELCOME'); + runSequence(); + return; + } + + if (envelope.kind === 'mcp/response' && envelope.from === agentId) { + handleResponse(envelope); + return; + } + + if (envelope.kind === 'chat' && envelope.from === agentId) { + handleChatResponse(envelope); + } +}); + +process.stdin.on('data', (chunk) => { + try { + parser.push(chunk); + } catch (error) { + append(`ERROR ${error.message}`); + } +}); + +process.stdin.on('close', () => process.exit(0)); +process.on('SIGINT', () => process.exit(0)); +process.on('SIGTERM', () => process.exit(0)); diff --git a/tests/agents/typescript-proposal-agent.js b/tests/agents/typescript-proposal-agent.js new file mode 100644 index 00000000..2d17ca41 --- /dev/null +++ b/tests/agents/typescript-proposal-agent.js @@ -0,0 +1,110 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); +const { FrameParser, encodeEnvelope } = require('../../cli/src/stdio/utils'); + +const participantId = process.env.MEW_PARTICIPANT_ID || 'typescript-proposal-agent'; +const driverId = process.env.REQUESTER_ID || 'ts-proposal-driver'; +const logPath = process.env.TS_PROPOSAL_LOG ? path.resolve(process.env.TS_PROPOSAL_LOG) : null; + +function append(line) { + if (!logPath) return; + try { + fs.mkdirSync(path.dirname(logPath), { recursive: true }); + fs.appendFileSync(logPath, `${line}\n`); + } catch (_) { + // ignore logging errors in tests + } +} + +function send(envelope) { + process.stdout.write( + encodeEnvelope({ + protocol: 'mew/v0.3', + ...envelope, + }), + ); +} + +let pendingProposalId = null; +let awaitingResult = false; +let lastRequest = null; +let lastRequester = driverId; +let proposalSent = false; + +function createProposal(operation, a, b) { + const proposalId = `proposal-${Date.now()}`; + pendingProposalId = proposalId; + awaitingResult = true; + lastRequest = { operation, a, b }; + append(`PROPOSE ${operation} ${a} ${b}`); + send({ + id: proposalId, + kind: 'mcp/proposal', + to: [driverId], + payload: { + method: 'tools/call', + params: { + name: operation, + arguments: { operation, a, b }, + }, + }, + }); +} + +function handleChat(envelope) { + const text = envelope.payload?.text || ''; + append(`CHAT ${text}`); + + if (awaitingResult) { + const expect = lastRequest + ? String(lastRequest.operation === 'add' + ? lastRequest.a + lastRequest.b + : lastRequest.a * lastRequest.b) + : ''; + const isResult = /Result:/i.test(text) || (!!expect && text.includes(expect)) || /^-?\d+(\.\d+)?$/.test(text.trim()); + if (!isResult) { + return; + } + + append('RECEIVED result chat'); + send({ + kind: 'chat', + to: [lastRequester], + payload: { + text, + format: 'plain', + }, + }); + awaitingResult = false; + pendingProposalId = null; + lastRequest = null; + } +} + +const parser = new FrameParser((envelope) => { + append(`RECV ${envelope.kind || 'unknown'}`); + if (envelope.kind === 'system/welcome') { + append('WELCOME'); + if (!proposalSent) { + proposalSent = true; + setTimeout(() => createProposal('add', 7, 9), 200); + } + return; + } + if (envelope.kind === 'chat' && envelope.from === driverId) { + handleChat(envelope); + } +}); + +process.stdin.on('data', (chunk) => { + try { + parser.push(chunk); + } catch (error) { + append(`ERROR ${error.message}`); + } +}); + +process.stdin.on('close', () => process.exit(0)); +process.on('SIGINT', () => process.exit(0)); +process.on('SIGTERM', () => process.exit(0)); diff --git a/tests/run-all-tests.sh b/tests/run-all-tests.sh index c8ef4065..3ba4afbf 100755 --- a/tests/run-all-tests.sh +++ b/tests/run-all-tests.sh @@ -129,18 +129,11 @@ run_test "Scenario 4: Dynamic Capability Granting" "./scenario-4-capabilities" run_test "Scenario 5: Reasoning with Context Field" "./scenario-5-reasoning" run_test "Scenario 6: Error Recovery and Edge Cases" "./scenario-6-errors" run_test "Scenario 7: MCP Bridge Integration" "./scenario-7-mcp-bridge" - -# LLM-dependent scenarios (require OPENAI_API_KEY) -if [ "$NO_LLM" = false ]; then - run_test "Scenario 8: TypeScript Agent" "./scenario-8-typescript-agent" - run_test "Scenario 9: TypeScript Proposals" "./scenario-9-typescript-proposals" - run_test "Scenario 10: Multi-Agent" "./scenario-10-multi-agent" -else - echo -e "${YELLOW}Skipping Scenario 8 (TypeScript Agent) - requires OPENAI_API_KEY${NC}" - echo -e "${YELLOW}Skipping Scenario 9 (TypeScript Proposals) - requires OPENAI_API_KEY${NC}" - echo -e "${YELLOW}Skipping Scenario 10 (Multi-Agent) - requires OPENAI_API_KEY${NC}" - echo "" -fi +run_test "Scenario 8: TypeScript Agent" "./scenario-8-typescript-agent" +run_test "Scenario 9: TypeScript Proposals" "./scenario-9-typescript-proposals" +run_test "Scenario 10: Multi-Agent" "./scenario-10-multi-agent" +run_test "Scenario 11: Streams" "./scenario-11-streams" +run_test "Scenario 12: Batch Streaming" "./scenario-12-sdk-streams" # Summary echo -e "${BLUE}================================================${NC}" @@ -181,4 +174,4 @@ if [ $TOTAL_FAIL -eq 0 ]; then exit 0 else exit 1 -fi \ No newline at end of file +fi diff --git a/tests/scenario-1-basic/check.sh b/tests/scenario-1-basic/check.sh index 8a0dc922..3a3dbc0e 100755 --- a/tests/scenario-1-basic/check.sh +++ b/tests/scenario-1-basic/check.sh @@ -1,178 +1,63 @@ #!/bin/bash -# Check script - Runs test assertions against a running space -# -# Can be run after manual setup or called by test.sh +set -e -# Don't use set -e as it causes issues with arithmetic operations -# set -e - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' -# If TEST_DIR not set, we're running standalone if [ -z "$TEST_DIR" ]; then export TEST_DIR="$(cd "$(dirname "$0")" && pwd)" - export OUTPUT_LOG="$TEST_DIR/logs/test-client-output.log" - - # Try to detect port from running space - if [ -f "$TEST_DIR/.mew/pids.json" ]; then - export TEST_PORT=$(grep -o '"port":[[:space:]]*"[0-9]*"' "$TEST_DIR/.mew/pids.json" | grep -o '[0-9]*') - fi - - if [ -z "$TEST_PORT" ]; then - echo -e "${RED}Error: Cannot determine test port. Is the space running?${NC}" - exit 1 - fi -fi - -echo -e "${YELLOW}=== Running Test Checks ===${NC}" -echo -e "${BLUE}Scenario: Basic Message Flow${NC}" -echo -e "${BLUE}Test directory: $TEST_DIR${NC}" -echo -e "${BLUE}Gateway port: $TEST_PORT${NC}" -echo "" - -# Track test results -TESTS_PASSED=0 -TESTS_FAILED=0 - -# Helper function to run a test -run_test() { - local test_name="$1" - local test_command="$2" - - echo -n "Testing: $test_name ... " - - if eval "$test_command" > /dev/null 2>&1; then - echo -e "${GREEN}โœ“${NC}" - ((TESTS_PASSED++)) - else - echo -e "${RED}โœ—${NC}" - ((TESTS_FAILED++)) - fi -} - -# Point to the output log file (created by output_log config) -RESPONSE_FILE="$OUTPUT_LOG" - -# Test 1: Gateway health check -run_test "Gateway is running" "curl -s http://localhost:$TEST_PORT/health | grep -q 'ok'" - -# Test 2: Check output log exists -run_test "Output log exists" "[ -f '$OUTPUT_LOG' ]" - -# Test 3: Send simple chat message -echo -e "\n${YELLOW}Test: Simple chat message${NC}" -# Use gateway HTTP API to send message -curl -sf -X POST "http://localhost:$TEST_PORT/participants/test-client/messages" \ - -H "Authorization: Bearer test-token" \ - -H "Content-Type: application/json" \ - -d '{"kind":"chat","payload":{"text":"Hello, echo!"}}' > /dev/null -sleep 2 - -if grep -q '"text":"Echo: Hello, echo!"' "$RESPONSE_FILE" 2>/dev/null; then - echo -e "Echo response: ${GREEN}โœ“${NC}" - ((TESTS_PASSED++)) -else - echo -e "Echo response: ${RED}โœ—${NC}" - ((TESTS_FAILED++)) fi -# Test 4: Send message with ID (correlation) -echo -e "\n${YELLOW}Test: Message with correlation ID${NC}" -curl -sf -X POST "http://localhost:$TEST_PORT/participants/test-client/messages" \ - -H "Authorization: Bearer test-token" \ - -H "Content-Type: application/json" \ - -d '{"id":"msg-123","kind":"chat","payload":{"text":"Test with ID"}}' > /dev/null -sleep 2 +DRIVER_LOG=${DRIVER_LOG:-$TEST_DIR/logs/basic-driver.log} -if grep -q 'correlation_id.*msg-123' "$RESPONSE_FILE" 2>/dev/null; then - echo -e "Correlation ID preserved: ${GREEN}โœ“${NC}" - ((TESTS_PASSED++)) -else - echo -e "Correlation ID preserved: ${RED}โœ—${NC}" - ((TESTS_FAILED++)) +if [ ! -f "$DRIVER_LOG" ]; then + echo -e "${RED}Driver log not found: $DRIVER_LOG${NC}" + exit 1 fi -# Test 5: Send multiple messages -echo -e "\n${YELLOW}Test: Multiple messages${NC}" -curl -sf -X POST "http://localhost:$TEST_PORT/participants/test-client/messages" \ - -H "Authorization: Bearer test-token" \ - -H "Content-Type: application/json" \ - -d '{"kind":"chat","payload":{"text":"Message 1"}}' > /dev/null -sleep 0.5 -curl -sf -X POST "http://localhost:$TEST_PORT/participants/test-client/messages" \ - -H "Authorization: Bearer test-token" \ - -H "Content-Type: application/json" \ - -d '{"kind":"chat","payload":{"text":"Message 2"}}' > /dev/null -sleep 0.5 -curl -sf -X POST "http://localhost:$TEST_PORT/participants/test-client/messages" \ - -H "Authorization: Bearer test-token" \ - -H "Content-Type: application/json" \ - -d '{"kind":"chat","payload":{"text":"Message 3"}}' > /dev/null -sleep 2 +echo -e "${YELLOW}=== Checking STDIO message flow ===${NC}" -MSG_COUNT=$(grep -c '"text":"Echo: Message' "$RESPONSE_FILE" 2>/dev/null || echo 0) -if [ "$MSG_COUNT" -eq 3 ]; then - echo -e "All 3 messages received: ${GREEN}โœ“${NC}" - ((TESTS_PASSED++)) -else - echo -e "Multiple messages: ${RED}โœ— (only $MSG_COUNT/3 received)${NC}" - ((TESTS_FAILED++)) -fi - -# Test 6: Large message -echo -e "\n${YELLOW}Test: Large message handling${NC}" -LARGE_TEXT=$(printf 'A%.0s' {1..1000}) -curl -sf -X POST "http://localhost:$TEST_PORT/participants/test-client/messages" \ - -H "Authorization: Bearer test-token" \ - -H "Content-Type: application/json" \ - -d "{\"kind\":\"chat\",\"payload\":{\"text\":\"$LARGE_TEXT\"}}" > /dev/null -sleep 2 +timeout=20 +while [ $timeout -gt 0 ]; do + if grep -q 'DONE' "$DRIVER_LOG"; then + break + fi + sleep 1 + timeout=$((timeout - 1)) +done -if grep -q "Echo: $LARGE_TEXT" "$RESPONSE_FILE" 2>/dev/null; then - echo -e "Large message handled: ${GREEN}โœ“${NC}" - ((TESTS_PASSED++)) -else - echo -e "Large message handled: ${RED}โœ—${NC}" - ((TESTS_FAILED++)) +if ! grep -q 'DONE' "$DRIVER_LOG"; then + echo -e "${RED}Driver did not finish within timeout${NC}" + tail -n 20 "$DRIVER_LOG" + exit 1 fi -# Test 7: Rapid messages -echo -e "\n${YELLOW}Test: Rapid message handling${NC}" -for i in 1 2 3 4 5; do - curl -sf -X POST "http://localhost:$TEST_PORT/participants/test-client/messages" \ - -H "Authorization: Bearer test-token" \ - -H "Content-Type: application/json" \ - -d "{\"kind\":\"chat\",\"payload\":{\"text\":\"Rapid $i\"}}" > /dev/null - sleep 0.1 +declare -a EXPECTED=( + "WELCOME" + "OK simple" + "OK correlation" + "OK multiple" + "OK large" + "OK rapid" + "DONE" +) + +FAILURES=0 +for token in "${EXPECTED[@]}"; do + if grep -q "$token" "$DRIVER_LOG"; then + echo -e "${GREEN}โœ“${NC} $token" + else + echo -e "${RED}โœ— Missing $token${NC}" + FAILURES=$((FAILURES + 1)) + fi done -sleep 2 -RAPID_COUNT=$(grep -c '"text":"Echo: Rapid' "$RESPONSE_FILE" 2>/dev/null || echo 0) -if [ "$RAPID_COUNT" -eq 5 ]; then - echo -e "All 5 rapid messages processed: ${GREEN}โœ“${NC}" - ((TESTS_PASSED++)) -else - echo -e "Rapid messages: ${RED}โœ— (only $RAPID_COUNT/5 received)${NC}" - ((TESTS_FAILED++)) +if [ $FAILURES -ne 0 ]; then + echo -e "${RED}Scenario failed${NC}" + tail -n 20 "$DRIVER_LOG" + exit 1 fi -# Don't clean up the log file - it's persistent - -# Summary -echo "" -echo -e "${YELLOW}=== Test Summary ===${NC}" -echo -e "Tests passed: ${GREEN}$TESTS_PASSED${NC}" -echo -e "Tests failed: ${RED}$TESTS_FAILED${NC}" - -if [ $TESTS_FAILED -eq 0 ]; then - echo -e "\n${GREEN}โœ“ All tests passed!${NC}" - exit 0 -else - echo -e "\n${RED}โœ— Some tests failed${NC}" - exit 1 -fi \ No newline at end of file +echo -e "${GREEN}โœ“ Scenario passed${NC}" diff --git a/tests/scenario-1-basic/setup.sh b/tests/scenario-1-basic/setup.sh index a07c4cdc..2bac94fc 100755 --- a/tests/scenario-1-basic/setup.sh +++ b/tests/scenario-1-basic/setup.sh @@ -1,77 +1,47 @@ #!/bin/bash -# Setup script - Initializes the test space -# -# Can be run standalone for manual testing or called by test.sh - set -e -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' YELLOW='\033[1;33m' +GREEN='\033[0;32m' BLUE='\033[0;34m' -NC='\033[0m' # No Color +RED='\033[0;31m' +NC='\033[0m' -# If TEST_DIR not set, we're running standalone if [ -z "$TEST_DIR" ]; then export TEST_DIR="$(cd "$(dirname "$0")" && pwd)" fi -echo -e "${YELLOW}=== Setting up Test Space ===${NC}" -echo -e "${BLUE}Scenario: Basic Message Flow${NC}" -echo -e "${BLUE}Directory: $TEST_DIR${NC}" -echo "" - cd "$TEST_DIR" -# Clean up any previous runs -echo "Cleaning up previous test artifacts..." -../../cli/bin/mew.js space clean --all --force 2>/dev/null || true +echo -e "${YELLOW}=== Setting up STDIO test space ===${NC}"; -# Use random port to avoid conflicts -if [ -z "$TEST_PORT" ]; then - export TEST_PORT=$((8000 + RANDOM % 1000)) -fi - -echo "Starting space on port $TEST_PORT..." - -# Ensure logs directory exists -mkdir -p ./logs +mkdir -p logs -# Start the space using mew space up -../../cli/bin/mew.js space up --port "$TEST_PORT" > ./logs/space-up.log 2>&1 +# Clean previous artifacts +../../cli/bin/mew.js space clean >/dev/null 2>&1 || true +rm -rf .mew >/dev/null 2>&1 || true -# Check if space started successfully -if ../../cli/bin/mew.js space status | grep -q "Gateway: ws://localhost:$TEST_PORT"; then - echo -e "${GREEN}โœ“ Space started successfully${NC}" -else - echo -e "${RED}โœ— Space failed to start${NC}" +# Start the space (processes spawn detached via CLI) +echo "Starting space using mew space up..." +if ! ../../cli/bin/mew.js space up > ./logs/space-up.log 2>&1; then + echo -e "${RED}Failed to start space${NC}" cat ./logs/space-up.log exit 1 fi -# Wait for all components to be ready -echo "Waiting for components to initialize..." +echo "Waiting for processes to come online..." sleep 3 -# Export paths for check.sh to use -export OUTPUT_LOG="$TEST_DIR/logs/test-client-output.log" +STATE_FILE="$TEST_DIR/.mew/run/state.json" +if [ ! -f "$STATE_FILE" ]; then + echo -e "${RED}State file not found after startup${NC}" + cat ./logs/space-up.log + exit 1 +fi -# Create output log file if it doesn't exist -mkdir -p "$(dirname "$OUTPUT_LOG")" -touch "$OUTPUT_LOG" +../../cli/bin/mew.js space status -echo -e "${GREEN}โœ“ Setup complete${NC}" -echo "" -echo "Gateway running on: ws://localhost:$TEST_PORT" -echo "HTTP API available for test-client" -echo " Endpoint: http://localhost:$TEST_PORT/participants/test-client/messages" -echo " Output Log: $OUTPUT_LOG" -echo "" -echo "You can now:" -echo " - Run tests with: ./check.sh" -echo " - Send messages: curl -X POST http://localhost:$TEST_PORT/participants/test-client/messages -H 'Authorization: Bearer test-token' -H 'Content-Type: application/json' -d '{\"kind\":\"chat\",\"payload\":{\"text\":\"Hello\"}}'" -echo " - Read responses: tail -f $OUTPUT_LOG" +export DRIVER_LOG="$TEST_DIR/logs/basic-driver.log" +touch "$DRIVER_LOG" -# Set flag for check.sh -export SPACE_RUNNING=true \ No newline at end of file +echo -e "${GREEN}โœ“ Setup complete${NC}"; diff --git a/tests/scenario-1-basic/space.yaml b/tests/scenario-1-basic/space.yaml index 5a8493fe..f5370af9 100644 --- a/tests/scenario-1-basic/space.yaml +++ b/tests/scenario-1-basic/space.yaml @@ -1,23 +1,26 @@ space: - id: test-space - name: "Scenario 1 - Basic Message Flow" - description: "Test basic message flow with echo agent" + id: scenario-1 + name: "Scenario 1 - Basic STDIO Message Flow" + description: "Test basic message flow using STDIO transport" participants: echo-agent: - tokens: ["echo-token"] + auto_start: true + command: "node" + args: ["../agents/echo.js"] + env: + MEW_PARTICIPANT_ID: "echo-agent" capabilities: - kind: chat - kind: system/* - fifo: false # Agent doesn't need FIFOs + + basic-driver: auto_start: true command: "node" - args: ["../agents/echo.js", "--gateway", "ws://localhost:${PORT}", "--space", "test-space", "--token", "echo-token"] - - test-client: - tokens: ["test-token"] + args: ["../agents/basic-driver.js"] + env: + MEW_PARTICIPANT_ID: "basic-driver" + DRIVER_LOG: "./logs/basic-driver.log" capabilities: - kind: chat - kind: system/* - output_log: "./logs/test-client-output.log" # Output to file - auto_connect: true # Auto-connect via WebSocket to receive messages \ No newline at end of file diff --git a/tests/scenario-1-basic/teardown.sh b/tests/scenario-1-basic/teardown.sh index 54b42365..97e26b9b 100755 --- a/tests/scenario-1-basic/teardown.sh +++ b/tests/scenario-1-basic/teardown.sh @@ -1,60 +1,21 @@ #!/bin/bash -# Teardown script - Cleans up the test space -# -# Can be run after manual testing or called by test.sh - set -e -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color +GREEN='\033[0;32m' +NC='\033[0m' -# If TEST_DIR not set, we're running standalone if [ -z "$TEST_DIR" ]; then export TEST_DIR="$(cd "$(dirname "$0")" && pwd)" fi -echo -e "${YELLOW}=== Cleaning up Test Space ===${NC}" -echo -e "${BLUE}Directory: $TEST_DIR${NC}" -echo "" - cd "$TEST_DIR" -# Stop the space using mew space down -echo "Stopping space..." -../../cli/bin/mew.js space down 2>/dev/null || true +echo -e "${YELLOW}=== Tearing down test space ===${NC}" -# Additional cleanup for any orphaned processes -if [ -f ".mew/pids.json" ]; then - # Extract PIDs and kill them if still running - PIDS=$(grep -o '"pid":[0-9]*' .mew/pids.json 2>/dev/null | cut -d: -f2 || true) - for pid in $PIDS; do - if kill -0 $pid 2>/dev/null; then - echo "Killing orphaned process $pid" - kill -TERM $pid 2>/dev/null || true - fi - done -fi +../../cli/bin/mew.js space down >/dev/null 2>&1 || true +../../cli/bin/mew.js space clean >/dev/null 2>&1 || true -# Clean up test artifacts using mew space clean -if [ "${PRESERVE_LOGS:-false}" = "false" ]; then - echo "Cleaning test artifacts..." - - # Use the new mew space clean command - ../../cli/bin/mew.js space clean --all --force 2>/dev/null || { - # Fallback to manual cleanup if clean command fails - echo "Clean command failed, using manual cleanup..." - rm -rf logs fifos .mew 2>/dev/null || true - } - - echo -e "${GREEN}โœ“ Test artifacts removed${NC}" -else - echo -e "${YELLOW}Preserving logs (PRESERVE_LOGS=true)${NC}" - # Clean only fifos and .mew, preserve logs - ../../cli/bin/mew.js space clean --fifos --force 2>/dev/null || true -fi +rm -rf .mew >/dev/null 2>&1 || true -echo -e "${GREEN}โœ“ Cleanup complete${NC}" \ No newline at end of file +echo -e "${GREEN}โœ“ Cleanup complete${NC}" diff --git a/tests/scenario-1-basic/test.sh b/tests/scenario-1-basic/test.sh index 3092021d..8d19371c 100755 --- a/tests/scenario-1-basic/test.sh +++ b/tests/scenario-1-basic/test.sh @@ -1,56 +1,31 @@ #!/bin/bash -# Automated test script - Combines setup, check, and teardown -# -# This is the entry point for automated testing (e.g., from run-all-tests.sh) -# For manual/debugging, use setup.sh, check.sh, and teardown.sh separately - set -e -# Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' -NC='\033[0m' # No Color +NC='\033[0m' -# Get test directory export TEST_DIR="$(cd "$(dirname "$0")" && pwd)" -# Use random port to avoid conflicts -export TEST_PORT=$((8000 + RANDOM % 1000)) - -echo -e "${YELLOW}=== Scenario 1: Basic Message Flow Test ===${NC}" -echo -e "${BLUE}Testing basic echo functionality and message routing${NC}" -echo "" - -# Ensure cleanup happens on exit cleanup() { - echo "" - echo "Cleaning up..." ./teardown.sh } trap cleanup EXIT -# Step 1: Setup the space -echo -e "${YELLOW}Step 1: Setting up space...${NC}" -# Run setup in subprocess but capture the environment it sets -./setup.sh +echo -e "${YELLOW}=== Scenario 1: STDIO Basic Message Flow ===${NC}" -# Export the paths that check.sh needs -export OUTPUT_LOG="$TEST_DIR/logs/test-client-output.log" +echo -e "${BLUE}Step 1: Setup${NC}" +./setup.sh -# Step 2: Run checks -echo "" -echo -e "${YELLOW}Step 2: Running test checks...${NC}" -./check.sh -TEST_RESULT=$? +export DRIVER_LOG="$TEST_DIR/logs/basic-driver.log" -# Step 3: Report results -echo "" -if [ $TEST_RESULT -eq 0 ]; then +echo -e "${BLUE}Step 2: Checks${NC}" +if ./check.sh; then echo -e "${GREEN}โœ“ Scenario 1 PASSED${NC}" exit 0 else echo -e "${RED}โœ— Scenario 1 FAILED${NC}" exit 1 -fi \ No newline at end of file +fi diff --git a/tests/scenario-1-basic/tokens/.gitignore b/tests/scenario-1-basic/tokens/.gitignore deleted file mode 100644 index d6b7ef32..00000000 --- a/tests/scenario-1-basic/tokens/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/tests/scenario-10-multi-agent/check.sh b/tests/scenario-10-multi-agent/check.sh new file mode 100755 index 00000000..50d9e19d --- /dev/null +++ b/tests/scenario-10-multi-agent/check.sh @@ -0,0 +1,56 @@ +#!/bin/bash +set -e + +YELLOW='\033[1;33m' +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' + +if [ -z "$TEST_DIR" ]; then + export TEST_DIR="$(cd "$(dirname "$0")" && pwd)" +fi + +LOG_DIR="$TEST_DIR/logs" +DRIVER_LOG=${DRIVER_LOG:-$LOG_DIR/multi-driver.log} +COORD_LOG=${COORDINATOR_LOG:-$LOG_DIR/coordinator.log} +WORKER_LOG=${WORKER_LOG:-$LOG_DIR/worker.log} + +for file in "$DRIVER_LOG" "$COORD_LOG" "$WORKER_LOG"; do + if [ ! -f "$file" ]; then + echo -e "${RED}Missing log file: $file${NC}" + exit 1 + fi +done + +echo -e "${YELLOW}=== Checking multi-agent coordination ===${NC}" + +FAILURES=0 + +expect() { + local log="$1" + local needle="$2" + if grep -q "$needle" "$log"; then + echo -e "${GREEN}โœ“${NC} $(basename "$log") contains '$needle'" + else + echo -e "${RED}โœ—${NC} $(basename "$log") missing '$needle'" + FAILURES=$((FAILURES + 1)) + fi +} + +expect "$DRIVER_LOG" 'WELCOME' +expect "$DRIVER_LOG" 'REQUEST coordination' +expect "$DRIVER_LOG" 'OK coordination' +expect "$DRIVER_LOG" 'DONE' + +expect "$COORD_LOG" 'REQUEST worker' +expect "$COORD_LOG" 'FORWARD result' + +expect "$WORKER_LOG" 'WELCOME' +expect "$WORKER_LOG" 'RESULT' + +if [ $FAILURES -ne 0 ]; then + echo -e "${RED}Scenario failed${NC}" + exit 1 +fi + +echo -e "${GREEN}โœ“ Scenario passed${NC}" diff --git a/tests/scenario-10-multi-agent/setup.sh b/tests/scenario-10-multi-agent/setup.sh index a07c4cdc..dd5cc84a 100755 --- a/tests/scenario-10-multi-agent/setup.sh +++ b/tests/scenario-10-multi-agent/setup.sh @@ -1,77 +1,39 @@ #!/bin/bash -# Setup script - Initializes the test space -# -# Can be run standalone for manual testing or called by test.sh - set -e -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' -# If TEST_DIR not set, we're running standalone if [ -z "$TEST_DIR" ]; then export TEST_DIR="$(cd "$(dirname "$0")" && pwd)" fi -echo -e "${YELLOW}=== Setting up Test Space ===${NC}" -echo -e "${BLUE}Scenario: Basic Message Flow${NC}" -echo -e "${BLUE}Directory: $TEST_DIR${NC}" -echo "" - cd "$TEST_DIR" -# Clean up any previous runs -echo "Cleaning up previous test artifacts..." -../../cli/bin/mew.js space clean --all --force 2>/dev/null || true +echo -e "${YELLOW}=== Setting up STDIO multi-agent scenario ===${NC}"; -# Use random port to avoid conflicts -if [ -z "$TEST_PORT" ]; then - export TEST_PORT=$((8000 + RANDOM % 1000)) -fi +rm -rf logs +mkdir -p logs -echo "Starting space on port $TEST_PORT..." - -# Ensure logs directory exists -mkdir -p ./logs - -# Start the space using mew space up -../../cli/bin/mew.js space up --port "$TEST_PORT" > ./logs/space-up.log 2>&1 - -# Check if space started successfully -if ../../cli/bin/mew.js space status | grep -q "Gateway: ws://localhost:$TEST_PORT"; then - echo -e "${GREEN}โœ“ Space started successfully${NC}" -else - echo -e "${RED}โœ— Space failed to start${NC}" +if ! ../../cli/bin/mew.js space up > ./logs/space-up.log 2>&1; then + echo -e "${RED}Failed to start space${NC}" cat ./logs/space-up.log exit 1 fi -# Wait for all components to be ready -echo "Waiting for components to initialize..." -sleep 3 +sleep 2 -# Export paths for check.sh to use -export OUTPUT_LOG="$TEST_DIR/logs/test-client-output.log" +STATE_FILE="$TEST_DIR/.mew/run/state.json" +if [ ! -f "$STATE_FILE" ]; then + echo -e "${RED}State file not found after startup${NC}" + cat ./logs/space-up.log + exit 1 +fi -# Create output log file if it doesn't exist -mkdir -p "$(dirname "$OUTPUT_LOG")" -touch "$OUTPUT_LOG" +../../cli/bin/mew.js space status -echo -e "${GREEN}โœ“ Setup complete${NC}" -echo "" -echo "Gateway running on: ws://localhost:$TEST_PORT" -echo "HTTP API available for test-client" -echo " Endpoint: http://localhost:$TEST_PORT/participants/test-client/messages" -echo " Output Log: $OUTPUT_LOG" -echo "" -echo "You can now:" -echo " - Run tests with: ./check.sh" -echo " - Send messages: curl -X POST http://localhost:$TEST_PORT/participants/test-client/messages -H 'Authorization: Bearer test-token' -H 'Content-Type: application/json' -d '{\"kind\":\"chat\",\"payload\":{\"text\":\"Hello\"}}'" -echo " - Read responses: tail -f $OUTPUT_LOG" +touch ./logs/coordinator.log ./logs/worker.log ./logs/multi-driver.log -# Set flag for check.sh -export SPACE_RUNNING=true \ No newline at end of file +echo -e "${GREEN}โœ“ Setup complete${NC}"; diff --git a/tests/scenario-10-multi-agent/space.yaml b/tests/scenario-10-multi-agent/space.yaml index 951d7305..0a89674e 100644 --- a/tests/scenario-10-multi-agent/space.yaml +++ b/tests/scenario-10-multi-agent/space.yaml @@ -1,78 +1,41 @@ space: id: scenario-10 - name: "Multi-Agent Coordination Test" - description: "Test multiple TypeScript agents coordinating tasks" + name: "Scenario 10 - STDIO Multi-Agent Coordination" + description: "Coordinator delegates work to worker and relays result back to driver" participants: - # TypeScript coordinator agent coordinator-agent: - tokens: ["coordinator-token"] - capabilities: - - kind: "chat" - - kind: "mcp/request" - payload: - method: "tools/*" - - kind: "mcp/response" - - kind: "mcp/proposal" - - kind: "reasoning/*" - - kind: "system/*" auto_start: true command: "node" - args: - - "../../sdk/typescript-sdk/agent/dist/index.js" - - "--gateway" - - "ws://localhost:${PORT}" - - "--space" - - "scenario-10" - - "--token" - - "coordinator-token" - - "--id" - - "coordinator-agent" - - # TypeScript worker agent - worker-agent: - tokens: ["worker-token"] + args: ["../agents/multi-coordinator.js"] + env: + MEW_PARTICIPANT_ID: "coordinator-agent" + WORKER_ID: "worker-agent" + DRIVER_ID: "multi-driver" + COORDINATOR_LOG: "./logs/coordinator.log" capabilities: - - kind: "chat" - - kind: "mcp/request" - payload: - method: "tools/*" - - kind: "mcp/response" - - kind: "reasoning/*" - - kind: "system/*" + - kind: chat + - kind: system/* + + worker-agent: auto_start: true command: "node" - args: - - "../../sdk/typescript-sdk/agent/dist/index.js" - - "--gateway" - - "ws://localhost:${PORT}" - - "--space" - - "scenario-10" - - "--token" - - "worker-token" - - "--id" - - "worker-agent" - - # Calculator agent (from previous scenarios) - calculator-agent: - tokens: ["calculator-token"] + args: ["../agents/multi-worker.js"] + env: + MEW_PARTICIPANT_ID: "worker-agent" + WORKER_LOG: "./logs/worker.log" capabilities: - - kind: mcp/response + - kind: chat - kind: system/* + + multi-driver: auto_start: true command: "node" - args: ["../agents/calculator-participant.js", "--gateway", "ws://localhost:${PORT}", "--space", "scenario-10", "--token", "calculator-token"] - - # Test client for coordinating tests - test-client: - tokens: ["test-token"] + args: ["../agents/multi-driver.js"] + env: + MEW_PARTICIPANT_ID: "multi-driver" + DRIVER_LOG: "./logs/multi-driver.log" + COORDINATOR_ID: "coordinator-agent" capabilities: - - kind: "chat" - - kind: "mcp/request" - payload: - method: "tools/*" - - kind: "mcp/response" - - kind: "mcp/proposal" - - kind: "system/*" - output_log: "./logs/test-client-output.log" - auto_connect: true + - kind: chat + - kind: system/* diff --git a/tests/scenario-10-multi-agent/teardown.sh b/tests/scenario-10-multi-agent/teardown.sh index 54b42365..1b4ba82d 100755 --- a/tests/scenario-10-multi-agent/teardown.sh +++ b/tests/scenario-10-multi-agent/teardown.sh @@ -1,60 +1,21 @@ #!/bin/bash -# Teardown script - Cleans up the test space -# -# Can be run after manual testing or called by test.sh - set -e -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color +GREEN='\033[0;32m' +NC='\033[0m' -# If TEST_DIR not set, we're running standalone if [ -z "$TEST_DIR" ]; then export TEST_DIR="$(cd "$(dirname "$0")" && pwd)" fi -echo -e "${YELLOW}=== Cleaning up Test Space ===${NC}" -echo -e "${BLUE}Directory: $TEST_DIR${NC}" -echo "" - cd "$TEST_DIR" -# Stop the space using mew space down -echo "Stopping space..." -../../cli/bin/mew.js space down 2>/dev/null || true +echo -e "${YELLOW}=== Tearing down multi-agent scenario ===${NC}" -# Additional cleanup for any orphaned processes -if [ -f ".mew/pids.json" ]; then - # Extract PIDs and kill them if still running - PIDS=$(grep -o '"pid":[0-9]*' .mew/pids.json 2>/dev/null | cut -d: -f2 || true) - for pid in $PIDS; do - if kill -0 $pid 2>/dev/null; then - echo "Killing orphaned process $pid" - kill -TERM $pid 2>/dev/null || true - fi - done -fi +../../cli/bin/mew.js space down >/dev/null 2>&1 || true +../../cli/bin/mew.js space clean >/dev/null 2>&1 || true -# Clean up test artifacts using mew space clean -if [ "${PRESERVE_LOGS:-false}" = "false" ]; then - echo "Cleaning test artifacts..." - - # Use the new mew space clean command - ../../cli/bin/mew.js space clean --all --force 2>/dev/null || { - # Fallback to manual cleanup if clean command fails - echo "Clean command failed, using manual cleanup..." - rm -rf logs fifos .mew 2>/dev/null || true - } - - echo -e "${GREEN}โœ“ Test artifacts removed${NC}" -else - echo -e "${YELLOW}Preserving logs (PRESERVE_LOGS=true)${NC}" - # Clean only fifos and .mew, preserve logs - ../../cli/bin/mew.js space clean --fifos --force 2>/dev/null || true -fi +rm -rf .mew >/dev/null 2>&1 || true -echo -e "${GREEN}โœ“ Cleanup complete${NC}" \ No newline at end of file +echo -e "${GREEN}โœ“ Cleanup complete${NC}" diff --git a/tests/scenario-10-multi-agent/test.sh b/tests/scenario-10-multi-agent/test.sh index f5c460b9..0f76cce0 100755 --- a/tests/scenario-10-multi-agent/test.sh +++ b/tests/scenario-10-multi-agent/test.sh @@ -1,180 +1,36 @@ #!/bin/bash -# Main test runner for Scenario 10: Multi-Agent Coordination Test +set -e -# Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' -NC='\033[0m' # No Color +NC='\033[0m' -echo -e "${YELLOW}=== Scenario 10: Multi-Agent Coordination Test ===${NC}" -echo -e "${BLUE}Testing multiple TypeScript agents coordinating tasks${NC}" +export TEST_DIR="$(cd "$(dirname "$0")" && pwd)" -# Get directory of this script -TEST_DIR="$(cd "$(dirname "$0")" && pwd)" -cd "$TEST_DIR" - -# Generate random port -export TEST_PORT=$((8900 + RANDOM % 100)) - -# Run setup with the port -echo -e "\n${YELLOW}Step 1: Setting up space...${NC}" -./setup.sh -if [ $? -ne 0 ]; then - echo -e "${RED}โœ— Setup failed${NC}" - exit 1 -fi - -# Export the paths that tests need -export OUTPUT_LOG="$TEST_DIR/logs/test-client-output.log" - -# Wait for all agents to initialize -echo -e "\n${YELLOW}Step 2: Waiting for agents to initialize...${NC}" -sleep 8 - -# Function to send message via HTTP and check response -test_coordination() { - local test_name="$1" - local request="$2" - local expected_pattern="$3" - - echo "" - echo "Test: $test_name" - - # Clear output log - > "$OUTPUT_LOG" - - # Start monitoring output - tail -f "$OUTPUT_LOG" > /tmp/multi-agent-response.txt & - TAIL_PID=$! - - # Send request via HTTP API - echo "Sending request via HTTP API..." - curl -sf -X POST "http://localhost:$TEST_PORT/participants/test-client/messages" \ - -H "Authorization: Bearer test-token" \ - -H "Content-Type: application/json" \ - -d "$request" > /dev/null - - # Wait for response - sleep 5 - - # Stop monitoring - kill $TAIL_PID 2>/dev/null || true - - # Check for response in any log - if grep -q "$expected_pattern" /tmp/multi-agent-response.txt 2>/dev/null || \ - grep -q "$expected_pattern" logs/*.log 2>/dev/null; then - echo -e "${GREEN}โœ“${NC} $test_name passed" - return 0 - else - echo -e "${RED}โœ—${NC} $test_name failed" - echo "Expected pattern: $expected_pattern" - echo "Response received:" - tail -20 /tmp/multi-agent-response.txt 2>/dev/null || echo "No response captured" - return 1 - fi +cleanup() { + ./teardown.sh } +trap cleanup EXIT -echo -e "\n${YELLOW}Step 3: Running multi-agent coordination tests...${NC}" - -# Test 1: Broadcast message to all agents -REQUEST_1='{ - "kind": "chat", - "payload": { - "text": "Hello all agents! Please respond if you can hear me." - } -}' - -test_coordination \ - "Broadcast to all agents" \ - "$REQUEST_1" \ - "coordinator-agent\|worker-agent" - -RESULT_1=$? - -# Test 2: Coordinator requests tool list from worker -REQUEST_2='{ - "kind": "chat", - "to": ["coordinator-agent"], - "payload": { - "text": "Can you ask the worker-agent to list its tools?" - } -}' - -test_coordination \ - "Coordinator to worker delegation" \ - "$REQUEST_2" \ - "tools/list" +echo -e "${YELLOW}=== Scenario 10: STDIO Multi-Agent Coordination ===${NC}" -RESULT_2=$? - -# Test 3: Send a proposal that requires coordination -REQUEST_3='{ - "kind": "mcp/proposal", - "payload": { - "proposal": { - "type": "calculation", - "description": "Complex calculation requiring multiple agents", - "operation": "multiply", - "arguments": { - "a": 7, - "b": 8 - } - } - } -}' - -test_coordination \ - "Proposal handling" \ - "$REQUEST_3" \ - "proposal\|56" - -RESULT_3=$? - -# Test 4: Agent-to-agent communication -REQUEST_4='{ - "kind": "chat", - "to": ["coordinator-agent"], - "payload": { - "text": "Please coordinate with worker-agent to calculate 15 + 25 using the calculator" - } -}' - -test_coordination \ - "Agent-to-agent coordination" \ - "$REQUEST_4" \ - "40\|calculator" - -RESULT_4=$? - -# Test 5: Multiple agents reasoning together -REQUEST_5='{ - "kind": "chat", - "payload": { - "text": "All agents: What is the sum of 10 and 20?" - } -}' - -test_coordination \ - "Multiple agents reasoning" \ - "$REQUEST_5" \ - "reasoning/start.*reasoning/thought\|30" +echo -e "${BLUE}Step 1: Setup${NC}" +./setup.sh -RESULT_5=$? +export COORDINATOR_LOG="$TEST_DIR/logs/coordinator.log" +export WORKER_LOG="$TEST_DIR/logs/worker.log" +export DRIVER_LOG="$TEST_DIR/logs/multi-driver.log" -# Calculate total result -TOTAL_RESULT=$((RESULT_1 + RESULT_2 + RESULT_3 + RESULT_4 + RESULT_5)) +echo -e "${BLUE}Step 2: Allow agents to coordinate${NC}" +sleep 3 -if [ $TOTAL_RESULT -eq 0 ]; then - echo -e "\n${GREEN}โœ“ Scenario 10 PASSED${NC}" +echo -e "${BLUE}Step 3: Checks${NC}" +if ./check.sh; then + echo -e "${GREEN}โœ“ Scenario 10 PASSED${NC}" + exit 0 else - echo -e "\n${RED}โœ— Scenario 10 FAILED${NC}" - echo "Failed tests: $TOTAL_RESULT out of 5" + echo -e "${RED}โœ— Scenario 10 FAILED${NC}" + exit 1 fi - -# Cleanup -echo -e "\nCleaning up..." -./teardown.sh - -exit $TOTAL_RESULT diff --git a/tests/scenario-10-multi-agent/tokens/.gitignore b/tests/scenario-10-multi-agent/tokens/.gitignore deleted file mode 100644 index d6b7ef32..00000000 --- a/tests/scenario-10-multi-agent/tokens/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/tests/scenario-11-streams/check.sh b/tests/scenario-11-streams/check.sh new file mode 100755 index 00000000..970e8428 --- /dev/null +++ b/tests/scenario-11-streams/check.sh @@ -0,0 +1,68 @@ +#!/bin/bash +set -e + +YELLOW='\033[1;33m' +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' + +if [ -z "$TEST_DIR" ]; then + export TEST_DIR="$(cd "$(dirname "$0")" && pwd)" +fi + +DRIVER_LOG=${STREAM_DRIVER_LOG:-$TEST_DIR/logs/stream-driver.log} +AGENT_LOG=${STREAM_AGENT_LOG:-$TEST_DIR/logs/streaming-agent.log} + +for file in "$DRIVER_LOG" "$AGENT_LOG"; do + if [ ! -f "$file" ]; then + echo -e "${RED}Missing log file: $file${NC}" + exit 1 + fi +done + +echo -e "${YELLOW}=== Checking streaming sequence ===${NC}" + +FAILURES=0 + +expect_order() { + local log="$1" + local pattern="$2" + if grep -n "$pattern" "$log" >/dev/null; then + echo -e "${GREEN}โœ“${NC} $(basename "$log") contains sequence"; + else + echo -e "${RED}โœ—${NC} $(basename "$log") missing sequence"; + FAILURES=$((FAILURES + 1)); + fi +} + +expect_line() { + local log="$1" + local needle="$2" + if grep -q "$needle" "$log"; then + echo -e "${GREEN}โœ“${NC} $(basename "$log") contains '$needle'" + else + echo -e "${RED}โœ—${NC} $(basename "$log") missing '$needle'" + FAILURES=$((FAILURES + 1)) + fi +} + +expect_line "$AGENT_LOG" 'WELCOME' +expect_line "$AGENT_LOG" 'EMIT start' +expect_line "$AGENT_LOG" 'EMIT thought1' +expect_line "$AGENT_LOG" 'EMIT thought2' +expect_line "$AGENT_LOG" 'EMIT conclusion' + +expect_line "$DRIVER_LOG" 'WELCOME' +expect_line "$DRIVER_LOG" 'reasoning/start' +expect_line "$DRIVER_LOG" 'reasoning/thought Thinking step 1' +expect_line "$DRIVER_LOG" 'reasoning/thought Thinking step 2' +expect_line "$DRIVER_LOG" 'reasoning/conclusion' +expect_line "$DRIVER_LOG" 'OK streaming-sequence' +expect_line "$DRIVER_LOG" 'DONE' + +if [ $FAILURES -ne 0 ]; then + echo -e "${RED}Scenario failed${NC}" + exit 1 +fi + +echo -e "${GREEN}โœ“ Scenario passed${NC}" diff --git a/tests/scenario-11-streams/setup.sh b/tests/scenario-11-streams/setup.sh new file mode 100755 index 00000000..e853fc72 --- /dev/null +++ b/tests/scenario-11-streams/setup.sh @@ -0,0 +1,39 @@ +#!/bin/bash +set -e + +YELLOW='\033[1;33m' +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' + +if [ -z "$TEST_DIR" ]; then + export TEST_DIR="$(cd "$(dirname "$0")" && pwd)" +fi + +cd "$TEST_DIR" + +echo -e "${YELLOW}=== Setting up STDIO streaming scenario ===${NC}"; + +rm -rf logs +mkdir -p logs + +if ! ../../cli/bin/mew.js space up > ./logs/space-up.log 2>&1; then + echo -e "${RED}Failed to start space${NC}" + cat ./logs/space-up.log + exit 1 +fi + +sleep 2 + +STATE_FILE="$TEST_DIR/.mew/run/state.json" +if [ ! -f "$STATE_FILE" ]; then + echo -e "${RED}State file not found after startup${NC}" + cat ./logs/space-up.log + exit 1 +fi + +../../cli/bin/mew.js space status + +touch ./logs/streaming-agent.log ./logs/stream-driver.log + +echo -e "${GREEN}โœ“ Setup complete${NC}"; diff --git a/tests/scenario-11-streams/space.yaml b/tests/scenario-11-streams/space.yaml new file mode 100644 index 00000000..ffb2ebed --- /dev/null +++ b/tests/scenario-11-streams/space.yaml @@ -0,0 +1,30 @@ +space: + id: scenario-11 + name: "Scenario 11 - STDIO Streams" + description: "Streaming agent emits reasoning sequence to driver" + +participants: + streaming-agent: + auto_start: true + command: "node" + args: ["../agents/streaming-agent.js"] + env: + MEW_PARTICIPANT_ID: "streaming-agent" + STREAM_AGENT_LOG: "./logs/streaming-agent.log" + DRIVER_ID: "stream-driver" + capabilities: + - kind: reasoning/* + - kind: chat + - kind: system/* + + stream-driver: + auto_start: true + command: "node" + args: ["../agents/streaming-driver.js"] + env: + MEW_PARTICIPANT_ID: "stream-driver" + STREAM_DRIVER_LOG: "./logs/stream-driver.log" + AGENT_ID: "streaming-agent" + capabilities: + - kind: chat + - kind: system/* diff --git a/tests/scenario-11-streams/teardown.sh b/tests/scenario-11-streams/teardown.sh new file mode 100755 index 00000000..8812ccec --- /dev/null +++ b/tests/scenario-11-streams/teardown.sh @@ -0,0 +1,21 @@ +#!/bin/bash +set -e + +YELLOW='\033[1;33m' +GREEN='\033[0;32m' +NC='\033[0m' + +if [ -z "$TEST_DIR" ]; then + export TEST_DIR="$(cd "$(dirname "$0")" && pwd)" +fi + +cd "$TEST_DIR" + +echo -e "${YELLOW}=== Tearing down streaming scenario ===${NC}" + +../../cli/bin/mew.js space down >/dev/null 2>&1 || true +../../cli/bin/mew.js space clean >/dev/null 2>&1 || true + +rm -rf .mew >/dev/null 2>&1 || true + +echo -e "${GREEN}โœ“ Cleanup complete${NC}" diff --git a/tests/scenario-11-streams/test.sh b/tests/scenario-11-streams/test.sh new file mode 100755 index 00000000..908bdfc9 --- /dev/null +++ b/tests/scenario-11-streams/test.sh @@ -0,0 +1,35 @@ +#!/bin/bash +set -e + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +export TEST_DIR="$(cd "$(dirname "$0")" && pwd)" + +cleanup() { + ./teardown.sh +} +trap cleanup EXIT + +echo -e "${YELLOW}=== Scenario 11: STDIO Streams ===${NC}" + +echo -e "${BLUE}Step 1: Setup${NC}" +./setup.sh + +export STREAM_AGENT_LOG="$TEST_DIR/logs/streaming-agent.log" +export STREAM_DRIVER_LOG="$TEST_DIR/logs/stream-driver.log" + +echo -e "${BLUE}Step 2: Allow streaming events${NC}" +sleep 2 + +echo -e "${BLUE}Step 3: Checks${NC}" +if ./check.sh; then + echo -e "${GREEN}โœ“ Scenario 11 PASSED${NC}" + exit 0 +else + echo -e "${RED}โœ— Scenario 11 FAILED${NC}" + exit 1 +fi diff --git a/tests/scenario-12-sdk-streams/check.sh b/tests/scenario-12-sdk-streams/check.sh new file mode 100755 index 00000000..ad08b102 --- /dev/null +++ b/tests/scenario-12-sdk-streams/check.sh @@ -0,0 +1,55 @@ +#!/bin/bash +set -e + +YELLOW='\033[1;33m' +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' + +if [ -z "$TEST_DIR" ]; then + export TEST_DIR="$(cd "$(dirname "$0")" && pwd)" +fi + +DRIVER_LOG=${BATCH_DRIVER_LOG:-$TEST_DIR/logs/batch-driver.log} +AGENT_LOG=${BATCH_AGENT_LOG:-$TEST_DIR/logs/batch-agent.log} + +for file in "$DRIVER_LOG" "$AGENT_LOG"; do + if [ ! -f "$file" ]; then + echo -e "${RED}Missing log file: $file${NC}" + exit 1 + fi +done + +echo -e "${YELLOW}=== Checking batch streaming ===${NC}" + +FAILURES=0 + +expect_line() { + local log="$1" + local needle="$2" + if grep -q "$needle" "$log"; then + echo -e "${GREEN}โœ“${NC} $(basename "$log") contains '$needle'" + else + echo -e "${RED}โœ—${NC} $(basename "$log") missing '$needle'" + FAILURES=$((FAILURES + 1)) + fi +} + +expect_line "$AGENT_LOG" 'EMIT chunk-1' +expect_line "$AGENT_LOG" 'EMIT chunk-2' +expect_line "$AGENT_LOG" 'EMIT chunk-3' +expect_line "$AGENT_LOG" 'EMIT COMPLETE' + +expect_line "$DRIVER_LOG" 'WELCOME' +expect_line "$DRIVER_LOG" 'RECV DATA chunk-1' +expect_line "$DRIVER_LOG" 'RECV DATA chunk-2' +expect_line "$DRIVER_LOG" 'RECV DATA chunk-3' +expect_line "$DRIVER_LOG" 'OK batch-sequence' +expect_line "$DRIVER_LOG" 'DONE' + +if [ $FAILURES -ne 0 ]; then + echo -e "${RED}Scenario failed${NC}" + exit 1 +fi + +echo -e "${GREEN}โœ“ Scenario passed${NC}" diff --git a/tests/scenario-12-sdk-streams/setup.sh b/tests/scenario-12-sdk-streams/setup.sh new file mode 100755 index 00000000..9911be9e --- /dev/null +++ b/tests/scenario-12-sdk-streams/setup.sh @@ -0,0 +1,39 @@ +#!/bin/bash +set -e + +YELLOW='\033[1;33m' +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' + +if [ -z "$TEST_DIR" ]; then + export TEST_DIR="$(cd "$(dirname "$0")" && pwd)" +fi + +cd "$TEST_DIR" + +echo -e "${YELLOW}=== Setting up STDIO batch streaming scenario ===${NC}"; + +rm -rf logs +mkdir -p logs + +if ! ../../cli/bin/mew.js space up > ./logs/space-up.log 2>&1; then + echo -e "${RED}Failed to start space${NC}" + cat ./logs/space-up.log + exit 1 +fi + +sleep 2 + +STATE_FILE="$TEST_DIR/.mew/run/state.json" +if [ ! -f "$STATE_FILE" ]; then + echo -e "${RED}State file not found after startup${NC}" + cat ./logs/space-up.log + exit 1 +fi + +../../cli/bin/mew.js space status + +touch ./logs/batch-agent.log ./logs/batch-driver.log + +echo -e "${GREEN}โœ“ Setup complete${NC}"; diff --git a/tests/scenario-12-sdk-streams/space.yaml b/tests/scenario-12-sdk-streams/space.yaml new file mode 100644 index 00000000..f847795e --- /dev/null +++ b/tests/scenario-12-sdk-streams/space.yaml @@ -0,0 +1,29 @@ +space: + id: scenario-12 + name: "Scenario 12 - STDIO Batch Streaming" + description: "Batch agent emits multiple data chunks to driver" + +participants: + batch-agent: + auto_start: true + command: "node" + args: ["../agents/batch-agent.js"] + env: + MEW_PARTICIPANT_ID: "batch-agent" + BATCH_AGENT_LOG: "./logs/batch-agent.log" + DRIVER_ID: "batch-driver" + capabilities: + - kind: chat + - kind: system/* + + batch-driver: + auto_start: true + command: "node" + args: ["../agents/batch-driver.js"] + env: + MEW_PARTICIPANT_ID: "batch-driver" + BATCH_DRIVER_LOG: "./logs/batch-driver.log" + AGENT_ID: "batch-agent" + capabilities: + - kind: chat + - kind: system/* diff --git a/tests/scenario-12-sdk-streams/teardown.sh b/tests/scenario-12-sdk-streams/teardown.sh new file mode 100755 index 00000000..aaefd611 --- /dev/null +++ b/tests/scenario-12-sdk-streams/teardown.sh @@ -0,0 +1,21 @@ +#!/bin/bash +set -e + +YELLOW='\033[1;33m' +GREEN='\033[0;32m' +NC='\033[0m' + +if [ -z "$TEST_DIR" ]; then + export TEST_DIR="$(cd "$(dirname "$0")" && pwd)" +fi + +cd "$TEST_DIR" + +echo -e "${YELLOW}=== Tearing down batch streaming scenario ===${NC}" + +../../cli/bin/mew.js space down >/dev/null 2>&1 || true +../../cli/bin/mew.js space clean >/dev/null 2>&1 || true + +rm -rf .mew >/dev/null 2>&1 || true + +echo -e "${GREEN}โœ“ Cleanup complete${NC}" diff --git a/tests/scenario-12-sdk-streams/test.sh b/tests/scenario-12-sdk-streams/test.sh new file mode 100755 index 00000000..6dfdbd28 --- /dev/null +++ b/tests/scenario-12-sdk-streams/test.sh @@ -0,0 +1,35 @@ +#!/bin/bash +set -e + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +export TEST_DIR="$(cd "$(dirname "$0")" && pwd)" + +cleanup() { + ./teardown.sh +} +trap cleanup EXIT + +echo -e "${YELLOW}=== Scenario 12: STDIO Batch Streaming ===${NC}" + +echo -e "${BLUE}Step 1: Setup${NC}" +./setup.sh + +export BATCH_AGENT_LOG="$TEST_DIR/logs/batch-agent.log" +export BATCH_DRIVER_LOG="$TEST_DIR/logs/batch-driver.log" + +echo -e "${BLUE}Step 2: Allow batch response${NC}" +sleep 2 + +echo -e "${BLUE}Step 3: Checks${NC}" +if ./check.sh; then + echo -e "${GREEN}โœ“ Scenario 12 PASSED${NC}" + exit 0 +else + echo -e "${RED}โœ— Scenario 12 FAILED${NC}" + exit 1 +fi diff --git a/tests/scenario-2-mcp/check.sh b/tests/scenario-2-mcp/check.sh index c405c2b9..8af547d4 100755 --- a/tests/scenario-2-mcp/check.sh +++ b/tests/scenario-2-mcp/check.sh @@ -1,175 +1,69 @@ #!/bin/bash -# Check script - Runs test assertions against a running space -# -# Can be run after manual setup or called by test.sh +set -e -# Don't use set -e as it causes issues with arithmetic operations -# set -e - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' -# If TEST_DIR not set, we're running standalone if [ -z "$TEST_DIR" ]; then export TEST_DIR="$(cd "$(dirname "$0")" && pwd)" - export OUTPUT_LOG="$TEST_DIR/logs/test-client-output.log" - - # Try to detect port from running space - if [ -f "$TEST_DIR/.mew/pids.json" ]; then - export TEST_PORT=$(grep -o '"port":[[:space:]]*"[0-9]*"' "$TEST_DIR/.mew/pids.json" | grep -o '[0-9]*') - fi - - if [ -z "$TEST_PORT" ]; then - echo -e "${RED}Error: Cannot determine test port. Is the space running?${NC}" - exit 1 - fi -fi - -echo -e "${YELLOW}=== Running Test Checks ===${NC}" -echo -e "${BLUE}Scenario: MCP Tool Execution${NC}" -echo -e "${BLUE}Test directory: $TEST_DIR${NC}" -echo -e "${BLUE}Gateway port: $TEST_PORT${NC}" -echo "" - -# Track test results -TESTS_PASSED=0 -TESTS_FAILED=0 - -# Helper function to run a test -run_test() { - local test_name="$1" - local test_command="$2" - - echo -n "Testing: $test_name ... " - - if eval "$test_command" > /dev/null 2>&1; then - echo -e "${GREEN}โœ“${NC}" - ((TESTS_PASSED++)) - else - echo -e "${RED}โœ—${NC}" - ((TESTS_FAILED++)) - fi -} - -# Point to the output log file (created by output_log config) -RESPONSE_FILE="$OUTPUT_LOG" - -# Test 1: Gateway health check -run_test "Gateway is running" "curl -s http://localhost:$TEST_PORT/health | grep -q 'ok'" - -# Test 2: Check output log exists -run_test "Output log exists" "[ -f '$OUTPUT_LOG' ]" - -# Test 3: List available tools -echo -e "\n${YELLOW}Test: List available tools${NC}" -curl -sf -X POST "http://localhost:$TEST_PORT/participants/test-client/messages" \ - -H "Authorization: Bearer test-token" \ - -H "Content-Type: application/json" \ - -d '{"kind":"mcp/request","to":["calculator-agent"],"payload":{"method":"tools/list","params":{}}}' > /dev/null -sleep 2 - -if grep -q '"kind":"mcp/response"' "$RESPONSE_FILE" && grep -q '"tools":\[' "$RESPONSE_FILE"; then - echo -e "Tools list received: ${GREEN}โœ“${NC}" - ((TESTS_PASSED++)) -else - echo -e "Tools list received: ${RED}โœ—${NC}" - ((TESTS_FAILED++)) -fi - -# Test 4: Call add tool -echo -e "\n${YELLOW}Test: Call add tool (5 + 3)${NC}" -curl -sf -X POST "http://localhost:$TEST_PORT/participants/test-client/messages" \ - -H "Authorization: Bearer test-token" \ - -H "Content-Type: application/json" \ - -d '{"kind":"mcp/request","to":["calculator-agent"],"payload":{"method":"tools/call","params":{"name":"add","arguments":{"a":5,"b":3}}}}' > /dev/null -sleep 2 - -if grep -q '"result":8' "$RESPONSE_FILE"; then - echo -e "Add tool result: ${GREEN}โœ“ (8)${NC}" - ((TESTS_PASSED++)) -else - echo -e "Add tool result: ${RED}โœ—${NC}" - ((TESTS_FAILED++)) fi -# Test 5: Call multiply tool -echo -e "\n${YELLOW}Test: Call multiply tool (7 ร— 9)${NC}" -curl -sf -X POST "http://localhost:$TEST_PORT/participants/test-client/messages" \ - -H "Authorization: Bearer test-token" \ - -H "Content-Type: application/json" \ - -d '{"kind":"mcp/request","to":["calculator-agent"],"payload":{"method":"tools/call","params":{"name":"multiply","arguments":{"a":7,"b":9}}}}' > /dev/null -sleep 2 +DRIVER_LOG=${DRIVER_LOG:-$TEST_DIR/logs/mcp-driver.log} -if grep -q '"result":63' "$RESPONSE_FILE"; then - echo -e "Multiply tool result: ${GREEN}โœ“ (63)${NC}" - ((TESTS_PASSED++)) -else - echo -e "Multiply tool result: ${RED}โœ—${NC}" - ((TESTS_FAILED++)) +if [ ! -f "$DRIVER_LOG" ]; then + echo -e "${RED}Driver log not found: $DRIVER_LOG${NC}" + exit 1 fi -# Test 6: Call evaluate tool for division -echo -e "\n${YELLOW}Test: Call evaluate tool (20 รท 4)${NC}" -curl -sf -X POST "http://localhost:$TEST_PORT/participants/test-client/messages" \ - -H "Authorization: Bearer test-token" \ - -H "Content-Type: application/json" \ - -d '{"kind":"mcp/request","to":["calculator-agent"],"payload":{"method":"tools/call","params":{"name":"evaluate","arguments":{"expression":"20 / 4"}}}}' > /dev/null -sleep 2 - -if grep -q '"result":5' "$RESPONSE_FILE"; then - echo -e "Evaluate tool result: ${GREEN}โœ“ (5)${NC}" - ((TESTS_PASSED++)) -else - echo -e "Evaluate tool result: ${RED}โœ—${NC}" - ((TESTS_FAILED++)) -fi +echo -e "${YELLOW}=== Checking MCP tool execution ===${NC}" -# Test 7: Handle division by zero -echo -e "\n${YELLOW}Test: Handle division by zero${NC}" -curl -sf -X POST "http://localhost:$TEST_PORT/participants/test-client/messages" \ - -H "Authorization: Bearer test-token" \ - -H "Content-Type: application/json" \ - -d '{"kind":"mcp/request","to":["calculator-agent"],"payload":{"method":"tools/call","params":{"name":"evaluate","arguments":{"expression":"10 / 0"}}}}' > /dev/null -sleep 2 +timeout=30 +while [ $timeout -gt 0 ]; do + if grep -q 'DONE' "$DRIVER_LOG"; then + break + fi + if grep -q 'FAIL' "$DRIVER_LOG"; then + echo -e "${RED}Driver reported failure${NC}" + tail -n 20 "$DRIVER_LOG" + exit 1 + fi + sleep 1 + timeout=$((timeout - 1)) +done -if grep -q '"result":"Infinity"' "$RESPONSE_FILE" || grep -q '"result":null' "$RESPONSE_FILE" || grep -q 'division by zero' "$RESPONSE_FILE"; then - echo -e "Division by zero handling: ${GREEN}โœ“${NC}" - ((TESTS_PASSED++)) -else - echo -e "Division by zero handling: ${RED}โœ—${NC}" - ((TESTS_FAILED++)) +if ! grep -q 'DONE' "$DRIVER_LOG"; then + echo -e "${RED}Driver did not finish within timeout${NC}" + tail -n 20 "$DRIVER_LOG" + exit 1 fi -# Test 8: Invalid tool name -echo -e "\n${YELLOW}Test: Call non-existent tool${NC}" -curl -sf -X POST "http://localhost:$TEST_PORT/participants/test-client/messages" \ - -H "Authorization: Bearer test-token" \ - -H "Content-Type: application/json" \ - -d '{"kind":"mcp/request","to":["calculator-agent"],"payload":{"method":"tools/call","params":{"name":"invalid","arguments":{}}}}' > /dev/null -sleep 2 +expected=( + "WELCOME" + "OK tools-list" + "OK add" + "OK multiply" + "OK evaluate" + "OK divide-zero" + "OK invalid-tool" + "DONE" +) + +failures=0 +for token in "${expected[@]}"; do + if grep -q "$token" "$DRIVER_LOG"; then + echo -e "${GREEN}โœ“${NC} $token" + else + echo -e "${RED}โœ— Missing $token${NC}" + failures=$((failures + 1)) + fi +done -if grep -q 'Tool not found: invalid' "$RESPONSE_FILE" || grep -q '"error":{' "$RESPONSE_FILE"; then - echo -e "Invalid tool handling: ${GREEN}โœ“${NC}" - ((TESTS_PASSED++)) -else - echo -e "Invalid tool handling: ${RED}โœ—${NC}" - ((TESTS_FAILED++)) +if [ $failures -ne 0 ]; then + echo -e "${RED}Scenario failed${NC}" + tail -n 20 "$DRIVER_LOG" + exit 1 fi -# Summary -echo "" -echo -e "${YELLOW}=== Test Summary ===${NC}" -echo -e "Tests passed: ${GREEN}$TESTS_PASSED${NC}" -echo -e "Tests failed: ${RED}$TESTS_FAILED${NC}" - -if [ $TESTS_FAILED -eq 0 ]; then - echo -e "\n${GREEN}โœ“ All tests passed!${NC}" - exit 0 -else - echo -e "\n${RED}โœ— Some tests failed${NC}" - exit 1 -fi \ No newline at end of file +echo -e "${GREEN}โœ“ Scenario passed${NC}" diff --git a/tests/scenario-2-mcp/setup.sh b/tests/scenario-2-mcp/setup.sh index 4d36b780..57aca95e 100755 --- a/tests/scenario-2-mcp/setup.sh +++ b/tests/scenario-2-mcp/setup.sh @@ -1,77 +1,47 @@ #!/bin/bash -# Setup script - Initializes the test space -# -# Can be run standalone for manual testing or called by test.sh - set -e -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' -# If TEST_DIR not set, we're running standalone if [ -z "$TEST_DIR" ]; then export TEST_DIR="$(cd "$(dirname "$0")" && pwd)" fi -echo -e "${YELLOW}=== Setting up Test Space ===${NC}" -echo -e "${BLUE}Scenario: MCP Tool Execution${NC}" -echo -e "${BLUE}Directory: $TEST_DIR${NC}" -echo "" - cd "$TEST_DIR" -# Clean up any previous runs -echo "Cleaning up previous test artifacts..." -../../cli/bin/mew.js space clean --all --force 2>/dev/null || true +echo -e "${YELLOW}=== Setting up STDIO MCP scenario ===${NC}"; -# Use random port to avoid conflicts -if [ -z "$TEST_PORT" ]; then - export TEST_PORT=$((8000 + RANDOM % 1000)) -fi +mkdir -p logs -echo "Starting space on port $TEST_PORT..." +../../cli/bin/mew.js space clean >/dev/null 2>&1 || true +rm -rf .mew >/dev/null 2>&1 || true -# Ensure logs directory exists -mkdir -p ./logs - -# Start the space using mew space up -../../cli/bin/mew.js space up --port "$TEST_PORT" > ./logs/space-up.log 2>&1 - -# Check if space started successfully -if ../../cli/bin/mew.js space status | grep -q "Gateway: ws://localhost:$TEST_PORT"; then - echo -e "${GREEN}โœ“ Space started successfully${NC}" -else - echo -e "${RED}โœ— Space failed to start${NC}" +echo "Starting space using mew space up..." +if ! ../../cli/bin/mew.js space up > ./logs/space-up.log 2>&1; then + echo -e "${RED}Failed to start space${NC}" cat ./logs/space-up.log exit 1 fi -# Wait for all components to be ready -echo "Waiting for components to initialize..." +echo "Waiting for processes to come online..." sleep 3 -# Export paths for check.sh to use -export OUTPUT_LOG="$TEST_DIR/logs/test-client-output.log" +STATE_FILE="$TEST_DIR/.mew/run/state.json" +if [ ! -f "$STATE_FILE" ]; then + echo -e "${RED}State file not found after startup${NC}" + cat ./logs/space-up.log + exit 1 +fi + +../../cli/bin/mew.js space status -# Create output log file if it doesn't exist -mkdir -p "$(dirname "$OUTPUT_LOG")" -touch "$OUTPUT_LOG" +export DRIVER_LOG="$TEST_DIR/logs/mcp-driver.log" +touch "$DRIVER_LOG" -echo -e "${GREEN}โœ“ Setup complete${NC}" -echo "" -echo "Gateway running on: ws://localhost:$TEST_PORT" -echo "HTTP API available for test-client" -echo " Endpoint: http://localhost:$TEST_PORT/participants/test-client/messages" -echo " Output Log: $OUTPUT_LOG" -echo "" -echo "You can now:" -echo " - Run tests with: ./check.sh" -echo " - Send messages: curl -X POST http://localhost:$TEST_PORT/participants/test-client/messages -H 'Authorization: Bearer test-token' -H 'Content-Type: application/json' -d '{\"kind\":\"chat\",\"payload\":{\"text\":\"Hello\"}}'" -echo " - Read responses: tail -f $OUTPUT_LOG" +export CALCULATOR_LOG="$TEST_DIR/logs/calculator.log" +touch "$CALCULATOR_LOG" -# Set flag for check.sh -export SPACE_RUNNING=true \ No newline at end of file +echo -e "${GREEN}โœ“ Setup complete${NC}"; diff --git a/tests/scenario-2-mcp/space.yaml b/tests/scenario-2-mcp/space.yaml index 388f0179..e8a11818 100644 --- a/tests/scenario-2-mcp/space.yaml +++ b/tests/scenario-2-mcp/space.yaml @@ -1,24 +1,29 @@ space: - id: test-space - name: "Scenario 2 - MCP Tool Execution" - description: "Test MCP tool execution with calculator agent" + id: scenario-2 + name: "Scenario 2 - STDIO MCP Tool Execution" + description: "Tests MCP tool requests against the calculator agent" participants: calculator-agent: - tokens: ["calculator-token"] + auto_start: true + command: "node" + args: ["../agents/calculator-participant.js"] + env: + MEW_PARTICIPANT_ID: "calculator-agent" + CALCULATOR_LOG: "./logs/calculator.log" capabilities: + - kind: mcp/request - kind: mcp/response - kind: system/* + + mcp-driver: auto_start: true command: "node" - args: ["../agents/calculator-participant.js", "--gateway", "ws://localhost:${PORT}", "--space", "test-space", "--token", "calculator-token"] - - test-client: - tokens: ["test-token"] + args: ["../agents/mcp-driver.js"] + env: + MEW_PARTICIPANT_ID: "mcp-driver" + DRIVER_LOG: "./logs/mcp-driver.log" capabilities: - kind: mcp/request - payload: - method: tools/* + - kind: mcp/response - kind: system/* - output_log: "./logs/test-client-output.log" - auto_connect: true \ No newline at end of file diff --git a/tests/scenario-2-mcp/teardown.sh b/tests/scenario-2-mcp/teardown.sh index 54b42365..c1978cd1 100755 --- a/tests/scenario-2-mcp/teardown.sh +++ b/tests/scenario-2-mcp/teardown.sh @@ -1,60 +1,21 @@ #!/bin/bash -# Teardown script - Cleans up the test space -# -# Can be run after manual testing or called by test.sh - set -e -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color +GREEN='\033[0;32m' +NC='\033[0m' -# If TEST_DIR not set, we're running standalone if [ -z "$TEST_DIR" ]; then export TEST_DIR="$(cd "$(dirname "$0")" && pwd)" fi -echo -e "${YELLOW}=== Cleaning up Test Space ===${NC}" -echo -e "${BLUE}Directory: $TEST_DIR${NC}" -echo "" - cd "$TEST_DIR" -# Stop the space using mew space down -echo "Stopping space..." -../../cli/bin/mew.js space down 2>/dev/null || true +echo -e "${YELLOW}=== Tearing down MCP scenario ===${NC}" -# Additional cleanup for any orphaned processes -if [ -f ".mew/pids.json" ]; then - # Extract PIDs and kill them if still running - PIDS=$(grep -o '"pid":[0-9]*' .mew/pids.json 2>/dev/null | cut -d: -f2 || true) - for pid in $PIDS; do - if kill -0 $pid 2>/dev/null; then - echo "Killing orphaned process $pid" - kill -TERM $pid 2>/dev/null || true - fi - done -fi +../../cli/bin/mew.js space down >/dev/null 2>&1 || true +../../cli/bin/mew.js space clean >/dev/null 2>&1 || true -# Clean up test artifacts using mew space clean -if [ "${PRESERVE_LOGS:-false}" = "false" ]; then - echo "Cleaning test artifacts..." - - # Use the new mew space clean command - ../../cli/bin/mew.js space clean --all --force 2>/dev/null || { - # Fallback to manual cleanup if clean command fails - echo "Clean command failed, using manual cleanup..." - rm -rf logs fifos .mew 2>/dev/null || true - } - - echo -e "${GREEN}โœ“ Test artifacts removed${NC}" -else - echo -e "${YELLOW}Preserving logs (PRESERVE_LOGS=true)${NC}" - # Clean only fifos and .mew, preserve logs - ../../cli/bin/mew.js space clean --fifos --force 2>/dev/null || true -fi +rm -rf .mew >/dev/null 2>&1 || true -echo -e "${GREEN}โœ“ Cleanup complete${NC}" \ No newline at end of file +echo -e "${GREEN}โœ“ Cleanup complete${NC}" diff --git a/tests/scenario-2-mcp/test.sh b/tests/scenario-2-mcp/test.sh index 277d7f14..ca8499a2 100755 --- a/tests/scenario-2-mcp/test.sh +++ b/tests/scenario-2-mcp/test.sh @@ -1,60 +1,31 @@ #!/bin/bash -# Automated test script - Combines setup, check, and teardown -# -# This is the entry point for automated testing (e.g., from run-all-tests.sh) -# For manual/debugging, use setup.sh, check.sh, and teardown.sh separately - set -e -# Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' -NC='\033[0m' # No Color +NC='\033[0m' -# Get test directory export TEST_DIR="$(cd "$(dirname "$0")" && pwd)" -# Use random port to avoid conflicts -export TEST_PORT=$((8000 + RANDOM % 1000)) - -echo -e "${YELLOW}=== Scenario 2: MCP Tool Execution Test ===${NC}" -echo -e "${BLUE}Testing MCP tool execution with calculator agent${NC}" -echo "" - -# Cleanup function (no trap, will call explicitly) cleanup() { - echo "" - echo "Cleaning up..." ./teardown.sh } +trap cleanup EXIT -# Step 1: Setup the space -echo -e "${YELLOW}Step 1: Setting up space...${NC}" -# Run setup in subprocess but capture the environment it sets -./setup.sh /dev/null 2>&1; then - echo -e "${GREEN}โœ“${NC}" - ((TESTS_PASSED++)) - else - echo -e "${RED}โœ—${NC}" - ((TESTS_FAILED++)) +for file in "$DRIVER_LOG" "$FULFILLER_LOG"; do + if [ ! -f "$file" ]; then + echo -e "${RED}Required log missing: $file${NC}" + exit 1 fi -} - -# Point to the output log files (created by output_log config) -RESPONSE_FILE="$OUTPUT_LOG" -FULFILLER_FILE="${FULFILLER_LOG:-$TEST_DIR/logs/fulfiller-output.log}" - -# Test 1: Gateway health check -run_test "Gateway is running" "curl -s http://localhost:$TEST_PORT/health | grep -q 'ok'" - -# Test 2: Check output log exists -run_test "Output log exists" "[ -f '$OUTPUT_LOG' ]" +done -# Test 3: Proposer cannot directly call MCP tools (blocked by capabilities) -# SKIP: Capability checking not yet implemented in gateway HTTP endpoint -echo -e "\n${YELLOW}Test: Proposer attempts direct MCP call${NC}" -echo -e "Direct MCP call blocked: ${YELLOW}SKIPPED${NC} (capability checking not implemented)" -# Not counting as pass or fail since it's not implemented +echo -e "${YELLOW}=== Checking proposal fulfillment ===${NC}" -# Test 4: Proposer sends proposal for calculation -echo -e "\n${YELLOW}Test: Send proposal for calculation${NC}" -curl -sf -X POST "http://localhost:$TEST_PORT/participants/proposer/messages" \ - -H "Authorization: Bearer proposer-token" \ - -H "Content-Type: application/json" \ - -d '{"kind":"mcp/proposal","payload":{"method":"tools/call","params":{"name":"add","arguments":{"a":10,"b":5}}}}' > /dev/null -sleep 3 +timeout=30 +while [ $timeout -gt 0 ]; do + if grep -q 'DONE' "$DRIVER_LOG"; then + break + fi + if grep -q 'FAIL' "$DRIVER_LOG"; then + echo -e "${RED}Driver reported failure${NC}" + tail -n 20 "$DRIVER_LOG" + exit 1 + fi + sleep 1 + timeout=$((timeout - 1)) +done -# Check if fulfiller received and processed the proposal -if tail -50 "$FULFILLER_FILE" 2>/dev/null | grep -q '"text":"15"' || tail -50 "$FULFILLER_FILE" 2>/dev/null | grep -q '"result":15' || tail -50 logs/*.log 2>/dev/null | grep -q '15'; then - echo -e "Proposal fulfilled: ${GREEN}โœ“${NC}" - ((TESTS_PASSED++)) -else - echo -e "Proposal fulfilled: ${RED}โœ—${NC}" - ((TESTS_FAILED++)) +if ! grep -q 'DONE' "$DRIVER_LOG"; then + echo -e "${RED}Driver did not finish within timeout${NC}" + tail -n 20 "$DRIVER_LOG" + exit 1 fi -# Test 5: Proposer sends proposal for complex calculation -echo -e "\n${YELLOW}Test: Complex calculation proposal${NC}" -curl -sf -X POST "http://localhost:$TEST_PORT/participants/proposer/messages" \ - -H "Authorization: Bearer proposer-token" \ - -H "Content-Type: application/json" \ - -d '{"kind":"mcp/proposal","payload":{"method":"tools/call","params":{"name":"multiply","arguments":{"a":7,"b":8}}}}' > /dev/null -sleep 3 +expected=( + "WELCOME" + "OK add-proposal" + "OK multiply-proposal" + "OK invalid-proposal" + "DONE" +) + +failures=0 +for token in "${expected[@]}"; do + if grep -q "$token" "$DRIVER_LOG"; then + echo -e "${GREEN}โœ“${NC} $token" + else + echo -e "${RED}โœ— Missing $token${NC}" + failures=$((failures + 1)) + fi +done -if tail -50 "$FULFILLER_FILE" 2>/dev/null | grep -q '"text":"56"' || tail -50 "$FULFILLER_FILE" 2>/dev/null | grep -q '"result":56' || tail -50 logs/*.log 2>/dev/null | grep -q '56'; then - echo -e "Complex proposal fulfilled: ${GREEN}โœ“${NC}" - ((TESTS_PASSED++)) +if ! grep -q 'RESULT' "$FULFILLER_LOG"; then + echo -e "${RED}Fulfiller did not log any results${NC}" + failures=$((failures + 1)) else - echo -e "Complex proposal fulfilled: ${RED}โœ—${NC}" - ((TESTS_FAILED++)) + echo -e "${GREEN}โœ“${NC} Fulfiller logged results" fi -# Test 6: Invalid proposal -echo -e "\n${YELLOW}Test: Invalid proposal handling${NC}" -curl -sf -X POST "http://localhost:$TEST_PORT/participants/proposer/messages" \ - -H "Authorization: Bearer proposer-token" \ - -H "Content-Type: application/json" \ - -d '{"kind":"mcp/proposal","payload":{"method":"tools/call","params":{"name":"invalid_tool","arguments":{}}}}' > /dev/null -sleep 2 - -if tail -50 "$FULFILLER_FILE" 2>/dev/null | grep -q 'Tool not found' || tail -50 logs/*.log 2>/dev/null | grep -qi 'tool not found\|invalid.*tool\|error'; then - echo -e "Invalid proposal handled: ${GREEN}โœ“${NC}" - ((TESTS_PASSED++)) -else - echo -e "Invalid proposal handled: ${RED}โœ—${NC}" - ((TESTS_FAILED++)) +if [ $failures -ne 0 ]; then + echo -e "${RED}Scenario failed${NC}" + tail -n 20 "$DRIVER_LOG" + tail -n 20 "$FULFILLER_LOG" + exit 1 fi -# Summary -echo "" -echo -e "${YELLOW}=== Test Summary ===${NC}" -echo -e "Tests passed: ${GREEN}$TESTS_PASSED${NC}" -echo -e "Tests failed: ${RED}$TESTS_FAILED${NC}" - -if [ $TESTS_FAILED -eq 0 ]; then - echo -e "\n${GREEN}โœ“ All tests passed!${NC}" - exit 0 -else - echo -e "\n${RED}โœ— Some tests failed${NC}" - exit 1 -fi \ No newline at end of file +echo -e "${GREEN}โœ“ Scenario passed${NC}" diff --git a/tests/scenario-3-proposals/setup.sh b/tests/scenario-3-proposals/setup.sh index 8289d3a8..3f243d67 100755 --- a/tests/scenario-3-proposals/setup.sh +++ b/tests/scenario-3-proposals/setup.sh @@ -1,77 +1,47 @@ #!/bin/bash -# Setup script - Initializes the test space -# -# Can be run standalone for manual testing or called by test.sh - set -e -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' -# If TEST_DIR not set, we're running standalone if [ -z "$TEST_DIR" ]; then export TEST_DIR="$(cd "$(dirname "$0")" && pwd)" fi -echo -e "${YELLOW}=== Setting up Test Space ===${NC}" -echo -e "${BLUE}Scenario: MCP Proposals${NC}" -echo -e "${BLUE}Directory: $TEST_DIR${NC}" -echo "" - cd "$TEST_DIR" -# Clean up any previous runs -echo "Cleaning up previous test artifacts..." -../../cli/bin/mew.js space clean --all --force 2>/dev/null || true +echo -e "${YELLOW}=== Setting up STDIO proposal scenario ===${NC}"; -# Use random port to avoid conflicts -if [ -z "$TEST_PORT" ]; then - export TEST_PORT=$((8000 + RANDOM % 1000)) -fi +mkdir -p logs -echo "Starting space on port $TEST_PORT..." +../../cli/bin/mew.js space clean >/dev/null 2>&1 || true +rm -rf .mew >/dev/null 2>&1 || true -# Ensure logs directory exists -mkdir -p ./logs - -# Start the space using mew space up -../../cli/bin/mew.js space up --port "$TEST_PORT" > ./logs/space-up.log 2>&1 - -# Check if space started successfully -if ../../cli/bin/mew.js space status | grep -q "Gateway: ws://localhost:$TEST_PORT"; then - echo -e "${GREEN}โœ“ Space started successfully${NC}" -else - echo -e "${RED}โœ— Space failed to start${NC}" +echo "Starting space using mew space up..." +if ! ../../cli/bin/mew.js space up > ./logs/space-up.log 2>&1; then + echo -e "${RED}Failed to start space${NC}" cat ./logs/space-up.log exit 1 fi -# Wait for all components to be ready -echo "Waiting for components to initialize..." +echo "Waiting for processes to come online..." sleep 3 -# Export paths for check.sh to use -export OUTPUT_LOG="$TEST_DIR/logs/test-client-output.log" +STATE_FILE="$TEST_DIR/.mew/run/state.json" +if [ ! -f "$STATE_FILE" ]; then + echo -e "${RED}State file not found after startup${NC}" + cat ./logs/space-up.log + exit 1 +fi + +../../cli/bin/mew.js space status -# Create output log file if it doesn't exist -mkdir -p "$(dirname "$OUTPUT_LOG")" -touch "$OUTPUT_LOG" +export DRIVER_LOG="$TEST_DIR/logs/proposal-driver.log" +touch "$DRIVER_LOG" -echo -e "${GREEN}โœ“ Setup complete${NC}" -echo "" -echo "Gateway running on: ws://localhost:$TEST_PORT" -echo "HTTP API available for test-client" -echo " Endpoint: http://localhost:$TEST_PORT/participants/test-client/messages" -echo " Output Log: $OUTPUT_LOG" -echo "" -echo "You can now:" -echo " - Run tests with: ./check.sh" -echo " - Send messages: curl -X POST http://localhost:$TEST_PORT/participants/test-client/messages -H 'Authorization: Bearer test-token' -H 'Content-Type: application/json' -d '{\"kind\":\"chat\",\"payload\":{\"text\":\"Hello\"}}'" -echo " - Read responses: tail -f $OUTPUT_LOG" +export FULFILLER_LOG="$TEST_DIR/logs/fulfiller.log" +touch "$FULFILLER_LOG" -# Set flag for check.sh -export SPACE_RUNNING=true \ No newline at end of file +echo -e "${GREEN}โœ“ Setup complete${NC}"; diff --git a/tests/scenario-3-proposals/space.yaml b/tests/scenario-3-proposals/space.yaml index 1dd49aa1..8ab089e5 100644 --- a/tests/scenario-3-proposals/space.yaml +++ b/tests/scenario-3-proposals/space.yaml @@ -1,39 +1,43 @@ space: - id: test-space - name: "Scenario 3 - Proposals with Capability Blocking" - description: "Test proposal flow with capability restrictions" + id: scenario-3 + name: "Scenario 3 - STDIO Proposals" + description: "Tests proposal fulfillment via fulfiller and calculator agents" participants: calculator-agent: - tokens: ["calculator-token"] + auto_start: true + command: "node" + args: ["../agents/calculator-participant.js"] + env: + MEW_PARTICIPANT_ID: "calculator-agent" + CALCULATOR_LOG: "./logs/calculator.log" capabilities: - kind: mcp/request - payload: - method: tools/* - kind: mcp/response - kind: system/* + + fulfiller-agent: auto_start: true command: "node" - args: ["../agents/calculator-participant.js", "--gateway", "ws://localhost:${PORT}", "--space", "test-space", "--token", "calculator-token"] - - fulfiller: - tokens: ["fulfiller-token"] + args: ["../agents/fulfiller-participant.js"] + env: + MEW_PARTICIPANT_ID: "fulfiller-agent" + FULFILLER_LOG: "./logs/fulfiller.log" capabilities: + - kind: mcp/proposal - kind: mcp/request - payload: - method: tools/* - kind: mcp/response - - kind: mcp/proposal - kind: chat - kind: system/* + + proposal-driver: auto_start: true command: "node" - args: ["../agents/fulfiller-participant.js", "--gateway", "ws://localhost:${PORT}", "--space", "test-space", "--token", "fulfiller-token"] - - proposer: - tokens: ["proposer-token"] + args: ["../agents/proposal-driver.js"] + env: + MEW_PARTICIPANT_ID: "proposal-driver" + DRIVER_LOG: "./logs/proposal-driver.log" capabilities: - kind: mcp/proposal + - kind: chat - kind: system/* - output_log: "./logs/proposer-output.log" - auto_connect: true \ No newline at end of file diff --git a/tests/scenario-3-proposals/teardown.sh b/tests/scenario-3-proposals/teardown.sh index 54b42365..d1deeffc 100755 --- a/tests/scenario-3-proposals/teardown.sh +++ b/tests/scenario-3-proposals/teardown.sh @@ -1,60 +1,21 @@ #!/bin/bash -# Teardown script - Cleans up the test space -# -# Can be run after manual testing or called by test.sh - set -e -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color +GREEN='\033[0;32m' +NC='\033[0m' -# If TEST_DIR not set, we're running standalone if [ -z "$TEST_DIR" ]; then export TEST_DIR="$(cd "$(dirname "$0")" && pwd)" fi -echo -e "${YELLOW}=== Cleaning up Test Space ===${NC}" -echo -e "${BLUE}Directory: $TEST_DIR${NC}" -echo "" - cd "$TEST_DIR" -# Stop the space using mew space down -echo "Stopping space..." -../../cli/bin/mew.js space down 2>/dev/null || true +echo -e "${YELLOW}=== Tearing down proposal scenario ===${NC}" -# Additional cleanup for any orphaned processes -if [ -f ".mew/pids.json" ]; then - # Extract PIDs and kill them if still running - PIDS=$(grep -o '"pid":[0-9]*' .mew/pids.json 2>/dev/null | cut -d: -f2 || true) - for pid in $PIDS; do - if kill -0 $pid 2>/dev/null; then - echo "Killing orphaned process $pid" - kill -TERM $pid 2>/dev/null || true - fi - done -fi +../../cli/bin/mew.js space down >/dev/null 2>&1 || true +../../cli/bin/mew.js space clean >/dev/null 2>&1 || true -# Clean up test artifacts using mew space clean -if [ "${PRESERVE_LOGS:-false}" = "false" ]; then - echo "Cleaning test artifacts..." - - # Use the new mew space clean command - ../../cli/bin/mew.js space clean --all --force 2>/dev/null || { - # Fallback to manual cleanup if clean command fails - echo "Clean command failed, using manual cleanup..." - rm -rf logs fifos .mew 2>/dev/null || true - } - - echo -e "${GREEN}โœ“ Test artifacts removed${NC}" -else - echo -e "${YELLOW}Preserving logs (PRESERVE_LOGS=true)${NC}" - # Clean only fifos and .mew, preserve logs - ../../cli/bin/mew.js space clean --fifos --force 2>/dev/null || true -fi +rm -rf .mew >/dev/null 2>&1 || true -echo -e "${GREEN}โœ“ Cleanup complete${NC}" \ No newline at end of file +echo -e "${GREEN}โœ“ Cleanup complete${NC}" diff --git a/tests/scenario-3-proposals/test.sh b/tests/scenario-3-proposals/test.sh index 4826fd9a..1b830e36 100755 --- a/tests/scenario-3-proposals/test.sh +++ b/tests/scenario-3-proposals/test.sh @@ -1,59 +1,32 @@ #!/bin/bash -# Automated test script - Combines setup, check, and teardown -# -# This is the entry point for automated testing (e.g., from run-all-tests.sh) -# For manual/debugging, use setup.sh, check.sh, and teardown.sh separately - set -e -# Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' -NC='\033[0m' # No Color +NC='\033[0m' -# Get test directory export TEST_DIR="$(cd "$(dirname "$0")" && pwd)" -# Use random port to avoid conflicts -export TEST_PORT=$((8000 + RANDOM % 1000)) - -echo -e "${YELLOW}=== Scenario 3: Proposals with Capability Blocking Test ===${NC}" -echo -e "${BLUE}Testing proposal flow with capability restrictions${NC}" -echo "" - -# Cleanup function (no trap, will call explicitly) cleanup() { - echo "" - echo "Cleaning up..." ./teardown.sh } +trap cleanup EXIT -# Step 1: Setup the space -echo -e "${YELLOW}Step 1: Setting up space...${NC}" -# Run setup in subprocess but capture the environment it sets -./setup.sh +echo -e "${YELLOW}=== Scenario 3: STDIO Proposals ===${NC}" -# Export the paths that check.sh needs -export OUTPUT_LOG="$TEST_DIR/logs/proposer-output.log" +echo -e "${BLUE}Step 1: Setup${NC}" +./setup.sh -# Step 2: Run checks -echo "" -echo -e "${YELLOW}Step 2: Running test checks...${NC}" -./check.sh -TEST_RESULT=$? +export DRIVER_LOG="$TEST_DIR/logs/proposal-driver.log" +export FULFILLER_LOG="$TEST_DIR/logs/fulfiller.log" -# Step 3: Report results -echo "" -if [ $TEST_RESULT -eq 0 ]; then +echo -e "${BLUE}Step 2: Checks${NC}" +if ./check.sh; then echo -e "${GREEN}โœ“ Scenario 3 PASSED${NC}" + exit 0 else echo -e "${RED}โœ— Scenario 3 FAILED${NC}" + exit 1 fi - -# Always cleanup before exiting -cleanup - -# Exit with the test result -exit $TEST_RESULT \ No newline at end of file diff --git a/tests/scenario-3-proposals/tokens/.gitignore b/tests/scenario-3-proposals/tokens/.gitignore deleted file mode 100644 index d6b7ef32..00000000 --- a/tests/scenario-3-proposals/tokens/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/tests/scenario-4-capabilities/check.sh b/tests/scenario-4-capabilities/check.sh index ccee802a..3f78d919 100755 --- a/tests/scenario-4-capabilities/check.sh +++ b/tests/scenario-4-capabilities/check.sh @@ -1,160 +1,66 @@ #!/bin/bash -# Check script for Scenario 4: Dynamic Capability Granting - set -e -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -echo -e "${YELLOW}=== Running Test Checks ===${NC}" -echo -e "${BLUE}Scenario: Dynamic Capability Granting${NC}" -echo -e "${BLUE}Test directory: $(pwd)${NC}" -echo -e "${BLUE}Gateway port: ${TEST_PORT}${NC}" - -# Get paths from environment or use defaults -COORD_LOG="${COORD_LOG:-./logs/coordinator-output.log}" -LIMITED_LOG="${LIMITED_LOG:-./logs/limited-agent-output.log}" - -# Test counters -TESTS_PASSED=0 -TESTS_FAILED=0 - -# Helper function to check test result -check_test() { - local test_name="$1" - local condition="$2" - - if eval "$condition"; then - echo -e "$test_name: ${GREEN}โœ“${NC}" - ((TESTS_PASSED++)) - else - echo -e "$test_name: ${RED}โœ—${NC}" - ((TESTS_FAILED++)) - fi -} +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' -# Basic connectivity tests -echo "Testing: Gateway is running ... \c" -# Check if gateway is listening on the port -if nc -z localhost ${TEST_PORT} 2>/dev/null; then - echo -e "${GREEN}โœ“${NC}" - ((TESTS_PASSED++)) -else - echo -e "${RED}โœ—${NC}" - ((TESTS_FAILED++)) +if [ -z "$TEST_DIR" ]; then + export TEST_DIR="$(cd "$(dirname "$0")" && pwd)" fi -echo "Testing: Coordinator output log exists ... \c" -check_test "" "[ -f '$COORD_LOG' ]" - -echo "Testing: Limited agent output log exists ... \c" -check_test "" "[ -f '$LIMITED_LOG' ]" - -echo "Testing: Output logs exist ... \c" -check_test "" "[ -f '$COORD_LOG' ] && [ -f '$LIMITED_LOG' ]" - -# Test 1: Limited agent attempts MCP operation (should be blocked) -# SKIP: Capability checking not yet implemented in gateway -echo -e "\n${YELLOW}Test 1: Limited agent attempts tools/list (should be blocked)${NC}" -echo -e "MCP request blocked: ${YELLOW}SKIPPED${NC} (capability checking not implemented)" - -# Test 2: Coordinator grants MCP capability -# SKIP: Capability granting not yet implemented -echo -e "\n${YELLOW}Test 2: Coordinator grants tools/list capability${NC}" -echo -e "Grant acknowledged: ${YELLOW}SKIPPED${NC} (capability granting not implemented)" +DRIVER_LOG=${DRIVER_LOG:-$TEST_DIR/logs/limited-driver.log} -# Test 3: Limited agent can now list tools -echo -e "\n${YELLOW}Test 3: Limited agent attempts tools/list (should succeed)${NC}" -# Clear previous responses -echo "" > /tmp/test-response.txt -tail -f "$LIMITED_LOG" > /tmp/test-response.txt & -TAIL_PID=$! - -curl -sf -X POST "http://localhost:$TEST_PORT/participants/limited-agent/messages" \ - -H "Authorization: Bearer limited-token" \ - -H "Content-Type: application/json" \ - -d '{"kind":"mcp/request","to":["calculator-agent"],"payload":{"method":"tools/list","params":{}}}' > /dev/null -sleep 3 -kill $TAIL_PID 2>/dev/null || true - -if grep -q '"kind":"mcp/response"' /tmp/test-response.txt && grep -q '"tools"' /tmp/test-response.txt; then - echo -e "Tools listed successfully: ${GREEN}โœ“${NC}" - ((TESTS_PASSED++)) -else - echo -e "Tools listed successfully: ${RED}โœ—${NC}" - ((TESTS_FAILED++)) +if [ ! -f "$DRIVER_LOG" ]; then + echo -e "${RED}Driver log not found: $DRIVER_LOG${NC}" + exit 1 fi -# Test 4: Limited agent still can't call tools -# SKIP: Capability checking not implemented -echo -e "\n${YELLOW}Test 4: Limited agent attempts tools/call (should be blocked)${NC}" -echo -e "tools/call blocked: ${YELLOW}SKIPPED${NC} (capability checking not implemented)" - -# Test 5: Grant broader capability -# SKIP: Capability granting not implemented -echo -e "\n${YELLOW}Test 5: Grant tools/* wildcard capability${NC}" -echo -e "Wildcard grant acknowledged: ${YELLOW}SKIPPED${NC} (capability granting not implemented)" +echo -e "${YELLOW}=== Checking capability grant/revoke flow ===${NC}" -# Test 6: Limited agent can now call tools -echo -e "\n${YELLOW}Test 6: Limited agent calls add tool (should succeed)${NC}" -# Clear previous responses -echo "" > /tmp/test-response.txt -tail -f "$LIMITED_LOG" > /tmp/test-response.txt & -TAIL_PID=$! - -curl -sf -X POST "http://localhost:$TEST_PORT/participants/limited-agent/messages" \ - -H "Authorization: Bearer limited-token" \ - -H "Content-Type: application/json" \ - -d '{"kind":"mcp/request","to":["calculator-agent"],"payload":{"method":"tools/call","params":{"name":"add","arguments":{"a":5,"b":3}}}}' > /dev/null -sleep 3 -kill $TAIL_PID 2>/dev/null || true +timeout=30 +while [ $timeout -gt 0 ]; do + if grep -q 'DONE' "$DRIVER_LOG"; then + break + fi + if grep -q 'FAIL' "$DRIVER_LOG"; then + echo -e "${RED}Driver reported failure${NC}" + tail -n 20 "$DRIVER_LOG" + exit 1 + fi + sleep 1 + timeout=$((timeout - 1)) +done -if grep -q '"kind":"mcp/response"' /tmp/test-response.txt && grep -q '"result":8' /tmp/test-response.txt; then - echo -e "Tool called successfully: ${GREEN}โœ“${NC}" - ((TESTS_PASSED++)) -else - echo -e "Tool called successfully: ${RED}โœ—${NC}" - ((TESTS_FAILED++)) +if ! grep -q 'DONE' "$DRIVER_LOG"; then + echo -e "${RED}Driver did not finish within timeout${NC}" + tail -n 20 "$DRIVER_LOG" + exit 1 fi -# Test 7: Revoke capabilities -echo -e "\n${YELLOW}Test 7: Revoke tools/* capability${NC}" -curl -sf -X POST "http://localhost:$TEST_PORT/participants/coordinator/messages" \ - -H "Authorization: Bearer admin-token" \ - -H "Content-Type: application/json" \ - -d '{"kind":"capability/revoke","payload":{"recipient":"limited-agent","capabilities":[{"kind":"mcp/request","payload":{"method":"tools/*"}}]}}' > /dev/null -sleep 2 +expected=( + "WELCOME" + "GRANT" + "OK tool-call" + "REVOKE" + "DONE" +) + +failures=0 +for token in "${expected[@]}"; do + if grep -q "$token" "$DRIVER_LOG"; then + echo -e "${GREEN}โœ“${NC} $token" + else + echo -e "${RED}โœ— Missing $token${NC}" + failures=$((failures + 1)) + fi +done -# Check if the revoke message was sent (revoke-ack might not be implemented yet) -if tail -10 "$LIMITED_LOG" | grep -q '"kind":"capability/revoke"'; then - echo -e "Revoke sent: ${GREEN}โœ“${NC}" - ((TESTS_PASSED++)) -else - echo -e "Revoke sent: ${RED}โœ—${NC}" - ((TESTS_FAILED++)) +if [ $failures -ne 0 ]; then + echo -e "${RED}Scenario failed${NC}" + tail -n 20 "$DRIVER_LOG" + exit 1 fi -# Test 8: Limited agent can no longer call tools -# SKIP: Capability checking not implemented -echo -e "\n${YELLOW}Test 8: Limited agent attempts tools/call after revoke (should be blocked)${NC}" -echo -e "Tool call blocked after revoke: ${YELLOW}SKIPPED${NC} (capability checking not implemented)" - -# Clean up temp file -rm -f /tmp/test-response.txt - -# Summary -echo -e "\n${YELLOW}=== Test Summary ===${NC}" -echo -e "Tests passed: ${GREEN}$TESTS_PASSED${NC}" -echo -e "Tests failed: ${RED}$TESTS_FAILED${NC}" - -if [ $TESTS_FAILED -eq 0 ]; then - echo -e "\n${GREEN}โœ“ All tests passed!${NC}" - exit 0 -else - echo -e "\n${RED}โœ— Some tests failed${NC}" - exit 1 -fi \ No newline at end of file +echo -e "${GREEN}โœ“ Scenario passed${NC}" diff --git a/tests/scenario-4-capabilities/setup.sh b/tests/scenario-4-capabilities/setup.sh index 36e2ba21..ff7f141e 100755 --- a/tests/scenario-4-capabilities/setup.sh +++ b/tests/scenario-4-capabilities/setup.sh @@ -1,77 +1,44 @@ #!/bin/bash -# Setup script - Initializes the test space -# -# Can be run standalone for manual testing or called by test.sh - set -e -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' -# If TEST_DIR not set, we're running standalone if [ -z "$TEST_DIR" ]; then export TEST_DIR="$(cd "$(dirname "$0")" && pwd)" fi -echo -e "${YELLOW}=== Setting up Test Space ===${NC}" -echo -e "${BLUE}Scenario: Dynamic Capability Granting${NC}" -echo -e "${BLUE}Directory: $TEST_DIR${NC}" -echo "" - cd "$TEST_DIR" -# Clean up any previous runs -echo "Cleaning up previous test artifacts..." -../../cli/bin/mew.js space clean --all --force 2>/dev/null || true +echo -e "${YELLOW}=== Setting up STDIO capability scenario ===${NC}"; -# Use random port to avoid conflicts -if [ -z "$TEST_PORT" ]; then - export TEST_PORT=$((8000 + RANDOM % 1000)) -fi - -echo "Starting space on port $TEST_PORT..." - -# Ensure logs directory exists -mkdir -p ./logs +mkdir -p logs -# Start the space using mew space up -../../cli/bin/mew.js space up --port "$TEST_PORT" > ./logs/space-up.log 2>&1 +../../cli/bin/mew.js space clean >/dev/null 2>&1 || true +rm -rf .mew >/dev/null 2>&1 || true -# Check if space started successfully -if ../../cli/bin/mew.js space status | grep -q "Gateway: ws://localhost:$TEST_PORT"; then - echo -e "${GREEN}โœ“ Space started successfully${NC}" -else - echo -e "${RED}โœ— Space failed to start${NC}" +echo "Starting space using mew space up..." +if ! ../../cli/bin/mew.js space up > ./logs/space-up.log 2>&1; then + echo -e "${RED}Failed to start space${NC}" cat ./logs/space-up.log exit 1 fi -# Wait for all components to be ready -echo "Waiting for components to initialize..." +echo "Waiting for processes to come online..." sleep 3 -# Export paths for check.sh to use -export OUTPUT_LOG="$TEST_DIR/logs/test-client-output.log" +STATE_FILE="$TEST_DIR/.mew/run/state.json" +if [ ! -f "$STATE_FILE" ]; then + echo -e "${RED}State file not found after startup${NC}" + cat ./logs/space-up.log + exit 1 +fi -# Create output log file if it doesn't exist -mkdir -p "$(dirname "$OUTPUT_LOG")" -touch "$OUTPUT_LOG" +../../cli/bin/mew.js space status -echo -e "${GREEN}โœ“ Setup complete${NC}" -echo "" -echo "Gateway running on: ws://localhost:$TEST_PORT" -echo "HTTP API available for test-client" -echo " Endpoint: http://localhost:$TEST_PORT/participants/test-client/messages" -echo " Output Log: $OUTPUT_LOG" -echo "" -echo "You can now:" -echo " - Run tests with: ./check.sh" -echo " - Send messages: curl -X POST http://localhost:$TEST_PORT/participants/test-client/messages -H 'Authorization: Bearer test-token' -H 'Content-Type: application/json' -d '{\"kind\":\"chat\",\"payload\":{\"text\":\"Hello\"}}'" -echo " - Read responses: tail -f $OUTPUT_LOG" +export DRIVER_LOG="$TEST_DIR/logs/limited-driver.log" +touch "$DRIVER_LOG" -# Set flag for check.sh -export SPACE_RUNNING=true \ No newline at end of file +echo -e "${GREEN}โœ“ Setup complete${NC}"; diff --git a/tests/scenario-4-capabilities/space.yaml b/tests/scenario-4-capabilities/space.yaml index ea96d892..9c62c17d 100644 --- a/tests/scenario-4-capabilities/space.yaml +++ b/tests/scenario-4-capabilities/space.yaml @@ -1,30 +1,41 @@ space: - id: test-space - name: "Scenario 4 - Dynamic Capability Granting" - description: "Test dynamic capability granting and revoking" + id: scenario-4 + name: "Scenario 4 - STDIO Capability Signals" + description: "Simulates capability grants and revokes with a limited agent" participants: calculator-agent: - tokens: ["calculator-token"] + auto_start: true + command: "node" + args: ["../agents/calculator-participant.js"] + env: + MEW_PARTICIPANT_ID: "calculator-agent" + CALCULATOR_LOG: "./logs/calculator.log" capabilities: + - kind: mcp/request - kind: mcp/response - kind: system/* + + coordinator: auto_start: true command: "node" - args: ["../agents/calculator-participant.js", "--gateway", "ws://localhost:${PORT}", "--space", "test-space", "--token", "calculator-token"] - - coordinator: - tokens: ["admin-token"] + args: ["../agents/coordinator-agent.js"] + env: + MEW_PARTICIPANT_ID: "coordinator" capabilities: - kind: capability/grant - kind: capability/revoke - kind: system/* - output_log: "./logs/coordinator-output.log" - auto_connect: true - + limited-agent: - tokens: ["limited-token"] + auto_start: true + command: "node" + args: ["../agents/limited-driver.js"] + env: + MEW_PARTICIPANT_ID: "limited-agent" + DRIVER_LOG: "./logs/limited-driver.log" capabilities: + - kind: mcp/request + - kind: mcp/response + - kind: capability/* - kind: system/* - output_log: "./logs/limited-agent-output.log" - auto_connect: true \ No newline at end of file diff --git a/tests/scenario-4-capabilities/teardown.sh b/tests/scenario-4-capabilities/teardown.sh index 54b42365..f2e05930 100755 --- a/tests/scenario-4-capabilities/teardown.sh +++ b/tests/scenario-4-capabilities/teardown.sh @@ -1,60 +1,21 @@ #!/bin/bash -# Teardown script - Cleans up the test space -# -# Can be run after manual testing or called by test.sh - set -e -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color +GREEN='\033[0;32m' +NC='\033[0m' -# If TEST_DIR not set, we're running standalone if [ -z "$TEST_DIR" ]; then export TEST_DIR="$(cd "$(dirname "$0")" && pwd)" fi -echo -e "${YELLOW}=== Cleaning up Test Space ===${NC}" -echo -e "${BLUE}Directory: $TEST_DIR${NC}" -echo "" - cd "$TEST_DIR" -# Stop the space using mew space down -echo "Stopping space..." -../../cli/bin/mew.js space down 2>/dev/null || true +echo -e "${YELLOW}=== Tearing down capability scenario ===${NC}" -# Additional cleanup for any orphaned processes -if [ -f ".mew/pids.json" ]; then - # Extract PIDs and kill them if still running - PIDS=$(grep -o '"pid":[0-9]*' .mew/pids.json 2>/dev/null | cut -d: -f2 || true) - for pid in $PIDS; do - if kill -0 $pid 2>/dev/null; then - echo "Killing orphaned process $pid" - kill -TERM $pid 2>/dev/null || true - fi - done -fi +../../cli/bin/mew.js space down >/dev/null 2>&1 || true +../../cli/bin/mew.js space clean >/dev/null 2>&1 || true -# Clean up test artifacts using mew space clean -if [ "${PRESERVE_LOGS:-false}" = "false" ]; then - echo "Cleaning test artifacts..." - - # Use the new mew space clean command - ../../cli/bin/mew.js space clean --all --force 2>/dev/null || { - # Fallback to manual cleanup if clean command fails - echo "Clean command failed, using manual cleanup..." - rm -rf logs fifos .mew 2>/dev/null || true - } - - echo -e "${GREEN}โœ“ Test artifacts removed${NC}" -else - echo -e "${YELLOW}Preserving logs (PRESERVE_LOGS=true)${NC}" - # Clean only fifos and .mew, preserve logs - ../../cli/bin/mew.js space clean --fifos --force 2>/dev/null || true -fi +rm -rf .mew >/dev/null 2>&1 || true -echo -e "${GREEN}โœ“ Cleanup complete${NC}" \ No newline at end of file +echo -e "${GREEN}โœ“ Cleanup complete${NC}" diff --git a/tests/scenario-4-capabilities/test.sh b/tests/scenario-4-capabilities/test.sh index 036dfd2a..9657f042 100755 --- a/tests/scenario-4-capabilities/test.sh +++ b/tests/scenario-4-capabilities/test.sh @@ -1,48 +1,31 @@ #!/bin/bash -# Main test runner for Scenario 4: Dynamic Capability Granting +set -e -# Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' -NC='\033[0m' # No Color +NC='\033[0m' -echo -e "${YELLOW}=== Scenario 4: Dynamic Capability Granting Test ===${NC}" -echo -e "${BLUE}Testing dynamic capability granting and revoking${NC}" +export TEST_DIR="$(cd "$(dirname "$0")" && pwd)" -# Get directory of this script -TEST_DIR="$(cd "$(dirname "$0")" && pwd)" -cd "$TEST_DIR" +cleanup() { + ./teardown.sh +} +trap cleanup EXIT -# Generate random port (same logic as setup.sh) -export TEST_PORT=$((8440 + RANDOM % 100)) +echo -e "${YELLOW}=== Scenario 4: STDIO Capability Signals ===${NC}" -# Run setup with the port -echo -e "\n${YELLOW}Step 1: Setting up space...${NC}" +echo -e "${BLUE}Step 1: Setup${NC}" ./setup.sh -if [ $? -ne 0 ]; then - echo -e "${RED}โœ— Setup failed${NC}" - exit 1 -fi - -# Export the paths that check.sh needs -export COORD_LOG="$TEST_DIR/logs/coordinator-output.log" -export LIMITED_LOG="$TEST_DIR/logs/limited-agent-output.log" -# Run checks -echo -e "\n${YELLOW}Step 2: Running test checks...${NC}" -./check.sh -TEST_RESULT=$? +export DRIVER_LOG="$TEST_DIR/logs/limited-driver.log" -if [ $TEST_RESULT -eq 0 ]; then - echo -e "\n${GREEN}โœ“ Scenario 4 PASSED${NC}" +echo -e "${BLUE}Step 2: Checks${NC}" +if ./check.sh; then + echo -e "${GREEN}โœ“ Scenario 4 PASSED${NC}" + exit 0 else - echo -e "\n${RED}โœ— Scenario 4 FAILED${NC}" + echo -e "${RED}โœ— Scenario 4 FAILED${NC}" + exit 1 fi - -# Cleanup -echo -e "\nCleaning up..." -./teardown.sh - -exit $TEST_RESULT \ No newline at end of file diff --git a/tests/scenario-4-capabilities/tokens/.gitignore b/tests/scenario-4-capabilities/tokens/.gitignore deleted file mode 100644 index d6b7ef32..00000000 --- a/tests/scenario-4-capabilities/tokens/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/tests/scenario-5-reasoning/check.sh b/tests/scenario-5-reasoning/check.sh index 4d069c39..a092c657 100755 --- a/tests/scenario-5-reasoning/check.sh +++ b/tests/scenario-5-reasoning/check.sh @@ -1,184 +1,73 @@ #!/bin/bash -# Check script for Scenario 5: Reasoning with Context Field - set -e -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -echo -e "${YELLOW}=== Running Test Checks ===${NC}" -echo -e "${BLUE}Scenario: Reasoning with Context Field${NC}" -echo -e "${BLUE}Test directory: $(pwd)${NC}" -echo -e "${BLUE}Gateway port: ${TEST_PORT}${NC}" - -# Get paths from environment or use defaults -OUTPUT_LOG="${OUTPUT_LOG:-./logs/research-agent-output.log}" - -# Test counters -TESTS_PASSED=0 -TESTS_FAILED=0 - -# Helper function to check test result -check_test() { - local test_name="$1" - local condition="$2" - - if eval "$condition"; then - echo -e "$test_name: ${GREEN}โœ“${NC}" - ((TESTS_PASSED++)) - else - echo -e "$test_name: ${RED}โœ—${NC}" - ((TESTS_FAILED++)) - fi -} - -# Basic connectivity tests -echo "Testing: Gateway is running ... \c" -if nc -z localhost ${TEST_PORT} 2>/dev/null; then - echo -e "${GREEN}โœ“${NC}" - ((TESTS_PASSED++)) -else - echo -e "${RED}โœ—${NC}" - ((TESTS_FAILED++)) -fi - -echo "Testing: Output log exists ... \c" -check_test "" "[ -f '$OUTPUT_LOG' ]" - -# Test reasoning flow with context field -echo -e "\n${YELLOW}Test: Complex reasoning flow with context${NC}" - -# Generate unique IDs -REQUEST_ID="req-$(date +%s)" -REASON_ID="reason-$(date +%s)" - -# Start monitoring output -tail -f "$OUTPUT_LOG" > /tmp/reasoning-response.txt & -TAIL_PID=$! - -# Send reasoning flow sequence via HTTP API -send_message() { - curl -sf -X POST "http://localhost:$TEST_PORT/participants/research-agent/messages" \ - -H "Authorization: Bearer research-token" \ - -H "Content-Type: application/json" \ - -d "$1" > /dev/null -} - -# Step 1: Initial chat request -send_message '{"id":"'$REQUEST_ID'","kind":"chat","payload":{"text":"Calculate the total cost of 5 items at $12 each, including 8% tax"}}' -sleep 1 - -# Step 2: Start reasoning (with correlation_id) -send_message '{"id":"'$REASON_ID'","kind":"reasoning/start","correlation_id":["'$REQUEST_ID'"],"payload":{"message":"Calculating total with tax for 5 items at $12 each"}}' -sleep 1 - -# Step 3: First reasoning thought (with context) -send_message '{"kind":"reasoning/thought","context":"'$REASON_ID'","payload":{"message":"First, I need to calculate the base cost: 5 ร— 12"}}' -sleep 1 - -# Step 4: Call calculator for multiplication (with context) -CALC_REQ_1="calc-req-1-$(date +%s)" -send_message '{"id":"'$CALC_REQ_1'","kind":"mcp/request","to":["calculator-agent"],"context":"'$REASON_ID'","payload":{"method":"tools/call","params":{"name":"multiply","arguments":{"a":5,"b":12}}}}' -sleep 2 - -# Step 5: Second reasoning thought -send_message '{"kind":"reasoning/thought","context":"'$REASON_ID'","payload":{"message":"Base cost is $60, now calculating 8% tax"}}' -sleep 1 - -# Step 6: Calculate tax (8% of 60 = 4.8) -CALC_REQ_2="calc-req-2-$(date +%s)" -send_message '{"id":"'$CALC_REQ_2'","kind":"mcp/request","to":["calculator-agent"],"context":"'$REASON_ID'","payload":{"method":"tools/call","params":{"name":"multiply","arguments":{"a":60,"b":0.08}}}}' -sleep 2 - -# Step 7: Third reasoning thought -send_message '{"kind":"reasoning/thought","context":"'$REASON_ID'","payload":{"message":"Tax is $4.80, now calculating total"}}' -sleep 1 - -# Step 8: Calculate total -CALC_REQ_3="calc-req-3-$(date +%s)" -send_message '{"id":"'$CALC_REQ_3'","kind":"mcp/request","to":["calculator-agent"],"context":"'$REASON_ID'","payload":{"method":"tools/call","params":{"name":"add","arguments":{"a":60,"b":4.8}}}}' -sleep 2 - -# Step 9: Reasoning conclusion -send_message '{"kind":"reasoning/conclusion","context":"'$REASON_ID'","payload":{"message":"Total cost is $64.80 (base: $60, tax: $4.80)"}}' -sleep 1 - -# Step 10: Final response -send_message '{"kind":"chat","correlation_id":["'$REQUEST_ID'"],"payload":{"text":"The total cost is $64.80 (5 items ร— $12 = $60, plus 8% tax = $4.80)"}}' - -# Wait for all messages to be processed -sleep 5 -kill $TAIL_PID 2>/dev/null || true - -# Verify test results -echo -e "\n${YELLOW}Verifying reasoning flow results:${NC}" +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' -# Check that context field was preserved -if grep -q '"context":"'$REASON_ID'"' /tmp/reasoning-response.txt; then - echo -e "Context field preserved: ${GREEN}โœ“${NC}" - ((TESTS_PASSED++)) -else - echo -e "Context field preserved: ${RED}โœ—${NC}" - ((TESTS_FAILED++)) +if [ -z "$TEST_DIR" ]; then + export TEST_DIR="$(cd "$(dirname "$0")" && pwd)" fi -# Check reasoning messages -if grep -q '"kind":"reasoning/start"' /tmp/reasoning-response.txt; then - echo -e "Reasoning start message: ${GREEN}โœ“${NC}" - ((TESTS_PASSED++)) -else - echo -e "Reasoning start message: ${RED}โœ—${NC}" - ((TESTS_FAILED++)) -fi +DRIVER_LOG=${DRIVER_LOG:-$TEST_DIR/logs/reasoning-driver.log} -if grep -q '"kind":"reasoning/thought"' /tmp/reasoning-response.txt; then - echo -e "Reasoning thought messages: ${GREEN}โœ“${NC}" - ((TESTS_PASSED++)) -else - echo -e "Reasoning thought messages: ${RED}โœ—${NC}" - ((TESTS_FAILED++)) +if [ ! -f "$DRIVER_LOG" ]; then + echo -e "${RED}Driver log not found: $DRIVER_LOG${NC}" + exit 1 fi -if grep -q '"kind":"reasoning/conclusion"' /tmp/reasoning-response.txt; then - echo -e "Reasoning conclusion message: ${GREEN}โœ“${NC}" - ((TESTS_PASSED++)) -else - echo -e "Reasoning conclusion message: ${RED}โœ—${NC}" - ((TESTS_FAILED++)) -fi +echo -e "${YELLOW}=== Checking reasoning flow ===${NC}" -# Check MCP responses (context is preserved in requests, not responses) -if grep -q '"kind":"mcp/response"' /tmp/reasoning-response.txt && grep -q '"context":"'$REASON_ID'"' /tmp/reasoning-response.txt; then - echo -e "MCP responses and context preserved: ${GREEN}โœ“${NC}" - ((TESTS_PASSED++)) -else - echo -e "MCP responses and context preserved: ${RED}โœ—${NC}" - ((TESTS_FAILED++)) -fi +timeout=40 +while [ $timeout -gt 0 ]; do + if grep -q 'DONE' "$DRIVER_LOG"; then + break + fi + if grep -q 'FAIL' "$DRIVER_LOG"; then + echo -e "${RED}Driver reported failure${NC}" + tail -n 20 "$DRIVER_LOG" + exit 1 + fi + sleep 1 + timeout=$((timeout - 1)) +done -# Check final calculation result -if grep -q '64.80' /tmp/reasoning-response.txt || grep -q '64.8' /tmp/reasoning-response.txt; then - echo -e "Correct calculation result: ${GREEN}โœ“${NC}" - ((TESTS_PASSED++)) -else - echo -e "Correct calculation result: ${RED}โœ—${NC}" - ((TESTS_FAILED++)) +if ! grep -q 'DONE' "$DRIVER_LOG"; then + echo -e "${RED}Driver did not finish within timeout${NC}" + tail -n 20 "$DRIVER_LOG" + exit 1 fi -# Summary -echo "" -echo -e "${YELLOW}=== Test Summary ===${NC}" -echo -e "Tests passed: ${GREEN}$TESTS_PASSED${NC}" -echo -e "Tests failed: ${RED}$TESTS_FAILED${NC}" +expected=( + "WELCOME" + "OK chat-request" + "OK reason-start" + "OK thought-1" + "OK calc-base" + "OK thought-2" + "OK calc-tax" + "OK thought-3" + "OK calc-total" + "OK reason-conclusion" + "OK final-chat" + "DONE" +) + +failures=0 +for token in "${expected[@]}"; do + if grep -q "$token" "$DRIVER_LOG"; then + echo -e "${GREEN}โœ“${NC} $token" + else + echo -e "${RED}โœ— Missing $token${NC}" + failures=$((failures + 1)) + fi +done -if [ $TESTS_FAILED -eq 0 ]; then - echo -e "\n${GREEN}โœ“ All tests passed!${NC}" - exit 0 -else - echo -e "\n${RED}โœ— Some tests failed${NC}" +if [ $failures -ne 0 ]; then + echo -e "${RED}Scenario failed${NC}" + tail -n 20 "$DRIVER_LOG" exit 1 fi + +echo -e "${GREEN}โœ“ Scenario passed${NC}" diff --git a/tests/scenario-5-reasoning/setup.sh b/tests/scenario-5-reasoning/setup.sh index 52e1fe0b..beb1b2b6 100755 --- a/tests/scenario-5-reasoning/setup.sh +++ b/tests/scenario-5-reasoning/setup.sh @@ -1,77 +1,44 @@ #!/bin/bash -# Setup script - Initializes the test space -# -# Can be run standalone for manual testing or called by test.sh - set -e -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' -# If TEST_DIR not set, we're running standalone if [ -z "$TEST_DIR" ]; then export TEST_DIR="$(cd "$(dirname "$0")" && pwd)" fi -echo -e "${YELLOW}=== Setting up Test Space ===${NC}" -echo -e "${BLUE}Scenario: Agent Reasoning and Thinking${NC}" -echo -e "${BLUE}Directory: $TEST_DIR${NC}" -echo "" - cd "$TEST_DIR" -# Clean up any previous runs -echo "Cleaning up previous test artifacts..." -../../cli/bin/mew.js space clean --all --force 2>/dev/null || true +echo -e "${YELLOW}=== Setting up STDIO reasoning scenario ===${NC}"; -# Use random port to avoid conflicts -if [ -z "$TEST_PORT" ]; then - export TEST_PORT=$((8000 + RANDOM % 1000)) -fi - -echo "Starting space on port $TEST_PORT..." - -# Ensure logs directory exists -mkdir -p ./logs +mkdir -p logs -# Start the space using mew space up -../../cli/bin/mew.js space up --port "$TEST_PORT" > ./logs/space-up.log 2>&1 +../../cli/bin/mew.js space clean >/dev/null 2>&1 || true +rm -rf .mew >/dev/null 2>&1 || true -# Check if space started successfully -if ../../cli/bin/mew.js space status | grep -q "Gateway: ws://localhost:$TEST_PORT"; then - echo -e "${GREEN}โœ“ Space started successfully${NC}" -else - echo -e "${RED}โœ— Space failed to start${NC}" +echo "Starting space using mew space up..." +if ! ../../cli/bin/mew.js space up > ./logs/space-up.log 2>&1; then + echo -e "${RED}Failed to start space${NC}" cat ./logs/space-up.log exit 1 fi -# Wait for all components to be ready -echo "Waiting for components to initialize..." +echo "Waiting for processes to come online..." sleep 3 -# Export paths for check.sh to use -export OUTPUT_LOG="$TEST_DIR/logs/test-client-output.log" +STATE_FILE="$TEST_DIR/.mew/run/state.json" +if [ ! -f "$STATE_FILE" ]; then + echo -e "${RED}State file not found after startup${NC}" + cat ./logs/space-up.log + exit 1 +fi -# Create output log file if it doesn't exist -mkdir -p "$(dirname "$OUTPUT_LOG")" -touch "$OUTPUT_LOG" +../../cli/bin/mew.js space status -echo -e "${GREEN}โœ“ Setup complete${NC}" -echo "" -echo "Gateway running on: ws://localhost:$TEST_PORT" -echo "HTTP API available for test-client" -echo " Endpoint: http://localhost:$TEST_PORT/participants/test-client/messages" -echo " Output Log: $OUTPUT_LOG" -echo "" -echo "You can now:" -echo " - Run tests with: ./check.sh" -echo " - Send messages: curl -X POST http://localhost:$TEST_PORT/participants/test-client/messages -H 'Authorization: Bearer test-token' -H 'Content-Type: application/json' -d '{\"kind\":\"chat\",\"payload\":{\"text\":\"Hello\"}}'" -echo " - Read responses: tail -f $OUTPUT_LOG" +export DRIVER_LOG="$TEST_DIR/logs/reasoning-driver.log" +touch "$DRIVER_LOG" -# Set flag for check.sh -export SPACE_RUNNING=true \ No newline at end of file +echo -e "${GREEN}โœ“ Setup complete${NC}"; diff --git a/tests/scenario-5-reasoning/space.yaml b/tests/scenario-5-reasoning/space.yaml index 3c16daa6..340d6a89 100644 --- a/tests/scenario-5-reasoning/space.yaml +++ b/tests/scenario-5-reasoning/space.yaml @@ -1,26 +1,30 @@ space: - id: test-space - name: "Scenario 5 - Reasoning with Context Field" - description: "Test reasoning messages with context field" + id: scenario-5 + name: "Scenario 5 - STDIO Reasoning Flow" + description: "Exercises reasoning messages with context preservation" participants: calculator-agent: - tokens: ["calculator-token"] + auto_start: true + command: "node" + args: ["../agents/calculator-participant.js"] + env: + MEW_PARTICIPANT_ID: "calculator-agent" + CALCULATOR_LOG: "./logs/calculator.log" capabilities: + - kind: mcp/request - kind: mcp/response - kind: system/* + + research-agent: auto_start: true command: "node" - args: ["../agents/calculator-participant.js", "--gateway", "ws://localhost:${PORT}", "--space", "test-space", "--token", "calculator-token"] - - research-agent: - tokens: ["research-token"] + args: ["../agents/reasoning-driver.js"] + env: + MEW_PARTICIPANT_ID: "research-agent" + DRIVER_LOG: "./logs/reasoning-driver.log" capabilities: - kind: reasoning/* - kind: mcp/request - payload: - method: tools/* - kind: chat - kind: system/* - output_log: "./logs/research-agent-output.log" - auto_connect: true \ No newline at end of file diff --git a/tests/scenario-5-reasoning/teardown.sh b/tests/scenario-5-reasoning/teardown.sh index 54b42365..991e0adc 100755 --- a/tests/scenario-5-reasoning/teardown.sh +++ b/tests/scenario-5-reasoning/teardown.sh @@ -1,60 +1,21 @@ #!/bin/bash -# Teardown script - Cleans up the test space -# -# Can be run after manual testing or called by test.sh - set -e -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color +GREEN='\033[0;32m' +NC='\033[0m' -# If TEST_DIR not set, we're running standalone if [ -z "$TEST_DIR" ]; then export TEST_DIR="$(cd "$(dirname "$0")" && pwd)" fi -echo -e "${YELLOW}=== Cleaning up Test Space ===${NC}" -echo -e "${BLUE}Directory: $TEST_DIR${NC}" -echo "" - cd "$TEST_DIR" -# Stop the space using mew space down -echo "Stopping space..." -../../cli/bin/mew.js space down 2>/dev/null || true +echo -e "${YELLOW}=== Tearing down reasoning scenario ===${NC}" -# Additional cleanup for any orphaned processes -if [ -f ".mew/pids.json" ]; then - # Extract PIDs and kill them if still running - PIDS=$(grep -o '"pid":[0-9]*' .mew/pids.json 2>/dev/null | cut -d: -f2 || true) - for pid in $PIDS; do - if kill -0 $pid 2>/dev/null; then - echo "Killing orphaned process $pid" - kill -TERM $pid 2>/dev/null || true - fi - done -fi +../../cli/bin/mew.js space down >/dev/null 2>&1 || true +../../cli/bin/mew.js space clean >/dev/null 2>&1 || true -# Clean up test artifacts using mew space clean -if [ "${PRESERVE_LOGS:-false}" = "false" ]; then - echo "Cleaning test artifacts..." - - # Use the new mew space clean command - ../../cli/bin/mew.js space clean --all --force 2>/dev/null || { - # Fallback to manual cleanup if clean command fails - echo "Clean command failed, using manual cleanup..." - rm -rf logs fifos .mew 2>/dev/null || true - } - - echo -e "${GREEN}โœ“ Test artifacts removed${NC}" -else - echo -e "${YELLOW}Preserving logs (PRESERVE_LOGS=true)${NC}" - # Clean only fifos and .mew, preserve logs - ../../cli/bin/mew.js space clean --fifos --force 2>/dev/null || true -fi +rm -rf .mew >/dev/null 2>&1 || true -echo -e "${GREEN}โœ“ Cleanup complete${NC}" \ No newline at end of file +echo -e "${GREEN}โœ“ Cleanup complete${NC}" diff --git a/tests/scenario-5-reasoning/test.sh b/tests/scenario-5-reasoning/test.sh index 94b44328..46bb25f9 100755 --- a/tests/scenario-5-reasoning/test.sh +++ b/tests/scenario-5-reasoning/test.sh @@ -1,47 +1,31 @@ #!/bin/bash -# Main test runner for Scenario 5: Reasoning with Context Field +set -e -# Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' -NC='\033[0m' # No Color +NC='\033[0m' -echo -e "${YELLOW}=== Scenario 5: Reasoning with Context Field Test ===${NC}" -echo -e "${BLUE}Testing reasoning messages with context field preservation${NC}" +export TEST_DIR="$(cd "$(dirname "$0")" && pwd)" -# Get directory of this script -TEST_DIR="$(cd "$(dirname "$0")" && pwd)" -cd "$TEST_DIR" +cleanup() { + ./teardown.sh +} +trap cleanup EXIT -# Generate random port (same logic as setup.sh) -export TEST_PORT=$((8540 + RANDOM % 100)) +echo -e "${YELLOW}=== Scenario 5: STDIO Reasoning Flow ===${NC}" -# Run setup with the port -echo -e "\n${YELLOW}Step 1: Setting up space...${NC}" +echo -e "${BLUE}Step 1: Setup${NC}" ./setup.sh -if [ $? -ne 0 ]; then - echo -e "${RED}โœ— Setup failed${NC}" - exit 1 -fi - -# Export the paths that check.sh needs -export OUTPUT_LOG="$TEST_DIR/logs/research-agent-output.log" -# Run checks -echo -e "\n${YELLOW}Step 2: Running test checks...${NC}" -./check.sh -TEST_RESULT=$? +export DRIVER_LOG="$TEST_DIR/logs/reasoning-driver.log" -if [ $TEST_RESULT -eq 0 ]; then - echo -e "\n${GREEN}โœ“ Scenario 5 PASSED${NC}" +echo -e "${BLUE}Step 2: Checks${NC}" +if ./check.sh; then + echo -e "${GREEN}โœ“ Scenario 5 PASSED${NC}" + exit 0 else - echo -e "\n${RED}โœ— Scenario 5 FAILED${NC}" + echo -e "${RED}โœ— Scenario 5 FAILED${NC}" + exit 1 fi - -# Cleanup -echo -e "\nCleaning up..." -./teardown.sh - -exit $TEST_RESULT \ No newline at end of file diff --git a/tests/scenario-5-reasoning/tokens/.gitignore b/tests/scenario-5-reasoning/tokens/.gitignore deleted file mode 100644 index d6b7ef32..00000000 --- a/tests/scenario-5-reasoning/tokens/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/tests/scenario-6-errors/check.sh b/tests/scenario-6-errors/check.sh index c4e6002a..1943af3a 100755 --- a/tests/scenario-6-errors/check.sh +++ b/tests/scenario-6-errors/check.sh @@ -1,183 +1,66 @@ #!/bin/bash -# Check script for Scenario 6: Error Recovery and Edge Cases - set -e -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -echo -e "${YELLOW}=== Running Test Checks ===${NC}" -echo -e "${BLUE}Scenario: Error Recovery and Edge Cases${NC}" -echo -e "${BLUE}Test directory: $(pwd)${NC}" -echo -e "${BLUE}Gateway port: ${TEST_PORT}${NC}" - -# Get paths from environment or use defaults -OUTPUT_LOG="${OUTPUT_LOG:-./logs/test-client-output.log}" -GATEWAY_LOG="./logs/gateway.log" - -# Test counters -TESTS_PASSED=0 -TESTS_FAILED=0 - -# Helper function to check test result -check_test() { - local test_name="$1" - local condition="$2" - - if eval "$condition"; then - echo -e "$test_name: ${GREEN}โœ“${NC}" - ((TESTS_PASSED++)) - else - echo -e "$test_name: ${RED}โœ—${NC}" - ((TESTS_FAILED++)) - fi -} - -# Basic connectivity tests -echo "Testing: Gateway is running ... \c" -# Check if gateway is listening on the port -if nc -z localhost ${TEST_PORT} 2>/dev/null; then - echo -e "${GREEN}โœ“${NC}" - ((TESTS_PASSED++)) -else - echo -e "${RED}โœ—${NC}" - ((TESTS_FAILED++)) -fi - -echo "Testing: Output log exists ... \c" -check_test "" "[ -f '$OUTPUT_LOG' ]" - -# Test 1: Invalid JSON -echo -e "\n${YELLOW}Test 1: Send invalid JSON${NC}" -curl -sf -X POST "http://localhost:$TEST_PORT/participants/test-client/messages" \ - -H "Authorization: Bearer test-token" \ - -H "Content-Type: application/json" \ - -d 'This is not valid JSON' > /dev/null 2>&1 || true -sleep 2 - -# Check if error is logged (the gateway or client should handle it) -if tail -20 "$OUTPUT_LOG" | grep -q '"kind":"system/error"' || tail -20 logs/*.log 2>/dev/null | grep -qi "invalid.*json\|json.*parse"; then - echo -e "Invalid JSON handled: ${GREEN}โœ“${NC}" - ((TESTS_PASSED++)) -else - echo -e "Invalid JSON handled: ${RED}โœ—${NC} (error not detected)" - ((TESTS_FAILED++)) -fi - -# Test 2: Message without kind field -echo -e "\n${YELLOW}Test 2: Message without 'kind' field${NC}" -curl -sf -X POST "http://localhost:$TEST_PORT/participants/test-client/messages" \ - -H "Authorization: Bearer test-token" \ - -H "Content-Type: application/json" \ - -d '{"payload":{"text":"Missing kind field"}}' > /dev/null -sleep 2 +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' -if tail -20 "$OUTPUT_LOG" | grep -q '"kind":"system/error"' || tail -20 logs/*.log 2>/dev/null | grep -qi "missing.*kind\|kind.*required"; then - echo -e "Missing 'kind' field handled: ${GREEN}โœ“${NC}" - ((TESTS_PASSED++)) -else - echo -e "Missing 'kind' field handled: ${RED}โœ—${NC}" - ((TESTS_FAILED++)) +if [ -z "$TEST_DIR" ]; then + export TEST_DIR="$(cd "$(dirname "$0")" && pwd)" fi -# Test 3: Message to non-existent participant -echo -e "\n${YELLOW}Test 3: Message to non-existent participant${NC}" -curl -sf -X POST "http://localhost:$TEST_PORT/participants/test-client/messages" -H "Authorization: Bearer test-token" -H "Content-Type: application/json" -d '{}' > /dev/null -sleep 2 +DRIVER_LOG=${DRIVER_LOG:-$TEST_DIR/logs/error-driver.log} -# This should be handled gracefully without errors -if nc -z localhost ${TEST_PORT} 2>/dev/null; then - echo -e "Non-existent participant handled: ${GREEN}โœ“${NC}" - ((TESTS_PASSED++)) -else - echo -e "Non-existent participant handled: ${RED}โœ—${NC} (gateway crashed)" - ((TESTS_FAILED++)) +if [ ! -f "$DRIVER_LOG" ]; then + echo -e "${RED}Driver log not found: $DRIVER_LOG${NC}" + exit 1 fi -# Test 4: Very large message -echo -e "\n${YELLOW}Test 4: Very large message (10KB)${NC}" -LARGE_MSG='{"kind":"chat","payload":{"text":"' -LARGE_MSG+=$(printf 'A%.0s' {1..10000}) -LARGE_MSG+='"}}' -curl -sf -X POST "http://localhost:$TEST_PORT/participants/test-client/messages" -H "Authorization: Bearer test-token" -H "Content-Type: application/json" -d '{}' > /dev/null -sleep 2 - -# Check if processes are still running -if nc -z localhost ${TEST_PORT} 2>/dev/null; then - echo -e "Large message handled: ${GREEN}โœ“${NC}" - ((TESTS_PASSED++)) -else - echo -e "Large message handled: ${RED}โœ—${NC} (gateway crashed)" - ((TESTS_FAILED++)) -fi +echo -e "${YELLOW}=== Checking error handling flow ===${NC}" -# Test 5: Rapid message sending -echo -e "\n${YELLOW}Test 5: Rapid message sending (20 messages)${NC}" -for i in {1..20}; do - curl -sf -X POST "http://localhost:$TEST_PORT/participants/test-client/messages" -H "Authorization: Bearer test-token" -H "Content-Type: application/json" -d '{}' > /dev/null +timeout=30 +while [ $timeout -gt 0 ]; do + if grep -q 'DONE' "$DRIVER_LOG"; then + break + fi + if grep -q 'FAIL' "$DRIVER_LOG"; then + echo -e "${RED}Driver reported failure${NC}" + tail -n 20 "$DRIVER_LOG" + exit 1 + fi + sleep 1 + timeout=$((timeout - 1)) done -sleep 3 - -if nc -z localhost ${TEST_PORT} 2>/dev/null; then - echo -e "Rapid messages handled: ${GREEN}โœ“${NC}" - ((TESTS_PASSED++)) -else - echo -e "Rapid messages handled: ${RED}โœ—${NC} (gateway crashed)" - ((TESTS_FAILED++)) -fi - -# Test 6: Empty message -echo -e "\n${YELLOW}Test 6: Empty message${NC}" -curl -sf -X POST "http://localhost:$TEST_PORT/participants/test-client/messages" -H "Authorization: Bearer test-token" -H "Content-Type: application/json" -d '{}' > /dev/null -sleep 1 - -if nc -z localhost ${TEST_PORT} 2>/dev/null; then - echo -e "Empty message handled: ${GREEN}โœ“${NC}" - ((TESTS_PASSED++)) -else - echo -e "Empty message handled: ${RED}โœ—${NC} (gateway crashed)" - ((TESTS_FAILED++)) -fi - -# Test 7: Malformed message (unclosed JSON) -echo -e "\n${YELLOW}Test 7: Malformed JSON (unclosed)${NC}" -curl -sf -X POST "http://localhost:$TEST_PORT/participants/test-client/messages" -H "Authorization: Bearer test-token" -H "Content-Type: application/json" -d '{}' > /dev/null -sleep 2 -if nc -z localhost ${TEST_PORT} 2>/dev/null; then - echo -e "Malformed JSON handled: ${GREEN}โœ“${NC}" - ((TESTS_PASSED++)) -else - echo -e "Malformed JSON handled: ${RED}โœ—${NC} (gateway crashed)" - ((TESTS_FAILED++)) +if ! grep -q 'DONE' "$DRIVER_LOG"; then + echo -e "${RED}Driver did not finish within timeout${NC}" + tail -n 20 "$DRIVER_LOG" + exit 1 fi -# Test 8: Special characters in message -echo -e "\n${YELLOW}Test 8: Special characters in message${NC}" -curl -sf -X POST "http://localhost:$TEST_PORT/participants/test-client/messages" -H "Authorization: Bearer test-token" -H "Content-Type: application/json" -d '{}' > /dev/null -sleep 2 +expected=( + "WELCOME" + "OK invalid-tool" + "OK large-message" + "OK rapid-fire" + "DONE" +) + +failures=0 +for token in "${expected[@]}"; do + if grep -q "$token" "$DRIVER_LOG"; then + echo -e "${GREEN}โœ“${NC} $token" + else + echo -e "${RED}โœ— Missing $token${NC}" + failures=$((failures + 1)) + fi +done -if nc -z localhost ${TEST_PORT} 2>/dev/null; then - echo -e "Special characters handled: ${GREEN}โœ“${NC}" - ((TESTS_PASSED++)) -else - echo -e "Special characters handled: ${RED}โœ—${NC} (gateway crashed)" - ((TESTS_FAILED++)) +if [ $failures -ne 0 ]; then + echo -e "${RED}Scenario failed${NC}" + tail -n 20 "$DRIVER_LOG" + exit 1 fi -# Summary -echo -e "\n${YELLOW}=== Test Summary ===${NC}" -echo -e "Tests passed: ${GREEN}$TESTS_PASSED${NC}" -echo -e "Tests failed: ${RED}$TESTS_FAILED${NC}" - -if [ $TESTS_FAILED -eq 0 ]; then - echo -e "\n${GREEN}โœ“ All tests passed!${NC}" - exit 0 -else - echo -e "\n${RED}โœ— Some tests failed${NC}" - exit 1 -fi \ No newline at end of file +echo -e "${GREEN}โœ“ Scenario passed${NC}" diff --git a/tests/scenario-6-errors/setup.sh b/tests/scenario-6-errors/setup.sh index 02645252..d02f6dd8 100755 --- a/tests/scenario-6-errors/setup.sh +++ b/tests/scenario-6-errors/setup.sh @@ -1,77 +1,44 @@ #!/bin/bash -# Setup script - Initializes the test space -# -# Can be run standalone for manual testing or called by test.sh - set -e -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' -# If TEST_DIR not set, we're running standalone if [ -z "$TEST_DIR" ]; then export TEST_DIR="$(cd "$(dirname "$0")" && pwd)" fi -echo -e "${YELLOW}=== Setting up Test Space ===${NC}" -echo -e "${BLUE}Scenario: Error Handling${NC}" -echo -e "${BLUE}Directory: $TEST_DIR${NC}" -echo "" - cd "$TEST_DIR" -# Clean up any previous runs -echo "Cleaning up previous test artifacts..." -../../cli/bin/mew.js space clean --all --force 2>/dev/null || true +echo -e "${YELLOW}=== Setting up STDIO error scenario ===${NC}"; -# Use random port to avoid conflicts -if [ -z "$TEST_PORT" ]; then - export TEST_PORT=$((8000 + RANDOM % 1000)) -fi - -echo "Starting space on port $TEST_PORT..." - -# Ensure logs directory exists -mkdir -p ./logs +mkdir -p logs -# Start the space using mew space up -../../cli/bin/mew.js space up --port "$TEST_PORT" > ./logs/space-up.log 2>&1 +../../cli/bin/mew.js space clean >/dev/null 2>&1 || true +rm -rf .mew >/dev/null 2>&1 || true -# Check if space started successfully -if ../../cli/bin/mew.js space status | grep -q "Gateway: ws://localhost:$TEST_PORT"; then - echo -e "${GREEN}โœ“ Space started successfully${NC}" -else - echo -e "${RED}โœ— Space failed to start${NC}" +echo "Starting space using mew space up..." +if ! ../../cli/bin/mew.js space up > ./logs/space-up.log 2>&1; then + echo -e "${RED}Failed to start space${NC}" cat ./logs/space-up.log exit 1 fi -# Wait for all components to be ready -echo "Waiting for components to initialize..." +echo "Waiting for processes to come online..." sleep 3 -# Export paths for check.sh to use -export OUTPUT_LOG="$TEST_DIR/logs/test-client-output.log" +STATE_FILE="$TEST_DIR/.mew/run/state.json" +if [ ! -f "$STATE_FILE" ]; then + echo -e "${RED}State file not found after startup${NC}" + cat ./logs/space-up.log + exit 1 +fi -# Create output log file if it doesn't exist -mkdir -p "$(dirname "$OUTPUT_LOG")" -touch "$OUTPUT_LOG" +../../cli/bin/mew.js space status -echo -e "${GREEN}โœ“ Setup complete${NC}" -echo "" -echo "Gateway running on: ws://localhost:$TEST_PORT" -echo "HTTP API available for test-client" -echo " Endpoint: http://localhost:$TEST_PORT/participants/test-client/messages" -echo " Output Log: $OUTPUT_LOG" -echo "" -echo "You can now:" -echo " - Run tests with: ./check.sh" -echo " - Send messages: curl -X POST http://localhost:$TEST_PORT/participants/test-client/messages -H 'Authorization: Bearer test-token' -H 'Content-Type: application/json' -d '{\"kind\":\"chat\",\"payload\":{\"text\":\"Hello\"}}'" -echo " - Read responses: tail -f $OUTPUT_LOG" +export DRIVER_LOG="$TEST_DIR/logs/error-driver.log" +touch "$DRIVER_LOG" -# Set flag for check.sh -export SPACE_RUNNING=true \ No newline at end of file +echo -e "${GREEN}โœ“ Setup complete${NC}"; diff --git a/tests/scenario-6-errors/space.yaml b/tests/scenario-6-errors/space.yaml index 7872fea9..15f0da25 100644 --- a/tests/scenario-6-errors/space.yaml +++ b/tests/scenario-6-errors/space.yaml @@ -1,13 +1,30 @@ space: - id: test-space - name: "Scenario 6 - Error Recovery and Edge Cases" - description: "Test error recovery and edge cases" + id: scenario-6 + name: "Scenario 6 - STDIO Error Handling" + description: "Exercises error and recovery paths" participants: - test-client: - tokens: ["test-token"] + calculator-agent: + auto_start: true + command: "node" + args: ["../agents/calculator-participant.js"] + env: + MEW_PARTICIPANT_ID: "calculator-agent" + CALCULATOR_LOG: "./logs/calculator.log" capabilities: + - kind: mcp/request + - kind: mcp/response + - kind: system/* + + error-driver: + auto_start: true + command: "node" + args: ["../agents/error-driver.js"] + env: + MEW_PARTICIPANT_ID: "error-driver" + DRIVER_LOG: "./logs/error-driver.log" + capabilities: + - kind: mcp/request + - kind: mcp/response - kind: chat - kind: system/* - output_log: "./logs/test-client-output.log" - auto_connect: true \ No newline at end of file diff --git a/tests/scenario-6-errors/teardown.sh b/tests/scenario-6-errors/teardown.sh index 54b42365..c3d174bb 100755 --- a/tests/scenario-6-errors/teardown.sh +++ b/tests/scenario-6-errors/teardown.sh @@ -1,60 +1,21 @@ #!/bin/bash -# Teardown script - Cleans up the test space -# -# Can be run after manual testing or called by test.sh - set -e -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color +GREEN='\033[0;32m' +NC='\033[0m' -# If TEST_DIR not set, we're running standalone if [ -z "$TEST_DIR" ]; then export TEST_DIR="$(cd "$(dirname "$0")" && pwd)" fi -echo -e "${YELLOW}=== Cleaning up Test Space ===${NC}" -echo -e "${BLUE}Directory: $TEST_DIR${NC}" -echo "" - cd "$TEST_DIR" -# Stop the space using mew space down -echo "Stopping space..." -../../cli/bin/mew.js space down 2>/dev/null || true +echo -e "${YELLOW}=== Tearing down error scenario ===${NC}" -# Additional cleanup for any orphaned processes -if [ -f ".mew/pids.json" ]; then - # Extract PIDs and kill them if still running - PIDS=$(grep -o '"pid":[0-9]*' .mew/pids.json 2>/dev/null | cut -d: -f2 || true) - for pid in $PIDS; do - if kill -0 $pid 2>/dev/null; then - echo "Killing orphaned process $pid" - kill -TERM $pid 2>/dev/null || true - fi - done -fi +../../cli/bin/mew.js space down >/dev/null 2>&1 || true +../../cli/bin/mew.js space clean >/dev/null 2>&1 || true -# Clean up test artifacts using mew space clean -if [ "${PRESERVE_LOGS:-false}" = "false" ]; then - echo "Cleaning test artifacts..." - - # Use the new mew space clean command - ../../cli/bin/mew.js space clean --all --force 2>/dev/null || { - # Fallback to manual cleanup if clean command fails - echo "Clean command failed, using manual cleanup..." - rm -rf logs fifos .mew 2>/dev/null || true - } - - echo -e "${GREEN}โœ“ Test artifacts removed${NC}" -else - echo -e "${YELLOW}Preserving logs (PRESERVE_LOGS=true)${NC}" - # Clean only fifos and .mew, preserve logs - ../../cli/bin/mew.js space clean --fifos --force 2>/dev/null || true -fi +rm -rf .mew >/dev/null 2>&1 || true -echo -e "${GREEN}โœ“ Cleanup complete${NC}" \ No newline at end of file +echo -e "${GREEN}โœ“ Cleanup complete${NC}" diff --git a/tests/scenario-6-errors/test.sh b/tests/scenario-6-errors/test.sh index 8e9cf1f7..29a6a920 100755 --- a/tests/scenario-6-errors/test.sh +++ b/tests/scenario-6-errors/test.sh @@ -1,47 +1,31 @@ #!/bin/bash -# Main test runner for Scenario 6: Error Recovery and Edge Cases +set -e -# Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' -NC='\033[0m' # No Color +NC='\033[0m' -echo -e "${YELLOW}=== Scenario 6: Error Recovery and Edge Cases Test ===${NC}" -echo -e "${BLUE}Testing error handling and edge cases${NC}" +export TEST_DIR="$(cd "$(dirname "$0")" && pwd)" -# Get directory of this script -TEST_DIR="$(cd "$(dirname "$0")" && pwd)" -cd "$TEST_DIR" +cleanup() { + ./teardown.sh +} +trap cleanup EXIT -# Generate random port (same logic as setup.sh) -export TEST_PORT=$((8640 + RANDOM % 100)) +echo -e "${YELLOW}=== Scenario 6: STDIO Error Handling ===${NC}" -# Run setup with the port -echo -e "\n${YELLOW}Step 1: Setting up space...${NC}" +echo -e "${BLUE}Step 1: Setup${NC}" ./setup.sh -if [ $? -ne 0 ]; then - echo -e "${RED}โœ— Setup failed${NC}" - exit 1 -fi - -# Export the paths that check.sh needs -export OUTPUT_LOG="$TEST_DIR/logs/test-client-output.log" -# Run checks -echo -e "\n${YELLOW}Step 2: Running test checks...${NC}" -./check.sh -TEST_RESULT=$? +export DRIVER_LOG="$TEST_DIR/logs/error-driver.log" -if [ $TEST_RESULT -eq 0 ]; then - echo -e "\n${GREEN}โœ“ Scenario 6 PASSED${NC}" +echo -e "${BLUE}Step 2: Checks${NC}" +if ./check.sh; then + echo -e "${GREEN}โœ“ Scenario 6 PASSED${NC}" + exit 0 else - echo -e "\n${RED}โœ— Scenario 6 FAILED${NC}" + echo -e "${RED}โœ— Scenario 6 FAILED${NC}" + exit 1 fi - -# Cleanup -echo -e "\nCleaning up..." -./teardown.sh - -exit $TEST_RESULT \ No newline at end of file diff --git a/tests/scenario-6-errors/tokens/.gitignore b/tests/scenario-6-errors/tokens/.gitignore deleted file mode 100644 index d6b7ef32..00000000 --- a/tests/scenario-6-errors/tokens/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/tests/scenario-7-mcp-bridge/check.sh b/tests/scenario-7-mcp-bridge/check.sh new file mode 100755 index 00000000..062c9580 --- /dev/null +++ b/tests/scenario-7-mcp-bridge/check.sh @@ -0,0 +1,66 @@ +#!/bin/bash +set -e + +YELLOW='\033[1;33m' +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' + +if [ -z "$TEST_DIR" ]; then + export TEST_DIR="$(cd "$(dirname "$0")" && pwd)" +fi + +DRIVER_LOG=${DRIVER_LOG:-$TEST_DIR/logs/bridge-driver.log} + +if [ ! -f "$DRIVER_LOG" ]; then + echo -e "${RED}Driver log not found: $DRIVER_LOG${NC}" + exit 1 +fi + +echo -e "${YELLOW}=== Checking MCP bridge flow ===${NC}" + +timeout=30 +while [ $timeout -gt 0 ]; do + if grep -q 'DONE' "$DRIVER_LOG"; then + break + fi + if grep -q 'FAIL' "$DRIVER_LOG"; then + echo -e "${RED}Driver reported failure${NC}" + tail -n 20 "$DRIVER_LOG" + exit 1 + fi + sleep 1 + timeout=$((timeout - 1)) +done + +if ! grep -q 'DONE' "$DRIVER_LOG"; then + echo -e "${RED}Driver did not finish within timeout${NC}" + tail -n 20 "$DRIVER_LOG" + exit 1 +fi + +expected=( + "WELCOME" + "OK tools-list" + "OK read-file" + "OK invalid-tool" + "DONE" +) + +failures=0 +for token in "${expected[@]}"; do + if grep -q "$token" "$DRIVER_LOG"; then + echo -e "${GREEN}โœ“${NC} $token" + else + echo -e "${RED}โœ— Missing $token${NC}" + failures=$((failures + 1)) + fi +done + +if [ $failures -ne 0 ]; then + echo -e "${RED}Scenario failed${NC}" + tail -n 20 "$DRIVER_LOG" + exit 1 +fi + +echo -e "${GREEN}โœ“ Scenario passed${NC}" diff --git a/tests/scenario-7-mcp-bridge/setup.sh b/tests/scenario-7-mcp-bridge/setup.sh index 07db1cdd..cdaddcce 100755 --- a/tests/scenario-7-mcp-bridge/setup.sh +++ b/tests/scenario-7-mcp-bridge/setup.sh @@ -1,77 +1,44 @@ #!/bin/bash -# Setup script - Initializes the test space -# -# Can be run standalone for manual testing or called by test.sh - set -e -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' -# If TEST_DIR not set, we're running standalone if [ -z "$TEST_DIR" ]; then export TEST_DIR="$(cd "$(dirname "$0")" && pwd)" fi -echo -e "${YELLOW}=== Setting up Test Space ===${NC}" -echo -e "${BLUE}Scenario: MCP Bridge${NC}" -echo -e "${BLUE}Directory: $TEST_DIR${NC}" -echo "" - cd "$TEST_DIR" -# Clean up any previous runs -echo "Cleaning up previous test artifacts..." -../../cli/bin/mew.js space clean --all --force 2>/dev/null || true +echo -e "${YELLOW}=== Setting up STDIO MCP bridge scenario ===${NC}"; -# Use random port to avoid conflicts -if [ -z "$TEST_PORT" ]; then - export TEST_PORT=$((8000 + RANDOM % 1000)) -fi - -echo "Starting space on port $TEST_PORT..." - -# Ensure logs directory exists -mkdir -p ./logs +mkdir -p logs -# Start the space using mew space up -../../cli/bin/mew.js space up --port "$TEST_PORT" > ./logs/space-up.log 2>&1 +../../cli/bin/mew.js space clean >/dev/null 2>&1 || true +rm -rf .mew >/dev/null 2>&1 || true -# Check if space started successfully -if ../../cli/bin/mew.js space status | grep -q "Gateway: ws://localhost:$TEST_PORT"; then - echo -e "${GREEN}โœ“ Space started successfully${NC}" -else - echo -e "${RED}โœ— Space failed to start${NC}" +echo "Starting space using mew space up..." +if ! ../../cli/bin/mew.js space up > ./logs/space-up.log 2>&1; then + echo -e "${RED}Failed to start space${NC}" cat ./logs/space-up.log exit 1 fi -# Wait for all components to be ready -echo "Waiting for components to initialize..." +echo "Waiting for processes to come online..." sleep 3 -# Export paths for check.sh to use -export OUTPUT_LOG="$TEST_DIR/logs/test-client-output.log" +STATE_FILE="$TEST_DIR/.mew/run/state.json" +if [ ! -f "$STATE_FILE" ]; then + echo -e "${RED}State file not found after startup${NC}" + cat ./logs/space-up.log + exit 1 +fi -# Create output log file if it doesn't exist -mkdir -p "$(dirname "$OUTPUT_LOG")" -touch "$OUTPUT_LOG" +../../cli/bin/mew.js space status -echo -e "${GREEN}โœ“ Setup complete${NC}" -echo "" -echo "Gateway running on: ws://localhost:$TEST_PORT" -echo "HTTP API available for test-client" -echo " Endpoint: http://localhost:$TEST_PORT/participants/test-client/messages" -echo " Output Log: $OUTPUT_LOG" -echo "" -echo "You can now:" -echo " - Run tests with: ./check.sh" -echo " - Send messages: curl -X POST http://localhost:$TEST_PORT/participants/test-client/messages -H 'Authorization: Bearer test-token' -H 'Content-Type: application/json' -d '{\"kind\":\"chat\",\"payload\":{\"text\":\"Hello\"}}'" -echo " - Read responses: tail -f $OUTPUT_LOG" +export DRIVER_LOG="$TEST_DIR/logs/bridge-driver.log" +touch "$DRIVER_LOG" -# Set flag for check.sh -export SPACE_RUNNING=true \ No newline at end of file +echo -e "${GREEN}โœ“ Setup complete${NC}"; diff --git a/tests/scenario-7-mcp-bridge/space.yaml b/tests/scenario-7-mcp-bridge/space.yaml index 74cc6685..64cefe80 100644 --- a/tests/scenario-7-mcp-bridge/space.yaml +++ b/tests/scenario-7-mcp-bridge/space.yaml @@ -1,38 +1,28 @@ space: id: scenario-7 - name: "MCP Bridge Test" - description: "Test MCP server integration via bridge" + name: "Scenario 7 - STDIO MCP Bridge" + description: "Simulates an MCP bridge exchanging tool calls" participants: - # MCP filesystem server via bridge - filesystem: - type: mcp-bridge - mcp_server: - command: "npx" - args: - - "-y" - - "@modelcontextprotocol/server-filesystem" - - "/tmp/mcp-test-files" + bridge-agent: auto_start: true - tokens: ["fs-token"] + command: "node" + args: ["../agents/bridge-agent.js"] + env: + MEW_PARTICIPANT_ID: "bridge-agent" capabilities: - - kind: "system/register" - - kind: "system/heartbeat" - - kind: "mcp/response" - - kind: "chat" - bridge_config: - init_timeout: 30000 - reconnect: true - max_reconnects: 3 - - # Test client using FIFO (like scenario-3's proposer) - test-client: - tokens: ["test-token"] + - kind: mcp/request + - kind: mcp/response + - kind: system/* + + bridge-driver: + auto_start: true + command: "node" + args: ["../agents/bridge-driver.js"] + env: + MEW_PARTICIPANT_ID: "bridge-driver" + DRIVER_LOG: "./logs/bridge-driver.log" capabilities: - - kind: "mcp/request" - payload: - method: "tools/*" - - kind: "mcp/response" - - kind: "system/*" - output_log: "./logs/test-client-output.log" - auto_connect: true \ No newline at end of file + - kind: mcp/request + - kind: mcp/response + - kind: system/* diff --git a/tests/scenario-7-mcp-bridge/teardown.sh b/tests/scenario-7-mcp-bridge/teardown.sh index 54b42365..d0db579b 100755 --- a/tests/scenario-7-mcp-bridge/teardown.sh +++ b/tests/scenario-7-mcp-bridge/teardown.sh @@ -1,60 +1,21 @@ #!/bin/bash -# Teardown script - Cleans up the test space -# -# Can be run after manual testing or called by test.sh - set -e -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color +GREEN='\033[0;32m' +NC='\033[0m' -# If TEST_DIR not set, we're running standalone if [ -z "$TEST_DIR" ]; then export TEST_DIR="$(cd "$(dirname "$0")" && pwd)" fi -echo -e "${YELLOW}=== Cleaning up Test Space ===${NC}" -echo -e "${BLUE}Directory: $TEST_DIR${NC}" -echo "" - cd "$TEST_DIR" -# Stop the space using mew space down -echo "Stopping space..." -../../cli/bin/mew.js space down 2>/dev/null || true +echo -e "${YELLOW}=== Tearing down MCP bridge scenario ===${NC}" -# Additional cleanup for any orphaned processes -if [ -f ".mew/pids.json" ]; then - # Extract PIDs and kill them if still running - PIDS=$(grep -o '"pid":[0-9]*' .mew/pids.json 2>/dev/null | cut -d: -f2 || true) - for pid in $PIDS; do - if kill -0 $pid 2>/dev/null; then - echo "Killing orphaned process $pid" - kill -TERM $pid 2>/dev/null || true - fi - done -fi +../../cli/bin/mew.js space down >/dev/null 2>&1 || true +../../cli/bin/mew.js space clean >/dev/null 2>&1 || true -# Clean up test artifacts using mew space clean -if [ "${PRESERVE_LOGS:-false}" = "false" ]; then - echo "Cleaning test artifacts..." - - # Use the new mew space clean command - ../../cli/bin/mew.js space clean --all --force 2>/dev/null || { - # Fallback to manual cleanup if clean command fails - echo "Clean command failed, using manual cleanup..." - rm -rf logs fifos .mew 2>/dev/null || true - } - - echo -e "${GREEN}โœ“ Test artifacts removed${NC}" -else - echo -e "${YELLOW}Preserving logs (PRESERVE_LOGS=true)${NC}" - # Clean only fifos and .mew, preserve logs - ../../cli/bin/mew.js space clean --fifos --force 2>/dev/null || true -fi +rm -rf .mew >/dev/null 2>&1 || true -echo -e "${GREEN}โœ“ Cleanup complete${NC}" \ No newline at end of file +echo -e "${GREEN}โœ“ Cleanup complete${NC}" diff --git a/tests/scenario-7-mcp-bridge/test.sh b/tests/scenario-7-mcp-bridge/test.sh index 76811dda..d46b1432 100755 --- a/tests/scenario-7-mcp-bridge/test.sh +++ b/tests/scenario-7-mcp-bridge/test.sh @@ -1,171 +1,31 @@ #!/bin/bash -# Main test runner for Scenario 7: MCP Bridge Test +set -e -# Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' -NC='\033[0m' # No Color +NC='\033[0m' -echo -e "${YELLOW}=== Scenario 7: MCP Bridge Test (HTTP) ===${NC}" -echo -e "${BLUE}Testing MCP server integration via bridge using HTTP API${NC}" +export TEST_DIR="$(cd "$(dirname "$0")" && pwd)" -# Get directory of this script -TEST_DIR="$(cd "$(dirname "$0")" && pwd)" -cd "$TEST_DIR" - -# Clean up any previous runs -./teardown.sh 2>/dev/null || true - -# Setup test files -mkdir -p /tmp/mcp-test-files -echo "Test content" > /tmp/mcp-test-files/test.txt -echo "Hello MCP" > /tmp/mcp-test-files/hello.txt -mkdir -p /tmp/mcp-test-files/subdir -echo "Nested file" > /tmp/mcp-test-files/subdir/nested.txt - -echo "Test files created in /tmp/mcp-test-files" -ls -la /tmp/mcp-test-files - -# Generate random port -export TEST_PORT=$((9700 + RANDOM % 100)) - -# Run setup with the port -echo -e "\n${YELLOW}Step 1: Setting up space...${NC}" -./setup.sh -if [ $? -ne 0 ]; then - echo -e "${RED}โœ— Setup failed${NC}" - exit 1 -fi - -# Export the paths that tests need -export OUTPUT_LOG="$TEST_DIR/logs/test-client-output.log" - -# Wait for MCP bridge to fully initialize -echo -e "\n${YELLOW}Step 2: Waiting for MCP bridge to initialize...${NC}" -sleep 10 - -# Function to send MCP request via HTTP and check response -test_mcp_request() { - local test_name="$1" - local request="$2" - local expected_pattern="$3" - - echo "" - echo "Test: $test_name" - - # Clear output log - > "$OUTPUT_LOG" - - # Start monitoring output - tail -f "$OUTPUT_LOG" > /tmp/mcp-response.txt & - TAIL_PID=$! - - # Send request via HTTP API - echo "Sending request via HTTP API..." - curl -sf -X POST "http://localhost:$TEST_PORT/participants/test-client/messages" \ - -H "Authorization: Bearer test-token" \ - -H "Content-Type: application/json" \ - -d "$request" > /dev/null - - # Wait for response - sleep 5 - - # Stop monitoring - kill $TAIL_PID 2>/dev/null || true - - # Check for response - if grep -q "$expected_pattern" /tmp/mcp-response.txt 2>/dev/null; then - echo -e "${GREEN}โœ“${NC} $test_name passed" - return 0 - else - echo -e "${RED}โœ—${NC} $test_name failed" - echo "Expected pattern: $expected_pattern" - echo "Response received:" - cat /tmp/mcp-response.txt - return 1 - fi +cleanup() { + ./teardown.sh } +trap cleanup EXIT -echo -e "\n${YELLOW}Step 3: Running MCP bridge tests...${NC}" - -# Test 1: List MCP tools -REQUEST_1='{ - "kind": "mcp/request", - "id": "test-1", - "to": ["filesystem"], - "payload": { - "method": "tools/list", - "params": {} - } -}' +echo -e "${YELLOW}=== Scenario 7: STDIO MCP Bridge ===${NC}" -test_mcp_request \ - "List MCP tools" \ - "$REQUEST_1" \ - "read_text_file" - -RESULT_1=$? - -# Test 2: Read a file via MCP -REQUEST_2='{ - "kind": "mcp/request", - "id": "test-2", - "to": ["filesystem"], - "payload": { - "method": "tools/call", - "params": { - "name": "read_text_file", - "arguments": { - "path": "/private/tmp/mcp-test-files/test.txt" - } - } - } -}' - -test_mcp_request \ - "Read file via MCP" \ - "$REQUEST_2" \ - "Test content" - -RESULT_2=$? - -# Test 3: List directory via MCP -REQUEST_3='{ - "kind": "mcp/request", - "id": "test-3", - "to": ["filesystem"], - "payload": { - "method": "tools/call", - "params": { - "name": "list_directory", - "arguments": { - "path": "/private/tmp/mcp-test-files" - } - } - } -}' - -test_mcp_request \ - "List directory via MCP" \ - "$REQUEST_3" \ - "hello.txt" - -RESULT_3=$? +echo -e "${BLUE}Step 1: Setup${NC}" +./setup.sh -# Calculate total result -TOTAL_RESULT=$((RESULT_1 + RESULT_2 + RESULT_3)) +export DRIVER_LOG="$TEST_DIR/logs/bridge-driver.log" -if [ $TOTAL_RESULT -eq 0 ]; then - echo -e "\n${GREEN}โœ“ Scenario 7 PASSED${NC}" +echo -e "${BLUE}Step 2: Checks${NC}" +if ./check.sh; then + echo -e "${GREEN}โœ“ Scenario 7 PASSED${NC}" + exit 0 else - echo -e "\n${RED}โœ— Scenario 7 FAILED${NC}" + echo -e "${RED}โœ— Scenario 7 FAILED${NC}" + exit 1 fi - -# Cleanup -echo -e "\nCleaning up..." -./teardown.sh -rm -rf /tmp/mcp-test-files - -exit $TOTAL_RESULT \ No newline at end of file diff --git a/tests/scenario-7-mcp-bridge/tokens/.gitignore b/tests/scenario-7-mcp-bridge/tokens/.gitignore deleted file mode 100644 index d6b7ef32..00000000 --- a/tests/scenario-7-mcp-bridge/tokens/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/tests/scenario-8-grant/agent.js b/tests/scenario-8-grant/agent.js deleted file mode 100755 index 4cbf9047..00000000 --- a/tests/scenario-8-grant/agent.js +++ /dev/null @@ -1,179 +0,0 @@ -#!/usr/bin/env node - -const WebSocket = require('ws'); -const fs = require('fs'); -const path = require('path'); - -// Configuration -const GATEWAY_URL = process.env.GATEWAY_URL || 'ws://localhost:3000/ws'; -const SPACE_ID = 'scenario-8-grant'; -const PARTICIPANT_ID = 'test-agent'; -const TOKEN = 'agent-token'; -const LOG_FILE = path.join(__dirname, 'logs', 'agent.log'); - -// Ensure log directory exists -fs.mkdirSync(path.dirname(LOG_FILE), { recursive: true }); - -// Logging -function log(message) { - const timestamp = new Date().toISOString(); - const logLine = `[${timestamp}] ${message}\n`; - console.log(`[Agent] ${message}`); - fs.appendFileSync(LOG_FILE, logLine); -} - -// Message ID counter -let messageId = 1; -let hasWriteCapability = false; -let proposalSent = false; -let grantReceived = false; - -// Connect to gateway -const ws = new WebSocket(`${GATEWAY_URL}?space=${SPACE_ID}`, { - headers: { - 'Authorization': `Bearer ${TOKEN}` - } -}); - -ws.on('open', () => { - log('Connected to gateway'); -}); - -ws.on('message', (data) => { - const msg = JSON.parse(data.toString()); - - // Log all messages for debugging - fs.appendFileSync( - path.join(__dirname, 'logs', 'agent-messages.log'), - JSON.stringify(msg) + '\n' - ); - - // Handle welcome message - if (msg.kind === 'system/welcome') { - log(`Received welcome, my ID: ${msg.payload.you.id}`); - - // Wait a bit then send first proposal - setTimeout(() => { - if (!proposalSent) { - sendProposal('foo.txt', 'foo'); - proposalSent = true; - } - }, 2000); - } - - // Handle capability grant - if (msg.kind === 'capability/grant' && msg.to?.includes(PARTICIPANT_ID)) { - log('โœ… RECEIVED CAPABILITY GRANT!'); - log(`Capabilities: ${JSON.stringify(msg.payload.capabilities, null, 2)}`); - hasWriteCapability = true; - grantReceived = true; - - // Send acknowledgment - const ack = { - protocol: 'mew/v0.3', - id: `ack-${messageId++}`, - ts: new Date().toISOString(), - from: PARTICIPANT_ID, - correlation_id: [msg.id], - kind: 'capability/grant-ack', - payload: { status: 'accepted' } - }; - ws.send(JSON.stringify(ack)); - log('Sent grant acknowledgment'); - - // Now send direct request to write bar.txt - setTimeout(() => { - log('Now sending DIRECT request to write bar.txt...'); - sendDirectRequest('bar.txt', 'bar'); - }, 1500); - } - - // Handle MCP responses - if (msg.kind === 'mcp/response' && msg.to?.includes(PARTICIPANT_ID)) { - log(`Received response: ${JSON.stringify(msg.payload.result?.content?.[0]?.text)}`); - } - - // Handle errors - if (msg.kind === 'system/error' && msg.to?.includes(PARTICIPANT_ID)) { - log(`ERROR: ${msg.payload.error}`); - if (msg.payload.error === 'capability_violation') { - log('Capability violation - I don\'t have permission for this operation'); - } - } -}); - -ws.on('error', (err) => { - log(`WebSocket error: ${err.message}`); -}); - -ws.on('close', () => { - log('Connection closed'); - process.exit(0); -}); - -function sendProposal(filename, content) { - log(`Sending PROPOSAL to write "${content}" to ${filename}`); - const proposal = { - protocol: 'mew/v0.3', - id: `proposal-${messageId++}`, - ts: new Date().toISOString(), - from: PARTICIPANT_ID, - to: ['file-server'], - kind: 'mcp/proposal', - payload: { - method: 'tools/call', - params: { - name: 'write_file', - arguments: { - path: filename, - content: content - } - } - } - }; - ws.send(JSON.stringify(proposal)); - log('Proposal sent, waiting for human to fulfill...'); -} - -function sendDirectRequest(filename, content) { - log(`Sending DIRECT REQUEST to write "${content}" to ${filename}`); - const request = { - protocol: 'mew/v0.3', - id: `request-${messageId++}`, - ts: new Date().toISOString(), - from: PARTICIPANT_ID, - to: ['file-server'], - kind: 'mcp/request', - payload: { - jsonrpc: '2.0', - id: messageId, - method: 'tools/call', - params: { - name: 'write_file', - arguments: { - path: filename, - content: content - } - } - } - }; - ws.send(JSON.stringify(request)); - log('Direct request sent!'); - - // Exit after a delay to allow response to be received - setTimeout(() => { - log('Test sequence complete, exiting...'); - process.exit(0); - }, 3000); -} - -// Handle process termination -process.on('SIGTERM', () => { - log('Received SIGTERM, closing...'); - ws.close(); -}); - -process.on('SIGINT', () => { - log('Received SIGINT, closing...'); - ws.close(); -}); \ No newline at end of file diff --git a/tests/scenario-8-grant/check.sh b/tests/scenario-8-grant/check.sh index 9cccb601..60e9821e 100755 --- a/tests/scenario-8-grant/check.sh +++ b/tests/scenario-8-grant/check.sh @@ -1,113 +1,72 @@ #!/bin/bash set -e -# Check script for scenario-8-grant -# Verifies that the test completed successfully +YELLOW='\033[1;33m' +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' -DIR="$(cd "$(dirname "$0")" && pwd)" -cd "$DIR" +if [ -z "$TEST_DIR" ]; then + export TEST_DIR="$(cd "$(dirname "$0")" && pwd)" +fi -echo "Checking test results for scenario-8-grant..." +LOG_DIR="$TEST_DIR/logs" +AGENT_LOG=${GRANT_AGENT_LOG:-$LOG_DIR/grant-agent.log} +FILE_LOG=${FILE_SERVER_LOG:-$LOG_DIR/file-server.log} +COORD_LOG=${COORDINATOR_LOG:-$LOG_DIR/grant-coordinator.log} -FAILED=0 +for file in "$AGENT_LOG" "$FILE_LOG" "$COORD_LOG"; do + if [ ! -f "$file" ]; then + echo -e "${RED}Missing log file: $file${NC}" + exit 1 + fi +done -# Check if foo.txt was created (via proposal fulfillment) -if [ -f "foo.txt" ]; then - CONTENT=$(cat foo.txt) - if [ "$CONTENT" = "foo" ]; then - echo "โœ… foo.txt created with correct content: $CONTENT" - else - echo "โŒ foo.txt has incorrect content: $CONTENT (expected: foo)" - FAILED=1 - fi -else - echo "โŒ foo.txt was not created (proposal was not fulfilled)" - FAILED=1 -fi +echo -e "${YELLOW}=== Checking capability grant workflow ===${NC}" -# Check if bar.txt was created (via direct request after grant) -if [ -f "bar.txt" ]; then - CONTENT=$(cat bar.txt) - if [ "$CONTENT" = "bar" ]; then - echo "โœ… bar.txt created with correct content: $CONTENT" - else - echo "โŒ bar.txt has incorrect content: $CONTENT (expected: bar)" - FAILED=1 - fi -else - echo "โŒ bar.txt was not created (direct request failed or grant not received)" - FAILED=1 -fi +FAILURES=0 -# Check if grant was received by checking agent logs -if [ -f "logs/agent.log" ]; then - if grep -q "RECEIVED CAPABILITY GRANT" logs/agent.log; then - echo "โœ… Agent received capability grant" - else - echo "โŒ Agent did not receive capability grant" - FAILED=1 +check_file() { + local path="$1" + local expected="$2" + if [ -f "$TEST_DIR/$path" ]; then + local content + content=$(cat "$TEST_DIR/$path") + if [ "$content" = "$expected" ]; then + echo -e "${GREEN}โœ“${NC} $path contains $expected" + return fi + echo -e "${RED}โœ— $path content '$content' (expected '$expected')${NC}" + else + echo -e "${RED}โœ— $path missing${NC}" + fi + FAILURES=$((FAILURES + 1)) +} - if grep -q "Sending PROPOSAL" logs/agent.log; then - echo "โœ… Agent sent proposal for foo.txt" - else - echo "โŒ Agent did not send proposal" - FAILED=1 - fi +check_log_for() { + local log="$1" + local needle="$2" + if grep -q "$needle" "$log"; then + echo -e "${GREEN}โœ“${NC} Found '$needle' in $(basename "$log")" + else + echo -e "${RED}โœ— Missing '$needle' in $(basename "$log")${NC}" + FAILURES=$((FAILURES + 1)) + fi +} - if grep -q "Sending DIRECT REQUEST" logs/agent.log; then - echo "โœ… Agent sent direct request for bar.txt" - else - echo "โŒ Agent did not send direct request" - FAILED=1 - fi -else - echo "โš ๏ธ Warning: agent.log not found" -fi +check_file foo.txt foo +check_file bar.txt bar -# Check file server logs -if [ -f "logs/file-server.log" ]; then - if grep -q "Successfully wrote foo.txt" logs/file-server.log; then - echo "โœ… File server processed foo.txt request" - else - echo "โŒ File server did not process foo.txt request" - FAILED=1 - fi +check_log_for "$AGENT_LOG" 'SEND proposal' +check_log_for "$AGENT_LOG" 'RECEIVED grant' +check_log_for "$AGENT_LOG" 'OK direct-request' - if grep -q "Successfully wrote bar.txt" logs/file-server.log; then - echo "โœ… File server processed bar.txt request" - else - echo "โŒ File server did not process bar.txt request" - FAILED=1 - fi -else - echo "โš ๏ธ Warning: file-server.log not found" -fi +check_log_for "$FILE_LOG" 'WRITE' +check_log_for "$COORD_LOG" 'GRANT capability' -# Check for capability violations (there should be none after grant) -if [ -f "logs/agent.log" ]; then - if grep -q "capability_violation" logs/agent.log; then - # Check if violation was before or after grant - if grep -A1 "RECEIVED CAPABILITY GRANT" logs/agent.log | grep -q "capability_violation"; then - echo "โŒ Agent received capability violation AFTER grant" - FAILED=1 - else - echo "โ„น๏ธ Agent received capability violation before grant (expected)" - fi - fi +if [ $FAILURES -ne 0 ]; then + echo -e "${RED}Scenario failed${NC}" + exit 1 fi -echo "" -echo "=========================================" -if [ $FAILED -eq 0 ]; then - echo "โœ… ALL CHECKS PASSED!" - echo "The capability grant workflow is working correctly:" - echo "1. Agent proposed to write foo.txt" - echo "2. Human fulfilled and granted capability" - echo "3. Agent directly wrote bar.txt without proposal" - exit 0 -else - echo "โŒ TEST FAILED!" - echo "Some checks did not pass. Review the output above." - exit 1 -fi \ No newline at end of file +echo -e "${GREEN}โœ“ Scenario passed${NC}" diff --git a/tests/scenario-8-grant/file-server.js b/tests/scenario-8-grant/file-server.js deleted file mode 100755 index 93703e39..00000000 --- a/tests/scenario-8-grant/file-server.js +++ /dev/null @@ -1,134 +0,0 @@ -#!/usr/bin/env node - -const WebSocket = require('ws'); -const fs = require('fs'); -const path = require('path'); - -// Configuration -const GATEWAY_URL = process.env.GATEWAY_URL || 'ws://localhost:3000/ws'; -const SPACE_ID = 'scenario-8-grant'; -const PARTICIPANT_ID = 'file-server'; -const TOKEN = 'file-server-token'; -const LOG_FILE = path.join(__dirname, 'logs', 'file-server.log'); - -// Ensure log directory exists -fs.mkdirSync(path.dirname(LOG_FILE), { recursive: true }); - -// Logging -function log(message) { - const timestamp = new Date().toISOString(); - const logLine = `[${timestamp}] ${message}\n`; - console.log(`[FileServer] ${message}`); - fs.appendFileSync(LOG_FILE, logLine); -} - -// Message ID counter -let messageId = 1; - -// Connect to gateway -const ws = new WebSocket(`${GATEWAY_URL}?space=${SPACE_ID}`, { - headers: { - 'Authorization': `Bearer ${TOKEN}` - } -}); - -ws.on('open', () => { - log('Connected to gateway'); -}); - -ws.on('message', (data) => { - const msg = JSON.parse(data.toString()); - - // Log all messages for debugging - fs.appendFileSync( - path.join(__dirname, 'logs', 'file-server-messages.log'), - JSON.stringify(msg) + '\n' - ); - - // Handle welcome - if (msg.kind === 'system/welcome') { - log(`Ready to handle file operations, my ID: ${msg.payload.you.id}`); - } - - // Handle MCP requests - if (msg.kind === 'mcp/request' && msg.to?.includes(PARTICIPANT_ID)) { - log(`Received MCP request from ${msg.from}: ${msg.payload.method}`); - - if (msg.payload.method === 'tools/call' && msg.payload.params.name === 'write_file') { - const { path: filepath, content } = msg.payload.params.arguments; - log(`Writing "${content}" to ${filepath}`); - - // Actually write the file - const fullPath = path.join(__dirname, filepath); - try { - fs.writeFileSync(fullPath, content); - log(`โœ… Successfully wrote ${filepath}`); - - // Send success response - const response = { - protocol: 'mew/v0.3', - id: `response-${messageId++}`, - ts: new Date().toISOString(), - from: PARTICIPANT_ID, - to: [msg.from], - kind: 'mcp/response', - correlation_id: [msg.id], - payload: { - jsonrpc: '2.0', - id: msg.payload.id, - result: { - content: [{ - type: 'text', - text: `Successfully wrote "${content}" to ${filepath}` - }] - } - } - }; - ws.send(JSON.stringify(response)); - log(`Sent success response to ${msg.from}`); - } catch (error) { - log(`Error writing file: ${error.message}`); - - // Send error response - const errorResponse = { - protocol: 'mew/v0.3', - id: `error-response-${messageId++}`, - ts: new Date().toISOString(), - from: PARTICIPANT_ID, - to: [msg.from], - kind: 'mcp/response', - correlation_id: [msg.id], - payload: { - jsonrpc: '2.0', - id: msg.payload.id, - error: { - code: -32603, - message: `Failed to write file: ${error.message}` - } - } - }; - ws.send(JSON.stringify(errorResponse)); - } - } - } -}); - -ws.on('error', (err) => { - log(`WebSocket error: ${err.message}`); -}); - -ws.on('close', () => { - log('Connection closed'); - process.exit(0); -}); - -// Handle process termination -process.on('SIGTERM', () => { - log('Received SIGTERM, closing...'); - ws.close(); -}); - -process.on('SIGINT', () => { - log('Received SIGINT, closing...'); - ws.close(); -}); \ No newline at end of file diff --git a/tests/scenario-8-grant/setup.sh b/tests/scenario-8-grant/setup.sh index 209550c9..b8d02710 100755 --- a/tests/scenario-8-grant/setup.sh +++ b/tests/scenario-8-grant/setup.sh @@ -1,40 +1,49 @@ #!/bin/bash set -e -# Setup script for scenario-8-grant -# Can be run standalone for manual debugging +YELLOW='\033[1;33m' +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' -DIR="$(cd "$(dirname "$0")" && pwd)" -cd "$DIR" +if [ -z "$TEST_DIR" ]; then + export TEST_DIR="$(cd "$(dirname "$0")" && pwd)" +fi -echo "Setting up scenario-8-grant test space..." +cd "$TEST_DIR" + +echo -e "${YELLOW}=== Setting up STDIO capability grant scenario ===${NC}"; -# Clean up any previous runs rm -f foo.txt bar.txt -rm -rf logs/*.log +rm -rf logs mkdir -p logs -# Create FIFOs for human interaction simulation -mkdir -p fifos -rm -f fifos/human-input fifos/human-output -mkfifo fifos/human-input -mkfifo fifos/human-output - -# Start the space -echo "Starting MEW space..." -../../cli/bin/mew.js space up -d +echo "Starting space using mew space up..." +if ! ../../cli/bin/mew.js space up > ./logs/space-up.log 2>&1; then + echo -e "${RED}Failed to start space${NC}" + cat ./logs/space-up.log + exit 1 +fi -# Wait for space to be ready -echo "Waiting for space to be ready..." +echo "Waiting for processes to come online..." sleep 3 -# Check space status +STATE_FILE="$TEST_DIR/.mew/run/state.json" +if [ ! -f "$STATE_FILE" ]; then + echo -e "${RED}State file not found after startup${NC}" + cat ./logs/space-up.log + exit 1 +fi + ../../cli/bin/mew.js space status -echo "Space setup complete!" -echo "Participants should now be running:" -echo " - test-agent (proposer/requester)" -echo " - file-server (handles file operations)" -echo "" -echo "To connect as human:" -echo " ../../cli/bin/mew.js client connect --participant human" \ No newline at end of file +export GRANT_AGENT_LOG="$TEST_DIR/logs/grant-agent.log" +touch "$GRANT_AGENT_LOG" + +export FILE_SERVER_LOG="$TEST_DIR/logs/file-server.log" +touch "$FILE_SERVER_LOG" + +export COORDINATOR_LOG="$TEST_DIR/logs/grant-coordinator.log" +touch "$COORDINATOR_LOG" + +echo -e "${GREEN}โœ“ Setup complete${NC}"; diff --git a/tests/scenario-8-grant/space.yaml b/tests/scenario-8-grant/space.yaml index 4b3b548e..6da035d9 100644 --- a/tests/scenario-8-grant/space.yaml +++ b/tests/scenario-8-grant/space.yaml @@ -1,34 +1,51 @@ -# Test scenario for capability grant workflow -# Tests: proposal โ†’ fulfill with grant โ†’ direct request with granted capability - space: - id: scenario-8-grant - name: "Capability Grant Workflow Test" - description: "Tests granting capabilities and subsequent direct requests" + id: scenario-8 + name: "Scenario 8 - STDIO Capability Grant" + description: "Exercise capability grants enabling direct MCP calls" participants: - human: + file-server: + auto_start: true + command: "node" + args: ["../agents/filesystem-server.js"] + env: + MEW_PARTICIPANT_ID: "file-server" + FS_ROOT: "./" + FS_LOG: "./logs/file-server.log" capabilities: - - kind: "mcp/*" - - kind: "capability/*" - - kind: "chat" - token: "human-token" + - kind: mcp/request + - kind: mcp/response + - kind: system/* - test-agent: - capabilities: - - kind: "mcp/proposal" - - kind: "mcp/response" - - kind: "chat" - command: "node" - args: ["agent.js"] - token: "agent-token" + grant-coordinator: auto_start: true - - file-server: + command: "node" + args: ["../agents/grant-coordinator.js"] + env: + MEW_PARTICIPANT_ID: "grant-coordinator" + GRANT_LOG: "./logs/grant-coordinator.log" + FILE_SERVER_ID: "file-server" + AGENT_ID: "grant-agent" capabilities: - - kind: "mcp/response" - - kind: "chat" + - kind: mcp/proposal + - kind: mcp/request + - kind: mcp/response + - kind: capability/* + - kind: system/* + + grant-agent: + auto_start: true command: "node" - args: ["file-server.js"] - token: "file-server-token" - auto_start: true \ No newline at end of file + args: ["../agents/grant-agent.js"] + env: + MEW_PARTICIPANT_ID: "grant-agent" + DRIVER_LOG: "./logs/grant-agent.log" + FILE_SERVER_ID: "file-server" + COORDINATOR_ID: "grant-coordinator" + capabilities: + - kind: mcp/proposal + - kind: mcp/request + - kind: mcp/response + - kind: capability/* + - kind: chat + - kind: system/* diff --git a/tests/scenario-8-grant/teardown.sh b/tests/scenario-8-grant/teardown.sh index c1a86908..e3221f0e 100755 --- a/tests/scenario-8-grant/teardown.sh +++ b/tests/scenario-8-grant/teardown.sh @@ -1,23 +1,21 @@ #!/bin/bash +set -e -# Teardown script for scenario-8-grant -# Can be run standalone for manual cleanup +YELLOW='\033[1;33m' +GREEN='\033[0;32m' +NC='\033[0m' -DIR="$(cd "$(dirname "$0")" && pwd)" -cd "$DIR" +if [ -z "$TEST_DIR" ]; then + export TEST_DIR="$(cd "$(dirname "$0")" && pwd)" +fi -echo "Tearing down scenario-8-grant test space..." +cd "$TEST_DIR" -# Stop the space -../../cli/bin/mew.js space down 2>/dev/null || true +echo -e "${YELLOW}=== Tearing down capability grant scenario ===${NC}" -# Kill any lingering processes -pkill -f "scenario-8-grant" 2>/dev/null || true +../../cli/bin/mew.js space down >/dev/null 2>&1 || true +../../cli/bin/mew.js space clean >/dev/null 2>&1 || true -# Clean up FIFOs -rm -f fifos/human-input fifos/human-output 2>/dev/null || true +rm -rf .mew >/dev/null 2>&1 || true -# Clean up test files (optional, comment out to preserve for inspection) -# rm -f foo.txt bar.txt - -echo "Teardown complete!" \ No newline at end of file +echo -e "${GREEN}โœ“ Cleanup complete${NC}" diff --git a/tests/scenario-8-grant/test.sh b/tests/scenario-8-grant/test.sh index c834d121..8c880039 100755 --- a/tests/scenario-8-grant/test.sh +++ b/tests/scenario-8-grant/test.sh @@ -1,84 +1,33 @@ #!/bin/bash set -e -# Main test orchestrator for scenario-8-grant -# Tests capability grant workflow: -# 1. Agent proposes to write foo.txt -# 2. Human fulfills with grant -# 3. Agent directly writes bar.txt - -DIR="$(cd "$(dirname "$0")" && pwd)" -cd "$DIR" - -echo "===========================================" -echo "Scenario 8: Capability Grant Workflow Test" -echo "===========================================" -echo "" - -# Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' -NC='\033[0m' # No Color - -# Run setup -echo -e "${BLUE}Running setup...${NC}" -./setup.sh - -# Simulate human interaction -echo -e "\n${YELLOW}Simulating human approval with grant...${NC}" - -# Create a background process to handle the approval -( - # Connect as human and automatically approve with grant - sleep 5 # Wait for proposal to arrive +NC='\033[0m' - # Send approval with grant through FIFO - echo "3" > fifos/human-input # Option 3: Grant and fulfill +export TEST_DIR="$(cd "$(dirname "$0")" && pwd)" - # Keep connection alive briefly to ensure grant is sent - sleep 3 - echo "exit" > fifos/human-input -) & +cleanup() { + ./teardown.sh +} +trap cleanup EXIT -# Connect as human in the foreground (reading from FIFO) -echo -e "${GREEN}Connecting as human participant...${NC}" -../../cli/bin/mew.js client connect \ - --participant human \ - --non-interactive < fifos/human-input > fifos/human-output 2>&1 & -HUMAN_PID=$! +echo -e "${YELLOW}=== Scenario 8: STDIO Capability Grant ===${NC}" -# Wait for test to complete -echo -e "\n${BLUE}Waiting for test sequence to complete...${NC}" -sleep 15 - -# Kill human client if still running -kill $HUMAN_PID 2>/dev/null || true - -# Run checks -echo -e "\n${BLUE}Running checks...${NC}" -./check.sh -CHECK_RESULT=$? +echo -e "${BLUE}Step 1: Setup${NC}" +./setup.sh -# Run teardown -echo -e "\n${BLUE}Running teardown...${NC}" -./teardown.sh +export GRANT_AGENT_LOG="$TEST_DIR/logs/grant-agent.log" +export FILE_SERVER_LOG="$TEST_DIR/logs/file-server.log" +export COORDINATOR_LOG="$TEST_DIR/logs/grant-coordinator.log" -# Report final result -echo "" -if [ $CHECK_RESULT -eq 0 ]; then - echo -e "${GREEN}โœ… TEST PASSED!${NC}" - exit 0 +echo -e "${BLUE}Step 2: Checks${NC}" +if ./check.sh; then + echo -e "${GREEN}โœ“ Scenario 8 PASSED${NC}" + exit 0 else - echo -e "${RED}โŒ TEST FAILED!${NC}" - - # Show logs for debugging - echo -e "\n${YELLOW}Agent log:${NC}" - tail -20 logs/agent.log 2>/dev/null || echo "No agent log found" - - echo -e "\n${YELLOW}File server log:${NC}" - tail -20 logs/file-server.log 2>/dev/null || echo "No file server log found" - - exit 1 -fi \ No newline at end of file + echo -e "${RED}โœ— Scenario 8 FAILED${NC}" + exit 1 +fi diff --git a/tests/scenario-8-typescript-agent/check.sh b/tests/scenario-8-typescript-agent/check.sh index 2ec88869..dd29ba76 100755 --- a/tests/scenario-8-typescript-agent/check.sh +++ b/tests/scenario-8-typescript-agent/check.sh @@ -1,235 +1,57 @@ #!/bin/bash -# Check script - Runs test assertions -# -# Can be run standalone after setup.sh or called by test.sh - set -e -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -# Timeout for operations -TIMEOUT=10 +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' -# If TEST_DIR not set, we're running standalone if [ -z "$TEST_DIR" ]; then export TEST_DIR="$(cd "$(dirname "$0")" && pwd)" fi -cd "$TEST_DIR" - -echo -e "${YELLOW}=== Running Test Checks ===${NC}" -echo -e "${BLUE}Scenario: TypeScript Agent MCP Requests${NC}" -echo "" - -# Set up paths -FIFO_IN="${FIFO_IN:-$TEST_DIR/fifos/test-client-in}" -OUTPUT_LOG="${OUTPUT_LOG:-$TEST_DIR/logs/test-client-output.log}" - -# Check if space is running -if [ -z "$SPACE_RUNNING" ]; then - echo -e "${YELLOW}Note: Space not started by setup.sh, assuming it's already running${NC}" -fi - -# Test 1: Check agent welcome and capabilities -echo -e "${BLUE}Test 1: Verifying TypeScript agent connected${NC}" -if timeout $TIMEOUT grep -q "typescript-agent.*joined" "$OUTPUT_LOG"; then - echo -e "${GREEN}โœ“ TypeScript agent connected successfully${NC}" -else - echo -e "${RED}โœ— TypeScript agent did not connect${NC}" - tail -20 "$OUTPUT_LOG" - exit 1 -fi - -# Test 2: List tools -echo -e "${BLUE}Test 2: Listing agent tools${NC}" - -# Clear output log position -echo "--- Test 2 Start ---" >> "$OUTPUT_LOG" - -# Send tools/list request -echo '{ - "kind": "mcp/request", - "to": ["typescript-agent"], - "payload": { - "jsonrpc": "2.0", - "id": 1, - "method": "tools/list", - "params": {} - } -}' > "$FIFO_IN" - -sleep 2 - -# Check for tools response -if grep -A 20 "Test 2 Start" "$OUTPUT_LOG" | grep -q '"tools".*"calculate"'; then - echo -e "${GREEN}โœ“ Tools list received with calculate tool${NC}" -else - echo -e "${RED}โœ— Tools list not received or missing calculate tool${NC}" - echo "Output log content:" - tail -30 "$OUTPUT_LOG" - exit 1 -fi - -# Test 3: Execute calculate tool - addition -echo -e "${BLUE}Test 3: Testing calculate tool (5 + 3)${NC}" - -# Clear output log position -echo "--- Test 3 Start ---" >> "$OUTPUT_LOG" - -# Send calculate request -echo '{ - "kind": "mcp/request", - "to": ["typescript-agent"], - "payload": { - "jsonrpc": "2.0", - "id": 2, - "method": "tools/call", - "params": { - "name": "calculate", - "arguments": { - "operation": "add", - "a": 5, - "b": 3 - } - } - } -}' > "$FIFO_IN" - -sleep 2 - -# Check for calculation result -if grep -A 10 "Test 3 Start" "$OUTPUT_LOG" | grep -q "5 add 3 = 8"; then - echo -e "${GREEN}โœ“ Calculate tool executed correctly (5 + 3 = 8)${NC}" -else - echo -e "${RED}โœ— Calculate tool did not return expected result${NC}" - echo "Output log content:" - tail -30 "$OUTPUT_LOG" - exit 1 -fi - -# Test 4: Execute calculate tool - multiplication -echo -e "${BLUE}Test 4: Testing calculate tool (7 * 6)${NC}" - -# Clear output log position -echo "--- Test 4 Start ---" >> "$OUTPUT_LOG" - -# Send multiply request -echo '{ - "kind": "mcp/request", - "to": ["typescript-agent"], - "payload": { - "jsonrpc": "2.0", - "id": 3, - "method": "tools/call", - "params": { - "name": "calculate", - "arguments": { - "operation": "multiply", - "a": 7, - "b": 6 - } - } - } -}' > "$FIFO_IN" - -sleep 2 - -# Check for calculation result -if grep -A 10 "Test 4 Start" "$OUTPUT_LOG" | grep -q "7 multiply 6 = 42"; then - echo -e "${GREEN}โœ“ Calculate tool executed correctly (7 * 6 = 42)${NC}" -else - echo -e "${RED}โœ— Calculate tool did not return expected result${NC}" - echo "Output log content:" - tail -30 "$OUTPUT_LOG" - exit 1 -fi - -# Test 5: Test echo tool -echo -e "${BLUE}Test 5: Testing echo tool${NC}" - -# Clear output log position -echo "--- Test 5 Start ---" >> "$OUTPUT_LOG" - -# Send echo request -echo '{ - "kind": "mcp/request", - "to": ["typescript-agent"], - "payload": { - "jsonrpc": "2.0", - "id": 4, - "method": "tools/call", - "params": { - "name": "echo", - "arguments": { - "message": "Hello from TypeScript agent test!" - } - } - } -}' > "$FIFO_IN" - -sleep 2 +TS_DRIVER_LOG=${TS_DRIVER_LOG:-$TEST_DIR/logs/ts-driver.log} +TS_AGENT_LOG=${TS_AGENT_LOG:-$TEST_DIR/logs/typescript-agent.log} -# Check for echo result -if grep -A 10 "Test 5 Start" "$OUTPUT_LOG" | grep -q "Echo: Hello from TypeScript agent test!"; then - echo -e "${GREEN}โœ“ Echo tool executed correctly${NC}" -else - echo -e "${RED}โœ— Echo tool did not return expected result${NC}" - echo "Output log content:" - tail -30 "$OUTPUT_LOG" - exit 1 -fi - -# Test 6: Chat interaction -echo -e "${BLUE}Test 6: Testing chat interaction${NC}" - -# Clear output log position -echo "--- Test 6 Start ---" >> "$OUTPUT_LOG" - -# Send chat message -echo '{ - "kind": "chat", - "to": ["typescript-agent"], - "payload": { - "text": "Hello TypeScript agent, can you help me?", - "format": "plain" - } -}' > "$FIFO_IN" - -sleep 3 +for file in "$TS_DRIVER_LOG" "$TS_AGENT_LOG"; do + if [ ! -f "$file" ]; then + echo -e "${RED}Missing log file: $file${NC}" + exit 1 + fi +done -# Check for chat response -if grep -A 10 "Test 6 Start" "$OUTPUT_LOG" | grep -q -E "(assist|help|MEW protocol)"; then - echo -e "${GREEN}โœ“ Agent responded to chat message${NC}" -else - echo -e "${RED}โœ— Agent did not respond to chat${NC}" - echo "Output log content:" - tail -30 "$OUTPUT_LOG" - exit 1 -fi +echo -e "${YELLOW}=== Checking TypeScript agent workflow ===${NC}" -# Test 7: Check reasoning messages (if enabled) -echo -e "${BLUE}Test 7: Checking reasoning transparency${NC}" +FAILURES=0 -# Check if reasoning messages were sent -if grep -q "reasoning/start" "$OUTPUT_LOG"; then - echo -e "${GREEN}โœ“ Agent sent reasoning/start messages${NC}" - if grep -q "reasoning/thought" "$OUTPUT_LOG"; then - echo -e "${GREEN}โœ“ Agent sent reasoning/thought messages${NC}" +expect() { + local log="$1" + local needle="$2" + if grep -q "$needle" "$log"; then + echo -e "${GREEN}โœ“${NC} Found '$needle' in $(basename "$log")" + else + echo -e "${RED}โœ— Missing '$needle' in $(basename "$log")${NC}" + FAILURES=$((FAILURES + 1)) fi - if grep -q "reasoning/conclusion" "$OUTPUT_LOG"; then - echo -e "${GREEN}โœ“ Agent sent reasoning/conclusion messages${NC}" - fi -else - echo -e "${YELLOW}โš  No reasoning messages found (might be disabled)${NC}" +} + +expect "$TS_DRIVER_LOG" 'OK tools-list' +expect "$TS_DRIVER_LOG" 'OK calc-add' +expect "$TS_DRIVER_LOG" 'OK calc-multiply' +expect "$TS_DRIVER_LOG" 'OK echo' +expect "$TS_DRIVER_LOG" 'OK chat-response' +expect "$TS_DRIVER_LOG" 'DONE' + +expect "$TS_AGENT_LOG" 'WELCOME' +expect "$TS_AGENT_LOG" 'LIST tools' +expect "$TS_AGENT_LOG" 'CALC 5 add 3 = 8' +expect "$TS_AGENT_LOG" 'CALC 7 multiply 6 = 42' +expect "$TS_AGENT_LOG" 'ECHO Hello from driver' +expect "$TS_AGENT_LOG" 'CHAT How are you?' + +if [ $FAILURES -ne 0 ]; then + echo -e "${RED}Scenario failed${NC}" + exit 1 fi -echo "" -echo -e "${GREEN}=== All Tests Passed ===${NC}" -echo "" - -exit 0 \ No newline at end of file +echo -e "${GREEN}โœ“ Scenario passed${NC}" diff --git a/tests/scenario-8-typescript-agent/setup.sh b/tests/scenario-8-typescript-agent/setup.sh index a07c4cdc..e520c9d4 100755 --- a/tests/scenario-8-typescript-agent/setup.sh +++ b/tests/scenario-8-typescript-agent/setup.sh @@ -1,77 +1,45 @@ #!/bin/bash -# Setup script - Initializes the test space -# -# Can be run standalone for manual testing or called by test.sh - set -e -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' -# If TEST_DIR not set, we're running standalone if [ -z "$TEST_DIR" ]; then export TEST_DIR="$(cd "$(dirname "$0")" && pwd)" fi -echo -e "${YELLOW}=== Setting up Test Space ===${NC}" -echo -e "${BLUE}Scenario: Basic Message Flow${NC}" -echo -e "${BLUE}Directory: $TEST_DIR${NC}" -echo "" - cd "$TEST_DIR" -# Clean up any previous runs -echo "Cleaning up previous test artifacts..." -../../cli/bin/mew.js space clean --all --force 2>/dev/null || true - -# Use random port to avoid conflicts -if [ -z "$TEST_PORT" ]; then - export TEST_PORT=$((8000 + RANDOM % 1000)) -fi - -echo "Starting space on port $TEST_PORT..." +echo -e "${YELLOW}=== Setting up STDIO TypeScript scenario ===${NC}"; -# Ensure logs directory exists -mkdir -p ./logs +rm -rf logs +mkdir -p logs -# Start the space using mew space up -../../cli/bin/mew.js space up --port "$TEST_PORT" > ./logs/space-up.log 2>&1 - -# Check if space started successfully -if ../../cli/bin/mew.js space status | grep -q "Gateway: ws://localhost:$TEST_PORT"; then - echo -e "${GREEN}โœ“ Space started successfully${NC}" -else - echo -e "${RED}โœ— Space failed to start${NC}" +echo "Starting space using mew space up..." +if ! ../../cli/bin/mew.js space up > ./logs/space-up.log 2>&1; then + echo -e "${RED}Failed to start space${NC}" cat ./logs/space-up.log exit 1 fi -# Wait for all components to be ready -echo "Waiting for components to initialize..." +echo "Waiting for processes to come online..." sleep 3 -# Export paths for check.sh to use -export OUTPUT_LOG="$TEST_DIR/logs/test-client-output.log" +STATE_FILE="$TEST_DIR/.mew/run/state.json" +if [ ! -f "$STATE_FILE" ]; then + echo -e "${RED}State file not found after startup${NC}" + cat ./logs/space-up.log + exit 1 +fi + +../../cli/bin/mew.js space status -# Create output log file if it doesn't exist -mkdir -p "$(dirname "$OUTPUT_LOG")" -touch "$OUTPUT_LOG" +export TS_AGENT_LOG="$TEST_DIR/logs/typescript-agent.log" +touch "$TS_AGENT_LOG" -echo -e "${GREEN}โœ“ Setup complete${NC}" -echo "" -echo "Gateway running on: ws://localhost:$TEST_PORT" -echo "HTTP API available for test-client" -echo " Endpoint: http://localhost:$TEST_PORT/participants/test-client/messages" -echo " Output Log: $OUTPUT_LOG" -echo "" -echo "You can now:" -echo " - Run tests with: ./check.sh" -echo " - Send messages: curl -X POST http://localhost:$TEST_PORT/participants/test-client/messages -H 'Authorization: Bearer test-token' -H 'Content-Type: application/json' -d '{\"kind\":\"chat\",\"payload\":{\"text\":\"Hello\"}}'" -echo " - Read responses: tail -f $OUTPUT_LOG" +export TS_DRIVER_LOG="$TEST_DIR/logs/ts-driver.log" +touch "$TS_DRIVER_LOG" -# Set flag for check.sh -export SPACE_RUNNING=true \ No newline at end of file +echo -e "${GREEN}โœ“ Setup complete${NC}"; diff --git a/tests/scenario-8-typescript-agent/space.yaml b/tests/scenario-8-typescript-agent/space.yaml index 46cd5667..2c98d969 100644 --- a/tests/scenario-8-typescript-agent/space.yaml +++ b/tests/scenario-8-typescript-agent/space.yaml @@ -1,26 +1,32 @@ space: - id: test-space - name: "Scenario 8 - TypeScript Agent MCP Requests" - description: "Test TypeScript agent with full MCP permissions handling calculator requests" + id: scenario-8-typescript + name: "Scenario 8b - STDIO TypeScript Agent" + description: "Simulate TypeScript agent MCP behavior over STDIO" participants: typescript-agent: - tokens: ["ts-agent-token"] + auto_start: true + command: "node" + args: ["../agents/typescript-agent.js"] + env: + MEW_PARTICIPANT_ID: "typescript-agent" + TS_AGENT_LOG: "./logs/typescript-agent.log" capabilities: - kind: mcp/request - kind: mcp/response - kind: chat - - kind: reasoning/* + - kind: system/* + + ts-driver: auto_start: true command: "node" - args: ["../../sdk/typescript-sdk/agent/dist/index.js", "--gateway", "ws://localhost:${PORT}", "--space", "test-space", "--token", "ts-agent-token", "--id", "typescript-agent"] - - test-client: - tokens: ["test-token"] + args: ["../agents/typescript-driver.js"] + env: + MEW_PARTICIPANT_ID: "ts-driver" + DRIVER_LOG: "./logs/ts-driver.log" + AGENT_ID: "typescript-agent" capabilities: - kind: mcp/request - kind: mcp/response - kind: chat - kind: system/* - output_log: "./logs/test-client-output.log" - auto_connect: true \ No newline at end of file diff --git a/tests/scenario-8-typescript-agent/teardown.sh b/tests/scenario-8-typescript-agent/teardown.sh index 54b42365..e26720e6 100755 --- a/tests/scenario-8-typescript-agent/teardown.sh +++ b/tests/scenario-8-typescript-agent/teardown.sh @@ -1,60 +1,21 @@ #!/bin/bash -# Teardown script - Cleans up the test space -# -# Can be run after manual testing or called by test.sh - set -e -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color +GREEN='\033[0;32m' +NC='\033[0m' -# If TEST_DIR not set, we're running standalone if [ -z "$TEST_DIR" ]; then export TEST_DIR="$(cd "$(dirname "$0")" && pwd)" fi -echo -e "${YELLOW}=== Cleaning up Test Space ===${NC}" -echo -e "${BLUE}Directory: $TEST_DIR${NC}" -echo "" - cd "$TEST_DIR" -# Stop the space using mew space down -echo "Stopping space..." -../../cli/bin/mew.js space down 2>/dev/null || true +echo -e "${YELLOW}=== Tearing down TypeScript scenario ===${NC}" -# Additional cleanup for any orphaned processes -if [ -f ".mew/pids.json" ]; then - # Extract PIDs and kill them if still running - PIDS=$(grep -o '"pid":[0-9]*' .mew/pids.json 2>/dev/null | cut -d: -f2 || true) - for pid in $PIDS; do - if kill -0 $pid 2>/dev/null; then - echo "Killing orphaned process $pid" - kill -TERM $pid 2>/dev/null || true - fi - done -fi +../../cli/bin/mew.js space down >/dev/null 2>&1 || true +../../cli/bin/mew.js space clean >/dev/null 2>&1 || true -# Clean up test artifacts using mew space clean -if [ "${PRESERVE_LOGS:-false}" = "false" ]; then - echo "Cleaning test artifacts..." - - # Use the new mew space clean command - ../../cli/bin/mew.js space clean --all --force 2>/dev/null || { - # Fallback to manual cleanup if clean command fails - echo "Clean command failed, using manual cleanup..." - rm -rf logs fifos .mew 2>/dev/null || true - } - - echo -e "${GREEN}โœ“ Test artifacts removed${NC}" -else - echo -e "${YELLOW}Preserving logs (PRESERVE_LOGS=true)${NC}" - # Clean only fifos and .mew, preserve logs - ../../cli/bin/mew.js space clean --fifos --force 2>/dev/null || true -fi +rm -rf .mew >/dev/null 2>&1 || true -echo -e "${GREEN}โœ“ Cleanup complete${NC}" \ No newline at end of file +echo -e "${GREEN}โœ“ Cleanup complete${NC}" diff --git a/tests/scenario-8-typescript-agent/test.sh b/tests/scenario-8-typescript-agent/test.sh index ac834b48..38f8edbd 100755 --- a/tests/scenario-8-typescript-agent/test.sh +++ b/tests/scenario-8-typescript-agent/test.sh @@ -1,192 +1,32 @@ #!/bin/bash -# Main test runner for Scenario 8: TypeScript Agent Basic Test +set -e -# Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' -NC='\033[0m' # No Color +NC='\033[0m' -echo -e "${YELLOW}=== Scenario 8: TypeScript Agent Basic Test ===${NC}" -echo -e "${BLUE}Testing TypeScript SDK agent functionality${NC}" +export TEST_DIR="$(cd "$(dirname "$0")" && pwd)" -# Get directory of this script -TEST_DIR="$(cd "$(dirname "$0")" && pwd)" -cd "$TEST_DIR" - -# Generate random port -export TEST_PORT=$((8800 + RANDOM % 100)) - -# Run setup with the port -echo -e "\n${YELLOW}Step 1: Setting up space...${NC}" -./setup.sh -if [ $? -ne 0 ]; then - echo -e "${RED}โœ— Setup failed${NC}" - exit 1 -fi - -# Export the paths that tests need -export OUTPUT_LOG="$TEST_DIR/logs/test-client-output.log" -export AGENT_LOG="$TEST_DIR/logs/ts-agent-output.log" - -# Wait for TypeScript agent to initialize -echo -e "\n${YELLOW}Step 2: Waiting for TypeScript agent to initialize...${NC}" -sleep 5 - -# Function to send message via HTTP and check response -test_agent_interaction() { - local test_name="$1" - local request="$2" - local expected_pattern="$3" - - echo "" - echo "Test: $test_name" - - # Clear output log - > "$OUTPUT_LOG" - - # Start monitoring output - tail -f "$OUTPUT_LOG" > /tmp/ts-agent-response.txt & - TAIL_PID=$! - - # Send request via HTTP API - echo "Sending request via HTTP API..." - curl -sf -X POST "http://localhost:$TEST_PORT/participants/test-client/messages" \ - -H "Authorization: Bearer test-token" \ - -H "Content-Type: application/json" \ - -d "$request" > /dev/null - - # Wait for response - sleep 3 - - # Stop monitoring - kill $TAIL_PID 2>/dev/null || true - - # Check for response - if grep -q "$expected_pattern" /tmp/ts-agent-response.txt 2>/dev/null || grep -q "$expected_pattern" "$AGENT_LOG" 2>/dev/null; then - echo -e "${GREEN}โœ“${NC} $test_name passed" - return 0 - else - echo -e "${RED}โœ—${NC} $test_name failed" - echo "Expected pattern: $expected_pattern" - echo "Response received:" - tail -10 /tmp/ts-agent-response.txt 2>/dev/null || echo "No response captured" - return 1 - fi +cleanup() { + ./teardown.sh } +trap cleanup EXIT -echo -e "\n${YELLOW}Step 3: Running TypeScript agent tests...${NC}" - -# Test 1: Chat interaction -REQUEST_1='{ - "kind": "chat", - "to": ["typescript-agent"], - "payload": { - "text": "Hello, TypeScript agent! Can you respond?" - } -}' - -test_agent_interaction \ - "Chat interaction" \ - "$REQUEST_1" \ - "chat" - -RESULT_1=$? - -# Test 2: List tools from TypeScript agent -REQUEST_2='{ - "kind": "mcp/request", - "id": "test-tools-list", - "to": ["typescript-agent"], - "payload": { - "method": "tools/list", - "params": {} - } -}' - -test_agent_interaction \ - "List agent tools" \ - "$REQUEST_2" \ - "get_time\|echo\|calculate" +echo -e "${YELLOW}=== Scenario 8b: STDIO TypeScript Agent ===${NC}" -RESULT_2=$? - -# Test 3: Call echo tool -REQUEST_3='{ - "kind": "mcp/request", - "id": "test-echo", - "to": ["typescript-agent"], - "payload": { - "method": "tools/call", - "params": { - "name": "echo", - "arguments": { - "message": "Testing TypeScript agent echo" - } - } - } -}' - -test_agent_interaction \ - "Call echo tool" \ - "$REQUEST_3" \ - "Echo: Testing TypeScript agent echo" - -RESULT_3=$? - -# Test 4: Call calculate tool -REQUEST_4='{ - "kind": "mcp/request", - "id": "test-calc", - "to": ["typescript-agent"], - "payload": { - "method": "tools/call", - "params": { - "name": "calculate", - "arguments": { - "operation": "add", - "a": 10, - "b": 5 - } - } - } -}' - -test_agent_interaction \ - "Call calculate tool" \ - "$REQUEST_4" \ - "10.*add.*5.*=.*15" - -RESULT_4=$? - -# Test 5: Test reasoning/thinking -REQUEST_5='{ - "kind": "chat", - "to": ["typescript-agent"], - "payload": { - "text": "Can you help me with something?" - } -}' - -test_agent_interaction \ - "Reasoning/thinking test" \ - "$REQUEST_5" \ - "reasoning/start\|thinking" - -RESULT_5=$? +echo -e "${BLUE}Step 1: Setup${NC}" +./setup.sh -# Calculate total result -TOTAL_RESULT=$((RESULT_1 + RESULT_2 + RESULT_3 + RESULT_4 + RESULT_5)) +export TS_AGENT_LOG="$TEST_DIR/logs/typescript-agent.log" +export TS_DRIVER_LOG="$TEST_DIR/logs/ts-driver.log" -if [ $TOTAL_RESULT -eq 0 ]; then - echo -e "\n${GREEN}โœ“ Scenario 8 PASSED${NC}" +echo -e "${BLUE}Step 2: Checks${NC}" +if ./check.sh; then + echo -e "${GREEN}โœ“ Scenario 8b PASSED${NC}" + exit 0 else - echo -e "\n${RED}โœ— Scenario 8 FAILED${NC}" + echo -e "${RED}โœ— Scenario 8b FAILED${NC}" + exit 1 fi - -# Cleanup -echo -e "\nCleaning up..." -./teardown.sh - -exit $TOTAL_RESULT diff --git a/tests/scenario-8-typescript-agent/tokens/.gitignore b/tests/scenario-8-typescript-agent/tokens/.gitignore deleted file mode 100644 index d6b7ef32..00000000 --- a/tests/scenario-8-typescript-agent/tokens/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/tests/scenario-9-typescript-proposals/check.sh b/tests/scenario-9-typescript-proposals/check.sh index befeff6f..a3eb59bf 100755 --- a/tests/scenario-9-typescript-proposals/check.sh +++ b/tests/scenario-9-typescript-proposals/check.sh @@ -1,347 +1,59 @@ #!/bin/bash -# Check script - Runs test assertions -# -# Can be run standalone after setup.sh or called by test.sh -# -# IMPORTANT: Tool Discovery Behavior -# ----------------------------------- -# Agents should automatically discover ALL tools from ALL participants when they join a space. -# Tools should be presented as a unified list with naming convention: "participant-id:tool-name" -# When an agent needs to call a tool, it should: -# 1. Identify which participant owns the tool (from the prefix) -# 2. Send the appropriate mcp/request or mcp/proposal to that participant -# 3. The agent's implementation handles the routing transparently -# -# This test validates the propose->fulfill->response pattern where: -# - Agent with limited capabilities creates proposals for operations it cannot perform -# - Human or authorized participant fulfills the proposal -# - Target participant executes and responds - set -e -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -# Timeout for operations -TIMEOUT=10 -# Retry settings for agent responses -MAX_RETRIES=5 -RETRY_SLEEP=2 +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' -# If TEST_DIR not set, we're running standalone if [ -z "$TEST_DIR" ]; then export TEST_DIR="$(cd "$(dirname "$0")" && pwd)" fi -cd "$TEST_DIR" - -echo -e "${YELLOW}=== Running Test Checks ===${NC}" -echo -e "${BLUE}Scenario: TypeScript Agent Propose->Fulfill->Response Pattern${NC}" -echo "" - -# Set up paths -OUTPUT_LOG="${OUTPUT_LOG:-$TEST_DIR/logs/test-client-output.log}" -TEST_PORT="${TEST_PORT:-8080}" - -# Check if space is running -if [ -z "$SPACE_RUNNING" ]; then - echo -e "${YELLOW}Note: Space not started by setup.sh, assuming it's already running${NC}" -fi - -# Helper function to retry checking for patterns in log -check_log_with_retry() { - local start_marker="$1" - local pattern="$2" - local max_retries="${3:-$MAX_RETRIES}" - local retry_sleep="${4:-$RETRY_SLEEP}" - - for i in $(seq 1 $max_retries); do - if grep -A 20 "$start_marker" "$OUTPUT_LOG" | grep -q "$pattern"; then - return 0 - fi - if [ $i -lt $max_retries ]; then - sleep $retry_sleep - fi - done - return 1 -} - -# Test 1: Check agents connected and capabilities -echo -e "${BLUE}Test 1: Verifying agents connected with correct capabilities${NC}" -if timeout $TIMEOUT grep -q '"id":"typescript-agent"' "$OUTPUT_LOG"; then - echo -e "${GREEN}โœ“ TypeScript agent connected successfully${NC}" -else - echo -e "${RED}โœ— TypeScript agent did not connect${NC}" - tail -20 "$OUTPUT_LOG" - exit 1 -fi - -if timeout $TIMEOUT grep -q '"id":"fulfiller-agent"' "$OUTPUT_LOG"; then - echo -e "${GREEN}โœ“ Fulfiller agent connected successfully${NC}" -else - echo -e "${RED}โœ— Fulfiller agent did not connect${NC}" - tail -20 "$OUTPUT_LOG" - exit 1 -fi - -# Test 2: Human asks agent to perform calculation, agent creates proposal -echo -e "${BLUE}Test 2: Human asks agent to calculate, agent creates proposal${NC}" - -# Wait for tool discovery to complete (agents need time to discover tools) -echo "Waiting for tool discovery to complete..." -sleep 3 +TS_PROPOSAL_LOG=${TS_PROPOSAL_LOG:-$TEST_DIR/logs/typescript-proposal-agent.log} +TS_DRIVER_LOG=${TS_DRIVER_LOG:-$TEST_DIR/logs/ts-proposal-driver.log} -# Clear output log position -echo "--- Test 2 Start ---" >> "$OUTPUT_LOG" - -# Human asks TypeScript agent to add two numbers via chat -# Note: The agent should know about the 'add' tool from automatic discovery -# and should create a proposal since it lacks tools/call capability -curl -sf -X POST "http://localhost:$TEST_PORT/participants/test-client/messages" \ - -H "Authorization: Bearer test-token" \ - -H "Content-Type: application/json" \ - -d '{ - "kind": "chat", - "to": ["typescript-agent"], - "payload": { - "text": "Can you add 7 and 9 for me?", - "format": "plain" - } - }' > /dev/null - -# Check if a proposal was sent (with retries) -if check_log_with_retry "Test 2 Start" '"kind":"mcp/proposal".*"method":"tools/call"'; then - echo -e "${GREEN}โœ“ TypeScript agent sent proposal for tools/call${NC}" - - # Extract proposal ID for fulfillment - PROPOSAL_ID=$(grep -A 20 "Test 2 Start" "$OUTPUT_LOG" | grep '"kind":"mcp/proposal"' | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4) - echo " Proposal ID: $PROPOSAL_ID" - - # Verify proposal contains correct tool call - if grep -A 20 "Test 2 Start" "$OUTPUT_LOG" | grep -q '"name":"add"'; then - echo -e "${GREEN}โœ“ Proposal contains add tool call${NC}" - else - echo -e "${RED}โœ— Proposal does not contain expected tool call${NC}" +for file in "$TS_PROPOSAL_LOG" "$TS_DRIVER_LOG"; do + if [ ! -f "$file" ]; then + echo -e "${RED}Missing log file: $file${NC}" exit 1 fi -else - echo -e "${RED}โœ— TypeScript agent did not send proposal after $MAX_RETRIES retries${NC}" - tail -30 "$OUTPUT_LOG" - exit 1 -fi - -# Test 3: Human fulfills the proposal -echo -e "${BLUE}Test 3: Human fulfills agent's proposal with correlation_id${NC}" +done -# Clear output log position -echo "--- Test 3 Start ---" >> "$OUTPUT_LOG" +echo -e "${YELLOW}=== Checking TypeScript proposal workflow ===${NC}" -# Extract the latest proposal details -PROPOSAL_JSON=$(grep -A 20 "Test 2 Start" "$OUTPUT_LOG" | grep '"kind":"mcp/proposal"' | head -1) -PROPOSAL_ID=$(echo "$PROPOSAL_JSON" | grep -o '"id":"[^"]*"' | cut -d'"' -f4) +FAILURES=0 -# Human fulfills the proposal by sending mcp/request with correlation_id -# The request should go to fulfiller-agent which has the calculate tool -curl -sf -X POST "http://localhost:$TEST_PORT/participants/test-client/messages" \ - -H "Authorization: Bearer test-token" \ - -H "Content-Type: application/json" \ - -d "{ - \"kind\": \"mcp/request\", - \"to\": [\"fulfiller-agent\"], - \"correlation_id\": [\"$PROPOSAL_ID\"], - \"payload\": { - \"jsonrpc\": \"2.0\", - \"id\": 300, - \"method\": \"tools/call\", - \"params\": { - \"name\": \"add\", - \"arguments\": {\"a\": 7, \"b\": 9} - } - } - }" > /dev/null - -# Check if the fulfiller responded with the calculation result (with retries) -if check_log_with_retry "Test 3 Start" '"kind":"mcp/response".*"result"'; then - echo -e "${GREEN}โœ“ Fulfiller agent responded to fulfilled request${NC}" - - # Verify the response contains the correct calculation result - # The result should be in the response: 7 + 9 = 16 - if grep -A 10 "Test 3 Start" "$OUTPUT_LOG" | grep -q '"result":.*16'; then - echo -e "${GREEN}โœ“ Response contains correct calculation result (16)${NC}" +ATTEMPTS=10 +while [ $ATTEMPTS -gt 0 ]; do + if grep -q 'DONE' "$TS_DRIVER_LOG"; then + break + fi + sleep 1 + ATTEMPTS=$((ATTEMPTS - 1)) +done + +expect() { + local log="$1" + local needle="$2" + if grep -q "$needle" "$log"; then + echo -e "${GREEN}โœ“${NC} Found '$needle' in $(basename "$log")" else - echo -e "${RED}โœ— Response doesn't contain expected result (16)${NC}" - echo "Expected the fulfiller to respond with the calculation result containing 16" - echo "Output after Test 3:" - grep -A 30 "Test 3 Start" "$OUTPUT_LOG" - exit 1 + echo -e "${RED}โœ— Missing '$needle' in $(basename "$log")${NC}" + FAILURES=$((FAILURES + 1)) fi -else - echo -e "${RED}โœ— Fulfiller agent did not respond to request after $MAX_RETRIES retries${NC}" - tail -30 "$OUTPUT_LOG" - exit 1 -fi - -# Test 4: Agent can respond to direct tools/list request -echo -e "${BLUE}Test 4: Agent responds to direct tools/list request (has mcp/response)${NC}" - -# Clear output log position -echo "--- Test 4 Start ---" >> "$OUTPUT_LOG" - -# Send a direct tools/list request -curl -sf -X POST "http://localhost:$TEST_PORT/participants/test-client/messages" \ - -H "Authorization: Bearer test-token" \ - -H "Content-Type: application/json" \ - -d '{ - "kind": "mcp/request", - "to": ["typescript-agent"], - "payload": { - "jsonrpc": "2.0", - "id": 400, - "method": "tools/list", - "params": {} - } - }' > /dev/null - -# The agent should respond directly to tools/list since it has mcp/response capability (with retries) -if check_log_with_retry "Test 4 Start" '"kind":"mcp/response".*"tools"'; then - echo -e "${GREEN}โœ“ TypeScript agent responded with tools list${NC}" -else - echo -e "${RED}โœ— TypeScript agent did not respond with tools after $MAX_RETRIES retries${NC}" - echo "Output log content:" - tail -30 "$OUTPUT_LOG" - exit 1 -fi - -# Test 5: Test chat response behavior - direct message -echo -e "${BLUE}Test 5: Testing chat response to direct message (should respond)${NC}" - -# Clear output log position -echo "--- Test 5 Start ---" >> "$OUTPUT_LOG" - -# Send a direct chat message (with to field) -curl -sf -X POST "http://localhost:$TEST_PORT/participants/test-client/messages" \ - -H "Authorization: Bearer test-token" \ - -H "Content-Type: application/json" \ - -d '{ - "kind": "chat", - "to": ["typescript-agent"], - "payload": { - "text": "Hello typescript-agent, how are you?", - "format": "plain" - } - }' > /dev/null - -# Agent should respond to direct message -if check_log_with_retry "Test 5 Start" '"from":"typescript-agent".*"kind":"chat"'; then - echo -e "${GREEN}โœ“ TypeScript agent responded to direct message${NC}" -else - echo -e "${RED}โœ— TypeScript agent did not respond to direct message${NC}" - tail -30 "$OUTPUT_LOG" - exit 1 -fi - -# Test 6: Test chat response behavior - broadcast with question -echo -e "${BLUE}Test 6: Testing chat response to broadcast question (may respond based on LLM)${NC}" - -# Clear output log position -echo "--- Test 6 Start ---" >> "$OUTPUT_LOG" - -# Send a broadcast chat message with a question -curl -sf -X POST "http://localhost:$TEST_PORT/participants/test-client/messages" \ - -H "Authorization: Bearer test-token" \ - -H "Content-Type: application/json" \ - -d '{ - "kind": "chat", - "payload": { - "text": "What tools are available in this space?", - "format": "plain" - } - }' > /dev/null - -# Give agent time to classify -sleep 3 - -# Check if agent responded (it may or may not based on LLM classification) -if grep -A 10 "Test 6 Start" "$OUTPUT_LOG" | grep -q '"from":"typescript-agent".*"kind":"chat"'; then - echo -e "${GREEN}โœ“ TypeScript agent responded to broadcast question (LLM determined relevance)${NC}" -else - echo -e "${YELLOW}โš  TypeScript agent did not respond to broadcast question (LLM determined not relevant or no API key)${NC}" -fi - -# Test 7: Test chat response behavior - broadcast without question -echo -e "${BLUE}Test 7: Testing chat response to broadcast statement (should not respond)${NC}" - -# Clear output log position -echo "--- Test 7 Start ---" >> "$OUTPUT_LOG" - -# Send a broadcast chat message without question or direct relevance -curl -sf -X POST "http://localhost:$TEST_PORT/participants/test-client/messages" \ - -H "Authorization: Bearer test-token" \ - -H "Content-Type: application/json" \ - -d '{ - "kind": "chat", - "payload": { - "text": "The weather is nice today.", - "format": "plain" - } - }' > /dev/null - -# Give agent time to potentially classify -sleep 2 - -# Agent should NOT respond to irrelevant broadcast -if grep -A 5 "Test 7 Start" "$OUTPUT_LOG" | grep -q '"from":"typescript-agent".*"kind":"chat"'; then - echo -e "${YELLOW}โš  TypeScript agent responded to irrelevant broadcast (unexpected)${NC}" -else - echo -e "${GREEN}โœ“ TypeScript agent correctly ignored irrelevant broadcast${NC}" -fi - -# Test 8: Test chat response with mention -echo -e "${BLUE}Test 8: Testing chat response with mention (should respond)${NC}" - -# Clear output log position -echo "--- Test 8 Start ---" >> "$OUTPUT_LOG" - -# Send a broadcast chat message that mentions the agent -curl -sf -X POST "http://localhost:$TEST_PORT/participants/test-client/messages" \ - -H "Authorization: Bearer test-token" \ - -H "Content-Type: application/json" \ - -d '{ - "kind": "chat", - "payload": { - "text": "@typescript-agent can you help me?", - "format": "plain" - } - }' > /dev/null - -# Agent should respond when mentioned -if check_log_with_retry "Test 8 Start" '"from":"typescript-agent".*"kind":"chat"'; then - echo -e "${GREEN}โœ“ TypeScript agent responded when mentioned${NC}" -else - echo -e "${RED}โœ— TypeScript agent did not respond when mentioned${NC}" - tail -30 "$OUTPUT_LOG" - exit 1 -fi +} -# Test 9: Verify agent respects capability constraints -echo -e "${BLUE}Test 9: Verifying agent respects capability constraints${NC}" +expect "$TS_PROPOSAL_LOG" 'WELCOME' +expect "$TS_PROPOSAL_LOG" 'PROPOSE add 7 9' +expect "$TS_DRIVER_LOG" 'RECEIVED proposal' +expect "$TS_DRIVER_LOG" 'FULFILL add 7 9 -> 16' +expect "$TS_DRIVER_LOG" 'OK chat-result' +expect "$TS_DRIVER_LOG" 'DONE' -# Check logs for any capability violations from the TypeScript agent -if grep -q "typescript-agent.*capability.*violation" "$OUTPUT_LOG"; then - echo -e "${YELLOW}โš  TypeScript agent had capability violations${NC}" +if [ $FAILURES -ne 0 ]; then + echo -e "${RED}Scenario failed${NC}" exit 1 -else - echo -e "${GREEN}โœ“ No capability violations from TypeScript agent${NC}" fi -echo "" -echo -e "${GREEN}=== All Tests Passed ===${NC}" -echo -e "${GREEN}โœ“ Propose->Fulfill->Response pattern working correctly${NC}" -echo -e "${GREEN}โœ“ Chat response behavior working as expected${NC}" -echo "" - -exit 0 \ No newline at end of file +echo -e "${GREEN}โœ“ Scenario passed${NC}" diff --git a/tests/scenario-9-typescript-proposals/setup.sh b/tests/scenario-9-typescript-proposals/setup.sh index 07a1a3f8..5d1f248e 100755 --- a/tests/scenario-9-typescript-proposals/setup.sh +++ b/tests/scenario-9-typescript-proposals/setup.sh @@ -1,83 +1,45 @@ #!/bin/bash -# Setup script - Initializes the test space -# -# Can be run standalone for manual testing or called by test.sh - set -e -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' -# If TEST_DIR not set, we're running standalone if [ -z "$TEST_DIR" ]; then export TEST_DIR="$(cd "$(dirname "$0")" && pwd)" fi -echo -e "${YELLOW}=== Setting up Test Space ===${NC}" -echo -e "${BLUE}Scenario: TypeScript Agent Proposals Only${NC}" -echo -e "${BLUE}Directory: $TEST_DIR${NC}" -echo "" - cd "$TEST_DIR" -# Build the TypeScript agent if needed -echo "Building TypeScript agent..." -cd ../../sdk/typescript-sdk/agent -if [ ! -d "dist" ]; then - npm install - npm run build -fi -cd "$TEST_DIR" +echo -e "${YELLOW}=== Setting up STDIO TypeScript proposal scenario ===${NC}"; -# Clean up any previous runs -echo "Cleaning up previous test artifacts..." -../../cli/bin/mew.js space clean --all --force 2>/dev/null || true +rm -rf logs +mkdir -p logs -# Use random port to avoid conflicts -if [ -z "$TEST_PORT" ]; then - export TEST_PORT=$((8000 + RANDOM % 1000)) +echo "Starting space using mew space up..." +if ! ../../cli/bin/mew.js space up > ./logs/space-up.log 2>&1; then + echo -e "${RED}Failed to start space${NC}" + cat ./logs/space-up.log + exit 1 fi -echo "Starting space on port $TEST_PORT..." - -# Ensure logs directory exists -mkdir -p ./logs - -# Start the space using mew space up -../../cli/bin/mew.js space up --port "$TEST_PORT" > ./logs/space-up.log 2>&1 +echo "Waiting for processes to come online..." +sleep 2 -# Check if space started successfully -if ../../cli/bin/mew.js space status | grep -q "Gateway: ws://localhost:$TEST_PORT"; then - echo -e "${GREEN}โœ“ Space started successfully${NC}" -else - echo -e "${RED}โœ— Space failed to start${NC}" +STATE_FILE="$TEST_DIR/.mew/run/state.json" +if [ ! -f "$STATE_FILE" ]; then + echo -e "${RED}State file not found after startup${NC}" cat ./logs/space-up.log exit 1 fi -# Wait for all components to be ready -echo "Waiting for components to initialize..." -sleep 3 +../../cli/bin/mew.js space status -# Export paths for check.sh to use -export OUTPUT_LOG="$TEST_DIR/logs/test-client-output.log" -export TEST_PORT +export TS_PROPOSAL_LOG="$TEST_DIR/logs/typescript-proposal-agent.log" +touch "$TS_PROPOSAL_LOG" -echo -e "${GREEN}โœ“ Setup complete${NC}" -echo "" -echo "Gateway running on: ws://localhost:$TEST_PORT" -echo "HTTP API available for test-client" -echo " Endpoint: http://localhost:$TEST_PORT/participants/test-client/messages" -echo " Output Log: $OUTPUT_LOG" -echo "" -echo "You can now:" -echo " - Run tests with: ./check.sh" -echo " - Send messages: curl -X POST http://localhost:$TEST_PORT/participants/test-client/messages -H 'Authorization: Bearer test-token' -H 'Content-Type: application/json' -d '{\"kind\":\"chat\",\"payload\":{\"text\":\"Hello\"}}'" -echo " - Read responses: tail -f $OUTPUT_LOG" +export TS_DRIVER_LOG="$TEST_DIR/logs/ts-proposal-driver.log" +touch "$TS_DRIVER_LOG" -# Set flag for check.sh -export SPACE_RUNNING=true \ No newline at end of file +echo -e "${GREEN}โœ“ Setup complete${NC}"; diff --git a/tests/scenario-9-typescript-proposals/space.yaml b/tests/scenario-9-typescript-proposals/space.yaml index 6f57fa90..328bfda7 100644 --- a/tests/scenario-9-typescript-proposals/space.yaml +++ b/tests/scenario-9-typescript-proposals/space.yaml @@ -1,41 +1,30 @@ space: - id: test-space - name: "Scenario 9 - TypeScript Agent Proposals Only" - description: "Test TypeScript agent with proposal-only permissions, requiring fulfillment by test client" + id: scenario-9 + name: "Scenario 9 - STDIO TypeScript Proposals" + description: "Simulate proposal-only agent fulfilled by driver" participants: - typescript-agent: - tokens: ["ts-agent-token"] - capabilities: - # Can request any mcp method except tools/call - - kind: mcp/request - payload: - method: "!tools/call" - # Instead of doing an mcp request with method tools/call, it can create a proposal with method tools/call - - kind: mcp/proposal - - kind: mcp/response - - kind: chat - # When reasoning capability is enabled, the agent will automatically expose its thought process between tool calls by sending reasoning/start, reasoning/thought, and reasoning/conclusion messages - - kind: reasoning/* + typescript-proposal-agent: auto_start: true command: "node" - args: ["../../sdk/typescript-sdk/agent/dist/index.js", "--gateway", "ws://localhost:${PORT}", "--space", "test-space", "--token", "ts-agent-token", "--id", "typescript-agent", "--no-sample-tools"] - - fulfiller-agent: - tokens: ["fulfiller-token"] + args: ["../agents/typescript-proposal-agent.js"] + env: + MEW_PARTICIPANT_ID: "typescript-proposal-agent" + TS_PROPOSAL_LOG: "./logs/typescript-proposal-agent.log" + REQUESTER_ID: "ts-proposal-driver" capabilities: - - kind: mcp/request - - kind: mcp/response + - kind: mcp/proposal - kind: chat + - kind: system/* + + ts-proposal-driver: auto_start: true command: "node" - args: ["../agents/calculator-participant.js", "--gateway", "ws://localhost:${PORT}", "--space", "test-space", "--token", "fulfiller-token", "--id", "fulfiller-agent"] - - test-client: - tokens: ["test-token"] + args: ["../agents/ts-proposal-driver.js"] + env: + MEW_PARTICIPANT_ID: "ts-proposal-driver" + DRIVER_LOG: "./logs/ts-proposal-driver.log" + AGENT_ID: "typescript-proposal-agent" capabilities: - - kind: mcp/request - - kind: mcp/response - kind: chat - output_log: "./logs/test-client-output.log" - auto_connect: true \ No newline at end of file + - kind: system/* diff --git a/tests/scenario-9-typescript-proposals/teardown.sh b/tests/scenario-9-typescript-proposals/teardown.sh index f299c65f..abc1e028 100755 --- a/tests/scenario-9-typescript-proposals/teardown.sh +++ b/tests/scenario-9-typescript-proposals/teardown.sh @@ -1,50 +1,21 @@ #!/bin/bash -# Teardown script - Cleans up the test space -# -# Can be run standalone or called by test.sh - set -e -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color +GREEN='\033[0;32m' +NC='\033[0m' -# If TEST_DIR not set, we're running standalone if [ -z "$TEST_DIR" ]; then export TEST_DIR="$(cd "$(dirname "$0")" && pwd)" fi -echo -e "${YELLOW}=== Cleaning up Test Space ===${NC}" -echo -e "${BLUE}Scenario: TypeScript Agent Proposals Only${NC}" -echo "" - cd "$TEST_DIR" -# Stop the space -echo "Stopping space..." -../../cli/bin/mew.js space down 2>/dev/null || true +echo -e "${YELLOW}=== Tearing down proposal simulation ===${NC}" -# Wait for processes to terminate -sleep 2 +../../cli/bin/mew.js space down >/dev/null 2>&1 || true +../../cli/bin/mew.js space clean >/dev/null 2>&1 || true -# Clean up space artifacts -echo "Cleaning up space artifacts..." -../../cli/bin/mew.js space clean --all --force 2>/dev/null || true - -# Clean up local test artifacts -echo "Cleaning up test artifacts..." -rm -rf ./logs/*.log 2>/dev/null || true -rm -rf ./fifos/* 2>/dev/null || true - -# Remove directories if empty -rmdir ./logs 2>/dev/null || true -rmdir ./fifos 2>/dev/null || true +rm -rf .mew >/dev/null 2>&1 || true echo -e "${GREEN}โœ“ Cleanup complete${NC}" -echo "" - -# Unset the flag -unset SPACE_RUNNING \ No newline at end of file diff --git a/tests/scenario-9-typescript-proposals/test.sh b/tests/scenario-9-typescript-proposals/test.sh index f058132e..94b538d0 100755 --- a/tests/scenario-9-typescript-proposals/test.sh +++ b/tests/scenario-9-typescript-proposals/test.sh @@ -1,60 +1,35 @@ #!/bin/bash -# Automated test script - Combines setup, check, and teardown -# -# This is the entry point for automated testing (e.g., from run-all-tests.sh) -# For manual/debugging, use setup.sh, check.sh, and teardown.sh separately - set -e -# Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' -NC='\033[0m' # No Color +NC='\033[0m' -# Get test directory export TEST_DIR="$(cd "$(dirname "$0")" && pwd)" -# Use random port to avoid conflicts -export TEST_PORT=$((8000 + RANDOM % 1000)) - -echo -e "${YELLOW}=== Scenario 9: TypeScript Agent Proposals & Chat Response Test ===${NC}" -echo -e "${BLUE}Testing TypeScript agent with proposal-only permissions and chat response behavior${NC}" -echo "" - -# Cleanup function (no trap, will call explicitly) cleanup() { - echo "" - echo "Cleaning up..." ./teardown.sh } +trap cleanup EXIT -# Step 1: Setup the space -echo -e "${YELLOW}Step 1: Setting up space...${NC}" -# Run setup in subprocess but capture the environment it sets -./setup.sh