From 622d031f7d9c4663539c5f7cedb76950fd288d92 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Tue, 27 Jan 2026 20:29:30 +0100 Subject: [PATCH 1/9] feat(ci): add post-publish verification for npm package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds automated verification that tests the published agent-relay package across Node.js 18, 20, and 22 using: - Global npm install - npx execution - Local project install Includes Docker-based isolation and GitHub Actions workflow that runs automatically after successful publish. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .github/workflows/publish.yml | 15 +- .github/workflows/verify-publish.yml | 223 +++++++++++++++++ scripts/post-publish-verify/Dockerfile | 37 +++ scripts/post-publish-verify/README.md | 80 +++++++ .../post-publish-verify/docker-compose.yml | 57 +++++ scripts/post-publish-verify/run-verify.sh | 127 ++++++++++ scripts/post-publish-verify/verify-install.sh | 226 ++++++++++++++++++ 7 files changed, 764 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/verify-publish.yml create mode 100644 scripts/post-publish-verify/Dockerfile create mode 100644 scripts/post-publish-verify/README.md create mode 100644 scripts/post-publish-verify/docker-compose.yml create mode 100755 scripts/post-publish-verify/run-verify.sh create mode 100755 scripts/post-publish-verify/verify-install.sh diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 40c524cbb..dd5922dd5 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -446,9 +446,21 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Trigger post-publish verification + verify-publish: + name: Verify Published Package + needs: [build, publish-main] + if: | + always() && + github.event.inputs.dry_run != 'true' && + needs.publish-main.result == 'success' + uses: ./.github/workflows/verify-publish.yml + with: + version: ${{ needs.build.outputs.new_version }} + summary: name: Summary - needs: [build, publish-packages, publish-main] + needs: [build, publish-packages, publish-main, verify-publish] runs-on: ubuntu-latest if: always() @@ -464,3 +476,4 @@ jobs: echo "### Results" >> $GITHUB_STEP_SUMMARY echo "- Packages: ${{ needs.publish-packages.result }}" >> $GITHUB_STEP_SUMMARY echo "- Main: ${{ needs.publish-main.result }}" >> $GITHUB_STEP_SUMMARY + echo "- Verification: ${{ needs.verify-publish.result }}" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/verify-publish.yml b/.github/workflows/verify-publish.yml new file mode 100644 index 000000000..2f9cd76d3 --- /dev/null +++ b/.github/workflows/verify-publish.yml @@ -0,0 +1,223 @@ +name: Verify Published Package + +# This workflow verifies that the published npm package works correctly +# across multiple Node.js versions using both global install and npx. +# +# Triggered: +# - Automatically after publish workflow completes +# - Manually via workflow_dispatch + +on: + workflow_dispatch: + inputs: + version: + description: "Package version to verify (default: latest)" + required: false + type: string + default: "latest" + workflow_call: + inputs: + version: + description: "Package version to verify" + required: false + type: string + default: "latest" + +jobs: + verify: + name: Verify Node ${{ matrix.node }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node: ["18", "20", "22"] + + steps: + - name: Checkout (for scripts) + uses: actions/checkout@v4 + + - name: Setup Node.js ${{ matrix.node }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + + - name: Get package spec + id: pkg + run: | + VERSION="${{ inputs.version || 'latest' }}" + if [ "$VERSION" = "latest" ]; then + SPEC="agent-relay" + else + SPEC="agent-relay@${VERSION}" + fi + echo "spec=$SPEC" >> $GITHUB_OUTPUT + echo "Testing: $SPEC" + + # Wait for npm to propagate the package + - name: Wait for npm propagation + if: inputs.version != 'latest' + run: | + echo "Waiting for npm to propagate version ${{ inputs.version }}..." + for i in {1..30}; do + if npm view agent-relay@${{ inputs.version }} version 2>/dev/null; then + echo "Package found on npm registry" + break + fi + echo "Attempt $i: Package not yet available, waiting 10s..." + sleep 10 + done + + # Test 1: Global npm install + - name: "Test: Global npm install" + run: | + echo "Installing ${{ steps.pkg.outputs.spec }} globally..." + npm install -g ${{ steps.pkg.outputs.spec }} + + - name: "Test: Global --version" + run: | + VERSION=$(agent-relay --version) + echo "Version output: $VERSION" + if echo "$VERSION" | grep -qE '[0-9]+\.[0-9]+\.[0-9]+'; then + echo "Version check passed" + else + echo "Version check failed - no version number found" + exit 1 + fi + + - name: "Test: Global -V flag" + run: agent-relay -V + + - name: "Test: Global version command" + run: | + OUTPUT=$(agent-relay version) + echo "$OUTPUT" + if echo "$OUTPUT" | grep -qE '[0-9]+\.[0-9]+\.[0-9]+'; then + echo "Version command check passed" + else + exit 1 + fi + + - name: "Test: Global --help" + run: | + agent-relay --help | head -20 + + - name: Cleanup global install + run: npm uninstall -g agent-relay + + # Test 2: npx execution + - name: "Test: npx --version" + run: | + VERSION=$(npx -y ${{ steps.pkg.outputs.spec }} --version) + echo "npx version output: $VERSION" + if echo "$VERSION" | grep -qE '[0-9]+\.[0-9]+\.[0-9]+'; then + echo "npx version check passed" + else + exit 1 + fi + + - name: "Test: npx --help" + run: npx -y ${{ steps.pkg.outputs.spec }} --help | head -20 + + - name: "Test: npx version command" + run: | + OUTPUT=$(npx -y ${{ steps.pkg.outputs.spec }} version) + echo "$OUTPUT" + if echo "$OUTPUT" | grep -qE '[0-9]+\.[0-9]+\.[0-9]+'; then + echo "npx version command check passed" + else + exit 1 + fi + + # Test 3: Local project install + - name: "Test: Local project install" + run: | + mkdir -p /tmp/test-project + cd /tmp/test-project + npm init -y + npm install ${{ steps.pkg.outputs.spec }} + + - name: "Test: Local npx execution" + run: | + cd /tmp/test-project + VERSION=$(npx agent-relay --version) + echo "Local npx version: $VERSION" + if echo "$VERSION" | grep -qE '[0-9]+\.[0-9]+\.[0-9]+'; then + echo "Local npx check passed" + else + exit 1 + fi + + - name: "Test: Local bin executable" + run: | + cd /tmp/test-project + if [ -x "./node_modules/.bin/agent-relay" ]; then + VERSION=$(./node_modules/.bin/agent-relay --version) + echo "Local bin version: $VERSION" + else + echo "Local bin not found or not executable" + exit 1 + fi + + - name: Cleanup + run: rm -rf /tmp/test-project + + # Docker-based verification (more isolated) + verify-docker: + name: Verify Docker (Node ${{ matrix.node }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node: ["18", "20", "22"] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build verification image + run: | + VERSION="${{ inputs.version || 'latest' }}" + docker build \ + --build-arg NODE_VERSION=${{ matrix.node }} \ + --build-arg PACKAGE_VERSION=$VERSION \ + -t agent-relay-verify:node${{ matrix.node }} \ + -f scripts/post-publish-verify/Dockerfile \ + scripts/post-publish-verify/ + + - name: Run verification + run: | + docker run --rm agent-relay-verify:node${{ matrix.node }} + + summary: + name: Verification Summary + needs: [verify, verify-docker] + runs-on: ubuntu-latest + if: always() + + steps: + - name: Summary + run: | + echo "## Post-Publish Verification Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Package**: \`agent-relay@${{ inputs.version || 'latest' }}\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Native Tests" >> $GITHUB_STEP_SUMMARY + echo "| Node Version | Status |" >> $GITHUB_STEP_SUMMARY + echo "|--------------|--------|" >> $GITHUB_STEP_SUMMARY + echo "| Node 18 | ${{ needs.verify.result == 'success' && '✅' || '❌' }} |" >> $GITHUB_STEP_SUMMARY + echo "| Node 20 | ${{ needs.verify.result == 'success' && '✅' || '❌' }} |" >> $GITHUB_STEP_SUMMARY + echo "| Node 22 | ${{ needs.verify.result == 'success' && '✅' || '❌' }} |" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Docker Tests" >> $GITHUB_STEP_SUMMARY + echo "| Node Version | Status |" >> $GITHUB_STEP_SUMMARY + echo "|--------------|--------|" >> $GITHUB_STEP_SUMMARY + echo "| Node 18 | ${{ needs.verify-docker.result == 'success' && '✅' || '❌' }} |" >> $GITHUB_STEP_SUMMARY + echo "| Node 20 | ${{ needs.verify-docker.result == 'success' && '✅' || '❌' }} |" >> $GITHUB_STEP_SUMMARY + echo "| Node 22 | ${{ needs.verify-docker.result == 'success' && '✅' || '❌' }} |" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Overall" >> $GITHUB_STEP_SUMMARY + if [ "${{ needs.verify.result }}" = "success" ] && [ "${{ needs.verify-docker.result }}" = "success" ]; then + echo "✅ All verification tests passed!" >> $GITHUB_STEP_SUMMARY + else + echo "❌ Some verification tests failed" >> $GITHUB_STEP_SUMMARY + fi diff --git a/scripts/post-publish-verify/Dockerfile b/scripts/post-publish-verify/Dockerfile new file mode 100644 index 000000000..bdd8c6da7 --- /dev/null +++ b/scripts/post-publish-verify/Dockerfile @@ -0,0 +1,37 @@ +# Post-publish verification Dockerfile +# Tests agent-relay npm package across Node.js versions +# +# Build args: +# NODE_VERSION: Node.js version to test (18, 20, 22) +# PACKAGE_VERSION: agent-relay version to test (default: latest) + +ARG NODE_VERSION=20 + +FROM node:${NODE_VERSION}-slim + +ARG PACKAGE_VERSION=latest + +# Install dependencies for native modules and better-sqlite3 +RUN apt-get update && apt-get install -y \ + python3 \ + make \ + g++ \ + && rm -rf /var/lib/apt/lists/* + +# Create test user (don't run as root) +RUN useradd -m -s /bin/bash testuser +WORKDIR /home/testuser + +# Store package version for verification script +ENV PACKAGE_VERSION=${PACKAGE_VERSION} +ENV NODE_VERSION=${NODE_VERSION} + +# Copy verification script +COPY verify-install.sh /usr/local/bin/verify-install.sh +RUN chmod +x /usr/local/bin/verify-install.sh + +# Switch to test user +USER testuser + +# Run verification +CMD ["/usr/local/bin/verify-install.sh"] diff --git a/scripts/post-publish-verify/README.md b/scripts/post-publish-verify/README.md new file mode 100644 index 000000000..e15dd5539 --- /dev/null +++ b/scripts/post-publish-verify/README.md @@ -0,0 +1,80 @@ +# Post-Publish Verification + +Automated tests to verify the `agent-relay` npm package works correctly after publishing. + +## What It Tests + +1. **Global npm install** (`npm install -g agent-relay`) + - `--version` flag + - `-V` flag + - `version` command + - `--help` flag + +2. **npx execution** (`npx agent-relay`) + - Version commands + - Help output + +3. **Local project install** (`npm install agent-relay`) + - npx within project + - Direct bin execution (`./node_modules/.bin/agent-relay`) + +## Node.js Versions + +Tests run across all supported Node.js versions: +- Node.js 18 (minimum supported) +- Node.js 20 (LTS) +- Node.js 22 (Current) + +## Usage + +### Local Testing (Docker) + +```bash +# Test latest published version +./scripts/post-publish-verify/run-verify.sh + +# Test specific version +./scripts/post-publish-verify/run-verify.sh 2.0.25 + +# Test in parallel (faster) +./scripts/post-publish-verify/run-verify.sh latest --parallel + +# Test single Node.js version +./scripts/post-publish-verify/run-verify.sh latest --node 20 +``` + +### Docker Compose Directly + +```bash +cd scripts/post-publish-verify + +# Test latest +docker compose up --build + +# Test specific version +PACKAGE_VERSION=2.0.25 docker compose up --build + +# Test single Node version +docker compose up --build node20 + +# Cleanup +docker compose down --rmi local +``` + +### GitHub Actions + +The verification runs automatically after publishing via the `verify-publish.yml` workflow. + +You can also trigger it manually: +1. Go to Actions tab +2. Select "Verify Published Package" +3. Click "Run workflow" +4. Optionally specify a version to test + +## Files + +- `Dockerfile` - Multi-stage Dockerfile supporting different Node versions +- `verify-install.sh` - Main verification script run inside containers +- `docker-compose.yml` - Orchestrates tests across Node versions +- `run-verify.sh` - Local runner script with nice output +- `.github/workflows/verify-publish.yml` - GitHub Actions workflow diff --git a/scripts/post-publish-verify/docker-compose.yml b/scripts/post-publish-verify/docker-compose.yml new file mode 100644 index 000000000..e0184fc31 --- /dev/null +++ b/scripts/post-publish-verify/docker-compose.yml @@ -0,0 +1,57 @@ +# Post-publish verification - Multi-Node.js version testing +# +# Tests agent-relay npm package installation across Node.js 18, 20, and 22 +# +# Usage: +# # Test latest published version across all Node versions +# docker compose -f scripts/post-publish-verify/docker-compose.yml up --build +# +# # Test specific version +# PACKAGE_VERSION=2.0.25 docker compose -f scripts/post-publish-verify/docker-compose.yml up --build +# +# # Test single Node version +# docker compose -f scripts/post-publish-verify/docker-compose.yml up --build node20 +# +# # Cleanup +# docker compose -f scripts/post-publish-verify/docker-compose.yml down --rmi local + +services: + # Node.js 18 (minimum supported version) + node18: + build: + context: . + dockerfile: Dockerfile + args: + NODE_VERSION: "18" + PACKAGE_VERSION: "${PACKAGE_VERSION:-latest}" + environment: + NODE_VERSION: "18" + PACKAGE_VERSION: "${PACKAGE_VERSION:-latest}" + + # Node.js 20 (LTS) + node20: + build: + context: . + dockerfile: Dockerfile + args: + NODE_VERSION: "20" + PACKAGE_VERSION: "${PACKAGE_VERSION:-latest}" + environment: + NODE_VERSION: "20" + PACKAGE_VERSION: "${PACKAGE_VERSION:-latest}" + + # Node.js 22 (Current) + node22: + build: + context: . + dockerfile: Dockerfile + args: + NODE_VERSION: "22" + PACKAGE_VERSION: "${PACKAGE_VERSION:-latest}" + environment: + NODE_VERSION: "22" + PACKAGE_VERSION: "${PACKAGE_VERSION:-latest}" + +networks: + default: + name: agent-relay-publish-verify diff --git a/scripts/post-publish-verify/run-verify.sh b/scripts/post-publish-verify/run-verify.sh new file mode 100755 index 000000000..710c24fe0 --- /dev/null +++ b/scripts/post-publish-verify/run-verify.sh @@ -0,0 +1,127 @@ +#!/bin/bash +# Post-publish verification runner +# +# Tests agent-relay npm package across multiple Node.js versions using Docker +# +# Usage: +# ./run-verify.sh # Test latest version +# ./run-verify.sh 2.0.25 # Test specific version +# ./run-verify.sh latest --parallel # Run all versions in parallel +# ./run-verify.sh 2.0.25 --node 20 # Test specific Node.js version only + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +log_info() { echo -e "${BLUE}[INFO]${NC} $1"; } +log_success() { echo -e "${GREEN}[PASS]${NC} $1"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +log_error() { echo -e "${RED}[FAIL]${NC} $1"; } +log_header() { echo -e "\n${BLUE}════════════════════════════════════════${NC}"; echo -e "${BLUE} $1${NC}"; echo -e "${BLUE}════════════════════════════════════════${NC}\n"; } + +# Parse arguments +PACKAGE_VERSION="${1:-latest}" +PARALLEL=false +SPECIFIC_NODE="" +FAILED_VERSIONS=() + +shift || true +while [[ $# -gt 0 ]]; do + case $1 in + --parallel|-p) + PARALLEL=true + shift + ;; + --node|-n) + SPECIFIC_NODE="$2" + shift 2 + ;; + --help|-h) + echo "Usage: $0 [VERSION] [OPTIONS]" + echo "" + echo "Arguments:" + echo " VERSION Package version to test (default: latest)" + echo "" + echo "Options:" + echo " --parallel, -p Run all Node versions in parallel" + echo " --node, -n VER Test only specific Node.js version (18, 20, or 22)" + echo " --help, -h Show this help" + echo "" + echo "Examples:" + echo " $0 # Test latest across all Node versions" + echo " $0 2.0.25 # Test version 2.0.25" + echo " $0 latest --parallel # Test in parallel" + echo " $0 2.0.25 --node 20 # Test only Node 20" + exit 0 + ;; + *) + log_error "Unknown option: $1" + exit 1 + ;; + esac +done + +log_header "Agent Relay Post-Publish Verification" +log_info "Package version: $PACKAGE_VERSION" +log_info "Parallel mode: $PARALLEL" +if [ -n "$SPECIFIC_NODE" ]; then + log_info "Testing Node.js version: $SPECIFIC_NODE only" +fi + +# Export for docker-compose +export PACKAGE_VERSION + +# Determine which services to run +if [ -n "$SPECIFIC_NODE" ]; then + SERVICES="node${SPECIFIC_NODE}" +else + SERVICES="node18 node20 node22" +fi + +# Build images +log_info "Building Docker images..." +docker compose build $SERVICES + +# Run tests +if [ "$PARALLEL" = true ]; then + log_info "Running verification in parallel..." + docker compose up --abort-on-container-exit $SERVICES + EXIT_CODE=$? +else + # Run sequentially to see output clearly + for service in $SERVICES; do + log_header "Testing $service" + if docker compose up --abort-on-container-exit "$service"; then + log_success "$service verification passed" + else + log_error "$service verification failed" + FAILED_VERSIONS+=("$service") + fi + # Clean up container + docker compose rm -f "$service" 2>/dev/null || true + done +fi + +# Cleanup +log_info "Cleaning up..." +docker compose down --rmi local 2>/dev/null || true + +# Summary +log_header "Verification Complete" +log_info "Package version tested: $PACKAGE_VERSION" + +if [ ${#FAILED_VERSIONS[@]} -eq 0 ]; then + log_success "All Node.js versions passed verification!" + exit 0 +else + log_error "Failed versions: ${FAILED_VERSIONS[*]}" + exit 1 +fi diff --git a/scripts/post-publish-verify/verify-install.sh b/scripts/post-publish-verify/verify-install.sh new file mode 100755 index 000000000..990424c49 --- /dev/null +++ b/scripts/post-publish-verify/verify-install.sh @@ -0,0 +1,226 @@ +#!/bin/bash +# Post-publish verification script +# Tests both global npm install and npx installation of agent-relay +# +# Environment variables: +# PACKAGE_VERSION: Version to install (default: latest) +# NODE_VERSION: Node version being tested (for logging) + +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 + +log_info() { echo -e "${BLUE}[INFO]${NC} $1"; } +log_success() { echo -e "${GREEN}[PASS]${NC} $1"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +log_error() { echo -e "${RED}[FAIL]${NC} $1"; } +log_header() { echo -e "\n${BLUE}========================================${NC}"; echo -e "${BLUE}$1${NC}"; echo -e "${BLUE}========================================${NC}"; } + +# Track test results +TESTS_PASSED=0 +TESTS_FAILED=0 + +record_pass() { + ((TESTS_PASSED++)) + log_success "$1" +} + +record_fail() { + ((TESTS_FAILED++)) + log_error "$1" +} + +# Get package specification +PACKAGE_SPEC="agent-relay" +if [ -n "$PACKAGE_VERSION" ] && [ "$PACKAGE_VERSION" != "latest" ]; then + PACKAGE_SPEC="agent-relay@${PACKAGE_VERSION}" +fi + +log_header "Post-Publish Verification" +log_info "Node.js version: $(node --version)" +log_info "npm version: $(npm --version)" +log_info "Package to test: $PACKAGE_SPEC" +log_info "User: $(whoami)" +log_info "Working directory: $(pwd)" + +# ============================================ +# Test 1: Global npm install +# ============================================ +log_header "Test 1: Global npm install" + +# Clean any previous installation +log_info "Cleaning previous global installation..." +npm uninstall -g agent-relay 2>/dev/null || true + +# Install globally +log_info "Installing ${PACKAGE_SPEC} globally..." +if npm install -g "$PACKAGE_SPEC" 2>&1; then + record_pass "Global npm install succeeded" +else + record_fail "Global npm install failed" +fi + +# Test --version flag +log_info "Testing 'agent-relay --version'..." +GLOBAL_VERSION=$(agent-relay --version 2>&1) || true +if [ -n "$GLOBAL_VERSION" ]; then + log_info "Output: $GLOBAL_VERSION" + # Verify it contains a version number pattern + if echo "$GLOBAL_VERSION" | grep -qE '[0-9]+\.[0-9]+\.[0-9]+'; then + record_pass "Global install --version returns valid version: $GLOBAL_VERSION" + else + record_fail "Global install --version output doesn't contain version number" + fi +else + record_fail "Global install --version returned empty output" +fi + +# Test -V flag (short version flag) +log_info "Testing 'agent-relay -V'..." +GLOBAL_V=$(agent-relay -V 2>&1) || true +if [ -n "$GLOBAL_V" ]; then + record_pass "Global install -V works: $GLOBAL_V" +else + record_fail "Global install -V failed" +fi + +# Test version command +log_info "Testing 'agent-relay version'..." +GLOBAL_VERSION_CMD=$(agent-relay version 2>&1) || true +if echo "$GLOBAL_VERSION_CMD" | grep -qE '[0-9]+\.[0-9]+\.[0-9]+'; then + record_pass "Global install 'version' command works" +else + record_fail "Global install 'version' command failed" +fi + +# Test help command +log_info "Testing 'agent-relay --help'..." +GLOBAL_HELP=$(agent-relay --help 2>&1) || true +if echo "$GLOBAL_HELP" | grep -q "agent-relay"; then + record_pass "Global install --help works" +else + record_fail "Global install --help failed" +fi + +# Cleanup global install +log_info "Cleaning up global installation..." +npm uninstall -g agent-relay 2>/dev/null || true + +# ============================================ +# Test 2: npx execution (without prior install) +# ============================================ +log_header "Test 2: npx execution" + +# Clear npm cache to ensure fresh download +log_info "Clearing npm cache for npx test..." +npm cache clean --force 2>/dev/null || true + +# Test npx --version +log_info "Testing 'npx ${PACKAGE_SPEC} --version'..." +NPX_VERSION=$(npx -y "$PACKAGE_SPEC" --version 2>&1) || true +if [ -n "$NPX_VERSION" ]; then + log_info "Output: $NPX_VERSION" + if echo "$NPX_VERSION" | grep -qE '[0-9]+\.[0-9]+\.[0-9]+'; then + record_pass "npx --version returns valid version: $NPX_VERSION" + else + record_fail "npx --version output doesn't contain version number" + fi +else + record_fail "npx --version returned empty output" +fi + +# Test npx help +log_info "Testing 'npx ${PACKAGE_SPEC} --help'..." +NPX_HELP=$(npx -y "$PACKAGE_SPEC" --help 2>&1) || true +if echo "$NPX_HELP" | grep -q "agent-relay"; then + record_pass "npx --help works" +else + record_fail "npx --help failed" +fi + +# Test npx version command +log_info "Testing 'npx ${PACKAGE_SPEC} version'..." +NPX_VERSION_CMD=$(npx -y "$PACKAGE_SPEC" version 2>&1) || true +if echo "$NPX_VERSION_CMD" | grep -qE '[0-9]+\.[0-9]+\.[0-9]+'; then + record_pass "npx 'version' command works" +else + record_fail "npx 'version' command failed" +fi + +# ============================================ +# Test 3: Local project install +# ============================================ +log_header "Test 3: Local project install" + +# Create a test project +TEST_PROJECT_DIR=$(mktemp -d) +log_info "Created test project at: $TEST_PROJECT_DIR" +cd "$TEST_PROJECT_DIR" + +# Initialize package.json +log_info "Initializing package.json..." +npm init -y > /dev/null 2>&1 + +# Install as local dependency +log_info "Installing ${PACKAGE_SPEC} locally..." +if npm install "$PACKAGE_SPEC" 2>&1; then + record_pass "Local npm install succeeded" +else + record_fail "Local npm install failed" +fi + +# Test via npx (should use local version) +log_info "Testing 'npx agent-relay --version' (local)..." +LOCAL_VERSION=$(npx agent-relay --version 2>&1) || true +if [ -n "$LOCAL_VERSION" ]; then + if echo "$LOCAL_VERSION" | grep -qE '[0-9]+\.[0-9]+\.[0-9]+'; then + record_pass "Local install via npx works: $LOCAL_VERSION" + else + record_fail "Local install via npx doesn't return version" + fi +else + record_fail "Local install via npx failed" +fi + +# Test via node_modules/.bin +log_info "Testing './node_modules/.bin/agent-relay --version'..." +if [ -x "./node_modules/.bin/agent-relay" ]; then + BIN_VERSION=$(./node_modules/.bin/agent-relay --version 2>&1) || true + if echo "$BIN_VERSION" | grep -qE '[0-9]+\.[0-9]+\.[0-9]+'; then + record_pass "Local bin executable works: $BIN_VERSION" + else + record_fail "Local bin executable doesn't return version" + fi +else + record_fail "Local bin executable not found or not executable" +fi + +# Cleanup test project +log_info "Cleaning up test project..." +cd /home/testuser +rm -rf "$TEST_PROJECT_DIR" + +# ============================================ +# Summary +# ============================================ +log_header "Verification Summary" +echo "" +log_info "Node.js: $(node --version)" +log_info "Package: $PACKAGE_SPEC" +echo "" +echo -e "Tests passed: ${GREEN}${TESTS_PASSED}${NC}" +echo -e "Tests failed: ${RED}${TESTS_FAILED}${NC}" +echo "" + +if [ $TESTS_FAILED -eq 0 ]; then + log_success "All tests passed!" + exit 0 +else + log_error "Some tests failed!" + exit 1 +fi From 6598bf3b6c74cbf18b10c1bc65d6e9e92c75ce16 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Tue, 27 Jan 2026 20:30:04 +0100 Subject: [PATCH 2/9] ci: add PR trigger for verify-publish workflow testing --- .github/workflows/verify-publish.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/verify-publish.yml b/.github/workflows/verify-publish.yml index 2f9cd76d3..0a888fb15 100644 --- a/.github/workflows/verify-publish.yml +++ b/.github/workflows/verify-publish.yml @@ -6,8 +6,13 @@ name: Verify Published Package # Triggered: # - Automatically after publish workflow completes # - Manually via workflow_dispatch +# - On PR (for testing the workflow itself) on: + pull_request: + paths: + - ".github/workflows/verify-publish.yml" + - "scripts/post-publish-verify/**" workflow_dispatch: inputs: version: From a1810b1bcc6947f47bf27e16601181353c727bd4 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Tue, 27 Jan 2026 21:05:25 +0100 Subject: [PATCH 3/9] mcp parity --- packages/daemon/src/orchestrator.ts | 7 +- packages/mcp/package.json | 1 + packages/mcp/src/client.ts | 408 +++++---- packages/mcp/src/server.ts | 100 +++ packages/mcp/src/tools/index.ts | 55 ++ packages/mcp/src/tools/relay-broadcast.ts | 32 + packages/mcp/src/tools/relay-channel.ts | 93 +++ packages/mcp/src/tools/relay-consensus.ts | 92 +++ packages/mcp/src/tools/relay-shadow.ts | 67 ++ packages/mcp/src/tools/relay-subscribe.ts | 61 ++ packages/mcp/tests/client.test.ts | 276 +++++++ packages/mcp/tests/tools.test.ts | 960 ++++++++++++++++++++++ packages/protocol/src/types.ts | 124 ++- packages/sdk/src/client.test.ts | 2 +- packages/sdk/src/client.ts | 8 +- packages/sdk/src/protocol/framing.test.ts | 4 +- packages/sdk/src/protocol/framing.ts | 242 ------ packages/sdk/src/protocol/index.ts | 122 +-- packages/sdk/src/protocol/types.ts | 718 ---------------- 19 files changed, 2077 insertions(+), 1295 deletions(-) create mode 100644 packages/mcp/src/tools/relay-broadcast.ts create mode 100644 packages/mcp/src/tools/relay-channel.ts create mode 100644 packages/mcp/src/tools/relay-consensus.ts create mode 100644 packages/mcp/src/tools/relay-shadow.ts create mode 100644 packages/mcp/src/tools/relay-subscribe.ts delete mode 100644 packages/sdk/src/protocol/framing.ts delete mode 100644 packages/sdk/src/protocol/types.ts diff --git a/packages/daemon/src/orchestrator.ts b/packages/daemon/src/orchestrator.ts index 34fabb1e4..56684a938 100644 --- a/packages/daemon/src/orchestrator.ts +++ b/packages/daemon/src/orchestrator.ts @@ -53,7 +53,7 @@ export interface OrchestratorConfig { /** * Determine the default host binding. - * - In cloud environments (Fly.io, Docker with WORKSPACE_ID), bind to 0.0.0.0 for external access + * - In cloud environments, bind to '::' for IPv6+IPv4 dual-stack (required for Fly.io 6PN) * - Locally, bind to localhost for security * - Can be overridden with AGENT_RELAY_API_HOST env var */ @@ -62,13 +62,14 @@ function getDefaultHost(): string { if (process.env.AGENT_RELAY_API_HOST) { return process.env.AGENT_RELAY_API_HOST; } - // Cloud environment detection - bind to all interfaces for load balancer access + // Cloud environment detection - bind to :: for IPv6 + IPv4 dual-stack + // Fly.io internal network uses IPv6 (fdaa:...), so 0.0.0.0 won't work const isCloudEnvironment = process.env.FLY_APP_NAME || // Fly.io process.env.WORKSPACE_ID || // Agent Relay workspace process.env.RELAY_WORKSPACE_ID || // Alternative workspace ID process.env.RUNNING_IN_DOCKER === 'true'; // Docker container - return isCloudEnvironment ? '0.0.0.0' : 'localhost'; + return isCloudEnvironment ? '::' : 'localhost'; } const DEFAULT_CONFIG: OrchestratorConfig = { diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 37a7d5ab0..eb16047d2 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -48,6 +48,7 @@ }, "dependencies": { "@agent-relay/config": "2.0.25", + "@agent-relay/protocol": "2.0.25", "@modelcontextprotocol/sdk": "^1.0.0", "smol-toml": "^1.6.0", "zod": "^3.23.8" diff --git a/packages/mcp/src/client.ts b/packages/mcp/src/client.ts index f60a2d85b..20305a400 100644 --- a/packages/mcp/src/client.ts +++ b/packages/mcp/src/client.ts @@ -1,5 +1,8 @@ /** * RelayClient - Client for connecting to the Agent Relay daemon + * + * This module uses @agent-relay/protocol for wire format handling + * to avoid code duplication with the SDK. */ import { createConnection, type Socket } from 'node:net'; @@ -7,117 +10,56 @@ import { randomUUID } from 'node:crypto'; import { discoverSocket } from './cloud.js'; import { DaemonNotRunningError } from './errors.js'; -// ============================================================================ -// Protocol Types - These MUST match @agent-relay/protocol types -// Keeping them local avoids circular dependencies but requires sync -// ============================================================================ +// Import shared protocol types and framing utilities +import { + type Envelope, + type MessageType, + type SendPayload, + type SpawnPayload, + type ReleasePayload, + type InboxPayload, + type ListAgentsPayload, + type HealthPayload, + type MetricsPayload, + type HealthResponsePayload, + type MetricsResponsePayload, + encodeFrameLegacy, + FrameParser, + PROTOCOL_VERSION, +} from '@agent-relay/protocol'; + +// Re-export response types for consumers +export type HealthResponse = HealthResponsePayload; +export type MetricsResponse = MetricsResponsePayload; -/** - * SendPayload for SEND messages. - * IMPORTANT: `from` and `to` go at envelope level, NOT in payload! - */ -interface SendPayload { - kind: 'message' | 'action' | 'state' | 'thinking'; - body: string; - data?: Record; - thread?: string; -} - -/** - * Envelope routing properties - go at top level, NOT in payload. - */ -interface EnvelopeRouting { - from?: string; - to?: string; -} - -/** - * SpawnPayload for SPAWN messages. - */ -interface SpawnPayload { - name: string; - cli: string; - task: string; - team?: string; - cwd?: string; - model?: string; - socketPath?: string; - spawnerName?: string; // Parent agent name - interactive?: boolean; -} - -/** - * ReleasePayload for RELEASE messages. - */ -interface ReleasePayload { - name: string; - reason?: string; -} +export interface RelayClient { + // Basic messaging + send(to: string, message: string, options?: { thread?: string }): Promise; + sendAndWait(to: string, message: string, options?: { thread?: string; timeoutMs?: number }): Promise<{ from: string; content: string; thread?: string }>; + broadcast(message: string, options?: { kind?: string }): Promise; -/** - * InboxPayload for INBOX queries. - */ -interface InboxPayload { - agent: string; - limit?: number; - unreadOnly?: boolean; - from?: string; - channel?: string; -} + // Spawn/Release + spawn(options: { name: string; cli: string; task: string; model?: string; cwd?: string }): Promise<{ success: boolean; error?: string; pid?: number }>; + release(name: string, reason?: string): Promise<{ success: boolean; error?: string }>; -/** - * ListAgentsPayload for LIST_AGENTS queries. - */ -interface ListAgentsPayload { - includeIdle?: boolean; - project?: string; -} + // Pub/Sub + subscribe(topic: string): Promise<{ success: boolean; error?: string }>; + unsubscribe(topic: string): Promise<{ success: boolean; error?: string }>; -/** - * HealthPayload for HEALTH queries. - */ -interface HealthPayload { - includeCrashes?: boolean; - includeAlerts?: boolean; -} + // Channel operations + joinChannel(channel: string, displayName?: string): Promise<{ success: boolean; error?: string }>; + leaveChannel(channel: string, reason?: string): Promise<{ success: boolean; error?: string }>; + sendChannelMessage(channel: string, message: string, options?: { thread?: string }): Promise; -/** - * MetricsPayload for METRICS queries. - */ -interface MetricsPayload { - agent?: string; -} + // Shadow agent operations + bindAsShadow(primaryAgent: string, options?: { speakOn?: string[] }): Promise<{ success: boolean; error?: string }>; + unbindAsShadow(primaryAgent: string): Promise<{ success: boolean; error?: string }>; -export interface HealthResponse { - healthScore: number; - summary: string; - issues: Array<{ severity: string; message: string }>; - recommendations: string[]; - crashes: Array<{ id: string; agentName: string; crashedAt: string; likelyCause: string; summary?: string }>; - alerts: Array<{ id: string; agentName: string; alertType: string; message: string; createdAt: string }>; - stats: { totalCrashes24h: number; totalAlerts24h: number; agentCount: number }; -} + // Consensus operations + createProposal(options: { id: string; description: string; options: string[]; votingMethod?: string; deadline?: number }): Promise<{ success: boolean; error?: string }>; + vote(options: { proposalId: string; vote: string; reason?: string }): Promise<{ success: boolean; error?: string }>; -export interface MetricsResponse { - agents: Array<{ - name: string; - pid?: number; - status: string; - rssBytes?: number; - cpuPercent?: number; - trend?: string; - alertLevel?: string; - highWatermark?: number; - uptimeMs?: number; - }>; - system: { totalMemory: number; freeMemory: number; heapUsed: number }; -} - -export interface RelayClient { - send(to: string, message: string, options?: { thread?: string }): Promise; - sendAndWait(to: string, message: string, options?: { thread?: string; timeoutMs?: number }): Promise<{ from: string; content: string; thread?: string }>; - spawn(options: { name: string; cli: string; task: string; model?: string; cwd?: string }): Promise<{ success: boolean; error?: string }>; - release(name: string, reason?: string): Promise<{ success: boolean; error?: string }>; + // Query operations getStatus(): Promise<{ connected: boolean; agentName: string; project: string; socketPath: string; daemonVersion?: string; uptime?: string }>; getInbox(options?: { limit?: number; unread_only?: boolean; from?: string; channel?: string }): Promise>; listAgents(options?: { include_idle?: boolean; project?: string }): Promise>; @@ -134,51 +76,6 @@ export interface RelayClientOptions { timeout?: number; } -// Protocol version -const PROTOCOL_VERSION = 1; - -/** - * Encode a message envelope into a length-prefixed frame (legacy format). - * Format: 4-byte big-endian length + JSON payload - */ -function encodeFrame(envelope: Record): Buffer { - const json = JSON.stringify(envelope); - const data = Buffer.from(json, 'utf-8'); - const header = Buffer.alloc(4); - header.writeUInt32BE(data.length, 0); - return Buffer.concat([header, data]); -} - -/** - * Frame parser for length-prefixed messages. - */ -class FrameParser { - private buffer = Buffer.alloc(0); - - push(data: Buffer): Array> { - this.buffer = Buffer.concat([this.buffer, data]); - const frames: Array> = []; - - while (this.buffer.length >= 4) { - const frameLength = this.buffer.readUInt32BE(0); - const totalLength = 4 + frameLength; - - if (this.buffer.length < totalLength) break; - - const payload = this.buffer.subarray(4, totalLength); - this.buffer = this.buffer.subarray(totalLength); - - try { - frames.push(JSON.parse(payload.toString('utf-8'))); - } catch { - // Skip malformed frames - } - } - - return frames; - } -} - export function createRelayClient(options: RelayClientOptions): RelayClient { const { agentName, project = 'default', timeout = 5000 } = options; // Prefer explicit socketPath option over discovery to avoid finding wrong daemon @@ -191,34 +88,27 @@ export function createRelayClient(options: RelayClientOptions): RelayClient { // Timeouts for different operations const RELEASE_TIMEOUT = 10000; // 10 seconds for release operations - /** Union of all payload types for type safety */ - type AnyPayload = SendPayload | SpawnPayload | ReleasePayload | InboxPayload | ListAgentsPayload | HealthPayload | MetricsPayload | Record; - /** * Fire-and-forget: Send a message without waiting for any response. * Used for SEND and SPAWN where we don't expect daemon to reply. - * @param type Message type - * @param payload Message payload (for SEND: must be SendPayload with kind, body, etc.) - * @param envelopeProps Envelope-level routing (from, to) - NOT in payload! */ - function fireAndForget(type: string, payload: AnyPayload, envelopeProps?: EnvelopeRouting): Promise { + function fireAndForget(type: MessageType, payload: Record, envelopeProps?: { from?: string; to?: string }): Promise { return new Promise((resolve, reject) => { const id = generateId(); - const envelope: Record = { + const envelope: Envelope = { v: PROTOCOL_VERSION, type, id, ts: Date.now(), payload, + from: envelopeProps?.from, + to: envelopeProps?.to, }; - // Add from/to at envelope level (required for SEND messages) - if (envelopeProps?.from) envelope.from = envelopeProps.from; - if (envelopeProps?.to) envelope.to = envelopeProps.to; const socket: Socket = createConnection(socketPath); socket.on('connect', () => { - socket.write(encodeFrame(envelope)); + socket.write(encodeFrameLegacy(envelope)); socket.end(); resolve(); }); @@ -237,32 +127,35 @@ export function createRelayClient(options: RelayClientOptions): RelayClient { /** * Request-response: Send a message and wait for daemon to respond. * Used for queries (STATUS, INBOX, etc.) and blocking sends (waits for ACK). - * @param type Message type - * @param payload Message payload (for SEND: must be SendPayload with kind, body, etc.) - * @param customTimeout Optional timeout override - * @param payloadMeta Optional sync metadata for blocking sends - * @param envelopeProps Envelope-level routing (from, to) - NOT in payload! */ - async function request(type: string, payload: AnyPayload, customTimeout?: number, payloadMeta?: { sync?: { blocking?: boolean; correlationId?: string; timeoutMs?: number } }, envelopeProps?: EnvelopeRouting): Promise { + async function request( + type: MessageType, + payload: Record, + customTimeout?: number, + payloadMeta?: { sync?: { blocking?: boolean; correlationId?: string; timeoutMs?: number } }, + envelopeProps?: { from?: string; to?: string } + ): Promise { return new Promise((resolve, reject) => { const id = generateId(); const correlationId = payloadMeta?.sync?.correlationId; - // Build a proper protocol envelope - const envelope: Record = { + + const envelope: Envelope = { v: PROTOCOL_VERSION, type, id, ts: Date.now(), payload, + from: envelopeProps?.from, + to: envelopeProps?.to, }; - // Add from/to at envelope level (required for SEND messages) - if (envelopeProps?.from) envelope.from = envelopeProps.from; - if (envelopeProps?.to) envelope.to = envelopeProps.to; + if (payloadMeta) { - envelope.payload_meta = payloadMeta; + (envelope as unknown as Record).payload_meta = payloadMeta; } + let timedOut = false; const parser = new FrameParser(); + parser.setLegacyMode(true); // Use legacy 4-byte header format const socket: Socket = createConnection(socketPath); @@ -273,16 +166,16 @@ export function createRelayClient(options: RelayClientOptions): RelayClient { reject(new Error(`Request timeout after ${effectiveTimeout}ms`)); }, effectiveTimeout); - socket.on('connect', () => socket.write(encodeFrame(envelope))); + socket.on('connect', () => socket.write(encodeFrameLegacy(envelope))); socket.on('data', (data) => { - // Ignore data if we've already timed out if (timedOut) return; const frames = parser.push(data); for (const response of frames) { const responsePayload = response.payload as { replyTo?: string; correlationId?: string; error?: string; message?: string; code?: string }; - // Check if this is a response to our request (by id, replyTo, or correlationId for blocking sends) + + // Check if this is a response to our request const isMatchingResponse = response.id === id || responsePayload?.replyTo === id || (correlationId && responsePayload?.correlationId === correlationId); @@ -290,7 +183,7 @@ export function createRelayClient(options: RelayClientOptions): RelayClient { if (isMatchingResponse) { clearTimeout(timeoutId); socket.end(); - // Handle error responses + if (response.type === 'ERROR') { reject(new Error(responsePayload?.message || responsePayload?.code || 'Unknown error')); } else if (responsePayload?.error) { @@ -305,7 +198,6 @@ export function createRelayClient(options: RelayClientOptions): RelayClient { socket.on('error', (err) => { clearTimeout(timeoutId); - // Provide user-friendly error for common connection failures const errno = (err as NodeJS.ErrnoException).code; if (errno === 'ECONNREFUSED' || errno === 'ENOENT') { reject(new DaemonNotRunningError(`Cannot connect to daemon at ${socketPath}`)); @@ -318,30 +210,117 @@ export function createRelayClient(options: RelayClientOptions): RelayClient { return { async send(to, message, opts = {}) { - // Fire-and-forget: daemon doesn't respond to non-blocking SEND - // from/to must be at envelope level, kind/body/thread in payload - await fireAndForget('SEND', { kind: 'message', body: message, thread: opts.thread }, { from: agentName, to }); + const payload: SendPayload = { kind: 'message', body: message, thread: opts.thread }; + await fireAndForget('SEND', payload as unknown as Record, { from: agentName, to }); }, + async sendAndWait(to, message, opts = {}) { - // Use proper SEND with sync.blocking - daemon handles the wait and returns ACK - // from/to must be at envelope level, kind/body/thread in payload const waitTimeout = opts.timeoutMs || 30000; const correlationId = randomUUID(); - const r = await request<{ correlationId?: string; response?: string; from?: string }>('SEND', { - kind: 'message', - body: message, - thread: opts.thread, - }, waitTimeout + 5000, { - sync: { - blocking: true, - correlationId, - timeoutMs: waitTimeout, - }, - }, { from: agentName, to }); + const payload: SendPayload = { kind: 'message', body: message, thread: opts.thread }; + + const r = await request<{ correlationId?: string; response?: string; from?: string }>( + 'SEND', + payload as unknown as Record, + waitTimeout + 5000, + { sync: { blocking: true, correlationId, timeoutMs: waitTimeout } }, + { from: agentName, to } + ); return { from: r.from ?? to, content: r.response ?? '', thread: opts.thread }; }, + + async broadcast(message, opts = {}) { + const payload: SendPayload = { kind: (opts.kind as SendPayload['kind']) || 'message', body: message }; + await fireAndForget('SEND', payload as unknown as Record, { from: agentName, to: '*' }); + }, + + async subscribe(topic) { + try { + await fireAndForget('SUBSCRIBE', { topic }, { from: agentName }); + return { success: true }; + } catch (e) { + return { success: false, error: e instanceof Error ? e.message : String(e) }; + } + }, + + async unsubscribe(topic) { + try { + await fireAndForget('UNSUBSCRIBE', { topic }, { from: agentName }); + return { success: true }; + } catch (e) { + return { success: false, error: e instanceof Error ? e.message : String(e) }; + } + }, + + async joinChannel(channel, displayName) { + try { + await fireAndForget('CHANNEL_JOIN', { channel, displayName }, { from: agentName }); + return { success: true }; + } catch (e) { + return { success: false, error: e instanceof Error ? e.message : String(e) }; + } + }, + + async leaveChannel(channel, reason) { + try { + await fireAndForget('CHANNEL_LEAVE', { channel, reason }, { from: agentName }); + return { success: true }; + } catch (e) { + return { success: false, error: e instanceof Error ? e.message : String(e) }; + } + }, + + async sendChannelMessage(channel, message, opts = {}) { + await fireAndForget('CHANNEL_MESSAGE', { channel, body: message, thread: opts.thread }, { from: agentName }); + }, + + async bindAsShadow(primaryAgent, opts = {}) { + try { + await fireAndForget('SHADOW_BIND', { primaryAgent, speakOn: opts.speakOn }, { from: agentName }); + return { success: true }; + } catch (e) { + return { success: false, error: e instanceof Error ? e.message : String(e) }; + } + }, + + async unbindAsShadow(primaryAgent) { + try { + await fireAndForget('SHADOW_UNBIND', { primaryAgent }, { from: agentName }); + return { success: true }; + } catch (e) { + return { success: false, error: e instanceof Error ? e.message : String(e) }; + } + }, + + async createProposal(opts) { + try { + await fireAndForget('PROPOSAL_CREATE', { + id: opts.id, + description: opts.description, + options: opts.options, + votingMethod: opts.votingMethod || 'majority', + deadline: opts.deadline, + }, { from: agentName }); + return { success: true }; + } catch (e) { + return { success: false, error: e instanceof Error ? e.message : String(e) }; + } + }, + + async vote(opts) { + try { + await fireAndForget('VOTE', { + proposalId: opts.proposalId, + vote: opts.vote, + reason: opts.reason, + }, { from: agentName }); + return { success: true }; + } catch (e) { + return { success: false, error: e instanceof Error ? e.message : String(e) }; + } + }, + async spawn(opts) { - // Fire-and-forget: daemon handles spawning, agent will message when ready try { const payload: SpawnPayload = { name: opts.name, @@ -349,29 +328,34 @@ export function createRelayClient(options: RelayClientOptions): RelayClient { task: opts.task, model: opts.model, cwd: opts.cwd, - spawnerName: agentName, // Parent agent making the spawn request + spawnerName: agentName, }; - await fireAndForget('SPAWN', payload); + await fireAndForget('SPAWN', payload as unknown as Record); return { success: true }; } catch (e) { return { success: false, error: e instanceof Error ? e.message : String(e) }; } }, + async release(name, reason) { try { const payload: ReleasePayload = { name, reason }; - await request('RELEASE', payload, RELEASE_TIMEOUT); + await request('RELEASE', payload as unknown as Record, RELEASE_TIMEOUT); return { success: true }; } catch (e) { return { success: false, error: e instanceof Error ? e.message : String(e) }; } }, + async getStatus() { try { const s = await request<{ version?: string; uptime?: number }>('STATUS', {}); return { connected: true, agentName, project, socketPath, daemonVersion: s.version, uptime: s.uptime ? Math.floor(s.uptime/1000)+'s' : undefined }; - } catch { return { connected: false, agentName, project, socketPath }; } + } catch { + return { connected: false, agentName, project, socketPath }; + } }, + async getInbox(opts = {}) { const payload: InboxPayload = { agent: agentName, @@ -380,37 +364,51 @@ export function createRelayClient(options: RelayClientOptions): RelayClient { from: opts.from, channel: opts.channel, }; - const response = await request<{ messages: Array<{ id: string; from: string; body: string; channel?: string; thread?: string; timestamp: number }> }>('INBOX', payload); + const response = await request<{ messages: Array<{ id: string; from: string; body: string; channel?: string; thread?: string; timestamp: number }> }>( + 'INBOX', + payload as unknown as Record + ); const msgs = response.messages || []; return msgs.map(m => ({ id: m.id, from: m.from, content: m.body, channel: m.channel, thread: m.thread })); }, + async listAgents(opts: { include_idle?: boolean; project?: string } = {}) { const payload: ListAgentsPayload = { includeIdle: opts.include_idle, project: opts.project, }; - const response = await request<{ agents: Array<{ name: string; cli?: string; idle?: boolean; parent?: string }> }>('LIST_AGENTS', payload); + const response = await request<{ agents: Array<{ name: string; cli?: string; idle?: boolean; parent?: string }> }>( + 'LIST_AGENTS', + payload as unknown as Record + ); return response.agents || []; }, + async listConnectedAgents(opts: { project?: string } = {}) { const payload = { project: opts.project }; - const response = await request<{ agents: Array<{ name: string; cli?: string; idle?: boolean; parent?: string }> }>('LIST_CONNECTED_AGENTS', payload); + const response = await request<{ agents: Array<{ name: string; cli?: string; idle?: boolean; parent?: string }> }>( + 'LIST_CONNECTED_AGENTS', + payload + ); return response.agents || []; }, + async removeAgent(name: string, opts: { removeMessages?: boolean } = {}) { const payload = { name, removeMessages: opts.removeMessages }; return request<{ success: boolean; removed: boolean; message?: string }>('REMOVE_AGENT', payload); }, + async getHealth(opts: { include_crashes?: boolean; include_alerts?: boolean } = {}) { const payload: HealthPayload = { includeCrashes: opts.include_crashes, includeAlerts: opts.include_alerts, }; - return request('HEALTH', payload); + return request('HEALTH', payload as unknown as Record); }, - async getMetrics(opts = {}) { + + async getMetrics(opts: { agent?: string } = {}) { const payload: MetricsPayload = { agent: opts.agent }; - return request('METRICS', payload); + return request('METRICS', payload as unknown as Record); }, }; } diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 6bdc9a599..ec3aab7be 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -46,6 +46,36 @@ import { relayRemoveAgentTool, relayRemoveAgentSchema, handleRelayRemoveAgent, + relayBroadcastTool, + relayBroadcastSchema, + handleRelayBroadcast, + relaySubscribeTool, + relaySubscribeSchema, + handleRelaySubscribe, + relayUnsubscribeTool, + relayUnsubscribeSchema, + handleRelayUnsubscribe, + relayChannelJoinTool, + relayChannelJoinSchema, + handleRelayChannelJoin, + relayChannelLeaveTool, + relayChannelLeaveSchema, + handleRelayChannelLeave, + relayChannelMessageTool, + relayChannelMessageSchema, + handleRelayChannelMessage, + relayShadowBindTool, + relayShadowBindSchema, + handleRelayShadowBind, + relayShadowUnbindTool, + relayShadowUnbindSchema, + handleRelayShadowUnbind, + relayProposalTool, + relayProposalSchema, + handleRelayProposal, + relayVoteTool, + relayVoteSchema, + handleRelayVote, } from './tools/index.js'; import { protocolPrompt, getProtocolPrompt } from './prompts/index.js'; import { @@ -73,6 +103,16 @@ const TOOLS = [ relayMetricsTool, relayHealthTool, relayContinuityTool, + relayBroadcastTool, + relaySubscribeTool, + relayUnsubscribeTool, + relayChannelJoinTool, + relayChannelLeaveTool, + relayChannelMessageTool, + relayShadowBindTool, + relayShadowUnbindTool, + relayProposalTool, + relayVoteTool, ]; /** @@ -199,6 +239,66 @@ export function createMCPServer(client: RelayClient, config?: MCPServerConfig): break; } + case 'relay_broadcast': { + const input = relayBroadcastSchema.parse(args); + result = await handleRelayBroadcast(client, input); + break; + } + + case 'relay_subscribe': { + const input = relaySubscribeSchema.parse(args); + result = await handleRelaySubscribe(client, input); + break; + } + + case 'relay_unsubscribe': { + const input = relayUnsubscribeSchema.parse(args); + result = await handleRelayUnsubscribe(client, input); + break; + } + + case 'relay_channel_join': { + const input = relayChannelJoinSchema.parse(args); + result = await handleRelayChannelJoin(client, input); + break; + } + + case 'relay_channel_leave': { + const input = relayChannelLeaveSchema.parse(args); + result = await handleRelayChannelLeave(client, input); + break; + } + + case 'relay_channel_message': { + const input = relayChannelMessageSchema.parse(args); + result = await handleRelayChannelMessage(client, input); + break; + } + + case 'relay_shadow_bind': { + const input = relayShadowBindSchema.parse(args); + result = await handleRelayShadowBind(client, input); + break; + } + + case 'relay_shadow_unbind': { + const input = relayShadowUnbindSchema.parse(args); + result = await handleRelayShadowUnbind(client, input); + break; + } + + case 'relay_proposal': { + const input = relayProposalSchema.parse(args); + result = await handleRelayProposal(client, input); + break; + } + + case 'relay_vote': { + const input = relayVoteSchema.parse(args); + result = await handleRelayVote(client, input); + break; + } + default: return { content: [ diff --git a/packages/mcp/src/tools/index.ts b/packages/mcp/src/tools/index.ts index 8fa754513..d12087ec2 100644 --- a/packages/mcp/src/tools/index.ts +++ b/packages/mcp/src/tools/index.ts @@ -76,3 +76,58 @@ export { handleRelayRemoveAgent, type RelayRemoveAgentInput, } from './relay-remove-agent.js'; + +export { + relayBroadcastTool, + relayBroadcastSchema, + handleRelayBroadcast, + type RelayBroadcastInput, +} from './relay-broadcast.js'; + +export { + relaySubscribeTool, + relaySubscribeSchema, + handleRelaySubscribe, + type RelaySubscribeInput, + relayUnsubscribeTool, + relayUnsubscribeSchema, + handleRelayUnsubscribe, + type RelayUnsubscribeInput, +} from './relay-subscribe.js'; + +export { + relayChannelJoinTool, + relayChannelJoinSchema, + handleRelayChannelJoin, + type RelayChannelJoinInput, + relayChannelLeaveTool, + relayChannelLeaveSchema, + handleRelayChannelLeave, + type RelayChannelLeaveInput, + relayChannelMessageTool, + relayChannelMessageSchema, + handleRelayChannelMessage, + type RelayChannelMessageInput, +} from './relay-channel.js'; + +export { + relayShadowBindTool, + relayShadowBindSchema, + handleRelayShadowBind, + type RelayShadowBindInput, + relayShadowUnbindTool, + relayShadowUnbindSchema, + handleRelayShadowUnbind, + type RelayShadowUnbindInput, +} from './relay-shadow.js'; + +export { + relayProposalTool, + relayProposalSchema, + handleRelayProposal, + type RelayProposalInput, + relayVoteTool, + relayVoteSchema, + handleRelayVote, + type RelayVoteInput, +} from './relay-consensus.js'; diff --git a/packages/mcp/src/tools/relay-broadcast.ts b/packages/mcp/src/tools/relay-broadcast.ts new file mode 100644 index 000000000..2fcf4d95e --- /dev/null +++ b/packages/mcp/src/tools/relay-broadcast.ts @@ -0,0 +1,32 @@ +/** + * relay_broadcast - Broadcast a message to all connected agents + */ + +import { z } from 'zod'; +import type { Tool } from '@modelcontextprotocol/sdk/types.js'; +import type { RelayClient } from '../client.js'; + +export const relayBroadcastSchema = z.object({ + message: z.string().describe('The message to broadcast to all agents'), + kind: z.enum(['message', 'action', 'state', 'thinking']).optional().describe('Message kind (default: message)'), +}); + +export type RelayBroadcastInput = z.infer; + +export const relayBroadcastTool: Tool = { + name: 'relay_broadcast', + description: 'Broadcast a message to ALL connected agents at once. Use this when you need to send the same message to everyone.', + inputSchema: { + type: 'object', + properties: { + message: { type: 'string', description: 'The message to broadcast to all agents' }, + kind: { type: 'string', enum: ['message', 'action', 'state', 'thinking'], description: 'Message kind (default: message)' }, + }, + required: ['message'], + }, +}; + +export async function handleRelayBroadcast(client: RelayClient, input: RelayBroadcastInput): Promise { + await client.broadcast(input.message, { kind: input.kind }); + return 'Message broadcast to all agents'; +} diff --git a/packages/mcp/src/tools/relay-channel.ts b/packages/mcp/src/tools/relay-channel.ts new file mode 100644 index 000000000..771c3d5ae --- /dev/null +++ b/packages/mcp/src/tools/relay-channel.ts @@ -0,0 +1,93 @@ +/** + * relay_channel_join / relay_channel_leave / relay_channel_message - Channel operations + */ + +import { z } from 'zod'; +import type { Tool } from '@modelcontextprotocol/sdk/types.js'; +import type { RelayClient } from '../client.js'; + +// Join channel +export const relayChannelJoinSchema = z.object({ + channel: z.string().describe('The channel name to join (e.g., "#general")'), + display_name: z.string().optional().describe('Optional display name to use in the channel'), +}); + +export type RelayChannelJoinInput = z.infer; + +export const relayChannelJoinTool: Tool = { + name: 'relay_channel_join', + description: 'Join a channel to participate in group conversations. Channels start with #.', + inputSchema: { + type: 'object', + properties: { + channel: { type: 'string', description: 'The channel name to join (e.g., "#general")' }, + display_name: { type: 'string', description: 'Optional display name to use in the channel' }, + }, + required: ['channel'], + }, +}; + +export async function handleRelayChannelJoin(client: RelayClient, input: RelayChannelJoinInput): Promise { + const result = await client.joinChannel(input.channel, input.display_name); + if (result.success) { + return `Joined channel "${input.channel}"`; + } + return `Failed to join channel: ${result.error}`; +} + +// Leave channel +export const relayChannelLeaveSchema = z.object({ + channel: z.string().describe('The channel name to leave'), + reason: z.string().optional().describe('Optional reason for leaving'), +}); + +export type RelayChannelLeaveInput = z.infer; + +export const relayChannelLeaveTool: Tool = { + name: 'relay_channel_leave', + description: 'Leave a channel you are currently in.', + inputSchema: { + type: 'object', + properties: { + channel: { type: 'string', description: 'The channel name to leave' }, + reason: { type: 'string', description: 'Optional reason for leaving' }, + }, + required: ['channel'], + }, +}; + +export async function handleRelayChannelLeave(client: RelayClient, input: RelayChannelLeaveInput): Promise { + const result = await client.leaveChannel(input.channel, input.reason); + if (result.success) { + return `Left channel "${input.channel}"`; + } + return `Failed to leave channel: ${result.error}`; +} + +// Send channel message +export const relayChannelMessageSchema = z.object({ + channel: z.string().describe('The channel to send the message to'), + message: z.string().describe('The message content'), + thread: z.string().optional().describe('Optional thread ID for threaded conversations'), +}); + +export type RelayChannelMessageInput = z.infer; + +export const relayChannelMessageTool: Tool = { + name: 'relay_channel_message', + description: 'Send a message to a channel. You must be a member of the channel.', + inputSchema: { + type: 'object', + properties: { + channel: { type: 'string', description: 'The channel to send the message to' }, + message: { type: 'string', description: 'The message content' }, + thread: { type: 'string', description: 'Optional thread ID for threaded conversations' }, + }, + required: ['channel', 'message'], + }, +}; + +export async function handleRelayChannelMessage(client: RelayClient, input: RelayChannelMessageInput): Promise { + await client.sendChannelMessage(input.channel, input.message, { thread: input.thread }); + return `Message sent to channel "${input.channel}"`; +} diff --git a/packages/mcp/src/tools/relay-consensus.ts b/packages/mcp/src/tools/relay-consensus.ts new file mode 100644 index 000000000..0a49db3d1 --- /dev/null +++ b/packages/mcp/src/tools/relay-consensus.ts @@ -0,0 +1,92 @@ +/** + * relay_proposal / relay_vote - Consensus/voting operations + */ + +import { z } from 'zod'; +import type { Tool } from '@modelcontextprotocol/sdk/types.js'; +import type { RelayClient } from '../client.js'; + +// Create proposal +export const relayProposalSchema = z.object({ + id: z.string().describe('Unique identifier for the proposal'), + description: z.string().describe('Description of what is being proposed'), + options: z.array(z.string()).describe('List of voting options'), + voting_method: z.enum(['majority', 'supermajority', 'unanimous', 'weighted', 'quorum']).optional() + .describe('Voting method (default: majority)'), + deadline: z.number().optional().describe('Optional deadline timestamp in milliseconds'), +}); + +export type RelayProposalInput = z.infer; + +export const relayProposalTool: Tool = { + name: 'relay_proposal', + description: 'Create a new proposal for agents to vote on. Use this to coordinate decisions among multiple agents.', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Unique identifier for the proposal' }, + description: { type: 'string', description: 'Description of what is being proposed' }, + options: { + type: 'array', + items: { type: 'string' }, + description: 'List of voting options', + }, + voting_method: { + type: 'string', + enum: ['majority', 'supermajority', 'unanimous', 'weighted', 'quorum'], + description: 'Voting method (default: majority)', + }, + deadline: { type: 'number', description: 'Optional deadline timestamp in milliseconds' }, + }, + required: ['id', 'description', 'options'], + }, +}; + +export async function handleRelayProposal(client: RelayClient, input: RelayProposalInput): Promise { + const result = await client.createProposal({ + id: input.id, + description: input.description, + options: input.options, + votingMethod: input.voting_method, + deadline: input.deadline, + }); + if (result.success) { + return `Proposal "${input.id}" created successfully. Options: ${input.options.join(', ')}`; + } + return `Failed to create proposal: ${result.error}`; +} + +// Vote on proposal +export const relayVoteSchema = z.object({ + proposal_id: z.string().describe('The ID of the proposal to vote on'), + vote: z.string().describe('Your vote (must be one of the proposal options, or "approve"/"reject"/"abstain")'), + reason: z.string().optional().describe('Optional reason for your vote'), +}); + +export type RelayVoteInput = z.infer; + +export const relayVoteTool: Tool = { + name: 'relay_vote', + description: 'Cast a vote on an existing proposal.', + inputSchema: { + type: 'object', + properties: { + proposal_id: { type: 'string', description: 'The ID of the proposal to vote on' }, + vote: { type: 'string', description: 'Your vote (must be one of the proposal options, or "approve"/"reject"/"abstain")' }, + reason: { type: 'string', description: 'Optional reason for your vote' }, + }, + required: ['proposal_id', 'vote'], + }, +}; + +export async function handleRelayVote(client: RelayClient, input: RelayVoteInput): Promise { + const result = await client.vote({ + proposalId: input.proposal_id, + vote: input.vote, + reason: input.reason, + }); + if (result.success) { + return `Vote "${input.vote}" cast on proposal "${input.proposal_id}"`; + } + return `Failed to vote: ${result.error}`; +} diff --git a/packages/mcp/src/tools/relay-shadow.ts b/packages/mcp/src/tools/relay-shadow.ts new file mode 100644 index 000000000..c671d5347 --- /dev/null +++ b/packages/mcp/src/tools/relay-shadow.ts @@ -0,0 +1,67 @@ +/** + * relay_shadow_bind / relay_shadow_unbind - Shadow agent operations + */ + +import { z } from 'zod'; +import type { Tool } from '@modelcontextprotocol/sdk/types.js'; +import type { RelayClient } from '../client.js'; + +// Bind as shadow +export const relayShadowBindSchema = z.object({ + primary_agent: z.string().describe('The name of the primary agent to shadow'), + speak_on: z.array(z.string()).optional().describe('Events that trigger the shadow to speak (e.g., ["SESSION_END", "CODE_WRITTEN"])'), +}); + +export type RelayShadowBindInput = z.infer; + +export const relayShadowBindTool: Tool = { + name: 'relay_shadow_bind', + description: 'Bind as a shadow agent to monitor another agent. Shadows can observe and optionally respond to specific events.', + inputSchema: { + type: 'object', + properties: { + primary_agent: { type: 'string', description: 'The name of the primary agent to shadow' }, + speak_on: { + type: 'array', + items: { type: 'string' }, + description: 'Events that trigger the shadow to speak (e.g., ["SESSION_END", "CODE_WRITTEN", "REVIEW_REQUEST"])', + }, + }, + required: ['primary_agent'], + }, +}; + +export async function handleRelayShadowBind(client: RelayClient, input: RelayShadowBindInput): Promise { + const result = await client.bindAsShadow(input.primary_agent, { speakOn: input.speak_on }); + if (result.success) { + return `Now shadowing agent "${input.primary_agent}"`; + } + return `Failed to bind as shadow: ${result.error}`; +} + +// Unbind from shadow +export const relayShadowUnbindSchema = z.object({ + primary_agent: z.string().describe('The name of the primary agent to stop shadowing'), +}); + +export type RelayShadowUnbindInput = z.infer; + +export const relayShadowUnbindTool: Tool = { + name: 'relay_shadow_unbind', + description: 'Stop shadowing an agent.', + inputSchema: { + type: 'object', + properties: { + primary_agent: { type: 'string', description: 'The name of the primary agent to stop shadowing' }, + }, + required: ['primary_agent'], + }, +}; + +export async function handleRelayShadowUnbind(client: RelayClient, input: RelayShadowUnbindInput): Promise { + const result = await client.unbindAsShadow(input.primary_agent); + if (result.success) { + return `Stopped shadowing agent "${input.primary_agent}"`; + } + return `Failed to unbind from shadow: ${result.error}`; +} diff --git a/packages/mcp/src/tools/relay-subscribe.ts b/packages/mcp/src/tools/relay-subscribe.ts new file mode 100644 index 000000000..1a01475fb --- /dev/null +++ b/packages/mcp/src/tools/relay-subscribe.ts @@ -0,0 +1,61 @@ +/** + * relay_subscribe / relay_unsubscribe - Pub/Sub topic subscription + */ + +import { z } from 'zod'; +import type { Tool } from '@modelcontextprotocol/sdk/types.js'; +import type { RelayClient } from '../client.js'; + +// Subscribe schema and tool +export const relaySubscribeSchema = z.object({ + topic: z.string().describe('The topic to subscribe to'), +}); + +export type RelaySubscribeInput = z.infer; + +export const relaySubscribeTool: Tool = { + name: 'relay_subscribe', + description: 'Subscribe to a topic to receive messages published to that topic.', + inputSchema: { + type: 'object', + properties: { + topic: { type: 'string', description: 'The topic to subscribe to' }, + }, + required: ['topic'], + }, +}; + +export async function handleRelaySubscribe(client: RelayClient, input: RelaySubscribeInput): Promise { + const result = await client.subscribe(input.topic); + if (result.success) { + return `Subscribed to topic "${input.topic}"`; + } + return `Failed to subscribe: ${result.error}`; +} + +// Unsubscribe schema and tool +export const relayUnsubscribeSchema = z.object({ + topic: z.string().describe('The topic to unsubscribe from'), +}); + +export type RelayUnsubscribeInput = z.infer; + +export const relayUnsubscribeTool: Tool = { + name: 'relay_unsubscribe', + description: 'Unsubscribe from a topic to stop receiving messages from it.', + inputSchema: { + type: 'object', + properties: { + topic: { type: 'string', description: 'The topic to unsubscribe from' }, + }, + required: ['topic'], + }, +}; + +export async function handleRelayUnsubscribe(client: RelayClient, input: RelayUnsubscribeInput): Promise { + const result = await client.unsubscribe(input.topic); + if (result.success) { + return `Unsubscribed from topic "${input.topic}"`; + } + return `Failed to unsubscribe: ${result.error}`; +} diff --git a/packages/mcp/tests/client.test.ts b/packages/mcp/tests/client.test.ts index 0a5c9ce15..66119f1a8 100644 --- a/packages/mcp/tests/client.test.ts +++ b/packages/mcp/tests/client.test.ts @@ -136,4 +136,280 @@ describe('RelayClient', () => { expect(result.success).toBe(false); expect(result.error).toContain('Cannot connect to daemon'); }); + + it('sends message with thread', async () => { + await client.send('Worker', 'Continue', { thread: 'task-123' }); + + const writeCall = mockSocket.write.mock.calls[0][0]; + const req = decodeFrame(writeCall); + expect(req.payload).toEqual({ + kind: 'message', + body: 'Continue', + thread: 'task-123', + }); + }); + + it('spawns worker with all options', async () => { + await client.spawn({ + name: 'TestWorker', + cli: 'claude', + task: 'Test task', + model: 'claude-3-opus', + cwd: '/tmp/project', + }); + + const writeCall = mockSocket.write.mock.calls[0][0]; + const req = decodeFrame(writeCall); + expect(req.type).toBe('SPAWN'); + expect(req.payload).toMatchObject({ + name: 'TestWorker', + cli: 'claude', + task: 'Test task', + model: 'claude-3-opus', + cwd: '/tmp/project', + spawnerName: 'test-agent', + }); + }); + + it('lists agents', async () => { + const mockAgents = [ + { name: 'Orchestrator', cli: 'sdk', idle: false }, + { name: 'Worker1', cli: 'claude', idle: false, parent: 'Orchestrator' }, + { name: 'Worker2', cli: 'claude', idle: true, parent: 'Orchestrator' }, + ]; + + mockSocket.on.mockImplementation((event: string, cb: any) => { + if (event === 'connect') cb(); + if (event === 'data') { + setTimeout(() => { + const writeCall = mockSocket.write.mock.calls[0][0]; + const req = decodeFrame(writeCall); + const response = { + id: req.id, + payload: { agents: mockAgents }, + }; + cb(encodeFrame(response)); + }, 10); + } + return mockSocket; + }); + + const agents = await client.listAgents({ include_idle: true }); + + expect(agents).toHaveLength(3); + expect(agents[0].name).toBe('Orchestrator'); + expect(agents[1].parent).toBe('Orchestrator'); + expect(agents[2].idle).toBe(true); + + const req = decodeFrame(mockSocket.write.mock.calls[0][0]); + expect(req.type).toBe('LIST_AGENTS'); + expect(req.payload).toMatchObject({ includeIdle: true }); + }); + + it('releases worker', async () => { + mockSocket.on.mockImplementation((event: string, cb: any) => { + if (event === 'connect') cb(); + if (event === 'data') { + setTimeout(() => { + const writeCall = mockSocket.write.mock.calls[0][0]; + const req = decodeFrame(writeCall); + const response = { + id: req.id, + payload: { success: true }, + }; + cb(encodeFrame(response)); + }, 10); + } + return mockSocket; + }); + + const result = await client.release('Worker1', 'task completed'); + + expect(result.success).toBe(true); + + const req = decodeFrame(mockSocket.write.mock.calls[0][0]); + expect(req.type).toBe('RELEASE'); + expect(req.payload).toMatchObject({ + name: 'Worker1', + reason: 'task completed', + }); + }); + + it('handles release of non-existent worker', async () => { + mockSocket.on.mockImplementation((event: string, cb: any) => { + if (event === 'connect') cb(); + if (event === 'data') { + setTimeout(() => { + const writeCall = mockSocket.write.mock.calls[0][0]; + const req = decodeFrame(writeCall); + const response = { + id: req.id, + payload: { success: false, error: 'Agent not found' }, + }; + cb(encodeFrame(response)); + }, 10); + } + return mockSocket; + }); + + const result = await client.release('NonExistent'); + + expect(result.success).toBe(false); + expect(result.error).toBe('Agent not found'); + }); + + it('gets status', async () => { + mockSocket.on.mockImplementation((event: string, cb: any) => { + if (event === 'connect') cb(); + if (event === 'data') { + setTimeout(() => { + const writeCall = mockSocket.write.mock.calls[0][0]; + const req = decodeFrame(writeCall); + const response = { + id: req.id, + payload: { version: '1.0.0', uptime: 3600000 }, + }; + cb(encodeFrame(response)); + }, 10); + } + return mockSocket; + }); + + const status = await client.getStatus(); + + expect(status.connected).toBe(true); + expect(status.agentName).toBe('test-agent'); + expect(status.daemonVersion).toBe('1.0.0'); + expect(status.uptime).toBe('3600s'); + }); + + it('handles ENOENT error (socket not found)', async () => { + mockSocket.on.mockImplementation((event: string, cb: any) => { + if (event === 'error') { + setTimeout(() => { + const err = new Error('Socket not found') as NodeJS.ErrnoException; + err.code = 'ENOENT'; + cb(err); + }, 10); + } + return mockSocket; + }); + + const result = await client.spawn({ + name: 'Worker', + cli: 'claude', + task: 'task', + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('Cannot connect to daemon'); + }); +}); + +// ============================================================================ +// Multi-Agent Client Scenarios (SDK parity) +// ============================================================================ + +describe('RelayClient multi-agent scenarios', () => { + let mockSocket: any; + + beforeEach(() => { + mockSocket = { + on: vi.fn(), + write: vi.fn(), + end: vi.fn(), + destroy: vi.fn(), + }; + vi.mocked(createConnection).mockReturnValue(mockSocket); + + mockSocket.on.mockImplementation((event: string, cb: any) => { + if (event === 'connect') { + setTimeout(cb, 0); + } + return mockSocket; + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('spawns multiple workers from same orchestrator', async () => { + const client = createRelayClient({ + agentName: 'orchestrator', + socketPath: '/tmp/test.sock', + }); + + // Spawn Worker1 + await client.spawn({ name: 'Worker1', cli: 'claude', task: 'Task 1' }); + + // Spawn Worker2 + await client.spawn({ name: 'Worker2', cli: 'claude', task: 'Task 2' }); + + // Spawn Worker3 + await client.spawn({ name: 'Worker3', cli: 'codex', task: 'Task 3' }); + + expect(mockSocket.write).toHaveBeenCalledTimes(3); + + // Verify each spawn has correct spawnerName + for (let i = 0; i < 3; i++) { + const req = decodeFrame(mockSocket.write.mock.calls[i][0]); + expect(req.type).toBe('SPAWN'); + expect((req.payload as any).spawnerName).toBe('orchestrator'); + } + }); + + it('sends messages to multiple agents', async () => { + const client = createRelayClient({ + agentName: 'coordinator', + socketPath: '/tmp/test.sock', + }); + + const targets = ['Alice', 'Bob', 'Charlie']; + for (const target of targets) { + await client.send(target, `Hello ${target}`); + } + + expect(mockSocket.write).toHaveBeenCalledTimes(3); + + // Verify each message has correct target + const reqs = mockSocket.write.mock.calls.map((call: any) => decodeFrame(call[0])); + expect(reqs[0].to).toBe('Alice'); + expect(reqs[1].to).toBe('Bob'); + expect(reqs[2].to).toBe('Charlie'); + }); + + it('handles inbox with multiple senders', async () => { + const mockMessages = [ + { id: '1', from: 'Alice', body: 'Hello from Alice' }, + { id: '2', from: 'Bob', body: 'Hello from Bob' }, + { id: '3', from: 'Charlie', body: 'Hello from Charlie' }, + ]; + + mockSocket.on.mockImplementation((event: string, cb: any) => { + if (event === 'connect') cb(); + if (event === 'data') { + setTimeout(() => { + const writeCall = mockSocket.write.mock.calls[0][0]; + const req = decodeFrame(writeCall); + const response = { + id: req.id, + payload: { messages: mockMessages }, + }; + cb(encodeFrame(response)); + }, 10); + } + return mockSocket; + }); + + const client = createRelayClient({ + agentName: 'coordinator', + socketPath: '/tmp/test.sock', + }); + + const inbox = await client.getInbox(); + + expect(inbox).toHaveLength(3); + expect(inbox.map(m => m.from)).toEqual(['Alice', 'Bob', 'Charlie']); + }); }); diff --git a/packages/mcp/tests/tools.test.ts b/packages/mcp/tests/tools.test.ts index ff64a5eec..f6c5802e0 100644 --- a/packages/mcp/tests/tools.test.ts +++ b/packages/mcp/tests/tools.test.ts @@ -10,6 +10,26 @@ import { handleRelaySpawn, handleRelayRelease, handleRelayStatus, + handleRelayBroadcast, + relayBroadcastSchema, + handleRelaySubscribe, + relaySubscribeSchema, + handleRelayUnsubscribe, + relayUnsubscribeSchema, + handleRelayChannelJoin, + relayChannelJoinSchema, + handleRelayChannelLeave, + relayChannelLeaveSchema, + handleRelayChannelMessage, + relayChannelMessageSchema, + handleRelayShadowBind, + relayShadowBindSchema, + handleRelayShadowUnbind, + relayShadowUnbindSchema, + handleRelayProposal, + relayProposalSchema, + handleRelayVote, + relayVoteSchema, } from '../src/tools/index.js'; /** @@ -25,6 +45,16 @@ function createMockClient(overrides: Partial { expect(result).toContain('Daemon Version: 0.1.0'); expect(result).toContain('Uptime: 1h'); }); + + it('handles disconnected status', async () => { + vi.mocked(mockClient.getStatus).mockResolvedValue({ + connected: false, + agentName: 'AgentA', + project: 'proj', + socketPath: '/tmp/socket', + }); + + const result = await handleRelayStatus(mockClient, {}); + + expect(result).toContain('Connected: No'); + }); +}); + +// ============================================================================ +// Multi-Agent Scenarios (SDK parity tests) +// ============================================================================ + +describe('multi-agent scenarios', () => { + let mockClient: RelayClient; + + beforeEach(() => { + vi.clearAllMocks(); + mockClient = createMockClient(); + }); + + it('spawns multiple workers sequentially', async () => { + vi.mocked(mockClient.spawn).mockResolvedValue({ success: true }); + + // Spawn Worker1 + const result1 = await handleRelaySpawn(mockClient, { + name: 'Worker1', + cli: 'claude', + task: 'Task 1', + }); + expect(result1).toContain('spawned successfully'); + + // Spawn Worker2 + const result2 = await handleRelaySpawn(mockClient, { + name: 'Worker2', + cli: 'claude', + task: 'Task 2', + }); + expect(result2).toContain('spawned successfully'); + + // Spawn Worker3 + const result3 = await handleRelaySpawn(mockClient, { + name: 'Worker3', + cli: 'claude', + task: 'Task 3', + }); + expect(result3).toContain('spawned successfully'); + + expect(mockClient.spawn).toHaveBeenCalledTimes(3); + }); + + it('lists multiple agents with different statuses', async () => { + vi.mocked(mockClient.listAgents).mockResolvedValue([ + { name: 'Orchestrator', cli: 'sdk', idle: false }, + { name: 'Worker1', cli: 'claude', idle: false, parent: 'Orchestrator' }, + { name: 'Worker2', cli: 'claude', idle: true, parent: 'Orchestrator' }, + { name: 'Worker3', cli: 'codex', idle: false, parent: 'Orchestrator' }, + ]); + + const input = relayWhoSchema.parse({ include_idle: true }); + const result = await handleRelayWho(mockClient, input); + + expect(result).toContain('4 agent(s) online:'); + expect(result).toContain('Orchestrator'); + expect(result).toContain('Worker1'); + expect(result).toContain('Worker2'); + expect(result).toContain('Worker3'); + }); + + it('releases multiple workers', async () => { + vi.mocked(mockClient.release) + .mockResolvedValueOnce({ success: true }) + .mockResolvedValueOnce({ success: true }) + .mockResolvedValueOnce({ success: false, error: 'already exited' }); + + const result1 = await handleRelayRelease(mockClient, { name: 'Worker1' }); + expect(result1).toBe('Worker "Worker1" released.'); + + const result2 = await handleRelayRelease(mockClient, { name: 'Worker2' }); + expect(result2).toBe('Worker "Worker2" released.'); + + const result3 = await handleRelayRelease(mockClient, { name: 'Worker3' }); + expect(result3).toBe('Failed to release worker: already exited'); + + expect(mockClient.release).toHaveBeenCalledTimes(3); + }); + + it('handles release of non-existent agent gracefully', async () => { + vi.mocked(mockClient.release).mockResolvedValue({ + success: false, + error: 'Agent not found: NonExistentAgent', + }); + + const result = await handleRelayRelease(mockClient, { + name: 'NonExistentAgent', + }); + + expect(result).toBe('Failed to release worker: Agent not found: NonExistentAgent'); + }); +}); + +// ============================================================================ +// Message Threading Scenarios +// ============================================================================ + +describe('message threading', () => { + let mockClient: RelayClient; + + beforeEach(() => { + vi.clearAllMocks(); + mockClient = createMockClient(); + }); + + it('sends message with thread ID', async () => { + vi.mocked(mockClient.send).mockResolvedValue(undefined); + + const input = relaySendSchema.parse({ + to: 'Worker', + message: 'Continue task', + thread: 'task-thread-123', + }); + await handleRelaySend(mockClient, input); + + expect(mockClient.send).toHaveBeenCalledWith('Worker', 'Continue task', { + thread: 'task-thread-123', + }); + }); + + it('filters inbox by thread', async () => { + vi.mocked(mockClient.getInbox).mockResolvedValue([ + { id: '1', from: 'Worker', content: 'Done step 1', thread: 'task-123' }, + { id: '2', from: 'Worker', content: 'Done step 2', thread: 'task-123' }, + ]); + + const input = relayInboxSchema.parse({ limit: 10 }); + const result = await handleRelayInbox(mockClient, input); + + expect(result).toContain('2 message(s):'); + expect(result).toContain('(thread: task-123)'); + }); +}); + +// ============================================================================ +// Broadcast-like Scenarios (send to multiple agents) +// ============================================================================ + +describe('broadcast scenarios', () => { + let mockClient: RelayClient; + + beforeEach(() => { + vi.clearAllMocks(); + mockClient = createMockClient(); + }); + + it('sends messages to multiple agents', async () => { + vi.mocked(mockClient.send).mockResolvedValue(undefined); + const agents = ['Alice', 'Bob', 'Charlie']; + + for (const agent of agents) { + const input = relaySendSchema.parse({ + to: agent, + message: 'Broadcast message to all', + }); + await handleRelaySend(mockClient, input); + } + + expect(mockClient.send).toHaveBeenCalledTimes(3); + expect(mockClient.send).toHaveBeenCalledWith('Alice', 'Broadcast message to all', { thread: undefined }); + expect(mockClient.send).toHaveBeenCalledWith('Bob', 'Broadcast message to all', { thread: undefined }); + expect(mockClient.send).toHaveBeenCalledWith('Charlie', 'Broadcast message to all', { thread: undefined }); + }); + + it('receives messages from multiple senders', async () => { + vi.mocked(mockClient.getInbox).mockResolvedValue([ + { id: '1', from: 'Alice', content: 'Hello from Alice' }, + { id: '2', from: 'Bob', content: 'Hello from Bob' }, + { id: '3', from: 'Charlie', content: 'Hello from Charlie' }, + ]); + + const input = relayInboxSchema.parse({ limit: 10 }); + const result = await handleRelayInbox(mockClient, input); + + expect(result).toContain('3 message(s):'); + expect(result).toContain('From Alice'); + expect(result).toContain('From Bob'); + expect(result).toContain('From Charlie'); + }); +}); + +// ============================================================================ +// Negotiation-like Workflow Scenarios +// ============================================================================ + +describe('negotiation workflow', () => { + let mockClient: RelayClient; + + beforeEach(() => { + vi.clearAllMocks(); + mockClient = createMockClient(); + }); + + it('coordinates multi-round communication', async () => { + vi.mocked(mockClient.spawn).mockResolvedValue({ success: true }); + vi.mocked(mockClient.send).mockResolvedValue(undefined); + vi.mocked(mockClient.sendAndWait) + .mockResolvedValueOnce({ from: 'Frontend', content: 'Frontend priorities: Design System, Accessibility' }) + .mockResolvedValueOnce({ from: 'Backend', content: 'Backend priorities: Microservices, Caching' }) + .mockResolvedValueOnce({ from: 'Infra', content: 'Infra priorities: Kubernetes, Multi-Region' }); + + // Spawn agents + const teams = ['Frontend', 'Backend', 'Infra']; + for (const team of teams) { + await handleRelaySpawn(mockClient, { + name: team, + cli: 'claude', + task: `You are the ${team} team lead`, + }); + } + expect(mockClient.spawn).toHaveBeenCalledTimes(3); + + // Request introductions (with await_response) + for (const team of teams) { + await handleRelaySend(mockClient, { + to: team, + message: 'Please introduce yourself', + await_response: true, + timeout_ms: 30000, + }); + } + expect(mockClient.sendAndWait).toHaveBeenCalledTimes(3); + }); + + it('handles voting responses', async () => { + vi.mocked(mockClient.getInbox).mockResolvedValue([ + { id: '1', from: 'Frontend', content: 'I VOTE: Frontend=$35000, Backend=$35000, Infra=$30000' }, + { id: '2', from: 'Backend', content: 'I VOTE: Frontend=$30000, Backend=$40000, Infra=$30000' }, + { id: '3', from: 'Infra', content: 'I VOTE: Frontend=$33000, Backend=$35000, Infra=$32000' }, + ]); + + const input = relayInboxSchema.parse({ limit: 10 }); + const result = await handleRelayInbox(mockClient, input); + + expect(result).toContain('3 message(s):'); + expect(result).toContain('I VOTE:'); + expect(result).toContain('Frontend'); + expect(result).toContain('Backend'); + expect(result).toContain('Infra'); + }); +}); + +// ============================================================================ +// Broadcast Tool Tests +// ============================================================================ + +describe('relay_broadcast', () => { + let mockClient: RelayClient; + + beforeEach(() => { + vi.clearAllMocks(); + mockClient = createMockClient(); + }); + + it('broadcasts a message to all agents', async () => { + vi.mocked(mockClient.broadcast).mockResolvedValue(undefined); + + const input = relayBroadcastSchema.parse({ + message: 'Hello everyone!', + }); + const result = await handleRelayBroadcast(mockClient, input); + + expect(result).toBe('Message broadcast to all agents'); + expect(mockClient.broadcast).toHaveBeenCalledWith('Hello everyone!', { kind: undefined }); + }); + + it('broadcasts with message kind', async () => { + vi.mocked(mockClient.broadcast).mockResolvedValue(undefined); + + const input = relayBroadcastSchema.parse({ + message: 'System update', + kind: 'action', + }); + const result = await handleRelayBroadcast(mockClient, input); + + expect(result).toBe('Message broadcast to all agents'); + expect(mockClient.broadcast).toHaveBeenCalledWith('System update', { kind: 'action' }); + }); + + it('supports different message kinds', async () => { + vi.mocked(mockClient.broadcast).mockResolvedValue(undefined); + + const kinds = ['message', 'action', 'state', 'thinking'] as const; + for (const kind of kinds) { + const input = relayBroadcastSchema.parse({ + message: `Test ${kind}`, + kind, + }); + await handleRelayBroadcast(mockClient, input); + } + + expect(mockClient.broadcast).toHaveBeenCalledTimes(4); + }); +}); + +// ============================================================================ +// Subscribe/Unsubscribe Tool Tests +// ============================================================================ + +describe('relay_subscribe', () => { + let mockClient: RelayClient; + + beforeEach(() => { + vi.clearAllMocks(); + mockClient = createMockClient(); + }); + + it('subscribes to a topic', async () => { + vi.mocked(mockClient.subscribe).mockResolvedValue({ success: true }); + + const input = relaySubscribeSchema.parse({ + topic: 'updates', + }); + const result = await handleRelaySubscribe(mockClient, input); + + expect(result).toBe('Subscribed to topic "updates"'); + expect(mockClient.subscribe).toHaveBeenCalledWith('updates'); + }); + + it('returns error when subscription fails', async () => { + vi.mocked(mockClient.subscribe).mockResolvedValue({ + success: false, + error: 'Topic does not exist', + }); + + const input = relaySubscribeSchema.parse({ + topic: 'nonexistent', + }); + const result = await handleRelaySubscribe(mockClient, input); + + expect(result).toBe('Failed to subscribe: Topic does not exist'); + }); +}); + +describe('relay_unsubscribe', () => { + let mockClient: RelayClient; + + beforeEach(() => { + vi.clearAllMocks(); + mockClient = createMockClient(); + }); + + it('unsubscribes from a topic', async () => { + vi.mocked(mockClient.unsubscribe).mockResolvedValue({ success: true }); + + const input = relayUnsubscribeSchema.parse({ + topic: 'updates', + }); + const result = await handleRelayUnsubscribe(mockClient, input); + + expect(result).toBe('Unsubscribed from topic "updates"'); + expect(mockClient.unsubscribe).toHaveBeenCalledWith('updates'); + }); + + it('returns error when unsubscribe fails', async () => { + vi.mocked(mockClient.unsubscribe).mockResolvedValue({ + success: false, + error: 'Not subscribed to topic', + }); + + const input = relayUnsubscribeSchema.parse({ + topic: 'random', + }); + const result = await handleRelayUnsubscribe(mockClient, input); + + expect(result).toBe('Failed to unsubscribe: Not subscribed to topic'); + }); +}); + +// ============================================================================ +// Channel Tool Tests +// ============================================================================ + +describe('relay_channel_join', () => { + let mockClient: RelayClient; + + beforeEach(() => { + vi.clearAllMocks(); + mockClient = createMockClient(); + }); + + it('joins a channel', async () => { + vi.mocked(mockClient.joinChannel).mockResolvedValue({ success: true }); + + const input = relayChannelJoinSchema.parse({ + channel: '#general', + }); + const result = await handleRelayChannelJoin(mockClient, input); + + expect(result).toBe('Joined channel "#general"'); + expect(mockClient.joinChannel).toHaveBeenCalledWith('#general', undefined); + }); + + it('joins a channel with display name', async () => { + vi.mocked(mockClient.joinChannel).mockResolvedValue({ success: true }); + + const input = relayChannelJoinSchema.parse({ + channel: '#dev-team', + display_name: 'Alice (Lead)', + }); + const result = await handleRelayChannelJoin(mockClient, input); + + expect(result).toBe('Joined channel "#dev-team"'); + expect(mockClient.joinChannel).toHaveBeenCalledWith('#dev-team', 'Alice (Lead)'); + }); + + it('returns error when join fails', async () => { + vi.mocked(mockClient.joinChannel).mockResolvedValue({ + success: false, + error: 'Channel is private', + }); + + const input = relayChannelJoinSchema.parse({ + channel: '#secret', + }); + const result = await handleRelayChannelJoin(mockClient, input); + + expect(result).toBe('Failed to join channel: Channel is private'); + }); +}); + +describe('relay_channel_leave', () => { + let mockClient: RelayClient; + + beforeEach(() => { + vi.clearAllMocks(); + mockClient = createMockClient(); + }); + + it('leaves a channel', async () => { + vi.mocked(mockClient.leaveChannel).mockResolvedValue({ success: true }); + + const input = relayChannelLeaveSchema.parse({ + channel: '#general', + }); + const result = await handleRelayChannelLeave(mockClient, input); + + expect(result).toBe('Left channel "#general"'); + expect(mockClient.leaveChannel).toHaveBeenCalledWith('#general', undefined); + }); + + it('leaves a channel with reason', async () => { + vi.mocked(mockClient.leaveChannel).mockResolvedValue({ success: true }); + + const input = relayChannelLeaveSchema.parse({ + channel: '#dev-team', + reason: 'Task completed', + }); + const result = await handleRelayChannelLeave(mockClient, input); + + expect(result).toBe('Left channel "#dev-team"'); + expect(mockClient.leaveChannel).toHaveBeenCalledWith('#dev-team', 'Task completed'); + }); + + it('returns error when leave fails', async () => { + vi.mocked(mockClient.leaveChannel).mockResolvedValue({ + success: false, + error: 'Not a member of channel', + }); + + const input = relayChannelLeaveSchema.parse({ + channel: '#random', + }); + const result = await handleRelayChannelLeave(mockClient, input); + + expect(result).toBe('Failed to leave channel: Not a member of channel'); + }); +}); + +describe('relay_channel_message', () => { + let mockClient: RelayClient; + + beforeEach(() => { + vi.clearAllMocks(); + mockClient = createMockClient(); + }); + + it('sends a message to a channel', async () => { + vi.mocked(mockClient.sendChannelMessage).mockResolvedValue(undefined); + + const input = relayChannelMessageSchema.parse({ + channel: '#general', + message: 'Hello channel!', + }); + const result = await handleRelayChannelMessage(mockClient, input); + + expect(result).toBe('Message sent to channel "#general"'); + expect(mockClient.sendChannelMessage).toHaveBeenCalledWith('#general', 'Hello channel!', { thread: undefined }); + }); + + it('sends a threaded message to a channel', async () => { + vi.mocked(mockClient.sendChannelMessage).mockResolvedValue(undefined); + + const input = relayChannelMessageSchema.parse({ + channel: '#dev-team', + message: 'Follow-up on this', + thread: 'thread-123', + }); + const result = await handleRelayChannelMessage(mockClient, input); + + expect(result).toBe('Message sent to channel "#dev-team"'); + expect(mockClient.sendChannelMessage).toHaveBeenCalledWith('#dev-team', 'Follow-up on this', { thread: 'thread-123' }); + }); +}); + +// ============================================================================ +// Shadow Agent Tool Tests +// ============================================================================ + +describe('relay_shadow_bind', () => { + let mockClient: RelayClient; + + beforeEach(() => { + vi.clearAllMocks(); + mockClient = createMockClient(); + }); + + it('binds as a shadow agent', async () => { + vi.mocked(mockClient.bindAsShadow).mockResolvedValue({ success: true }); + + const input = relayShadowBindSchema.parse({ + primary_agent: 'Alice', + }); + const result = await handleRelayShadowBind(mockClient, input); + + expect(result).toBe('Now shadowing agent "Alice"'); + expect(mockClient.bindAsShadow).toHaveBeenCalledWith('Alice', { speakOn: undefined }); + }); + + it('binds with speak_on events', async () => { + vi.mocked(mockClient.bindAsShadow).mockResolvedValue({ success: true }); + + const input = relayShadowBindSchema.parse({ + primary_agent: 'Alice', + speak_on: ['SESSION_END', 'CODE_WRITTEN'], + }); + const result = await handleRelayShadowBind(mockClient, input); + + expect(result).toBe('Now shadowing agent "Alice"'); + expect(mockClient.bindAsShadow).toHaveBeenCalledWith('Alice', { + speakOn: ['SESSION_END', 'CODE_WRITTEN'], + }); + }); + + it('returns error when bind fails', async () => { + vi.mocked(mockClient.bindAsShadow).mockResolvedValue({ + success: false, + error: 'Agent not found', + }); + + const input = relayShadowBindSchema.parse({ + primary_agent: 'Unknown', + }); + const result = await handleRelayShadowBind(mockClient, input); + + expect(result).toBe('Failed to bind as shadow: Agent not found'); + }); +}); + +describe('relay_shadow_unbind', () => { + let mockClient: RelayClient; + + beforeEach(() => { + vi.clearAllMocks(); + mockClient = createMockClient(); + }); + + it('unbinds from shadowing', async () => { + vi.mocked(mockClient.unbindAsShadow).mockResolvedValue({ success: true }); + + const input = relayShadowUnbindSchema.parse({ + primary_agent: 'Alice', + }); + const result = await handleRelayShadowUnbind(mockClient, input); + + expect(result).toBe('Stopped shadowing agent "Alice"'); + expect(mockClient.unbindAsShadow).toHaveBeenCalledWith('Alice'); + }); + + it('returns error when unbind fails', async () => { + vi.mocked(mockClient.unbindAsShadow).mockResolvedValue({ + success: false, + error: 'Not shadowing this agent', + }); + + const input = relayShadowUnbindSchema.parse({ + primary_agent: 'Bob', + }); + const result = await handleRelayShadowUnbind(mockClient, input); + + expect(result).toBe('Failed to unbind from shadow: Not shadowing this agent'); + }); +}); + +// ============================================================================ +// Consensus/Voting Tool Tests +// ============================================================================ + +describe('relay_proposal', () => { + let mockClient: RelayClient; + + beforeEach(() => { + vi.clearAllMocks(); + mockClient = createMockClient(); + }); + + it('creates a proposal', async () => { + vi.mocked(mockClient.createProposal).mockResolvedValue({ success: true }); + + const input = relayProposalSchema.parse({ + id: 'budget-2024', + description: 'Vote on Q1 budget allocation', + options: ['Option A: $50k', 'Option B: $75k', 'Option C: $100k'], + }); + const result = await handleRelayProposal(mockClient, input); + + expect(result).toBe('Proposal "budget-2024" created successfully. Options: Option A: $50k, Option B: $75k, Option C: $100k'); + expect(mockClient.createProposal).toHaveBeenCalledWith({ + id: 'budget-2024', + description: 'Vote on Q1 budget allocation', + options: ['Option A: $50k', 'Option B: $75k', 'Option C: $100k'], + votingMethod: undefined, + deadline: undefined, + }); + }); + + it('creates a proposal with voting method', async () => { + vi.mocked(mockClient.createProposal).mockResolvedValue({ success: true }); + + const input = relayProposalSchema.parse({ + id: 'critical-decision', + description: 'Requires unanimous agreement', + options: ['approve', 'reject'], + voting_method: 'unanimous', + }); + const result = await handleRelayProposal(mockClient, input); + + expect(result).toContain('created successfully'); + expect(mockClient.createProposal).toHaveBeenCalledWith({ + id: 'critical-decision', + description: 'Requires unanimous agreement', + options: ['approve', 'reject'], + votingMethod: 'unanimous', + deadline: undefined, + }); + }); + + it('creates a proposal with deadline', async () => { + vi.mocked(mockClient.createProposal).mockResolvedValue({ success: true }); + const deadline = Date.now() + 3600000; // 1 hour from now + + const input = relayProposalSchema.parse({ + id: 'timed-vote', + description: 'Vote must be completed within 1 hour', + options: ['yes', 'no'], + deadline, + }); + const result = await handleRelayProposal(mockClient, input); + + expect(result).toContain('created successfully'); + expect(mockClient.createProposal).toHaveBeenCalledWith({ + id: 'timed-vote', + description: 'Vote must be completed within 1 hour', + options: ['yes', 'no'], + votingMethod: undefined, + deadline, + }); + }); + + it('returns error when proposal creation fails', async () => { + vi.mocked(mockClient.createProposal).mockResolvedValue({ + success: false, + error: 'Proposal ID already exists', + }); + + const input = relayProposalSchema.parse({ + id: 'duplicate', + description: 'Test', + options: ['a', 'b'], + }); + const result = await handleRelayProposal(mockClient, input); + + expect(result).toBe('Failed to create proposal: Proposal ID already exists'); + }); +}); + +describe('relay_vote', () => { + let mockClient: RelayClient; + + beforeEach(() => { + vi.clearAllMocks(); + mockClient = createMockClient(); + }); + + it('casts a vote on a proposal', async () => { + vi.mocked(mockClient.vote).mockResolvedValue({ success: true }); + + const input = relayVoteSchema.parse({ + proposal_id: 'budget-2024', + vote: 'Option B: $75k', + }); + const result = await handleRelayVote(mockClient, input); + + expect(result).toBe('Vote "Option B: $75k" cast on proposal "budget-2024"'); + expect(mockClient.vote).toHaveBeenCalledWith({ + proposalId: 'budget-2024', + vote: 'Option B: $75k', + reason: undefined, + }); + }); + + it('casts a vote with reason', async () => { + vi.mocked(mockClient.vote).mockResolvedValue({ success: true }); + + const input = relayVoteSchema.parse({ + proposal_id: 'critical-decision', + vote: 'approve', + reason: 'This aligns with our Q2 goals', + }); + const result = await handleRelayVote(mockClient, input); + + expect(result).toBe('Vote "approve" cast on proposal "critical-decision"'); + expect(mockClient.vote).toHaveBeenCalledWith({ + proposalId: 'critical-decision', + vote: 'approve', + reason: 'This aligns with our Q2 goals', + }); + }); + + it('returns error when vote fails', async () => { + vi.mocked(mockClient.vote).mockResolvedValue({ + success: false, + error: 'Voting period has ended', + }); + + const input = relayVoteSchema.parse({ + proposal_id: 'expired-vote', + vote: 'yes', + }); + const result = await handleRelayVote(mockClient, input); + + expect(result).toBe('Failed to vote: Voting period has ended'); + }); + + it('supports standard vote options', async () => { + vi.mocked(mockClient.vote).mockResolvedValue({ success: true }); + + const standardVotes = ['approve', 'reject', 'abstain']; + for (const voteOption of standardVotes) { + const input = relayVoteSchema.parse({ + proposal_id: 'test-proposal', + vote: voteOption, + }); + await handleRelayVote(mockClient, input); + } + + expect(mockClient.vote).toHaveBeenCalledTimes(3); + }); +}); + +// ============================================================================ +// Complete SDK/MCP Parity Integration Tests +// ============================================================================ + +describe('SDK/MCP parity scenarios', () => { + let mockClient: RelayClient; + + beforeEach(() => { + vi.clearAllMocks(); + mockClient = createMockClient(); + }); + + it('full orchestration workflow with all tool types', async () => { + // Setup mocks + vi.mocked(mockClient.spawn).mockResolvedValue({ success: true }); + vi.mocked(mockClient.joinChannel).mockResolvedValue({ success: true }); + vi.mocked(mockClient.sendChannelMessage).mockResolvedValue(undefined); + vi.mocked(mockClient.broadcast).mockResolvedValue(undefined); + vi.mocked(mockClient.createProposal).mockResolvedValue({ success: true }); + vi.mocked(mockClient.vote).mockResolvedValue({ success: true }); + vi.mocked(mockClient.leaveChannel).mockResolvedValue({ success: true }); + vi.mocked(mockClient.release).mockResolvedValue({ success: true }); + + // 1. Spawn workers + await handleRelaySpawn(mockClient, { name: 'Worker1', cli: 'claude', task: 'Frontend' }); + await handleRelaySpawn(mockClient, { name: 'Worker2', cli: 'claude', task: 'Backend' }); + expect(mockClient.spawn).toHaveBeenCalledTimes(2); + + // 2. Join a coordination channel + await handleRelayChannelJoin(mockClient, { channel: '#coordination' }); + expect(mockClient.joinChannel).toHaveBeenCalledWith('#coordination', undefined); + + // 3. Broadcast kickoff message + await handleRelayBroadcast(mockClient, { message: 'Project started!' }); + expect(mockClient.broadcast).toHaveBeenCalled(); + + // 4. Create a proposal for decision making + await handleRelayProposal(mockClient, { + id: 'arch-decision', + description: 'Choose architecture', + options: ['Monolith', 'Microservices'], + }); + expect(mockClient.createProposal).toHaveBeenCalled(); + + // 5. Cast votes + await handleRelayVote(mockClient, { proposal_id: 'arch-decision', vote: 'Microservices' }); + expect(mockClient.vote).toHaveBeenCalled(); + + // 6. Send channel update + await handleRelayChannelMessage(mockClient, { channel: '#coordination', message: 'Decision made!' }); + expect(mockClient.sendChannelMessage).toHaveBeenCalled(); + + // 7. Cleanup - leave channel and release workers + await handleRelayChannelLeave(mockClient, { channel: '#coordination' }); + await handleRelayRelease(mockClient, { name: 'Worker1' }); + await handleRelayRelease(mockClient, { name: 'Worker2' }); + expect(mockClient.release).toHaveBeenCalledTimes(2); + }); + + it('shadow agent monitoring workflow', async () => { + vi.mocked(mockClient.spawn).mockResolvedValue({ success: true }); + vi.mocked(mockClient.bindAsShadow).mockResolvedValue({ success: true }); + vi.mocked(mockClient.send).mockResolvedValue(undefined); + vi.mocked(mockClient.unbindAsShadow).mockResolvedValue({ success: true }); + vi.mocked(mockClient.release).mockResolvedValue({ success: true }); + + // 1. Spawn primary worker + await handleRelaySpawn(mockClient, { name: 'PrimaryWorker', cli: 'claude', task: 'Main task' }); + + // 2. Spawn monitor/shadow + await handleRelaySpawn(mockClient, { name: 'Monitor', cli: 'claude', task: 'Monitor primary' }); + + // 3. Bind monitor as shadow + await handleRelayShadowBind(mockClient, { + primary_agent: 'PrimaryWorker', + speak_on: ['SESSION_END', 'CODE_WRITTEN'], + }); + expect(mockClient.bindAsShadow).toHaveBeenCalledWith('PrimaryWorker', { + speakOn: ['SESSION_END', 'CODE_WRITTEN'], + }); + + // 4. Primary does work, shadow observes (simulated by sending message) + await handleRelaySend(mockClient, { to: 'PrimaryWorker', message: 'Do the task' }); + + // 5. Unbind shadow when done + await handleRelayShadowUnbind(mockClient, { primary_agent: 'PrimaryWorker' }); + expect(mockClient.unbindAsShadow).toHaveBeenCalledWith('PrimaryWorker'); + + // 6. Release both agents + await handleRelayRelease(mockClient, { name: 'PrimaryWorker' }); + await handleRelayRelease(mockClient, { name: 'Monitor' }); + expect(mockClient.release).toHaveBeenCalledTimes(2); + }); + + it('pub/sub topic workflow', async () => { + vi.mocked(mockClient.spawn).mockResolvedValue({ success: true }); + vi.mocked(mockClient.subscribe).mockResolvedValue({ success: true }); + vi.mocked(mockClient.broadcast).mockResolvedValue(undefined); + vi.mocked(mockClient.unsubscribe).mockResolvedValue({ success: true }); + vi.mocked(mockClient.release).mockResolvedValue({ success: true }); + + // 1. Spawn multiple workers + const workers = ['Worker1', 'Worker2', 'Worker3']; + for (const name of workers) { + await handleRelaySpawn(mockClient, { name, cli: 'claude', task: 'Subscribe test' }); + } + expect(mockClient.spawn).toHaveBeenCalledTimes(3); + + // 2. Subscribe all workers to a topic + for (const _ of workers) { + await handleRelaySubscribe(mockClient, { topic: 'updates' }); + } + expect(mockClient.subscribe).toHaveBeenCalledTimes(3); + + // 3. Broadcast to topic (simulated) + await handleRelayBroadcast(mockClient, { message: 'Update for all subscribers' }); + expect(mockClient.broadcast).toHaveBeenCalled(); + + // 4. Unsubscribe workers + for (const _ of workers) { + await handleRelayUnsubscribe(mockClient, { topic: 'updates' }); + } + expect(mockClient.unsubscribe).toHaveBeenCalledTimes(3); + + // 5. Release workers + for (const name of workers) { + await handleRelayRelease(mockClient, { name }); + } + expect(mockClient.release).toHaveBeenCalledTimes(3); + }); + + it('multi-channel coordination workflow', async () => { + vi.mocked(mockClient.joinChannel).mockResolvedValue({ success: true }); + vi.mocked(mockClient.sendChannelMessage).mockResolvedValue(undefined); + vi.mocked(mockClient.leaveChannel).mockResolvedValue({ success: true }); + + const channels = ['#frontend', '#backend', '#devops']; + + // 1. Join multiple channels + for (const channel of channels) { + await handleRelayChannelJoin(mockClient, { channel }); + } + expect(mockClient.joinChannel).toHaveBeenCalledTimes(3); + + // 2. Send messages to each channel + for (const channel of channels) { + await handleRelayChannelMessage(mockClient, { channel, message: `Update for ${channel}` }); + } + expect(mockClient.sendChannelMessage).toHaveBeenCalledTimes(3); + + // 3. Leave all channels + for (const channel of channels) { + await handleRelayChannelLeave(mockClient, { channel, reason: 'Task complete' }); + } + expect(mockClient.leaveChannel).toHaveBeenCalledTimes(3); + }); }); diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index cf2958ac1..dd3516d58 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -55,7 +55,10 @@ export type MessageType = | 'HEALTH' | 'HEALTH_RESPONSE' | 'METRICS' - | 'METRICS_RESPONSE'; + | 'METRICS_RESPONSE' + // Consensus types + | 'PROPOSAL_CREATE' + | 'VOTE'; export type PayloadKind = 'message' | 'action' | 'state' | 'thinking'; @@ -239,7 +242,7 @@ export interface PongPayload { nonce: string; } -export type ErrorCode = 'BAD_REQUEST' | 'UNAUTHORIZED' | 'NOT_FOUND' | 'INTERNAL' | 'RESUME_TOO_OLD' | 'DUPLICATE_CONNECTION'; +export type ErrorCode = 'BAD_REQUEST' | 'UNAUTHORIZED' | 'NOT_FOUND' | 'INTERNAL' | 'RESUME_TOO_OLD' | 'DUPLICATE_CONNECTION' | 'TIMEOUT'; export interface ErrorPayload { /** Error code */ @@ -672,3 +675,120 @@ export type HealthEnvelope = Envelope; export type HealthResponseEnvelope = Envelope; export type MetricsEnvelope = Envelope; export type MetricsResponseEnvelope = Envelope; + +// ============================================================================= +// Consensus Types +// ============================================================================= + +export type ConsensusType = + | 'majority' // >50% agree + | 'supermajority' // >=threshold agree (default 2/3) + | 'unanimous' // 100% agree + | 'weighted' // Weighted by role + | 'quorum'; // Minimum participation + majority + +export type VoteValue = 'approve' | 'reject' | 'abstain'; + +export type ProposalStatus = + | 'pending' + | 'approved' + | 'rejected' + | 'expired' + | 'cancelled'; + +/** + * Options for creating a consensus proposal. + */ +export interface CreateProposalOptions { + /** Proposal title */ + title: string; + /** Detailed description */ + description: string; + /** Agents allowed to vote */ + participants: string[]; + /** Consensus type (default: majority) */ + consensusType?: ConsensusType; + /** Timeout in milliseconds (default: 5 minutes) */ + timeoutMs?: number; + /** Minimum votes required (for quorum type) */ + quorum?: number; + /** Threshold for supermajority (0-1, default 0.67) */ + threshold?: number; +} + +/** + * Options for voting on a proposal. + */ +export interface VoteOptions { + /** Proposal ID to vote on */ + proposalId: string; + /** Vote value */ + value: VoteValue; + /** Optional reason for the vote */ + reason?: string; +} + +// ============================================================================= +// Named Record Types (for reusability) +// ============================================================================= + +/** + * A stored message in the inbox. + */ +export interface InboxMessage { + id: string; + from: string; + body: string; + channel?: string; + thread?: string; + timestamp: number; +} + +/** + * Agent info returned by LIST_AGENTS. + */ +export interface AgentInfo { + name: string; + cli?: string; + idle?: boolean; + parent?: string; + task?: string; + connectedAt?: number; +} + +/** + * A crash record. + */ +export interface CrashRecord { + id: string; + agentName: string; + crashedAt: string; + likelyCause: string; + summary?: string; +} + +/** + * An alert record. + */ +export interface AlertRecord { + id: string; + agentName: string; + alertType: string; + message: string; + createdAt: string; +} + +/** + * Metrics for a single agent. + */ +export interface AgentMetrics { + name: string; + pid?: number; + status: string; + rssBytes?: number; + cpuPercent?: number; + trend?: string; + alertLevel?: string; + highWatermark?: number; + uptimeMs?: number; +} diff --git a/packages/sdk/src/client.test.ts b/packages/sdk/src/client.test.ts index a1fc316f2..4f727115f 100644 --- a/packages/sdk/src/client.test.ts +++ b/packages/sdk/src/client.test.ts @@ -10,7 +10,7 @@ import type { HealthResponsePayload, MetricsResponsePayload, InboxResponsePayload, -} from './protocol/types.js'; +} from '@agent-relay/protocol'; import { RelayClient } from './client.js'; describe('RelayClient', () => { diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 25c4a558c..ff1cecc5f 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -7,6 +7,7 @@ import net from 'node:net'; import { randomUUID } from 'node:crypto'; +// Import shared protocol types and framing utilities from @agent-relay/protocol import { type Envelope, type HelloPayload, @@ -47,13 +48,12 @@ import { type HealthResponsePayload, type MetricsPayload, type MetricsResponsePayload, - type ConsensusType, - type VoteValue, type CreateProposalOptions, type VoteOptions, PROTOCOL_VERSION, -} from './protocol/types.js'; -import { encodeFrameLegacy, FrameParser } from './protocol/framing.js'; + encodeFrameLegacy, + FrameParser, +} from '@agent-relay/protocol'; export type ClientState = 'DISCONNECTED' | 'CONNECTING' | 'HANDSHAKING' | 'READY' | 'BACKOFF'; diff --git a/packages/sdk/src/protocol/framing.test.ts b/packages/sdk/src/protocol/framing.test.ts index 230e4a475..f4cd1d9dd 100644 --- a/packages/sdk/src/protocol/framing.test.ts +++ b/packages/sdk/src/protocol/framing.test.ts @@ -6,8 +6,8 @@ import { HEADER_SIZE, LEGACY_HEADER_SIZE, MAX_FRAME_BYTES, -} from './framing.js'; -import type { Envelope } from './types.js'; + type Envelope, +} from '@agent-relay/protocol'; describe('protocol framing', () => { describe('legacy format (4-byte header)', () => { diff --git a/packages/sdk/src/protocol/framing.ts b/packages/sdk/src/protocol/framing.ts deleted file mode 100644 index 002a34d79..000000000 --- a/packages/sdk/src/protocol/framing.ts +++ /dev/null @@ -1,242 +0,0 @@ -/** - * Frame encoding/decoding for the Agent Relay protocol. - * @agent-relay/sdk - * - * Wire format: - * - 1 byte: format indicator (0 = JSON, 1 = MessagePack) - * - 4 bytes: big-endian payload length - * - N bytes: payload (JSON or MessagePack encoded) - * - * Legacy format (for backwards compatibility): - * - 4 bytes: big-endian payload length - * - N bytes: JSON payload - */ - -import type { Envelope } from './types.js'; - -export const MAX_FRAME_BYTES = 1024 * 1024; // 1 MiB -export const HEADER_SIZE = 5; // 1 byte format + 4 bytes length -export const LEGACY_HEADER_SIZE = 4; // For backwards compatibility - -export type WireFormat = 'json' | 'msgpack'; - -// Format indicator bytes -const FORMAT_JSON = 0; -const FORMAT_MSGPACK = 1; - -// Optional MessagePack - loaded dynamically if available -let msgpack: { encode: (obj: unknown) => Uint8Array; decode: (buf: Uint8Array) => unknown } | null = null; - -/** - * Initialize MessagePack support. - * Install @msgpack/msgpack to enable: npm install @msgpack/msgpack - */ -export async function initMessagePack(): Promise { - if (msgpack) return true; - - try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const mod = await import('@msgpack/msgpack' as any) as any; - const encode = mod.encode || mod.default?.encode; - const decode = mod.decode || mod.default?.decode; - if (encode && decode) { - msgpack = { encode, decode }; - return true; - } - return false; - } catch { - return false; - } -} - -/** - * Check if MessagePack is available. - */ -export function hasMessagePack(): boolean { - return msgpack !== null; -} - -/** - * Encode a message envelope into a framed buffer. - * - * @param envelope - The envelope to encode - * @param format - Wire format to use (default: 'json') - * @returns Framed buffer ready for socket write - */ -export function encodeFrame(envelope: Envelope, format: WireFormat = 'json'): Buffer { - let data: Buffer; - let formatByte: number; - - if (format === 'msgpack' && msgpack) { - const encoded = msgpack.encode(envelope); - data = Buffer.from(encoded.buffer, encoded.byteOffset, encoded.byteLength); - formatByte = FORMAT_MSGPACK; - } else { - data = Buffer.from(JSON.stringify(envelope), 'utf-8'); - formatByte = FORMAT_JSON; - } - - if (data.length > MAX_FRAME_BYTES) { - throw new Error(`Frame too large: ${data.length} > ${MAX_FRAME_BYTES}`); - } - - const header = Buffer.alloc(HEADER_SIZE); - header.writeUInt8(formatByte, 0); - header.writeUInt32BE(data.length, 1); - - return Buffer.concat([header, data]); -} - -/** - * Encode a frame in legacy format (no format byte, JSON only). - * Used for backwards compatibility with older clients. - */ -export function encodeFrameLegacy(envelope: Envelope): Buffer { - const json = JSON.stringify(envelope); - const data = Buffer.from(json, 'utf-8'); - - if (data.length > MAX_FRAME_BYTES) { - throw new Error(`Frame too large: ${data.length} > ${MAX_FRAME_BYTES}`); - } - - const header = Buffer.alloc(LEGACY_HEADER_SIZE); - header.writeUInt32BE(data.length, 0); - - return Buffer.concat([header, data]); -} - -/** - * Ring buffer-based frame parser for streaming data. - */ -export class FrameParser { - private ring: Buffer; - private head = 0; - private tail = 0; - private readonly capacity: number; - private readonly maxFrameBytes: number; - private format: WireFormat = 'json'; - private legacyMode = false; - - constructor(maxFrameBytes: number = MAX_FRAME_BYTES) { - this.maxFrameBytes = maxFrameBytes; - this.capacity = maxFrameBytes * 2 + HEADER_SIZE; - this.ring = Buffer.allocUnsafe(this.capacity); - } - - /** - * Set the expected wire format for parsing. - */ - setFormat(format: WireFormat): void { - this.format = format; - } - - /** - * Enable legacy mode (4-byte header, JSON only). - */ - setLegacyMode(legacy: boolean): void { - this.legacyMode = legacy; - } - - /** - * Get current unread bytes in buffer. - */ - get pendingBytes(): number { - return this.tail - this.head; - } - - /** - * Push data into the parser and extract complete frames. - * - * @param data - Incoming data buffer - * @returns Array of parsed envelope frames - */ - push(data: Buffer): Envelope[] { - const spaceAtEnd = this.capacity - this.tail; - - if (data.length > spaceAtEnd) { - this.compact(); - - if (data.length > this.capacity - this.tail) { - throw new Error(`Buffer overflow: data ${data.length} exceeds capacity`); - } - } - - data.copy(this.ring, this.tail); - this.tail += data.length; - - return this.extractFrames(); - } - - private extractFrames(): Envelope[] { - const frames: Envelope[] = []; - const headerSize = this.legacyMode ? LEGACY_HEADER_SIZE : HEADER_SIZE; - - while (this.pendingBytes >= headerSize) { - let formatByte = FORMAT_JSON; - let frameLength: number; - - if (this.legacyMode) { - frameLength = this.ring.readUInt32BE(this.head); - } else { - formatByte = this.ring.readUInt8(this.head); - frameLength = this.ring.readUInt32BE(this.head + 1); - } - - if (frameLength > this.maxFrameBytes) { - throw new Error(`Frame too large: ${frameLength} > ${this.maxFrameBytes}`); - } - - const totalLength = headerSize + frameLength; - - if (this.pendingBytes < totalLength) { - break; - } - - const payloadStart = this.head + headerSize; - const payloadEnd = this.head + totalLength; - - let envelope: Envelope; - try { - envelope = this.decodePayload(formatByte, payloadStart, payloadEnd); - } catch (err) { - throw new Error(`Invalid frame payload: ${err}`); - } - - this.head += totalLength; - frames.push(envelope); - } - - if (this.head > this.capacity / 2 && this.pendingBytes < this.capacity / 4) { - this.compact(); - } - - return frames; - } - - private decodePayload(formatByte: number, start: number, end: number): Envelope { - if (formatByte === FORMAT_MSGPACK && msgpack) { - return msgpack.decode(this.ring.subarray(start, end)) as Envelope; - } else { - return JSON.parse(this.ring.toString('utf-8', start, end)) as Envelope; - } - } - - private compact(): void { - if (this.head === 0) return; - - const unread = this.pendingBytes; - if (unread > 0) { - this.ring.copy(this.ring, 0, this.head, this.tail); - } - this.head = 0; - this.tail = unread; - } - - /** - * Reset parser state. - */ - reset(): void { - this.head = 0; - this.tail = 0; - } -} diff --git a/packages/sdk/src/protocol/index.ts b/packages/sdk/src/protocol/index.ts index 62424d157..1bc47a9cc 100644 --- a/packages/sdk/src/protocol/index.ts +++ b/packages/sdk/src/protocol/index.ts @@ -1,122 +1,8 @@ /** * Protocol exports for @agent-relay/sdk + * + * Re-exports from @agent-relay/protocol for backwards compatibility. + * New code should import directly from @agent-relay/protocol. */ -export { - PROTOCOL_VERSION, - type MessageType, - type PayloadKind, - type Envelope, - type EntityType, - // Handshake - type HelloPayload, - type WelcomePayload, - // Messaging - type SendPayload, - type SendMeta, - type SyncMeta, - type DeliveryInfo, - // ACK/NACK - type AckPayload, - type NackPayload, - // Control - type BusyPayload, - type PingPayload, - type PongPayload, - type ErrorCode, - type ErrorPayload, - type LogPayload, - // Sync/Resume - type SyncStream, - type SyncPayload, - // Shadow agents - type SpeakOnTrigger, - type ShadowConfig, - type ShadowBindPayload, - type ShadowUnbindPayload, - // Spawn/release - type SpawnPayload, - type SpawnPolicyDecision, - type SpawnResultPayload, - type ReleasePayload, - type ReleaseResultPayload, - // Consensus types - type ConsensusType, - type VoteValue, - type ProposalStatus, - type CreateProposalOptions, - type VoteOptions, - // Channel types - type MessageAttachment, - type ChannelJoinPayload, - type ChannelLeavePayload, - type ChannelMessagePayload, - // Query/response types - type StatusPayload, - type StatusResponsePayload, - type InboxPayload, - type InboxMessage, - type InboxResponsePayload, - type ListAgentsPayload, - type AgentInfo, - type ListAgentsResponsePayload, - type ListConnectedAgentsPayload, - type ListConnectedAgentsResponsePayload, - type RemoveAgentPayload, - type RemoveAgentResponsePayload, - type HealthPayload, - type CrashRecord, - type AlertRecord, - type HealthResponsePayload, - type MetricsPayload, - type AgentMetrics, - type MetricsResponsePayload, - // Typed envelopes - type HelloEnvelope, - type WelcomeEnvelope, - type SendEnvelope, - type DeliverEnvelope, - type AckEnvelope, - type NackEnvelope, - type PingEnvelope, - type PongEnvelope, - type ErrorEnvelope, - type BusyEnvelope, - type LogEnvelope, - type SyncEnvelope, - type ShadowBindEnvelope, - type ShadowUnbindEnvelope, - type SpawnEnvelope, - type SpawnResultEnvelope, - type ReleaseEnvelope, - type ReleaseResultEnvelope, - type ChannelJoinEnvelope, - type ChannelLeaveEnvelope, - type ChannelMessageEnvelope, - type StatusEnvelope, - type StatusResponseEnvelope, - type InboxEnvelope, - type InboxResponseEnvelope, - type ListAgentsEnvelope, - type ListAgentsResponseEnvelope, - type ListConnectedAgentsEnvelope, - type ListConnectedAgentsResponseEnvelope, - type RemoveAgentEnvelope, - type RemoveAgentResponseEnvelope, - type HealthEnvelope, - type HealthResponseEnvelope, - type MetricsEnvelope, - type MetricsResponseEnvelope, -} from './types.js'; - -export { - MAX_FRAME_BYTES, - HEADER_SIZE, - LEGACY_HEADER_SIZE, - type WireFormat, - initMessagePack, - hasMessagePack, - encodeFrame, - encodeFrameLegacy, - FrameParser, -} from './framing.js'; +export * from '@agent-relay/protocol'; diff --git a/packages/sdk/src/protocol/types.ts b/packages/sdk/src/protocol/types.ts deleted file mode 100644 index 8d6e1e6b6..000000000 --- a/packages/sdk/src/protocol/types.ts +++ /dev/null @@ -1,718 +0,0 @@ -/** - * Agent Relay Protocol Types - * @agent-relay/sdk - * - * These types define the wire protocol for agent-to-agent communication. - */ - -export const PROTOCOL_VERSION = 1; - -export type MessageType = - | 'HELLO' - | 'WELCOME' - | 'SEND' - | 'DELIVER' - | 'ACK' - | 'NACK' - | 'PING' - | 'PONG' - | 'ERROR' - | 'BUSY' - | 'RESUME' - | 'BYE' - | 'STATE' - | 'SYNC' - | 'SYNC_SNAPSHOT' - | 'SYNC_DELTA' - | 'SUBSCRIBE' - | 'UNSUBSCRIBE' - | 'SHADOW_BIND' - | 'SHADOW_UNBIND' - | 'LOG' - // Channel messaging types - | 'CHANNEL_JOIN' - | 'CHANNEL_LEAVE' - | 'CHANNEL_MESSAGE' - | 'CHANNEL_INFO' - | 'CHANNEL_MEMBERS' - | 'CHANNEL_TYPING' - // Spawn/release types - | 'SPAWN' - | 'SPAWN_RESULT' - | 'RELEASE' - | 'RELEASE_RESULT' - // Query types - | 'STATUS' - | 'STATUS_RESPONSE' - | 'INBOX' - | 'INBOX_RESPONSE' - | 'LIST_AGENTS' - | 'LIST_AGENTS_RESPONSE' - | 'LIST_CONNECTED_AGENTS' - | 'LIST_CONNECTED_AGENTS_RESPONSE' - | 'REMOVE_AGENT' - | 'REMOVE_AGENT_RESPONSE' - | 'HEALTH' - | 'HEALTH_RESPONSE' - | 'METRICS' - | 'METRICS_RESPONSE'; - -export type PayloadKind = 'message' | 'action' | 'state' | 'thinking'; - -/** - * Base envelope structure for all protocol messages. - */ -export interface Envelope { - /** Protocol version */ - v: number; - /** Message type */ - type: MessageType; - /** Unique message ID */ - id: string; - /** Timestamp (Unix ms) */ - ts: number; - /** Sender name */ - from?: string; - /** Recipient name or '*' for broadcast */ - to?: string | '*'; - /** Topic for pub/sub */ - topic?: string; - /** Message payload */ - payload: T; -} - -/** - * Entity type distinguishes between AI agents and human users. - */ -export type EntityType = 'agent' | 'user'; - -// ============================================================================= -// Handshake Payloads -// ============================================================================= - -export interface HelloPayload { - /** Agent name */ - agent: string; - /** Client capabilities */ - capabilities: { - ack: boolean; - resume: boolean; - max_inflight: number; - supports_topics: boolean; - }; - /** Entity type: 'agent' (default) or 'user' */ - entityType?: EntityType; - /** CLI identifier (claude, codex, gemini, etc.) */ - cli?: string; - /** Program identifier */ - program?: string; - /** Model identifier */ - model?: string; - /** Task/role description */ - task?: string; - /** Working directory */ - workingDirectory?: string; - /** Display name for human users */ - displayName?: string; - /** Avatar URL for human users */ - avatarUrl?: string; - /** Session resume info */ - session?: { - resume_token?: string; - }; -} - -export interface WelcomePayload { - /** Session ID assigned by server */ - session_id: string; - /** Token for session resume */ - resume_token?: string; - /** Server configuration */ - server: { - max_frame_bytes: number; - heartbeat_ms: number; - }; -} - -// ============================================================================= -// Message Payloads -// ============================================================================= - -export interface SendPayload { - /** Message type */ - kind: PayloadKind; - /** Message body */ - body: string; - /** Optional structured data */ - data?: Record; - /** Thread ID for grouping related messages */ - thread?: string; -} - -export interface SyncMeta { - /** Correlation ID for matching responses */ - correlationId: string; - /** Timeout for blocking sends (ms) */ - timeoutMs?: number; - /** Whether sender should block awaiting ACK */ - blocking: boolean; -} - -export interface SendMeta { - requires_ack?: boolean; - ttl_ms?: number; - /** Importance level (0-100, higher = more important) */ - importance?: number; - /** Correlation ID for replies */ - replyTo?: string; - /** Sync metadata for blocking sends */ - sync?: SyncMeta; -} - -export interface DeliveryInfo { - /** Delivery sequence number */ - seq: number; - /** Session ID */ - session_id: string; - /** Original 'to' field ('*' indicates broadcast) */ - originalTo?: string; -} - -// ============================================================================= -// ACK/NACK Payloads -// ============================================================================= - -export interface AckPayload { - /** ID of the message being acknowledged */ - ack_id: string; - /** Sequence number */ - seq: number; - /** Cumulative acknowledgment */ - cumulative_seq?: number; - /** Selective acknowledgments */ - sack?: number[]; - /** - * Correlation ID for matching ACK to original blocking SEND. - * Set by daemon when forwarding ACK back to the sender. - */ - correlationId?: string; - /** - * Response status for sync messaging. - * Common values: 'OK', 'ERROR', 'ACCEPTED', 'REJECTED'. - * Allows richer status codes than a simple boolean. - */ - response?: string; - /** - * Optional structured response data. - * Can contain any additional information the responder wants to include. - */ - responseData?: unknown; -} - -export interface NackPayload { - /** ID of the message being rejected */ - ack_id: string; - /** Rejection code */ - code?: 'BUSY' | 'INVALID' | 'FORBIDDEN' | 'STALE'; - /** Legacy reason field */ - reason?: 'busy' | 'invalid' | 'forbidden'; - /** Human-readable message */ - message?: string; -} - -// ============================================================================= -// Control Payloads -// ============================================================================= - -export interface BusyPayload { - /** Time before retry (ms) */ - retry_after_ms: number; - /** Current queue depth */ - queue_depth: number; -} - -export interface PingPayload { - nonce: string; -} - -export interface PongPayload { - nonce: string; -} - -export type ErrorCode = 'BAD_REQUEST' | 'UNAUTHORIZED' | 'NOT_FOUND' | 'INTERNAL' | 'RESUME_TOO_OLD'; - -export interface ErrorPayload { - /** Error code */ - code: ErrorCode; - /** Error message */ - message: string; - /** Whether the error is fatal (connection should be closed) */ - fatal: boolean; -} - -export interface LogPayload { - /** Log/output data */ - data: string; - /** Timestamp (defaults to envelope ts) */ - timestamp?: number; -} - -// ============================================================================= -// Sync/Resume Types -// ============================================================================= - -export interface SyncStream { - topic: string; - peer: string; - last_seq: number; - server_last_seq?: number; -} - -export interface SyncPayload { - session_id: string; - streams: SyncStream[]; -} - -// ============================================================================= -// Channel Types -// ============================================================================= - -/** - * Attachment metadata for messages. - */ -export interface MessageAttachment { - id: string; - filename: string; - mimeType: string; - size?: number; - url?: string; - data?: string; // Base64 for inline -} - -/** - * Payload for CHANNEL_JOIN message. - */ -export interface ChannelJoinPayload { - /** Channel to join (e.g., '#general') */ - channel: string; - /** Display name for the channel member list */ - displayName?: string; - /** Avatar URL */ - avatarUrl?: string; - /** Member name to add (for admin operations) */ - member?: string; -} - -/** - * Payload for CHANNEL_LEAVE message. - */ -export interface ChannelLeavePayload { - /** Channel to leave */ - channel: string; - /** Reason for leaving */ - reason?: string; - /** Member name to remove (for admin operations) */ - member?: string; -} - -/** - * Payload for CHANNEL_MESSAGE. - */ -export interface ChannelMessagePayload { - /** Target channel */ - channel: string; - /** Message content */ - body: string; - /** Thread ID for threaded replies */ - thread?: string; - /** Mentioned usernames/agent names */ - mentions?: string[]; - /** File attachments */ - attachments?: MessageAttachment[]; - /** Optional structured data */ - data?: Record; -} - -// ============================================================================= -// Shadow Agent Types -// ============================================================================= - -export type SpeakOnTrigger = - | 'SESSION_END' - | 'CODE_WRITTEN' - | 'REVIEW_REQUEST' - | 'EXPLICIT_ASK' - | 'ALL_MESSAGES'; - -export interface ShadowConfig { - /** Primary agent this shadow is attached to */ - primaryAgent: string; - /** When the shadow should speak */ - speakOn: SpeakOnTrigger[]; - /** Receive messages TO the primary */ - receiveIncoming?: boolean; - /** Receive messages FROM the primary */ - receiveOutgoing?: boolean; -} - -export interface ShadowBindPayload { - primaryAgent: string; - speakOn?: SpeakOnTrigger[]; - receiveIncoming?: boolean; - receiveOutgoing?: boolean; -} - -export interface ShadowUnbindPayload { - primaryAgent: string; -} - -// ============================================================================= -// Spawn/Release Types -// ============================================================================= - -export interface SpawnPayload { - /** Name for the new agent */ - name: string; - /** CLI to use (claude, codex, gemini, etc.) */ - cli: string; - /** Task description */ - task: string; - /** Team name */ - team?: string; - /** Working directory */ - cwd?: string; - /** Socket path for the spawned agent */ - socketPath?: string; - /** Parent agent name */ - spawnerName?: string; - /** Interactive mode */ - interactive?: boolean; - /** Spawn as shadow of this agent */ - shadowOf?: string; - /** Shadow speak-on triggers */ - shadowSpeakOn?: SpeakOnTrigger[]; - /** User ID for cloud persistence */ - userId?: string; -} - -export interface SpawnPolicyDecision { - allowed: boolean; - reason?: string; - quotaRemaining?: number; -} - -export interface SpawnResultPayload { - /** Correlation ID (matches original SPAWN envelope ID) */ - replyTo: string; - /** Whether spawn succeeded */ - success: boolean; - /** Spawned agent name */ - name: string; - /** Process ID (if successful) */ - pid?: number; - /** Error message (if failed) */ - error?: string; - /** Policy decision (if blocked) */ - policyDecision?: SpawnPolicyDecision; -} - -export interface ReleasePayload { - /** Agent name to release */ - name: string; -} - -export interface ReleaseResultPayload { - /** Correlation ID */ - replyTo: string; - /** Whether release succeeded */ - success: boolean; - /** Released agent name */ - name: string; - /** Error message (if failed) */ - error?: string; -} - -// ============================================================================= -// Consensus Types -// ============================================================================= - -export type ConsensusType = - | 'majority' // >50% agree - | 'supermajority' // >=threshold agree (default 2/3) - | 'unanimous' // 100% agree - | 'weighted' // Weighted by role - | 'quorum'; // Minimum participation + majority - -export type VoteValue = 'approve' | 'reject' | 'abstain'; - -export type ProposalStatus = - | 'pending' - | 'approved' - | 'rejected' - | 'expired' - | 'cancelled'; - -/** - * Options for creating a consensus proposal. - */ -export interface CreateProposalOptions { - /** Proposal title */ - title: string; - /** Detailed description */ - description: string; - /** Agents allowed to vote */ - participants: string[]; - /** Consensus type (default: majority) */ - consensusType?: ConsensusType; - /** Timeout in milliseconds (default: 5 minutes) */ - timeoutMs?: number; - /** Minimum votes required (for quorum type) */ - quorum?: number; - /** Threshold for supermajority (0-1, default 0.67) */ - threshold?: number; -} - -/** - * Options for voting on a proposal. - */ -export interface VoteOptions { - /** Proposal ID to vote on */ - proposalId: string; - /** Vote value */ - value: VoteValue; - /** Optional reason for the vote */ - reason?: string; -} - -// ============================================================================= -// Query/Response Types -// ============================================================================= - -/** - * Payload for STATUS request. - */ -export interface StatusPayload { - // Empty - no parameters needed -} - -/** - * Response payload for STATUS request. - */ -export interface StatusResponsePayload { - version?: string; - uptime?: number; - agentCount?: number; - messageCount?: number; -} - -/** - * Payload for INBOX request. - */ -export interface InboxPayload { - agent: string; - limit?: number; - unreadOnly?: boolean; - from?: string; - channel?: string; -} - -/** - * A stored message in the inbox. - */ -export interface InboxMessage { - id: string; - from: string; - body: string; - channel?: string; - thread?: string; - timestamp: number; -} - -/** - * Response payload for INBOX request. - */ -export interface InboxResponsePayload { - messages: InboxMessage[]; -} - -/** - * Payload for LIST_AGENTS request. - */ -export interface ListAgentsPayload { - includeIdle?: boolean; - project?: string; -} - -/** - * Agent info returned by LIST_AGENTS. - */ -export interface AgentInfo { - name: string; - cli?: string; - idle?: boolean; - parent?: string; - task?: string; - connectedAt?: number; -} - -/** - * Response payload for LIST_AGENTS request. - */ -export interface ListAgentsResponsePayload { - agents: AgentInfo[]; -} - -/** - * Payload for LIST_CONNECTED_AGENTS request. - * Returns only currently connected agents (not historical/registered agents). - */ -export interface ListConnectedAgentsPayload { - project?: string; -} - -/** - * Response payload for LIST_CONNECTED_AGENTS request. - */ -export interface ListConnectedAgentsResponsePayload { - agents: AgentInfo[]; -} - -/** - * Payload for REMOVE_AGENT request. - * Removes an agent from the registry (sessions, agents.json). - */ -export interface RemoveAgentPayload { - name: string; - /** If true, also removes all messages from/to this agent */ - removeMessages?: boolean; -} - -/** - * Response payload for REMOVE_AGENT request. - */ -export interface RemoveAgentResponsePayload { - success: boolean; - removed: boolean; - message?: string; -} - -/** - * Payload for HEALTH request. - */ -export interface HealthPayload { - includeCrashes?: boolean; - includeAlerts?: boolean; -} - -/** - * A crash record. - */ -export interface CrashRecord { - id: string; - agentName: string; - crashedAt: string; - likelyCause: string; - summary?: string; -} - -/** - * An alert record. - */ -export interface AlertRecord { - id: string; - agentName: string; - alertType: string; - message: string; - createdAt: string; -} - -/** - * Response payload for HEALTH request. - */ -export interface HealthResponsePayload { - healthScore: number; - summary: string; - issues: Array<{ severity: string; message: string }>; - recommendations: string[]; - crashes: CrashRecord[]; - alerts: AlertRecord[]; - stats: { - totalCrashes24h: number; - totalAlerts24h: number; - agentCount: number; - }; -} - -/** - * Payload for METRICS request. - */ -export interface MetricsPayload { - agent?: string; -} - -/** - * Metrics for a single agent. - */ -export interface AgentMetrics { - name: string; - pid?: number; - status: string; - rssBytes?: number; - cpuPercent?: number; - trend?: string; - alertLevel?: string; - highWatermark?: number; - uptimeMs?: number; -} - -/** - * Response payload for METRICS request. - */ -export interface MetricsResponsePayload { - agents: AgentMetrics[]; - system: { - totalMemory: number; - freeMemory: number; - heapUsed: number; - }; -} - -// ============================================================================= -// Typed Envelope Helpers -// ============================================================================= - -export type HelloEnvelope = Envelope; -export type WelcomeEnvelope = Envelope; -export type SendEnvelope = Envelope & { payload_meta?: SendMeta }; -export type DeliverEnvelope = Envelope & { delivery: DeliveryInfo; payload_meta?: SendMeta }; -export type AckEnvelope = Envelope; -export type NackEnvelope = Envelope; -export type PingEnvelope = Envelope; -export type PongEnvelope = Envelope; -export type ErrorEnvelope = Envelope; -export type BusyEnvelope = Envelope; -export type LogEnvelope = Envelope; -export type ShadowBindEnvelope = Envelope; -export type ShadowUnbindEnvelope = Envelope; -export type SyncEnvelope = Envelope; -export type SpawnEnvelope = Envelope; -export type SpawnResultEnvelope = Envelope; -export type ReleaseEnvelope = Envelope; -export type ReleaseResultEnvelope = Envelope; -export type ChannelJoinEnvelope = Envelope; -export type ChannelLeaveEnvelope = Envelope; -export type ChannelMessageEnvelope = Envelope; -export type StatusEnvelope = Envelope; -export type StatusResponseEnvelope = Envelope; -export type InboxEnvelope = Envelope; -export type InboxResponseEnvelope = Envelope; -export type ListAgentsEnvelope = Envelope; -export type ListAgentsResponseEnvelope = Envelope; -export type ListConnectedAgentsEnvelope = Envelope; -export type ListConnectedAgentsResponseEnvelope = Envelope; -export type RemoveAgentEnvelope = Envelope; -export type RemoveAgentResponseEnvelope = Envelope; -export type HealthEnvelope = Envelope; -export type HealthResponseEnvelope = Envelope; -export type MetricsEnvelope = Envelope; -export type MetricsResponseEnvelope = Envelope; From c5190e4ebe30960a5cdb67b8529d0a5ab0f255ef Mon Sep 17 00:00:00 2001 From: Khaliq Date: Tue, 27 Jan 2026 21:54:43 +0100 Subject: [PATCH 4/9] messages for dashboard fix --- packages/daemon/src/server.ts | 52 ++++++++++++++++++++++++++++++++++ packages/protocol/src/types.ts | 44 ++++++++++++++++++++++++++++ packages/sdk/src/client.ts | 34 ++++++++++++++++++++++ packages/sdk/src/index.ts | 1 + 4 files changed, 131 insertions(+) diff --git a/packages/daemon/src/server.ts b/packages/daemon/src/server.ts index 192328455..1544ea19d 100644 --- a/packages/daemon/src/server.ts +++ b/packages/daemon/src/server.ts @@ -24,6 +24,8 @@ import { type StatusResponsePayload, type InboxPayload, type InboxResponsePayload, + type MessagesQueryPayload, + type MessagesResponsePayload, type ListAgentsPayload, type ListAgentsResponsePayload, type ListConnectedAgentsPayload, @@ -1363,6 +1365,56 @@ export class Daemon { break; } + case 'MESSAGES_QUERY': { + // Query all messages (used by dashboard) - not filtered by recipient + const queryPayload = envelope.payload as MessagesQueryPayload; + + const getMessages = async () => { + if (!this.storage?.getMessages) { + return []; + } + try { + const messages = await this.storage.getMessages({ + limit: queryPayload.limit || 100, + sinceTs: queryPayload.sinceTs, + from: queryPayload.from, + to: queryPayload.to, + thread: queryPayload.thread, + order: queryPayload.order || 'desc', + }); + return messages.map(m => ({ + id: m.id, + from: m.from, + to: m.to, + body: m.body, + channel: (m.data as { channel?: string })?.channel, + thread: m.thread, + timestamp: m.ts, + status: m.status, + isBroadcast: m.is_broadcast, + replyCount: m.replyCount, + data: m.data, + })); + } catch { + return []; + } + }; + + getMessages().then(messages => { + const response: Envelope = { + v: PROTOCOL_VERSION, + type: 'MESSAGES_RESPONSE', + id: envelope.id, + ts: Date.now(), + payload: { messages }, + }; + connection.send(response); + }).catch(err => { + this.sendErrorEnvelope(connection, `Failed to get messages: ${err.message}`); + }); + break; + } + case 'LIST_AGENTS': { const listPayload = envelope.payload as ListAgentsPayload; diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index dd3516d58..42e1d82df 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -56,6 +56,9 @@ export type MessageType = | 'HEALTH_RESPONSE' | 'METRICS' | 'METRICS_RESPONSE' + // Messages query (for dashboard) + | 'MESSAGES_QUERY' + | 'MESSAGES_RESPONSE' // Consensus types | 'PROPOSAL_CREATE' | 'VOTE'; @@ -523,6 +526,45 @@ export interface InboxResponsePayload { }>; } +/** + * Payload for MESSAGES_QUERY request. + * Used by dashboard to query all messages (not filtered by recipient). + */ +export interface MessagesQueryPayload { + /** Maximum number of messages to return */ + limit?: number; + /** Only return messages after this timestamp (Unix ms) */ + sinceTs?: number; + /** Filter by sender */ + from?: string; + /** Filter by recipient */ + to?: string; + /** Filter by thread ID */ + thread?: string; + /** Sort order */ + order?: 'asc' | 'desc'; +} + +/** + * Payload for MESSAGES_RESPONSE. + */ +export interface MessagesResponsePayload { + /** Messages matching the query */ + messages: Array<{ + id: string; + from: string; + to: string; + body: string; + channel?: string; + thread?: string; + timestamp: number; + status?: string; + isBroadcast?: boolean; + replyCount?: number; + data?: Record; + }>; +} + /** * Payload for LIST_AGENTS request. */ @@ -595,6 +637,8 @@ export type StatusEnvelope = Envelope; export type StatusResponseEnvelope = Envelope; export type InboxEnvelope = Envelope; export type InboxResponseEnvelope = Envelope; +export type MessagesQueryEnvelope = Envelope; +export type MessagesResponseEnvelope = Envelope; export type ListAgentsEnvelope = Envelope; export type ListAgentsResponseEnvelope = Envelope; export type ListConnectedAgentsEnvelope = Envelope; diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index ff1cecc5f..cd2a80868 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -37,6 +37,8 @@ import { type InboxPayload, type InboxMessage, type InboxResponsePayload, + type MessagesQueryPayload, + type MessagesResponsePayload, type ListAgentsPayload, type AgentInfo, type ListAgentsResponsePayload, @@ -932,6 +934,38 @@ export class RelayClient { return response.messages || []; } + /** + * Query all messages (not filtered by recipient). + * Used by dashboard to get message history. + * @param options - Query options + * @param options.limit - Maximum number of messages to return (default: 100) + * @param options.sinceTs - Only return messages after this timestamp + * @param options.from - Filter by sender + * @param options.to - Filter by recipient + * @param options.thread - Filter by thread ID + * @param options.order - Sort order ('asc' or 'desc', default: 'desc') + * @returns Array of messages + */ + async queryMessages(options: { + limit?: number; + sinceTs?: number; + from?: string; + to?: string; + thread?: string; + order?: 'asc' | 'desc'; + } = {}): Promise { + const payload: MessagesQueryPayload = { + limit: options.limit, + sinceTs: options.sinceTs, + from: options.from, + to: options.to, + thread: options.thread, + order: options.order, + }; + const response = await this.query('MESSAGES_QUERY', payload); + return response.messages || []; + } + /** * List online agents. * @param options - Filter options diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index e2b1059dc..afaae46b7 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -71,6 +71,7 @@ export { // Query/response types type StatusResponsePayload, type InboxMessage, + type MessagesResponsePayload, type AgentInfo, type HealthResponsePayload, type CrashRecord, From c91e52bbdbe73a5078180f44f5960891cf04aa47 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Tue, 27 Jan 2026 21:56:44 +0100 Subject: [PATCH 5/9] fix(ci): configure npm global directory for non-root user --- scripts/post-publish-verify/Dockerfile | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/scripts/post-publish-verify/Dockerfile b/scripts/post-publish-verify/Dockerfile index bdd8c6da7..271815109 100644 --- a/scripts/post-publish-verify/Dockerfile +++ b/scripts/post-publish-verify/Dockerfile @@ -20,12 +20,21 @@ RUN apt-get update && apt-get install -y \ # Create test user (don't run as root) RUN useradd -m -s /bin/bash testuser + +# Create npm global directory owned by testuser +RUN mkdir -p /home/testuser/.npm-global \ + && chown -R testuser:testuser /home/testuser + WORKDIR /home/testuser # Store package version for verification script ENV PACKAGE_VERSION=${PACKAGE_VERSION} ENV NODE_VERSION=${NODE_VERSION} +# Configure npm to use user-owned global directory +ENV NPM_CONFIG_PREFIX=/home/testuser/.npm-global +ENV PATH=/home/testuser/.npm-global/bin:$PATH + # Copy verification script COPY verify-install.sh /usr/local/bin/verify-install.sh RUN chmod +x /usr/local/bin/verify-install.sh From c27194e831c338916b49779408e22c837e037728 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Tue, 27 Jan 2026 22:08:38 +0100 Subject: [PATCH 6/9] fix: resolve duplicate export conflicts between protocol, daemon, and hooks - Import ConsensusType, VoteValue, ProposalStatus from @agent-relay/protocol in daemon - Rename hooks' InboxMessage to ParsedInboxMessage to avoid conflict with protocol - Fix imports in enhanced-features.ts and consensus-integration.ts --- packages/daemon/src/consensus-integration.ts | 3 +-- packages/daemon/src/consensus.ts | 27 ++++++-------------- packages/daemon/src/enhanced-features.ts | 2 +- packages/hooks/src/inbox-check/types.ts | 7 ++++- packages/hooks/src/inbox-check/utils.ts | 8 +++--- 5 files changed, 20 insertions(+), 27 deletions(-) diff --git a/packages/daemon/src/consensus-integration.ts b/packages/daemon/src/consensus-integration.ts index d1c8e9121..48b0ef899 100644 --- a/packages/daemon/src/consensus-integration.ts +++ b/packages/daemon/src/consensus-integration.ts @@ -29,6 +29,7 @@ */ import { generateId } from '@agent-relay/wrapper'; +import type { VoteValue, ConsensusType } from '@agent-relay/protocol'; import { ConsensusEngine, createConsensusEngine, @@ -40,8 +41,6 @@ import { type Proposal, type ConsensusResult, type ConsensusConfig, - type VoteValue, - type ConsensusType, type ParsedProposalCommand, } from './consensus.js'; import type { Router } from './router.js'; diff --git a/packages/daemon/src/consensus.ts b/packages/daemon/src/consensus.ts index 4ecb401b7..7878f43fd 100644 --- a/packages/daemon/src/consensus.ts +++ b/packages/daemon/src/consensus.ts @@ -21,25 +21,14 @@ import { randomUUID } from 'node:crypto'; import { EventEmitter } from 'node:events'; -// ============================================================================= -// Types -// ============================================================================= - -export type ConsensusType = - | 'majority' // >50% agree - | 'supermajority' // >=threshold agree (default 2/3) - | 'unanimous' // 100% agree - | 'weighted' // Weighted by role - | 'quorum'; // Minimum participation + majority - -export type VoteValue = 'approve' | 'reject' | 'abstain'; - -export type ProposalStatus = - | 'pending' // Awaiting votes - | 'approved' // Consensus reached (approved) - | 'rejected' // Consensus reached (rejected) - | 'expired' // Timeout without consensus - | 'cancelled'; // Proposer cancelled +// Import shared types from protocol (canonical source) +// NOTE: These types are NOT re-exported to avoid duplicate export errors +// in the main agent-relay package. Import from @agent-relay/protocol instead. +import type { + ConsensusType, + VoteValue, + ProposalStatus, +} from '@agent-relay/protocol'; export interface AgentWeight { /** Agent name */ diff --git a/packages/daemon/src/enhanced-features.ts b/packages/daemon/src/enhanced-features.ts index fee0cc61f..d89b85070 100644 --- a/packages/daemon/src/enhanced-features.ts +++ b/packages/daemon/src/enhanced-features.ts @@ -56,6 +56,7 @@ import { type CompactionResult, } from '@agent-relay/memory'; +import type { VoteValue } from '@agent-relay/protocol'; import { ConsensusEngine, createConsensusEngine, @@ -65,7 +66,6 @@ import { type Proposal, type ConsensusResult, type ConsensusConfig, - type VoteValue, } from './consensus.js'; // ============================================================================= diff --git a/packages/hooks/src/inbox-check/types.ts b/packages/hooks/src/inbox-check/types.ts index 542da9d83..d72f04670 100644 --- a/packages/hooks/src/inbox-check/types.ts +++ b/packages/hooks/src/inbox-check/types.ts @@ -20,7 +20,12 @@ export interface HookOutput { reason?: string; } -export interface InboxMessage { +/** + * Parsed message from inbox file. + * Note: This is different from @agent-relay/protocol's InboxMessage + * which is for daemon communication. + */ +export interface ParsedInboxMessage { from: string; timestamp: string; body: string; diff --git a/packages/hooks/src/inbox-check/utils.ts b/packages/hooks/src/inbox-check/utils.ts index 8466d0504..45310f73f 100644 --- a/packages/hooks/src/inbox-check/utils.ts +++ b/packages/hooks/src/inbox-check/utils.ts @@ -4,7 +4,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; -import type { InboxConfig, InboxMessage } from './types.js'; +import type { InboxConfig, ParsedInboxMessage } from './types.js'; /** Default inbox directory */ export const DEFAULT_INBOX_DIR = '/tmp/agent-relay'; @@ -73,13 +73,13 @@ export function countMessages(inboxPath: string): number { /** * Parse messages from inbox content */ -export function parseMessages(inboxPath: string): InboxMessage[] { +export function parseMessages(inboxPath: string): ParsedInboxMessage[] { const content = readInbox(inboxPath); if (!content) { return []; } - const messages: InboxMessage[] = []; + const messages: ParsedInboxMessage[] = []; const messageBlocks = content.split(/(?=## Message from)/); for (const block of messageBlocks) { @@ -97,7 +97,7 @@ export function parseMessages(inboxPath: string): InboxMessage[] { /** * Format a message for display */ -export function formatMessagePreview(msg: InboxMessage, maxLength: number = 50): string { +export function formatMessagePreview(msg: ParsedInboxMessage, maxLength: number = 50): string { const preview = msg.body.length > maxLength ? msg.body.substring(0, maxLength) + '...' : msg.body; From f0bb36f443e41fd1cb4f7137a561e9b50e0aab88 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Tue, 27 Jan 2026 22:14:06 +0100 Subject: [PATCH 7/9] fix(ci): add npm global bin to PATH for verify tests --- .github/workflows/verify-publish.yml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/verify-publish.yml b/.github/workflows/verify-publish.yml index 0a888fb15..57c1c74e7 100644 --- a/.github/workflows/verify-publish.yml +++ b/.github/workflows/verify-publish.yml @@ -77,9 +77,13 @@ jobs: run: | echo "Installing ${{ steps.pkg.outputs.spec }} globally..." npm install -g ${{ steps.pkg.outputs.spec }} + # Add npm global bin to PATH + echo "$(npm config get prefix)/bin" >> $GITHUB_PATH - name: "Test: Global --version" run: | + # Ensure npm global bin is in PATH + export PATH="$(npm config get prefix)/bin:$PATH" VERSION=$(agent-relay --version) echo "Version output: $VERSION" if echo "$VERSION" | grep -qE '[0-9]+\.[0-9]+\.[0-9]+'; then @@ -90,10 +94,13 @@ jobs: fi - name: "Test: Global -V flag" - run: agent-relay -V + run: | + export PATH="$(npm config get prefix)/bin:$PATH" + agent-relay -V - name: "Test: Global version command" run: | + export PATH="$(npm config get prefix)/bin:$PATH" OUTPUT=$(agent-relay version) echo "$OUTPUT" if echo "$OUTPUT" | grep -qE '[0-9]+\.[0-9]+\.[0-9]+'; then @@ -104,10 +111,13 @@ jobs: - name: "Test: Global --help" run: | + export PATH="$(npm config get prefix)/bin:$PATH" agent-relay --help | head -20 - name: Cleanup global install - run: npm uninstall -g agent-relay + run: | + export PATH="$(npm config get prefix)/bin:$PATH" + npm uninstall -g agent-relay # Test 2: npx execution - name: "Test: npx --version" From 4d41810070b92baf7f3bfa14493be7c6d5787761 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Tue, 27 Jan 2026 22:16:35 +0100 Subject: [PATCH 8/9] fix(ci): fix PATH handling for npx and Docker verification tests - Use --package flag for explicit npx package specification - Add PATH debugging and ensure npm bin is in PATH in Docker script - Add binary existence check for debugging --- .github/workflows/verify-publish.yml | 7 ++++--- scripts/post-publish-verify/verify-install.sh | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/.github/workflows/verify-publish.yml b/.github/workflows/verify-publish.yml index 57c1c74e7..ba0780c77 100644 --- a/.github/workflows/verify-publish.yml +++ b/.github/workflows/verify-publish.yml @@ -122,7 +122,8 @@ jobs: # Test 2: npx execution - name: "Test: npx --version" run: | - VERSION=$(npx -y ${{ steps.pkg.outputs.spec }} --version) + # Use --package flag for explicit package specification + VERSION=$(npx --yes --package=${{ steps.pkg.outputs.spec }} agent-relay --version) echo "npx version output: $VERSION" if echo "$VERSION" | grep -qE '[0-9]+\.[0-9]+\.[0-9]+'; then echo "npx version check passed" @@ -131,11 +132,11 @@ jobs: fi - name: "Test: npx --help" - run: npx -y ${{ steps.pkg.outputs.spec }} --help | head -20 + run: npx --yes --package=${{ steps.pkg.outputs.spec }} agent-relay --help | head -20 - name: "Test: npx version command" run: | - OUTPUT=$(npx -y ${{ steps.pkg.outputs.spec }} version) + OUTPUT=$(npx --yes --package=${{ steps.pkg.outputs.spec }} agent-relay version) echo "$OUTPUT" if echo "$OUTPUT" | grep -qE '[0-9]+\.[0-9]+\.[0-9]+'; then echo "npx version command check passed" diff --git a/scripts/post-publish-verify/verify-install.sh b/scripts/post-publish-verify/verify-install.sh index 990424c49..ccfad9a1f 100755 --- a/scripts/post-publish-verify/verify-install.sh +++ b/scripts/post-publish-verify/verify-install.sh @@ -47,12 +47,20 @@ log_info "npm version: $(npm --version)" log_info "Package to test: $PACKAGE_SPEC" log_info "User: $(whoami)" log_info "Working directory: $(pwd)" +log_info "PATH: $PATH" +log_info "NPM prefix: $(npm config get prefix)" +log_info "NPM bin location: $(npm config get prefix)/bin" # ============================================ # Test 1: Global npm install # ============================================ log_header "Test 1: Global npm install" +# Ensure npm global bin is in PATH +NPM_BIN="$(npm config get prefix)/bin" +export PATH="$NPM_BIN:$PATH" +log_info "Updated PATH to include: $NPM_BIN" + # Clean any previous installation log_info "Cleaning previous global installation..." npm uninstall -g agent-relay 2>/dev/null || true @@ -65,6 +73,17 @@ else record_fail "Global npm install failed" fi +# Verify the binary exists +log_info "Checking if agent-relay binary exists..." +if [ -f "$NPM_BIN/agent-relay" ]; then + log_info "Binary found at: $NPM_BIN/agent-relay" + ls -la "$NPM_BIN/agent-relay" +else + log_warn "Binary not found at expected location: $NPM_BIN/agent-relay" + log_info "Contents of $NPM_BIN:" + ls -la "$NPM_BIN" 2>/dev/null || echo "Directory does not exist" +fi + # Test --version flag log_info "Testing 'agent-relay --version'..." GLOBAL_VERSION=$(agent-relay --version 2>&1) || true From 296618f8b2cf94d07a96532cd472d1e486bd2298 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Tue, 27 Jan 2026 22:20:12 +0100 Subject: [PATCH 9/9] fix(ci): fix npx syntax and remove set -e for better error visibility - Use 'npx package -- args' syntax instead of --package flag - Remove set -e so all tests run and report results - Add explicit exit code capture for npm install --- .github/workflows/verify-publish.yml | 8 ++++---- scripts/post-publish-verify/verify-install.sh | 10 +++++++--- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/.github/workflows/verify-publish.yml b/.github/workflows/verify-publish.yml index ba0780c77..2011b110e 100644 --- a/.github/workflows/verify-publish.yml +++ b/.github/workflows/verify-publish.yml @@ -122,8 +122,8 @@ jobs: # Test 2: npx execution - name: "Test: npx --version" run: | - # Use --package flag for explicit package specification - VERSION=$(npx --yes --package=${{ steps.pkg.outputs.spec }} agent-relay --version) + # npx with @ syntax - downloads and runs in one command + VERSION=$(npx ${{ steps.pkg.outputs.spec }} -- --version 2>&1) echo "npx version output: $VERSION" if echo "$VERSION" | grep -qE '[0-9]+\.[0-9]+\.[0-9]+'; then echo "npx version check passed" @@ -132,11 +132,11 @@ jobs: fi - name: "Test: npx --help" - run: npx --yes --package=${{ steps.pkg.outputs.spec }} agent-relay --help | head -20 + run: npx ${{ steps.pkg.outputs.spec }} -- --help | head -20 - name: "Test: npx version command" run: | - OUTPUT=$(npx --yes --package=${{ steps.pkg.outputs.spec }} agent-relay version) + OUTPUT=$(npx ${{ steps.pkg.outputs.spec }} -- version 2>&1) echo "$OUTPUT" if echo "$OUTPUT" | grep -qE '[0-9]+\.[0-9]+\.[0-9]+'; then echo "npx version command check passed" diff --git a/scripts/post-publish-verify/verify-install.sh b/scripts/post-publish-verify/verify-install.sh index ccfad9a1f..5a1fc333e 100755 --- a/scripts/post-publish-verify/verify-install.sh +++ b/scripts/post-publish-verify/verify-install.sh @@ -6,7 +6,8 @@ # PACKAGE_VERSION: Version to install (default: latest) # NODE_VERSION: Node version being tested (for logging) -set -e +# Don't use set -e so we can collect all test results +# set -e # Colors for output RED='\033[0;31m' @@ -67,10 +68,13 @@ npm uninstall -g agent-relay 2>/dev/null || true # Install globally log_info "Installing ${PACKAGE_SPEC} globally..." -if npm install -g "$PACKAGE_SPEC" 2>&1; then +npm install -g "$PACKAGE_SPEC" +INSTALL_EXIT=$? +log_info "npm install exit code: $INSTALL_EXIT" +if [ $INSTALL_EXIT -eq 0 ]; then record_pass "Global npm install succeeded" else - record_fail "Global npm install failed" + record_fail "Global npm install failed with exit code $INSTALL_EXIT" fi # Verify the binary exists