diff --git a/Cargo.lock b/Cargo.lock index 5eeeb97a1..804a4e4eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2608,6 +2608,27 @@ dependencies = [ "der", ] +[[package]] +name = "sprout-acp" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "futures-util", + "nostr", + "reqwest", + "serde", + "serde_json", + "sprout-core", + "thiserror", + "tokio", + "tokio-tungstenite 0.26.2", + "tracing", + "tracing-subscriber", + "url", + "uuid", +] + [[package]] name = "sprout-admin" version = "0.1.0" @@ -2825,6 +2846,7 @@ dependencies = [ name = "sprout-test-client" version = "0.1.0" dependencies = [ + "anyhow", "futures-util", "nostr", "reqwest", diff --git a/Cargo.toml b/Cargo.toml index 1cc7379af..70bdd281e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "crates/sprout-search", "crates/sprout-audit", "crates/sprout-mcp", + "crates/sprout-acp", "crates/sprout-proxy", "crates/sprout-huddle", "crates/sprout-test-client", @@ -26,7 +27,7 @@ repository = "https://github.com/sprout-rs/sprout" [workspace.dependencies] # Runtime -tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "sync", "io-util", "signal"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "sync", "io-util", "signal", "process"] } tokio-util = { version = "0.7", features = ["rt"] } # HTTP + WebSocket diff --git a/TESTING.md b/TESTING.md index 6ee459266..8c6178294 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1,322 +1,1271 @@ -# Sprout — Local Testing Guide +# Sprout Testing Guide -How to run a local Sprout instance and test it with multiple goose agents communicating over the relay. +This guide enables an AI agent (the **operator**) to run the full Sprout test suite: automated `cargo test` suites and a three-agent multi-agent E2E run that exercises all 36 MCP tools against a live relay. ---- +## Two Test Modes + +| Mode | What It Does | When to Use | +|------|-------------|-------------| +| **Automated** (`cargo test`) | Unit tests + REST/WebSocket/MCP integration tests | Fast CI check; verify no unit regressions | +| **Multi-Agent E2E** | Three agents (Alice, Bob, Charlie) run via `sprout-acp` harness, exercising all 36 MCP tools via real Nostr identities | Before merging relay/MCP/auth changes; full regression run; exploring new features | -## 1. Overview +Run both modes for a complete regression check. Run automated-only for a fast sanity check. -This guide walks through: -1. Starting the backing services (MySQL, Redis, Typesense) via Docker Compose -2. Building and running the relay server -3. Creating test channels and adding members via SQL -4. Minting API tokens for each agent via `sprout-admin` -5. Launching goose agents with the `sprout-mcp` extension -6. Verifying that agents can send and receive messages -7. Running the automated test suite (unit + integration + e2e) +--- -**Outcome:** Two or more goose agents connected to a local relay, exchanging messages through a shared channel, with all traffic verifiable in relay logs and the database. +## Table of Contents + +1. [Prerequisites](#1-prerequisites) +2. [Quick Start: Automated Tests Only](#2-quick-start-automated-tests-only) +3. [Multi-Agent E2E Testing](#3-multi-agent-e2e-testing) + - [3.1 Architecture](#31-architecture) + - [3.2 Infrastructure Setup](#32-infrastructure-setup) + - [3.3 Mint Agent Keys](#33-mint-agent-keys) + - [3.4 Launch Harness Instances](#34-launch-harness-instances) + - [3.5 Test Exercises](#35-test-exercises) + - [3.6 Monitoring & Verification](#36-monitoring--verification) + - [3.7 Expected Results](#37-expected-results) +4. [Advanced: ACP Harness Scenarios](#4-advanced-acp-harness-scenarios) +5. [Workflow YAML Reference](#5-workflow-yaml-reference) +6. [The 36 MCP Tools](#6-the-36-mcp-tools) +7. [Cleanup](#7-cleanup) +8. [Known Issues / Troubleshooting](#8-known-issues--troubleshooting) --- -## 2. Prerequisites +## 1. Prerequisites + +Verify each requirement before proceeding. All commands must succeed. + +### Docker + +```bash +docker --version +# Required: any recent version + +docker compose version +# Required: v2+ (uses "docker compose", not "docker-compose") +``` -| Requirement | Version | Notes | -|-------------|---------|-------| -| Docker + Docker Compose | 24+ | `docker compose` (v2 plugin) | -| Rust toolchain | 1.88+ | via [Hermit](https://cashapp.github.io/hermit/) or `rustup` | -| goose CLI | latest | `goose --version` | -| `mysql` client | any | for running SQL commands; or use Adminer at http://localhost:8082 | +### Rust 1.88+ -**Hermit (recommended):** If the repo has a `.hermit/` directory, activate it with `. bin/activate-hermit` — this pins the exact Rust version. +```bash +# From the sprout repo root — use Hermit if system Rust is older than 1.88 +. bin/activate-hermit + +rustc --version +# Required: rustc 1.88.0 or newer +``` + +### goose CLI + +```bash +goose --version +# Must be on $PATH and configured with a valid provider/model + +goose run --help | head -5 +# Must not error +``` + +### sqlx-cli + +```bash +sqlx --version +# If missing: +cargo install sqlx-cli --no-default-features --features mysql +``` + +### screen + +```bash +screen --version +# Must print a version string (note: on macOS this exits with code 1 — that's fine) +# If missing: brew install screen +``` + +### All clear + +If all commands above print version info, proceed. If any binary is missing, install it first — the tests will not work without all prerequisites. --- -## 3. Start Infrastructure +## 2. Quick Start: Automated Tests Only + +Run this when you want a fast check without spinning up multi-agent infrastructure. ```bash -cd REPOS/sprout +# Enter the repo and activate toolchain FIRST — all subsequent commands +# assume you are in the sprout repo root with hermit activated. +cd /path/to/sprout # e.g. ~/Development/goosetown_oss/REPOS/sprout +. bin/activate-hermit +``` -# Copy env config (only needed once) -cp .env.example .env +### Check for existing infrastructure -# Start MySQL, Redis, Typesense, and Adminer +If Docker services or a relay are already running from a previous session, you can +leave the Docker services up and just reset the database and relay: + +```bash +# Kill any existing relay +screen -S relay -X quit 2>/dev/null +lsof -ti :3000 | xargs kill -9 2>/dev/null + +# Check Docker services — if already running, skip `docker compose up` +docker compose ps --format '{{.Name}} {{.Status}}' 2>/dev/null +# If mysql/redis/typesense show "Up", you can skip to "Setup and build" below. +# If not running: docker compose up -d +``` -# Verify all services are healthy -docker compose ps +> **Port conflicts:** If `docker compose up -d` fails with "port already allocated", +> a container from another project may be using the port. Find it with +> `docker ps --format '{{.Names}} {{.Ports}}'` and stop it manually. + +> **Keycloak:** You may see `sprout-keycloak` as `unhealthy` or `starting` — this +> is fine. Keycloak is only needed for token-based auth and is not required for +> automated tests (which use dev-mode `X-Pubkey` header auth). You may also see +> extra containers like `sprout-postgres` from other projects — ignore them. + +### Setup and build + +```bash +# Configure environment +[ -f .env ] || cp .env.example .env +# Load env vars — ALWAYS required, even if .env already existed +export $(cat .env | grep -v "^#" | grep -v "^$" | xargs) 2>/dev/null + +# Reset database (fresh state for tests) +docker exec sprout-mysql mysql -u root -psprout_dev -e \ + "DROP DATABASE IF EXISTS sprout; CREATE DATABASE sprout;" 2>/dev/null +sqlx migrate run --database-url "$DATABASE_URL" + +# Build the full workspace (relay, MCP server, ACP harness, test client, etc.) +cargo build --release --workspace + +# Run unit tests +cargo test --workspace +``` + +### Integration Tests (require running relay) + +Start the relay (kill any stale instance first): + +```bash +screen -S relay -X quit 2>/dev/null +lsof -ti :3000 | xargs kill -9 2>/dev/null; sleep 1 +screen -dmS relay bash -c \ + 'export $(cat .env | grep -v "^#" | grep -v "^$" | xargs) 2>/dev/null; \ + ./target/release/sprout-relay 2>&1 | tee /tmp/sprout-relay.log' +sleep 3 && curl -s http://localhost:3000/health +# Must print: ok ``` -Expected output — all services should show `healthy`: +Then run the integration suites: + +```bash +# REST API integration tests (20 tests) +RELAY_URL=ws://localhost:3000 \ + cargo test -p sprout-test-client --test e2e_rest_api -- --ignored + +# WebSocket relay integration tests (13 tests) +RELAY_URL=ws://localhost:3000 \ + cargo test -p sprout-test-client --test e2e_relay -- --ignored + +# MCP server integration tests (7 tests) +RELAY_URL=ws://localhost:3000 \ + cargo test -p sprout-test-client --test e2e_mcp -- --ignored +``` + +### Expected Results + ``` -NAME STATUS -sprout-mysql running (healthy) -sprout-redis running (healthy) -sprout-typesense running (healthy) -sprout-adminer running +test result: ok. 20 passed; 0 failed; 0 ignored ← REST API +test result: ok. 13 passed; 0 failed; 0 ignored ← relay +test result: ok. 7 passed; 0 failed; 0 ignored ← MCP ``` -> **Tip:** If services aren't healthy after ~30 seconds, check logs: -> `docker compose logs mysql` or `docker compose logs redis` +All 40 integration tests pass. If any fail, check that the relay is running and Docker services are healthy before proceeding to E2E. + +--- + +## 3. Multi-Agent E2E Testing + +### 3.1 Architecture + +The E2E suite uses the `sprout-acp` harness — a process that bridges Sprout relay events to AI agents over the ACP protocol. The operator sends `@mention` events via the `mention` binary; each harness instance picks up mentions targeting its agent's pubkey and forwards them to a goose session with Sprout MCP tools pre-configured. + +``` +Operator (you) + │ + │ mention "task instructions" + ▼ +Sprout Relay ──WS (NIP-01)──► sprout-acp (harness) ──stdio (ACP)──► goose + │ + sprout-mcp-server + (36 MCP tools) + │ + Sprout Relay + (send_message, etc.) +``` + +Three harness instances run simultaneously — one each for Alice, Bob, and Charlie. Each has its own Nostr keypair (identity) and responds only to `@mentions` targeting its pubkey. + +**Key properties of the harness:** +- Discovers and subscribes to all accessible channels on startup +- Queues events per channel; one prompt in flight globally at a time +- Batches multiple rapid `@mentions` into a single prompt +- Auto-respawns the agent subprocess on crash +- Reconnects to the relay with a `since` filter on disconnect (no missed events) +- `GOOSE_MODE=auto` is **mandatory** — prevents goose from pausing for permission prompts + +### 3.2 Infrastructure Setup + +Run all commands from the sprout repo root. -**Run migrations:** ```bash -just migrate +cd /path/to/sprout +. bin/activate-hermit + +# 1. Start Docker services (MySQL, Redis, Typesense, Keycloak) +docker compose down -v && docker compose up -d +docker compose ps # All services should show "Up" + +# 2. Configure environment +[ -f .env ] || cp .env.example .env +export $(cat .env | grep -v "^#" | grep -v "^$" | xargs) 2>/dev/null + +# 3. Run database migrations +sqlx migrate run --database-url "$DATABASE_URL" + +# 4. Build all binaries (sprout-acp, sprout-mcp-server, mention, sprout-admin) +cargo build --release --workspace + +# 5. Add release binaries to PATH +export PATH="$PWD/target/release:$PATH" + +# 6. Verify key binaries are present +ls -la target/release/sprout-acp target/release/sprout-mcp-server \ + target/release/mention target/release/sprout-admin + +# 7. Start the relay +lsof -ti :3000 | xargs kill -9 2>/dev/null; sleep 1 +screen -dmS relay bash -c \ + 'export $(cat .env | grep -v "^#" | grep -v "^$" | xargs) 2>/dev/null; \ + ./target/release/sprout-relay 2>&1 | tee /tmp/sprout-relay.log' +sleep 3 + +# 8. Verify relay is up +curl -s http://localhost:3000/health +# Expected: {"status":"ok"} or similar ``` -Expected: +### 3.3 Mint Agent Keys + +Each agent needs its own Nostr keypair. Use `sprout-admin` to mint them — it handles all database interaction internally. + +```bash +# Mint keys for all three agents +for agent in alice bob charlie; do + echo "=== $agent ===" + cargo run -p sprout-admin -- mint-token \ + --name "$agent" \ + --scopes "messages:read,messages:write,channels:read" + echo "" +done +``` + +Each invocation prints an `nsec1...` private key, the corresponding pubkey hex, and an API token. **Save all three sets immediately — they are shown only once.** + +Set environment variables for the session: + +```bash +# Replace with actual values from mint-token output +export ALICE_NSEC="nsec1..." +export ALICE_PUBKEY="" + +export BOB_NSEC="nsec1..." +export BOB_PUBKEY="" + +export CHARLIE_NSEC="nsec1..." +export CHARLIE_PUBKEY="" ``` -Running migrations via sqlx... -Applied 1 migration(s). + +> **Tip:** Pipe the mint output to a temp file during setup: +> `cargo run -p sprout-admin -- mint-token --name alice ... | tee /tmp/alice-keys.txt` + +### 3.4 Launch Harness Instances + +Start one `sprout-acp` instance per agent in a dedicated screen session. `GOOSE_MODE=auto` is required on all three. + +```bash +# Alice's harness +SPROUT_PRIVATE_KEY="$ALICE_NSEC" \ +SPROUT_RELAY_URL="ws://localhost:3000" \ +GOOSE_MODE=auto \ + screen -dmS agent-alice bash -c \ + 'sprout-acp 2>&1 | tee /tmp/agent-alice.log' + +# Bob's harness +SPROUT_PRIVATE_KEY="$BOB_NSEC" \ +SPROUT_RELAY_URL="ws://localhost:3000" \ +GOOSE_MODE=auto \ + screen -dmS agent-bob bash -c \ + 'sprout-acp 2>&1 | tee /tmp/agent-bob.log' + +# Charlie's harness +SPROUT_PRIVATE_KEY="$CHARLIE_NSEC" \ +SPROUT_RELAY_URL="ws://localhost:3000" \ +GOOSE_MODE=auto \ + screen -dmS agent-charlie bash -c \ + 'sprout-acp 2>&1 | tee /tmp/agent-charlie.log' ``` -> **Alternative (no sqlx CLI):** `just migrate` falls back to `docker exec` automatically. +Wait ~5 seconds for all three to connect, then verify: + +```bash +sleep 5 + +for agent in alice bob charlie; do + echo "=== agent-$agent ===" + grep -E "connected|discovered|subscribed|error" /tmp/agent-$agent.log 2>/dev/null \ + || echo "(no log yet)" + echo "" +done +``` + +Expected startup output for each harness: + +``` +sprout-acp starting: relay=ws://localhost:3000 harness_pubkey=... agent_pubkey= +agent initialized: ... +connected to relay at ws://localhost:3000 +discovered N channel(s) +subscribed to channel +``` + +If you see `discovered 0 channel(s)`, the agent is not yet a member of any channels. Alice will create channels in the first exercise — after that, all three will discover them on subsequent subscriptions (open channels are accessible to any authenticated pubkey). --- -## 4. Build and Run the Relay +### 3.5 Test Exercises + +All exercises are delivered via `@mention` events using the `mention` binary: -> ⚠️ **Port 3000 conflict:** The relay binds to `0.0.0.0:3000` by default. If another process is using port 3000 (e.g., a Node.js dev server), set `SPROUT_BIND_ADDR=0.0.0.0:3001` in `.env` and update `RELAY_URL=ws://localhost:3001`. +``` +mention "task instructions" +``` + +The `mention` binary generates ephemeral sender keys — it does not need its own nsec. It requires `SPROUT_RELAY_URL` (defaults to `ws://localhost:3000`). -> ⚠️ **`.env` and `cargo run`:** `just relay` uses `set dotenv-load := true` so env vars are loaded automatically. If you run `cargo run -p sprout-relay` directly, the `.env` file is **not** loaded — export vars manually or use `just relay`. +**Important:** Channel UUIDs are dynamic. Alice creates the channels in Exercise A-1. After that step completes, query the REST API to get the UUIDs before proceeding with other exercises. ```bash -# Build the workspace first (catches compile errors early) -cargo build --workspace +# Helper: get channel UUID by name (run after Alice creates channels) +get_channel_uuid() { + local name="$1" + curl -s -H "X-Pubkey: $ALICE_PUBKEY" \ + "http://localhost:3000/api/channels" \ + | jq -r ".[] | select(.name == \"$name\") | .id" +} +``` + +--- -# Run the relay in a detached screen session -screen -dmS sprout-relay just relay +#### Alice — Infrastructure Creator + +Alice sets up the shared environment that Bob and Charlie will use. + +**A-1: Create channels and seed messages** + +Alice needs a bootstrap channel to receive her first `@mention`. Use the default test channel from the relay, or create one via the REST API first: + +```bash +# Create a bootstrap channel for Alice's first mention +BOOTSTRAP_CHANNEL=$(curl -s -X POST \ + -H "Content-Type: application/json" \ + -H "X-Pubkey: $ALICE_PUBKEY" \ + "http://localhost:3000/api/channels" \ + -d '{"name":"bootstrap","channel_type":"stream","visibility":"open"}' \ + | jq -r '.id') +echo "Bootstrap channel: $BOOTSTRAP_CHANNEL" ``` -Verify the relay is listening: +Then send Alice her first task: + ```bash -screen -r sprout-relay -# Press Ctrl-A D to detach without stopping +mention "$BOOTSTRAP_CHANNEL" "$ALICE_PUBKEY" \ + "Create 3 channels: 'general' (stream/open), 'alice-testing' (stream/open), and 'private-ops' (stream/private). Then send 3 messages to the 'general' channel introducing yourself and describing what you're testing." ``` -Expected log output: +Wait for Alice to complete (~30–60s), then capture channel UUIDs: + +```bash +sleep 60 +export GENERAL=$(get_channel_uuid "general") +export ALICE_TESTING=$(get_channel_uuid "alice-testing") +export PRIVATE_OPS=$(get_channel_uuid "private-ops") +echo "general=$GENERAL alice-testing=$ALICE_TESTING private-ops=$PRIVATE_OPS" ``` -INFO sprout_relay: listening on 0.0.0.0:3000 -WARN sprout_relay: SPROUT_REQUIRE_AUTH_TOKEN is false — relay accepts unauthenticated connections. + +**A-2: Channel metadata and canvas** + +```bash +mention "$GENERAL" "$ALICE_PUBKEY" \ + "Set the topic on the 'general' channel to 'Sprout E2E Testing'. Set the purpose to 'Multi-agent integration test run'. Then set the canvas on 'general' to a markdown document with a header '# Test Run Notes' and a bullet list of the 3 channels you created." ``` -> The auth warning is expected in local dev. Set `SPROUT_REQUIRE_AUTH_TOKEN=true` in `.env` to enforce token auth. +**A-3: Thread and reactions** + +```bash +mention "$GENERAL" "$ALICE_PUBKEY" \ + "Get the history of the 'general' channel. Reply to your first message there with a thread reply saying 'This is a thread reply from Alice'. Then add a 👍 reaction and a 🚀 reaction to your own first message." +``` + +**A-4: Workflow creation** + +```bash +mention "$GENERAL" "$ALICE_PUBKEY" \ + "Create a workflow named 'alice-notify' with a message_posted trigger on the 'general' channel. The workflow should have one step: send a message to the 'general' channel saying 'Workflow fired!'. Save the workflow ID and report it back." +``` + +**A-5: Profile and presence** + +```bash +mention "$GENERAL" "$ALICE_PUBKEY" \ + "Set your display name to 'Alice (Test Agent)'. Set your about/bio to 'I am Alice, the infrastructure creator for the Sprout E2E test suite.' Set your presence to online." +``` + +**A-6: Feed, search, and membership** + +```bash +mention "$GENERAL" "$ALICE_PUBKEY" \ + "Get your feed and the channel feed for 'general'. Search for messages containing the word 'Alice'. List the members of the 'general' channel. Then invite Bob (pubkey: $BOB_PUBKEY) to the 'private-ops' channel." +``` --- -## 5. Create Test Channels +#### Bob — Discoverer and Reactor + +Bob explores the environment Alice created and interacts with her content. -Connect to MySQL and create a channel, then add members after minting tokens (step 6 gives you pubkeys). +**B-1: Discovery and history** ```bash -mysql -u sprout -psprout_dev -h 127.0.0.1 sprout +mention "$GENERAL" "$BOB_PUBKEY" \ + "List all channels you have access to. Get the message history from the 'general' channel (last 20 messages). Report what you find — how many channels exist, and what did Alice write in general?" ``` -```sql --- Create a test channel (channel ID must be a 16-byte UUID stored as BINARY(16)) -INSERT INTO channels (id, name, channel_type, visibility, created_by) -VALUES ( - UNHEX(REPLACE(UUID(), '-', '')), - 'agent-test', - 'stream', - 'open', - X'0000000000000000000000000000000000000000000000000000000000000001' -); +**B-2: Reactions and DM** --- Capture the channel ID for later steps -SELECT HEX(id) AS channel_id, name FROM channels WHERE name = 'agent-test'; +```bash +mention "$GENERAL" "$BOB_PUBKEY" \ + "React to the first message in the 'general' channel with a ❤️ reaction. Then send a direct message to Alice (pubkey: $ALICE_PUBKEY) saying 'Hi Alice, Bob here — I can see your channels and messages. The setup looks great!'" ``` -> **Note:** `channel_members` entries require a valid `pubkey` (32-byte Nostr public key). Add members **after** minting tokens in step 6. +**B-3: DM history and canvas** + +```bash +mention "$GENERAL" "$BOB_PUBKEY" \ + "Get your DM conversation history with Alice. Read the canvas on the 'general' channel and report what it says. List all your DM conversations." +``` + +**B-4: Thread participation** + +```bash +mention "$GENERAL" "$BOB_PUBKEY" \ + "Find Alice's thread in the 'general' channel (a message that has replies). Add a thread reply saying 'Bob joining the thread — everything looks good from my end.' Then get the thread replies and report how many there are." +``` + +**B-5: Channel join and profile** + +```bash +mention "$GENERAL" "$BOB_PUBKEY" \ + "Join the 'alice-testing' channel. Get your own profile and set your display name to 'Bob (Test Agent)'. Search for any messages mentioning 'workflow' or 'canvas'." +``` + +**B-6: Private channel access test** + +```bash +mention "$GENERAL" "$BOB_PUBKEY" \ + "Try to get the message history from the 'private-ops' channel (ID: $PRIVATE_OPS). Report whether you can access it or get an error. Then try again — Alice should have invited you by now." +``` + +**B-7: Get presence** + +```bash +mention "$GENERAL" "$BOB_PUBKEY" \ + "Get the presence status for Alice (pubkey: $ALICE_PUBKEY). Get your own presence status. Report both." +``` --- -## 6. Mint Agent Tokens +#### Charlie — Edge Case Specialist -`sprout-admin` creates API tokens and optionally generates a new Nostr keypair per agent. Run once per agent. +Charlie tests error handling, idempotency, and lifecycle operations. -> ⚠️ **Save the output immediately** — the raw token and private key (`nsec`) are shown only once. +**C-1: Non-existent channel error** ```bash -# Agent 1 -cargo run -p sprout-admin -- mint-token \ - --name "agent-alice" \ - --scopes "messages:read,messages:write,channels:read" +mention "$GENERAL" "$CHARLIE_PUBKEY" \ + "Try to send a message to channel UUID '00000000-0000-0000-0000-000000000000'. Report the exact error you receive." ``` +**C-2: Unauthorized archive attempt** + ```bash -# Agent 2 -cargo run -p sprout-admin -- mint-token \ - --name "agent-bob" \ - --scopes "messages:read,messages:write,channels:read" +mention "$GENERAL" "$CHARLIE_PUBKEY" \ + "Try to archive the 'general' channel (ID: $GENERAL). You did not create it — report what error you get." ``` -Expected output (per agent): +**C-3: Canvas overwrite** + +```bash +mention "$GENERAL" "$CHARLIE_PUBKEY" \ + "Set the canvas on the 'general' channel to a new markdown document: '# Charlie Was Here\n\nCharlie overwrote the canvas on $(date -u +%Y-%m-%dT%H:%M:%SZ)'. Then immediately read the canvas back and confirm it shows your content." +``` + +**C-4: Reaction idempotency** + +```bash +mention "$GENERAL" "$CHARLIE_PUBKEY" \ + "Find the first message in the 'general' channel. Add a 🎉 reaction to it. Then try to add the same 🎉 reaction again. Report what happens the second time — does it error or succeed silently?" ``` -╔══════════════════════════════════════════════════════════════╗ -║ Token minted successfully! ║ -╠══════════════════════════════════════════════════════════════╣ -║ Token ID: ║ -║ Name: agent-alice ║ -║ Scopes: messages:read,messages:write,channels:read ║ -║ Pubkey: ... ║ -╠══════════════════════════════════════════════════════════════╣ -║ ⚠️ SAVE THESE — shown only once! ║ -╠══════════════════════════════════════════════════════════════╣ -║ Private key (nsec): ║ -║ nsec1... ║ -║ ║ -║ API Token: ║ -║ spr_... ║ -╚══════════════════════════════════════════════════════════════╝ + +**C-5: Channel lifecycle (create → archive → send → unarchive → send)** + +```bash +mention "$GENERAL" "$CHARLIE_PUBKEY" \ + "Create a new channel named 'charlie-lifecycle' (stream/open). Send a message to it saying 'Before archive'. Archive the channel. Try to send another message — report the error. Unarchive the channel. Send a message saying 'After unarchive'. Confirm the final message was accepted." ``` -**Add agents as channel members** (using the full pubkey hex from the output): -```sql --- In mysql client — replace with each agent's full 64-char hex pubkey -INSERT INTO channel_members (channel_id, pubkey, role) -SELECT id, UNHEX(''), 'member' -FROM channels WHERE name = 'agent-test'; +**C-6: Join, send, leave, verify** -INSERT INTO channel_members (channel_id, pubkey, role) -SELECT id, UNHEX(''), 'member' -FROM channels WHERE name = 'agent-test'; +```bash +mention "$GENERAL" "$CHARLIE_PUBKEY" \ + "Join the 'alice-testing' channel. Send a message there saying 'Charlie was here'. Then leave the channel. Try to send another message to 'alice-testing' — report whether it succeeds or fails after leaving." ``` -**List all tokens** to verify: +**C-7: Workflow trigger and cross-agent summary** + ```bash -cargo run -p sprout-admin -- list-tokens +mention "$GENERAL" "$CHARLIE_PUBKEY" \ + "Get the list of workflows. Find Alice's 'alice-notify' workflow and trigger it via webhook if it has a webhook trigger, or note that it uses message_posted. Then get the presence for both Alice (pubkey: $ALICE_PUBKEY) and Bob (pubkey: $BOB_PUBKEY). Finally, produce a summary report in the 'general' channel listing: (1) all channels created during this test run, (2) total messages sent, (3) any errors encountered." ``` --- -## 7. Launch Agents +### 3.6 Monitoring & Verification + +#### Watch harness logs live + +```bash +# Tail the log files (agent-safe — no TTY required) +tail -f /tmp/agent-alice.log & +tail -f /tmp/agent-bob.log & +tail -f /tmp/agent-charlie.log & + +# Or view recent output from a specific agent +tail -50 /tmp/agent-alice.log +``` + +> **Note:** Do NOT use `screen -r` to attach to harness sessions if you are an +> AI agent — it requires an interactive TTY and will hang indefinitely. Always +> use `tail` on the log files or `grep` for specific patterns instead. + +Key log patterns to watch for: + +``` +# Good — turn completed +turn complete for channel : end_turn + +# Good — agent is working +prompting agent for channel (session ..., N event(s)) -Each agent runs in its own terminal with its own token and private key. The `sprout-mcp` extension connects to the relay via stdio transport. +# Investigate — agent had trouble +turn complete for channel : max_tokens +turn timeout (300s) for channel — cancelling -**Environment variables for `sprout-mcp`:** +# Bad — needs attention +agent process exited — respawning +relay connection lost — reconnecting +``` -| Variable | Description | Default | -|----------|-------------|---------| -| `SPROUT_RELAY_URL` | WebSocket URL of the relay | `ws://localhost:3000` | -| `SPROUT_API_TOKEN` | API token from step 6 | (none — unauthenticated) | -| `SPROUT_PRIVATE_KEY` | Nostr private key (`nsec1...`) | generates ephemeral key | +#### Tail log files -**Terminal 1 — Agent Alice:** ```bash -SPROUT_RELAY_URL=ws://localhost:3000 \ -SPROUT_API_TOKEN=spr_ \ -SPROUT_PRIVATE_KEY=nsec1 \ -goose run --no-profile \ - --with-extension "cargo run -p sprout-mcp" \ - --instructions "You are Alice. Join the agent-test channel and say hello." +# All three agents at once +tail -f /tmp/agent-alice.log /tmp/agent-bob.log /tmp/agent-charlie.log + +# Filter for completions only +grep "turn complete\|turn timeout\|turn cancelled" \ + /tmp/agent-alice.log /tmp/agent-bob.log /tmp/agent-charlie.log ``` -**Terminal 2 — Agent Bob:** +#### REST API verification + ```bash -SPROUT_RELAY_URL=ws://localhost:3000 \ -SPROUT_API_TOKEN=spr_ \ -SPROUT_PRIVATE_KEY=nsec1 \ -goose run --no-profile \ - --with-extension "cargo run -p sprout-mcp" \ - --instructions "You are Bob. Join the agent-test channel and respond to Alice." +# List all channels (use any agent's pubkey) +curl -s -H "X-Pubkey: $ALICE_PUBKEY" \ + "http://localhost:3000/api/channels" \ + | jq '.[] | {id: .id, name: .name, visibility: .visibility}' + +# Recent messages in general +curl -s -H "X-Pubkey: $ALICE_PUBKEY" \ + "http://localhost:3000/api/channels/$GENERAL/messages?limit=20" \ + | jq '.[] | {sender: .pubkey[:16], body: .content[:120]}' + +# Messages from a specific agent +curl -s -H "X-Pubkey: $ALICE_PUBKEY" \ + "http://localhost:3000/api/channels/$GENERAL/messages?limit=50" \ + | jq --arg pk "$CHARLIE_PUBKEY" \ + '[.[] | select(.pubkey == $pk)] | {count: length, messages: [.[] | .content[:100]]}' + +# Channel members +curl -s -H "X-Pubkey: $ALICE_PUBKEY" \ + "http://localhost:3000/api/channels/$GENERAL/members" \ + | jq '.[] | {pubkey: .pubkey[:16], role: .role}' + +# Check private-ops membership (Bob should be there after A-6) +curl -s -H "X-Pubkey: $ALICE_PUBKEY" \ + "http://localhost:3000/api/channels/$PRIVATE_OPS/members" \ + | jq '.[] | .pubkey[:16]' ``` -> **Note:** `cargo run -p sprout-mcp` builds and runs the MCP server inline. For faster startup after the first build, use the compiled binary: `./target/debug/sprout-mcp-server`. +#### Screen session management + +```bash +# List all active sessions +screen -ls + +# Check if a session is still running +screen -ls | grep agent-alice + +# Capture current screen contents to file (non-destructive) +screen -S agent-alice -X hardcopy /tmp/alice-snapshot.txt +cat /tmp/alice-snapshot.txt +``` + +### 3.7 Expected Results + +After all exercises complete, the following should be true: + +| Check | Expected | +|-------|----------| +| Channels created | At least 5: general, alice-testing, private-ops, charlie-lifecycle, bootstrap | +| Messages in general | 10+ messages from Alice, Bob, and Charlie | +| Thread replies | At least 2 replies on Alice's first message | +| Reactions | 👍 🚀 (Alice), ❤️ (Bob), 🎉 (Charlie) on general messages | +| Canvas | Charlie's content (last writer wins) | +| DM conversation | Alice ↔ Bob DM exists | +| Bob in private-ops | Yes (Alice invited him in A-6) | +| Workflow | alice-notify created with message_posted trigger | +| Display names | Alice and Bob have display names set | +| Error handling | Charlie's C-1, C-2, C-5 exercises report correct errors | +| charlie-lifecycle | Unarchived and final message sent successfully | + +**Verify the full picture:** + +```bash +# Channel count +curl -s -H "X-Pubkey: $ALICE_PUBKEY" \ + "http://localhost:3000/api/channels" | jq 'length' + +# Message count in general +curl -s -H "X-Pubkey: $ALICE_PUBKEY" \ + "http://localhost:3000/api/channels/$GENERAL/messages?limit=200" | jq 'length' + +# Workflow list +curl -s -H "X-Pubkey: $ALICE_PUBKEY" \ + "http://localhost:3000/api/workflows" \ + | jq '.[] | {name: .name, trigger: .trigger.on}' +``` + +--- + +## 4. Advanced: ACP Harness Scenarios + +These scenarios test the `sprout-acp` harness itself — crash recovery, relay reconnection, turn timeout, and concurrent multi-agent operation. Run them independently from the main E2E suite. + +### Prerequisites for Advanced Scenarios + +```bash +# Use the single-agent test key from sprout-acp TESTING.md +export SPROUT_PRIVATE_KEY=nsec1ddyp0fufd6ejerfqkxcfqlmkktwzx7w45emalvgtcvyafefusj5q8fyllm +export AGENT_PUBKEY=ae670a075ac2446f445808ab5a1a796cec37c72c70b25e10ee39f7f0eab50feb +export TEST_CHANNEL=94a444a4-c0a3-5966-ab05-530c6ddc2301 + +# Start a single harness +SPROUT_PRIVATE_KEY="$SPROUT_PRIVATE_KEY" \ +SPROUT_RELAY_URL="ws://localhost:3000" \ +GOOSE_MODE=auto \ + screen -dmS harness bash -c 'sprout-acp 2>&1 | tee /tmp/harness.log' +sleep 5 +``` + +### Scenario A: Basic @mention → Agent Replies + +```bash +mention "$TEST_CHANNEL" "$AGENT_PUBKEY" "What is 2 + 2? Reply with just the number." +sleep 30 + +# Verify reply via REST API +curl -s -H "X-Pubkey: $AGENT_PUBKEY" \ + "http://localhost:3000/api/channels/$TEST_CHANNEL/messages?limit=5" \ + | jq --arg pk "$AGENT_PUBKEY" \ + '.[] | select(.pubkey == $pk) | {body: .content[:200]}' +``` + +Expected: agent replies with "4" via `send_message`. + +### Scenario B: Multi-Event Batch + +```bash +# Send 3 mentions in rapid succession +for i in 1 2 3; do + mention "$TEST_CHANNEL" "$AGENT_PUBKEY" "Batch message $i" & +done +wait +sleep 30 + +# Check harness log for batch size +grep "prompting agent" /tmp/harness.log | tail -5 +# Look for "(session ..., N event(s))" where N > 1 +``` + +### Scenario C: Agent Crash Recovery + +```bash +# Kill the agent subprocess +kill -9 $(pgrep -f "goose acp") 2>/dev/null + +# Send a new mention +sleep 2 +mention "$TEST_CHANNEL" "$AGENT_PUBKEY" "Are you still alive after the crash?" +sleep 30 + +# Verify recovery in logs +grep -E "agent process exited|agent respawned|turn complete" /tmp/harness.log | tail -10 +``` + +Expected log sequence: `agent process exited — respawning` → `agent respawned successfully` → `agent initialized` → `turn complete`. + +### Scenario D: Relay Disconnect Recovery + +```bash +# Stop the relay +screen -S relay -X stuff $'\003' # Ctrl-C +sleep 2 + +# Watch harness detect disconnect +grep "relay connection lost\|reconnecting" /tmp/harness.log + +# Restart relay +screen -dmS relay bash -c \ + 'export $(cat .env | grep -v "^#" | grep -v "^$" | xargs) 2>/dev/null; \ + ./target/release/sprout-relay 2>&1 | tee /tmp/sprout-relay.log' +sleep 5 + +# Verify reconnection +grep "relay reconnected\|subscribed" /tmp/harness.log | tail -5 + +# Confirm harness is functional again +mention "$TEST_CHANNEL" "$AGENT_PUBKEY" "Post-reconnect test — reply with OK" +sleep 30 +grep "turn complete" /tmp/harness.log | tail -3 +``` + +### Scenario E: Turn Timeout + +```bash +# Restart harness with 5-second timeout +screen -S harness -X quit +SPROUT_PRIVATE_KEY="$SPROUT_PRIVATE_KEY" \ +SPROUT_RELAY_URL="ws://localhost:3000" \ +SPROUT_ACP_TURN_TIMEOUT=5 \ +GOOSE_MODE=auto \ + screen -dmS harness bash -c 'sprout-acp 2>&1 | tee /tmp/harness.log' +sleep 3 + +# Send a prompt that will take longer than 5 seconds +mention "$TEST_CHANNEL" "$AGENT_PUBKEY" \ + "Write a detailed 500-word essay on the history of computing, then list 50 prime numbers." +sleep 15 + +# Verify timeout was triggered +grep "turn timeout\|turn cancelled" /tmp/harness.log | tail -5 + +# Reset to normal timeout +screen -S harness -X quit +SPROUT_PRIVATE_KEY="$SPROUT_PRIVATE_KEY" \ +SPROUT_RELAY_URL="ws://localhost:3000" \ +GOOSE_MODE=auto \ + screen -dmS harness bash -c 'sprout-acp 2>&1 | tee /tmp/harness.log' +``` + +Expected: `turn timeout (5s) for channel ... — cancelling` then `turn cancelled for channel ...`. Harness continues running. + +### Scenario F: Permission Handling (GOOSE_MODE=auto) + +```bash +mention "$TEST_CHANNEL" "$AGENT_PUBKEY" \ + "Use your tools to get the last 5 messages from this channel and summarize them." +sleep 60 + +# Verify no permission prompts appeared +grep -i "permission\|approval\|waiting" /tmp/harness.log | head -5 +# Should return nothing + +grep "turn complete" /tmp/harness.log | tail -3 +# Should show end_turn +``` + +### Scenario G: Channel Discovery + +```bash +# Restart harness fresh and watch discovery +screen -S harness -X quit +SPROUT_PRIVATE_KEY="$SPROUT_PRIVATE_KEY" \ +SPROUT_RELAY_URL="ws://localhost:3000" \ +GOOSE_MODE=auto \ + screen -dmS harness bash -c 'sprout-acp 2>&1 | tee /tmp/harness.log' +sleep 5 + +grep "discovered\|subscribed" /tmp/harness.log +# Expected: "discovered N channel(s)" then "subscribed to channel " for each +``` + +Verify channel membership via REST API: + +```bash +curl -s -H "X-Pubkey: $AGENT_PUBKEY" \ + "http://localhost:3000/api/channels" \ + | jq '.[] | {id: .id, name: .name}' +``` + +### Scenario H: Concurrent Channels (FIFO Fairness) + +```bash +# Get a second channel UUID (create one if needed) +CHANNEL_B=$(curl -s -X POST \ + -H "Content-Type: application/json" \ + -H "X-Pubkey: $AGENT_PUBKEY" \ + "http://localhost:3000/api/channels" \ + -d '{"name":"channel-b-test","channel_type":"stream","visibility":"open"}' \ + | jq -r '.id') + +# Send to channel A first, then B immediately +mention "$TEST_CHANNEL" "$AGENT_PUBKEY" "Channel A message — process me first" +mention "$CHANNEL_B" "$AGENT_PUBKEY" "Channel B message — process me second" +sleep 60 + +# Verify FIFO ordering in logs +grep "prompting agent for channel" /tmp/harness.log | tail -5 +# Channel A should appear before Channel B +# No two "prompting agent" lines without a "turn complete" between them +``` + +### Scenario I: Multi-Agent (3 Agents, 1 Channel) + +```bash +# Mint two additional keypairs +cargo run -p sprout-admin -- mint-token \ + --name "agent-b" --scopes "messages:read,messages:write,channels:read" \ + | tee /tmp/agent-b-keys.txt + +cargo run -p sprout-admin -- mint-token \ + --name "agent-c" --scopes "messages:read,messages:write,channels:read" \ + | tee /tmp/agent-c-keys.txt + +# Extract keys (adjust parsing as needed based on output format) +AGENT_B_NSEC=$(grep "nsec1" /tmp/agent-b-keys.txt | awk '{print $NF}') +AGENT_B_PUBKEY=$(grep "pubkey" /tmp/agent-b-keys.txt | awk '{print $NF}') +AGENT_C_NSEC=$(grep "nsec1" /tmp/agent-c-keys.txt | awk '{print $NF}') +AGENT_C_PUBKEY=$(grep "pubkey" /tmp/agent-c-keys.txt | awk '{print $NF}') + +# Start three harnesses +SPROUT_PRIVATE_KEY="$SPROUT_PRIVATE_KEY" GOOSE_MODE=auto \ + screen -dmS harness-a bash -c 'sprout-acp 2>&1 | tee /tmp/harness-a.log' + +SPROUT_PRIVATE_KEY="$AGENT_B_NSEC" GOOSE_MODE=auto \ + screen -dmS harness-b bash -c 'sprout-acp 2>&1 | tee /tmp/harness-b.log' + +SPROUT_PRIVATE_KEY="$AGENT_C_NSEC" GOOSE_MODE=auto \ + screen -dmS harness-c bash -c 'sprout-acp 2>&1 | tee /tmp/harness-c.log' + +sleep 5 + +# Send a targeted @mention to each agent +mention "$TEST_CHANNEL" "$AGENT_PUBKEY" "Hello agent-a, reply with PONG-A" +mention "$TEST_CHANNEL" "$AGENT_B_PUBKEY" "Hello agent-b, reply with PONG-B" +mention "$TEST_CHANNEL" "$AGENT_C_PUBKEY" "Hello agent-c, reply with PONG-C" +sleep 60 + +# Verify three distinct replies +curl -s -H "X-Pubkey: $AGENT_PUBKEY" \ + "http://localhost:3000/api/channels/$TEST_CHANNEL/messages?limit=20" \ + | jq '.[] | {sender: (.pubkey[:16] + "..."), body: .content[:100]}' +# Look for three distinct sender prefixes, each with a PONG reply + +# Cleanup +for s in harness-a harness-b harness-c; do screen -S $s -X quit; done +``` + +--- + +## 5. Workflow YAML Reference + +Workflows are created via the `create_workflow` MCP tool. The YAML structure: + +```yaml +name: My Workflow +trigger: + on: message_posted # Valid: message_posted | reaction_added | webhook + channel_id: "" # Optional: scope to a specific channel +steps: + - id: notify # Required: alphanumeric + underscores only + action: send_message # Action type + channel_id: "" # Action-specific fields are DIRECT properties (not nested) + text: "Workflow fired!" +``` + +**Valid triggers:** +- `message_posted` — fires when any message is posted (optionally scoped to a channel) +- `reaction_added` — fires when a reaction is added to a message +- `webhook` — fires when the webhook URL is called via HTTP POST + +**Step field rules:** +- `id` is required on every step (alphanumeric and underscores) +- Action fields (`channel_id`, `text`, etc.) are **direct properties** of the step object — do NOT nest them under a `params` key +- `create_workflow` tool accepts the YAML as a string parameter + +**Example: message_posted workflow** + +```yaml +name: welcome-new-messages +trigger: + on: message_posted + channel_id: "94a444a4-c0a3-5966-ab05-530c6ddc2301" +steps: + - id: echo_reply + action: send_message + channel_id: "94a444a4-c0a3-5966-ab05-530c6ddc2301" + text: "New message detected!" +``` + +**Example: webhook workflow** + +```yaml +name: external-trigger +trigger: + on: webhook +steps: + - id: notify_channel + action: send_message + channel_id: "94a444a4-c0a3-5966-ab05-530c6ddc2301" + text: "Webhook triggered this workflow!" +``` + +--- + +## 6. The 36 MCP Tools + +The `sprout-mcp-server` exposes 36 tools covering the full Sprout feature surface. All are available to agents running via the `sprout-acp` harness. + +### Channels (8) + +| Tool | Description | +|------|-------------| +| `list_channels` | List all channels accessible to the agent | +| `get_channel_info` | Get metadata for a specific channel | +| `create_channel` | Create a new channel (`channel_type`: stream\|forum, `visibility`: open\|private) | +| `update_channel` | Update channel name or metadata | +| `archive_channel` | Archive a channel (creator only) | +| `unarchive_channel` | Restore an archived channel | +| `join_channel` | Join an open channel | +| `leave_channel` | Leave a channel | + +### Messages (4) + +| Tool | Description | +|------|-------------| +| `send_message` | Post a message to a channel | +| `get_channel_history` | Get recent messages from a channel | +| `send_thread_reply` | Reply within a message thread | +| `get_thread_replies` | Get replies in a thread | + +### Reactions (3) + +| Tool | Description | +|------|-------------| +| `add_reaction` | Add an emoji reaction to a message | +| `remove_reaction` | Remove a reaction | +| `get_message_reactions` | List all reactions on a message | + +### Direct Messages (3) + +| Tool | Description | +|------|-------------| +| `send_dm` | Send a direct message to a user | +| `get_dm_history` | Get DM conversation history | +| `list_dm_conversations` | List all DM conversations | + +### Canvas (2) + +| Tool | Description | +|------|-------------| +| `get_canvas` | Read the canvas document for a channel | +| `set_canvas` | Write/overwrite the canvas document (last writer wins) | + +### Workflows (3) + +| Tool | Description | +|------|-------------| +| `create_workflow` | Create a new workflow with trigger and steps | +| `list_workflows` | List all workflows | +| `trigger_workflow` | Manually trigger a webhook workflow | + +### Feed (2) + +| Tool | Description | +|------|-------------| +| `get_feed` | Get the agent's personal activity feed | +| `get_channel_feed` | Get the activity feed for a specific channel | + +### Search (1) + +| Tool | Description | +|------|-------------| +| `search` | Full-text search across messages and channels | + +### Profile (3) + +| Tool | Description | +|------|-------------| +| `get_profile` | Get the agent's profile | +| `set_display_name` | Set the agent's display name | +| `set_about` | Set the agent's bio/about text | + +### Presence (2) + +| Tool | Description | +|------|-------------| +| `set_presence` | Set the agent's presence status (online/away/etc.) | +| `get_presence` | Get presence status for a user | + +### Members (2) + +| Tool | Description | +|------|-------------| +| `list_channel_members` | List members of a channel | +| `get_user_channels` | Get channels a user belongs to | + +### Admin (3) + +| Tool | Description | +|------|-------------| +| `set_channel_topic` | Set the topic for a channel | +| `set_channel_purpose` | Set the purpose for a channel | +| `invite_to_channel` | Invite a user (by pubkey) to a channel | --- -## 8. Verify Conversations +## 7. Cleanup + +### Stop harness instances -**Check relay logs** (in the screen session): ```bash -screen -r sprout-relay +# E2E test harnesses +for s in agent-alice agent-bob agent-charlie; do + screen -S $s -X quit 2>/dev/null && echo "stopped $s" || echo "$s not running" +done + +# Advanced scenario harnesses +for s in harness harness-a harness-b harness-c; do + screen -S $s -X quit 2>/dev/null +done ``` -Look for lines like: + +### Stop relay + +```bash +screen -S relay -X quit 2>/dev/null && echo "relay stopped" ``` -DEBUG sprout_relay: authenticated pubkey= -DEBUG sprout_relay: EVENT accepted kind=40001 channel= -DEBUG sprout_relay: delivered to 2 subscriber(s) + +### Verify all sessions gone + +```bash +screen -ls +# Should show "No Sockets found" or only unrelated sessions ``` -**Query the database for messages:** -```sql -SELECT - HEX(channel_id) AS channel, - content, - created_at -FROM events -WHERE channel_id = (SELECT id FROM channels WHERE name = 'agent-test') -ORDER BY created_at DESC -LIMIT 20; +### Tear down Docker services + +```bash +# Stop services and remove volumes (full reset) +docker compose down -v + +# Stop services only (preserve data for next run) +docker compose down ``` -**Read channel history via MCP** (from within a goose session with sprout-mcp loaded): +### Clean up temp files + +```bash +rm -f /tmp/agent-alice.log /tmp/agent-bob.log /tmp/agent-charlie.log +rm -f /tmp/harness.log /tmp/harness-a.log /tmp/harness-b.log /tmp/harness-c.log +rm -f /tmp/sprout-relay.log +rm -f /tmp/alice-keys.txt /tmp/agent-b-keys.txt /tmp/agent-c-keys.txt ``` -Use the sprout MCP tool to list messages in the agent-test channel. + +--- + +## 8. Known Issues / Troubleshooting + +### Current Status + +All automated tests pass as of 2026-03-10: + +- ✅ 20/20 REST API integration tests +- ✅ 13/13 WebSocket relay integration tests +- ✅ 7/7 MCP server integration tests +- ✅ Multi-agent E2E (Alice/Bob/Charlie) via sprout-acp harness + +--- + +### Harness exits immediately with "configuration error" + +**Cause:** `SPROUT_PRIVATE_KEY` not set or invalid. + +```bash +echo $SPROUT_PRIVATE_KEY +# Must be a valid nsec1... bech32 string +``` + +--- + +### "relay connect error" on startup + +**Cause:** Relay not running or wrong URL. + +```bash +# Check relay session +screen -ls | grep relay + +# If missing, start it +screen -dmS relay bash -c \ + 'export $(cat .env | grep -v "^#" | grep -v "^$" | xargs) 2>/dev/null; \ + ./target/release/sprout-relay 2>&1 | tee /tmp/sprout-relay.log' + +# Verify +curl -s http://localhost:3000/health ``` --- -## 9. Running the Test Suite +### "discovered 0 channel(s)" -### Unit tests (no infrastructure required) +**Cause:** Agent pubkey is not a member of any open channels. ```bash -just test-unit -# or equivalently: -./scripts/run-tests.sh unit +# Check what channels are accessible +curl -s -H "X-Pubkey: $ALICE_PUBKEY" \ + "http://localhost:3000/api/channels" | jq 'length' ``` -Runs `sprout-core` and `sprout-auth` unit tests. No Docker needed. +Open channels are accessible to any authenticated pubkey. If the relay has no open channels yet, Alice's bootstrap channel creation (Exercise A-1) will fix this. After Alice creates channels, restart Bob's and Charlie's harnesses so they rediscover. + +--- + +### "failed to spawn agent" + +**Cause:** `goose` binary not found or not on `$PATH`. + +```bash +which goose +goose --version +``` + +Ensure goose is installed and configured with a valid provider/model before starting the harness. + +--- + +### Agent hangs, turn never completes -### Integration tests (requires running services) +**Cause:** `GOOSE_MODE=auto` not set — goose is waiting for permission approval. ```bash -just test-integration -# or equivalently: -./scripts/run-tests.sh integration +# Kill and restart with GOOSE_MODE=auto +screen -S agent-alice -X quit +SPROUT_PRIVATE_KEY="$ALICE_NSEC" \ +SPROUT_RELAY_URL="ws://localhost:3000" \ +GOOSE_MODE=auto \ + screen -dmS agent-alice bash -c 'sprout-acp 2>&1 | tee /tmp/agent-alice.log' ``` -Starts services if not running, applies migrations, then tests `sprout-db` and `sprout-auth` integration. +`GOOSE_MODE=auto` is **mandatory** for all harness instances. Without it, the first MCP tool call will hang indefinitely. + +--- + +### No agent reply after @mention + +Checklist: -### All tests +1. Is the harness running? `pgrep -a sprout-acp` +2. Is the harness subscribed to the target channel? `grep "subscribed" /tmp/agent-alice.log` +3. Did `mention` use the correct pubkey hex? Check `$ALICE_PUBKEY` is set correctly. +4. Check harness logs for errors: `tail -50 /tmp/agent-alice.log` +5. Verify via REST API that the @mention event arrived: ```bash -just test +curl -s -H "X-Pubkey: $ALICE_PUBKEY" \ + "http://localhost:3000/api/channels/$GENERAL/messages?limit=10" \ + | jq '.[] | {kind: .kind, sender: .pubkey[:16], body: .content[:100]}' ``` -### E2E relay tests (requires running relay) +--- -The e2e tests in `crates/sprout-test-client/tests/e2e_relay.rs` are marked `#[ignore]` by default. Run them explicitly with a live relay: +### MCP tool calls failing + +**Cause:** `sprout-mcp-server` binary not found, or wrong relay URL passed to MCP. ```bash -# Relay must be running (step 4) -cargo test --test e2e_relay -- --ignored --nocapture +which sprout-mcp-server +# If missing: cargo build --release -p sprout-mcp-server +# Then: export PATH="$PWD/target/release:$PATH" +``` + +--- + +### Channel UUIDs not set after Alice's first exercise -# Override relay URL if not on default port: -RELAY_URL=ws://localhost:3001 cargo test --test e2e_relay -- --ignored --nocapture +If `$GENERAL`, `$ALICE_TESTING`, or `$PRIVATE_OPS` are empty, Alice may not have finished yet. Wait longer, then re-query: + +```bash +sleep 30 +export GENERAL=$(curl -s -H "X-Pubkey: $ALICE_PUBKEY" \ + "http://localhost:3000/api/channels" \ + | jq -r '.[] | select(.name == "general") | .id') +echo "GENERAL=$GENERAL" ``` -Key e2e tests: -- `test_connect_and_authenticate` — NIP-42 auth handshake -- `test_send_event_and_receive_via_subscription` — pub/sub round-trip -- `test_multiple_concurrent_clients` — 3 clients, 1 sender, all receive -- `test_unauthenticated_rejected` — auth enforcement -- `test_pubkey_mismatch_rejected` — impersonation prevention +If still empty, check Alice's harness logs for errors: `tail -50 /tmp/agent-alice.log`. --- -## 10. Troubleshooting - -| Symptom | Likely Cause | Fix | -|---------|-------------|-----| -| `Connection refused` on port 3000 | Relay not running | `screen -r sprout-relay` to check; restart with `screen -dmS sprout-relay just relay` | -| Port 3000 already in use | Another process (Node, etc.) | Set `SPROUT_BIND_ADDR=0.0.0.0:3001` and `RELAY_URL=ws://localhost:3001` in `.env` | -| `auth: invalid token` | Wrong or missing `SPROUT_API_TOKEN` | Re-run `mint-token`; verify token in `SPROUT_API_TOKEN` env var | -| Agent connects but can't post | Not a channel member | Run the `INSERT INTO channel_members` SQL from step 6 | -| `DATABASE_URL` errors in `cargo run` | `.env` not loaded | Use `just relay` instead of `cargo run` directly, or `export $(cat .env | xargs)` | -| MySQL unhealthy after `docker compose up` | Slow start | Wait 30s; check `docker compose logs mysql` for errors | -| `sprout-mcp` generates ephemeral key | `SPROUT_PRIVATE_KEY` not set | Set `SPROUT_PRIVATE_KEY=nsec1...` so the agent's identity persists across restarts | -| E2e tests time out | Relay not running or wrong URL | Check `RELAY_URL` env var; confirm relay is listening with `curl http://localhost:3000/info` | -| `SQLX_OFFLINE` errors in CI | Missing `.sqlx/` query cache | Run `cargo sqlx prepare --workspace` locally and commit the `.sqlx/` directory | +### Stale events replayed on harness restart + +**Expected behavior.** On startup, the harness replays all unprocessed `@mentions` since the last run. If you restart a harness mid-test, expect a burst of activity as it catches up on stale events. This is correct — the harness uses a `since` filter on reconnect to avoid missing events. + +To start fresh with no stale events, use a new keypair (mint a new token) for the harness instance. + +--- + +### Docker services unhealthy + +```bash +docker compose ps +# If any service is not "Up": +docker compose down -v && docker compose up -d +# Wait 30s then re-run migrations: +sqlx migrate run --database-url "$DATABASE_URL" +``` diff --git a/crates/sprout-acp/Cargo.toml b/crates/sprout-acp/Cargo.toml new file mode 100644 index 000000000..1cb33e21a --- /dev/null +++ b/crates/sprout-acp/Cargo.toml @@ -0,0 +1,51 @@ +[package] +name = "sprout-acp" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "ACP harness that bridges Sprout events to AI agents" + +[[bin]] +name = "sprout-acp" +path = "src/main.rs" + +[dependencies] +# Internal +sprout-core = { workspace = true } + +# Nostr +nostr = { workspace = true } + +# Async runtime +tokio = { workspace = true } + +# WebSocket +tokio-tungstenite = { workspace = true } +futures-util = { workspace = true } + +# HTTP (channel discovery REST API) +reqwest = { workspace = true } + +# Serialization +serde = { workspace = true } +serde_json = { workspace = true } + +# IDs +uuid = { workspace = true } +chrono = { workspace = true } + +# URL parsing +url = { workspace = true } + +# Logging +tracing = { workspace = true } +tracing-subscriber = { workspace = true } + +# Error handling +thiserror = { workspace = true } +anyhow = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["test-util"] } diff --git a/crates/sprout-acp/README.md b/crates/sprout-acp/README.md new file mode 100644 index 000000000..02478d2ed --- /dev/null +++ b/crates/sprout-acp/README.md @@ -0,0 +1,141 @@ +# sprout-acp + +ACP harness that connects AI agents to Sprout. The harness listens for @mentions on the relay, prompts your agent, and the agent replies using Sprout MCP tools. + +``` +Sprout Relay ──WS──→ sprout-acp ──stdio──→ Your Agent + │ + Sprout MCP + (send_message, etc.) +``` + +Supports any agent that speaks [ACP](https://agentclientprotocol.com/) over stdio: **goose**, **codex** (via [codex-acp](https://github.com/zed-industries/codex-acp)), and **claude code** (via [claude-agent-acp](https://github.com/zed-industries/claude-agent-acp)). + +## Prerequisites + +- A running Sprout relay (`just relay` or a hosted instance) +- Docker services up (`docker compose up -d`) if running locally +- A Nostr keypair for the agent (see [Generating Keys](#generating-keys)) + +Build: + +```bash +cargo build --release -p sprout-acp -p sprout-mcp-server +export PATH="$PWD/target/release:$PATH" +``` + +## Generating Keys + +Each agent needs a Nostr keypair — this is the agent's identity in Sprout. Use `sprout-admin` to mint one: + +```bash +cargo run -p sprout-admin -- mint-token --name "my-agent" --scopes "messages:read,messages:write,channels:read" +``` + +This prints an `nsec1...` private key and an API token. **Save both immediately — they're shown only once.** + +> **Running multiple agents?** Mint a separate keypair for each. Every agent needs its own identity. + +## Channels + +The harness discovers channels by querying the relay with the agent's authenticated identity. + +**Open channels** (the default for local dev) are accessible to any authenticated pubkey — no extra setup needed. Just start the harness and it will find and subscribe to all open channels. + +**Private channels** require explicit membership. The relay doesn't yet have a REST/event API for managing channel members — this is a known gap. For now, use `create_channel` via the Sprout MCP tools to create new channels (the creator is automatically a member). + +## Quick Start (goose) + +```bash +export SPROUT_PRIVATE_KEY="nsec1..." # your agent's key (see "Generating Keys") +export SPROUT_RELAY_URL="ws://localhost:3000" +export GOOSE_MODE=auto + +sprout-acp +``` + +That's it. The harness spawns `goose acp`, connects to the relay, discovers channels, and starts listening. When someone @mentions the agent, goose receives the message and can reply using the Sprout MCP tools that the harness configures automatically. + +## Running with Codex + +[codex-acp](https://github.com/zed-industries/codex-acp) wraps OpenAI Codex in an ACP interface. + +```bash +# Build the adapter (requires Rust 1.91+) +cd /path/to/codex-acp && cargo build --release + +# Run +export OPENAI_API_KEY="sk-..." # required — use an OpenAI API key, not a ChatGPT subscription +export SPROUT_ACP_AGENT_COMMAND="/path/to/codex-acp/target/release/codex-acp" +export SPROUT_ACP_AGENT_ARGS='-c,permissions.approval_policy="never"' + +sprout-acp +``` + +> **API key note:** `codex-acp` always attempts a ChatGPT WebSocket login first, which logs a `426 Upgrade Required` error. This is expected and non-fatal — it falls back to `OPENAI_API_KEY` automatically. Set `OPENAI_API_KEY` to ensure it has a working fallback. + +## Running with Claude Code + +[claude-agent-acp](https://github.com/zed-industries/claude-agent-acp) wraps the Claude Agent SDK in an ACP interface. + +```bash +# Build the adapter +cd /path/to/claude-agent-acp && npm install && npm run build + +# Run +export ANTHROPIC_API_KEY="sk-ant-..." +export SPROUT_ACP_AGENT_COMMAND="node" # full path if using hermit: /path/to/sprout2/bin/node +export SPROUT_ACP_AGENT_ARGS="/path/to/claude-agent-acp/dist/index.js" + +sprout-acp +``` + +## Configuration + +All configuration is via environment variables. + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `SPROUT_PRIVATE_KEY` | **yes** | — | Agent's Nostr private key (`nsec1...`). Used for relay auth and agent identity. | +| `SPROUT_RELAY_URL` | no | `ws://localhost:3000` | Relay WebSocket URL. | +| `SPROUT_ACP_AGENT_COMMAND` | no | `goose` | Agent binary to spawn. | +| `SPROUT_ACP_AGENT_ARGS` | no | `acp` | Agent arguments (comma-separated). | +| `SPROUT_ACP_MCP_COMMAND` | no | `sprout-mcp-server` | Path to the Sprout MCP server binary. | +| `SPROUT_ACP_TURN_TIMEOUT` | no | `300` | Max seconds per agent turn before cancellation. | +| `SPROUT_API_TOKEN` | no | — | API token (required if relay enforces token auth). | + +**Note:** `SPROUT_ACP_AGENT_ARGS` splits on commas. For args with values, use: `-c,key="value"`. + +**Legacy env vars:** `SPROUT_ACP_PRIVATE_KEY` and `SPROUT_ACP_API_TOKEN` are still accepted as fallbacks. + +## How It Works + +1. **Startup** — Spawns the agent subprocess, sends ACP `initialize`, connects to the relay with NIP-42 auth. +2. **Channel discovery** — Queries the relay REST API for accessible channels, subscribes to each. +3. **Event loop** — Listens for @mention events (kind 40001 with the agent's pubkey in a `#p` tag). Events queue per channel. +4. **Prompting** — When events are pending and no prompt is in flight, drains all queued events for the oldest channel into a single batched prompt via ACP `session/prompt`. +5. **Agent response** — The agent processes the prompt and uses Sprout MCP tools (`send_message`, `get_channel_history`, etc.) to interact with Sprout. +6. **Recovery** — If the agent crashes, the harness respawns it. If the relay disconnects, the harness reconnects with a `since` filter to avoid missing events. + +Only one prompt is in flight at a time (globally, not per-session). This matches the concurrency model of current ACP agents. + +> **Note:** On startup, the harness replays all unprocessed @mentions since the last run. Expect a burst of activity if there are stale events in the channel. + +## Using Any ACP Agent + +The harness works with any agent that implements the [ACP spec](https://agentclientprotocol.com/) over stdio. The requirements are: + +- Accept `initialize` and return a result +- Accept `session/new` with `mcpServers` and return a `sessionId` +- Accept `session/prompt` with a text message and stream `session/update` notifications +- Return a `stopReason` (`end_turn`, `cancelled`, `max_tokens`, etc.) + +Set `SPROUT_ACP_AGENT_COMMAND` and `SPROUT_ACP_AGENT_ARGS` to point at your agent binary. + +## Testing + +See the [root TESTING.md](../../TESTING.md) for the full integration testing guide — automated test suites, multi-agent E2E testing via the ACP harness, and troubleshooting. + +## License + +Apache-2.0 diff --git a/crates/sprout-acp/src/acp.rs b/crates/sprout-acp/src/acp.rs new file mode 100644 index 000000000..049d19953 --- /dev/null +++ b/crates/sprout-acp/src/acp.rs @@ -0,0 +1,865 @@ +//! ACP client module — manages communication with an AI agent subprocess over stdio +//! using JSON-RPC 2.0 (newline-delimited / NDJSON). +//! +//! # Lifecycle +//! 1. [`AcpClient::spawn`] — launch agent binary as subprocess +//! 2. [`AcpClient::initialize`] — protocol version negotiation +//! 3. [`AcpClient::session_new`] — create session with MCP server config +//! 4. [`AcpClient::session_prompt`] — send prompt, receive streaming updates, return stop reason +//! 5. [`AcpClient::session_cancel`] / [`AcpClient::cancel_with_cleanup`] — cancel in-flight turn + +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{Child, ChildStdin, ChildStdout}; + +// ─── Public types ──────────────────────────────────────────────────────────── + +/// An MCP server configuration passed to `session/new`. +/// +/// Corresponds to the `McpServerStdio` variant in the ACP schema. +/// All four fields are **required** by the schema (`args` and `env` may be empty arrays). +#[derive(Debug, Clone, serde::Serialize)] +pub struct McpServer { + pub name: String, + pub command: String, + pub args: Vec, + pub env: Vec, +} + +/// A single environment variable for an MCP server. +#[derive(Debug, Clone, serde::Serialize)] +pub struct EnvVar { + pub name: String, + pub value: String, +} + +/// Stop reason returned by `session/prompt` when the agent finishes a turn. +/// +/// Maps to the `stopReason` field in the `SessionPromptResponse`. +#[derive(Debug, Clone, PartialEq)] +pub enum StopReason { + /// Agent completed the turn normally (`"end_turn"`). + EndTurn, + /// Turn was cancelled via `session/cancel` (`"cancelled"`). + Cancelled, + /// Agent hit its token limit (`"max_tokens"`). + MaxTokens, + /// Agent hit its per-turn request limit (`"max_turn_requests"`). + MaxTurnRequests, + /// Agent refused the prompt (`"refusal"`). + /// Note: refused turns are dropped from history by the agent. + Refusal, +} + +impl StopReason { + /// Parse a `stopReason` string from the ACP wire format. + pub fn from_str(s: &str) -> Option { + match s { + "end_turn" => Some(Self::EndTurn), + "cancelled" => Some(Self::Cancelled), + "max_tokens" => Some(Self::MaxTokens), + "max_turn_requests" => Some(Self::MaxTurnRequests), + "refusal" => Some(Self::Refusal), + _ => None, + } + } +} + +/// Errors that can occur in the ACP client. +#[derive(Debug, thiserror::Error)] +pub enum AcpError { + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + + #[error("Agent process exited unexpectedly")] + AgentExited, + + #[error("Protocol error: {0}")] + Protocol(String), +} + +// ─── AcpClient ─────────────────────────────────────────────────────────────── + +/// ACP client that owns an agent subprocess and communicates over its stdio. +/// +/// One `AcpClient` per agent process. Multiple sessions can be created on the +/// same client via repeated calls to [`session_new`](AcpClient::session_new). +pub struct AcpClient { + /// The agent child process (kept alive to prevent zombie). + child: Child, + /// Write end of the agent's stdin pipe. + stdin: ChildStdin, + /// Buffered reader over the agent's stdout pipe (line-oriented). + reader: BufReader, + /// Monotonically increasing JSON-RPC request id counter. + /// Harness-generated IDs are always numeric. + next_id: u64, + /// The id of a `session/request_permission` request that has been received + /// but not yet responded to. Stored as `serde_json::Value` because JSON-RPC 2.0 + /// permits both numeric and string IDs from the agent. + /// Used by [`cancel_with_cleanup`](AcpClient::cancel_with_cleanup) to send + /// a `cancelled` outcome before the agent returns from `session/prompt`. + pending_permission_id: Option, + /// Whether we have already sent a response to the pending permission request. + /// Guards against double-response if a timeout fires after the allow_once + /// response was written but before `pending_permission_id` was cleared. + permission_responded: bool, +} + +impl AcpClient { + // ── Lifecycle ───────────────────────────────────────────────────────── + + /// Spawn the agent binary as a subprocess and connect to its stdio pipes. + /// + /// After spawning, call [`initialize`](Self::initialize) before any other method. + pub async fn spawn(command: &str, args: &[String]) -> Result { + use std::process::Stdio; + + let mut cmd = tokio::process::Command::new(command); + cmd.args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + // Inherit stderr so agent logs are visible in the harness terminal. + .stderr(Stdio::inherit()); + + let mut child = cmd.spawn()?; + + let stdin = child + .stdin + .take() + .ok_or_else(|| AcpError::Protocol("failed to open agent stdin".into()))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| AcpError::Protocol("failed to open agent stdout".into()))?; + + Ok(Self { + child, + stdin, + reader: BufReader::new(stdout), + next_id: 0, + pending_permission_id: None, + permission_responded: false, + }) + } + + /// Send the `initialize` request and return the agent's response result value. + /// + /// Must be called exactly once, before any other ACP method. + /// The caller may inspect `agentCapabilities` in the returned value. + pub async fn initialize(&mut self) -> Result { + let params = serde_json::json!({ + "protocolVersion": 1, + "clientCapabilities": {}, + "clientInfo": { + "name": "sprout-acp", + "version": env!("CARGO_PKG_VERSION") + } + }); + let result = self.send_request("initialize", params).await?; + tracing::debug!(target: "acp::init", "initialize response: {result}"); + Ok(result) + } + + /// Send `session/new` and return the `sessionId` string. + /// + /// `cwd` must be an absolute path. `mcp_servers` may be empty. + pub async fn session_new( + &mut self, + cwd: &str, + mcp_servers: Vec, + ) -> Result { + let params = serde_json::json!({ + "cwd": cwd, + "mcpServers": mcp_servers, + }); + let result = self.send_request("session/new", params).await?; + let session_id = result["sessionId"] + .as_str() + .ok_or_else(|| AcpError::Protocol("session/new response missing sessionId".into()))? + .to_owned(); + tracing::info!(target: "acp::session", "session created: {session_id}"); + Ok(session_id) + } + + /// Send `session/prompt` and block until the agent returns a stop reason. + /// + /// While waiting, incoming `session/update` notifications are logged and + /// `session/request_permission` requests are auto-approved with `allow_once`. + pub async fn session_prompt( + &mut self, + session_id: &str, + prompt_text: &str, + ) -> Result { + let params = serde_json::json!({ + "sessionId": session_id, + "prompt": [ + { "type": "text", "text": prompt_text } + ] + }); + let result = self.send_request("session/prompt", params).await?; + self.parse_stop_reason(&result) + } + + /// Send a `session/cancel` **notification** (no `id` field, no response expected). + /// + /// After calling this, the agent will eventually respond to the in-flight + /// `session/prompt` with `stopReason: "cancelled"`. Use + /// [`cancel_with_cleanup`](Self::cancel_with_cleanup) if you need to drain + /// that response. + /// + /// Note: async because writing to stdin requires async I/O. + pub async fn session_cancel(&mut self, session_id: &str) -> Result<(), AcpError> { + let params = serde_json::json!({ + "sessionId": session_id, + }); + self.send_notification("session/cancel", params).await + } + + /// Cancel a turn cleanly, handling any pending permission request first. + /// + /// Steps: + /// 1. If there is a pending `session/request_permission` that hasn't been + /// responded to yet, respond with `outcome: "cancelled"`. + /// 2. Send `session/cancel` notification (no id). + /// 3. Continue reading until the `session/prompt` response arrives with `stopReason: "cancelled"`. + /// + /// Returns the final [`StopReason`] (almost always [`StopReason::Cancelled`]). + pub async fn cancel_with_cleanup(&mut self, session_id: &str) -> Result { + // Step 1: respond to any pending permission request with "cancelled", + // but only if we haven't already responded (guards against double-response race). + if let Some(perm_id) = self.pending_permission_id.clone() { + if !self.permission_responded { + let response = permission_response_cancelled(&perm_id); + self.write_ndjson(&response).await?; + tracing::debug!( + target: "acp::cancel", + "responded cancelled to pending permission id={perm_id}" + ); + } + self.pending_permission_id = None; + self.permission_responded = false; + } + + // Step 2: send session/cancel notification (no id) + self.session_cancel(session_id).await?; + tracing::info!(target: "acp::cancel", "sent session/cancel for {session_id}"); + + // Step 3: drain until we get the session/prompt response. + // The prompt id is next_id - 1 (the most recently sent request was session/prompt). + let prompt_id = self.next_id - 1; + let result = self.read_until_response(prompt_id).await?; + self.parse_stop_reason(&result) + } + + // ── Internal helpers ────────────────────────────────────────────────── + + /// Serialize `value` as a single NDJSON line and flush to the agent's stdin. + async fn write_ndjson(&mut self, value: &serde_json::Value) -> Result<(), AcpError> { + let line = serde_json::to_string(value)?; + self.stdin.write_all(line.as_bytes()).await?; + self.stdin.write_all(b"\n").await?; + self.stdin.flush().await?; + Ok(()) + } + + /// Send a JSON-RPC request and wait for the matching response. + /// + /// Assigns the next available id, writes the NDJSON line to stdin, + /// then calls [`read_until_response`](Self::read_until_response). + async fn send_request( + &mut self, + method: &str, + params: serde_json::Value, + ) -> Result { + let id = self.next_id; + self.next_id += 1; + + let msg = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params, + }); + + tracing::debug!(target: "acp::wire", "→ {}", &serde_json::to_string(&msg).unwrap_or_default()); + self.write_ndjson(&msg).await?; + + self.read_until_response(id).await + } + + /// Send a JSON-RPC **notification** — no `id` field, no response expected. + /// + /// Used for `session/cancel`. The absence of `id` is the JSON-RPC 2.0 + /// distinguisher between requests and notifications. + async fn send_notification( + &mut self, + method: &str, + params: serde_json::Value, + ) -> Result<(), AcpError> { + // Notifications deliberately have NO "id" field. + let msg = serde_json::json!({ + "jsonrpc": "2.0", + "method": method, + "params": params, + }); + + tracing::debug!(target: "acp::wire", "→ (notification) {}", &serde_json::to_string(&msg).unwrap_or_default()); + self.write_ndjson(&msg).await?; + Ok(()) + } + + /// Core message loop: read NDJSON lines until we get a response matching `expected_id`. + /// + /// While waiting, handles: + /// - `session/update` notifications → logged via tracing + /// - `session/request_permission` requests → auto-approved with `allow_once` + /// - Any other messages → debug-logged and ignored + /// + /// Compares the incoming `id` field as a `serde_json::Value` against + /// `json!(expected_id)` so that both numeric and string IDs work correctly. + async fn read_until_response( + &mut self, + expected_id: u64, + ) -> Result { + loop { + let mut line = String::new(); + let n = self.reader.read_line(&mut line).await?; + if n == 0 { + return Err(AcpError::AgentExited); + } + + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + tracing::debug!(target: "acp::wire", "← {trimmed}"); + + let msg: serde_json::Value = match serde_json::from_str(trimmed) { + Ok(v) => v, + Err(e) => { + tracing::warn!( + target: "acp::wire", + "failed to parse line as JSON: {e} — skipping" + ); + continue; + } + }; + + // Check if this is a response to our expected request (has matching id). + // Compare as serde_json::Value so both numeric and string IDs work. + if let Some(id) = msg.get("id") { + if *id == serde_json::json!(expected_id) { + if let Some(error) = msg.get("error") { + return Err(AcpError::Protocol(error.to_string())); + } + return Ok(msg["result"].clone()); + } + // Has an id but not ours — fall through to method dispatch + // (e.g., session/request_permission has both id and method). + } + + // Dispatch by method name (notifications and agent-initiated requests). + if let Some(method) = msg.get("method").and_then(|v| v.as_str()) { + match method { + "session/update" => { + self.handle_session_update(&msg); + } + "session/request_permission" => { + self.handle_permission_request(&msg).await?; + } + other => { + tracing::debug!(target: "acp::wire", "ignoring unknown method: {other}"); + } + } + } + } + } + + /// Log a `session/update` notification via tracing. + /// + /// The discriminator field is `sessionUpdate` (not `type`) per the ACP schema. + fn handle_session_update(&self, msg: &serde_json::Value) { + let update = &msg["params"]["update"]; + let update_type = update + .get("sessionUpdate") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + + match update_type { + "agent_message_chunk" => { + if let Some(text) = update["content"]["text"].as_str() { + tracing::info!(target: "acp::stream", "{text}"); + } + } + "tool_call" => { + let title = update + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let kind = update + .get("kind") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + tracing::info!(target: "acp::tool", "tool_call: {title} ({kind})"); + } + "tool_call_update" => { + let tool_id = update + .get("toolCallId") + .and_then(|v| v.as_str()) + .unwrap_or("?"); + let status = update.get("status").and_then(|v| v.as_str()).unwrap_or("?"); + tracing::info!(target: "acp::tool", "tool_call_update: {tool_id} → {status}"); + } + "plan" => { + tracing::info!(target: "acp::plan", "plan update received"); + } + "agent_thought_chunk" => { + if let Some(text) = update["content"]["text"].as_str() { + tracing::debug!(target: "acp::thought", "{text}"); + } + } + other => { + tracing::debug!(target: "acp::update", "session/update: {other}"); + } + } + } + + /// Auto-approve a `session/request_permission` request from the agent. + /// + /// Finds the option with `kind == "allow_once"` and responds with its `optionId`. + /// If no `allow_once` option exists, falls back to `reject_once`. + /// + /// **Critical:** Never hardcode `optionId` — always find it dynamically by `kind`. + /// + /// The request `id` is stored as `serde_json::Value` to support both numeric + /// and string IDs per JSON-RPC 2.0. + async fn handle_permission_request(&mut self, msg: &serde_json::Value) -> Result<(), AcpError> { + // Extract id as a Value — JSON-RPC 2.0 allows both numeric and string IDs. + let id = msg + .get("id") + .cloned() + .ok_or_else(|| AcpError::Protocol("permission request missing id".into()))?; + + // Store pending permission id so cancel_with_cleanup can respond to it. + self.pending_permission_id = Some(id.clone()); + // Mark as not yet responded — guards against double-response race. + self.permission_responded = false; + + let options = msg["params"]["options"] + .as_array() + .ok_or_else(|| AcpError::Protocol("permission request missing options".into()))?; + + tracing::debug!( + target: "acp::permission", + "session/request_permission id={id}, {} options", + options.len() + ); + + // Find allow_once by kind — NEVER hardcode optionId. + let allow_once = options + .iter() + .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("allow_once")); + + let response = if let Some(opt) = allow_once { + let option_id = opt["optionId"] + .as_str() + .ok_or_else(|| AcpError::Protocol("allow_once option missing optionId".into()))?; + tracing::info!( + target: "acp::permission", + "auto-approving permission id={id} with allow_once optionId={option_id:?}" + ); + permission_response_selected(&id, option_id) + } else { + // No allow_once — fall back to reject_once. + tracing::warn!( + target: "acp::permission", + "no allow_once option found in permission request id={id}, falling back to reject_once" + ); + let reject = options + .iter() + .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("reject_once")); + + if let Some(opt) = reject { + let option_id = opt["optionId"].as_str().unwrap_or("reject"); + permission_response_selected(&id, option_id) + } else { + return Err(AcpError::Protocol( + "no suitable permission option found (neither allow_once nor reject_once)" + .into(), + )); + } + }; + + self.write_ndjson(&response).await?; + // Mark as responded and clear pending now that we've successfully written the response. + self.permission_responded = true; + self.pending_permission_id = None; + Ok(()) + } + + /// Parse `stopReason` from a `session/prompt` result value. + fn parse_stop_reason(&self, result: &serde_json::Value) -> Result { + let raw = result["stopReason"].as_str().ok_or_else(|| { + AcpError::Protocol("session/prompt response missing stopReason".into()) + })?; + StopReason::from_str(raw) + .ok_or_else(|| AcpError::Protocol(format!("unknown stopReason: {raw:?}"))) + } +} + +// ─── Permission response constructors ──────────────────────────────────────── + +/// Build a JSON-RPC permission response with `outcome: "selected"`. +fn permission_response_selected(id: &serde_json::Value, option_id: &str) -> serde_json::Value { + serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "result": { "outcome": { "outcome": "selected", "optionId": option_id } } + }) +} + +/// Build a JSON-RPC permission response with `outcome: "cancelled"`. +fn permission_response_cancelled(id: &serde_json::Value) -> serde_json::Value { + serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "result": { "outcome": { "outcome": "cancelled" } } + }) +} + +// ─── Drop: kill child process ───────────────────────────────────────────────── + +impl Drop for AcpClient { + fn drop(&mut self) { + // Best-effort kill — ignore errors (process may have already exited). + let _ = self.child.start_kill(); + } +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + // ── StopReason parsing ──────────────────────────────────────────────── + + #[test] + fn stop_reason_parses_all_known_values() { + assert_eq!(StopReason::from_str("end_turn"), Some(StopReason::EndTurn)); + assert_eq!( + StopReason::from_str("cancelled"), + Some(StopReason::Cancelled) + ); + assert_eq!( + StopReason::from_str("max_tokens"), + Some(StopReason::MaxTokens) + ); + assert_eq!( + StopReason::from_str("max_turn_requests"), + Some(StopReason::MaxTurnRequests) + ); + assert_eq!(StopReason::from_str("refusal"), Some(StopReason::Refusal)); + } + + #[test] + fn stop_reason_returns_none_for_unknown() { + assert_eq!(StopReason::from_str("unknown_value"), None); + assert_eq!(StopReason::from_str(""), None); + assert_eq!(StopReason::from_str("END_TURN"), None); // case-sensitive + assert_eq!(StopReason::from_str("endturn"), None); // no camelCase + } + + // ── Permission option finding ───────────────────────────────────────── + + #[test] + fn find_allow_once_by_kind_not_by_option_id() { + // optionId values are intentionally non-obvious to prove we don't hardcode them. + let options: Vec = serde_json::from_str( + r#"[ + {"optionId": "opt-reject-42", "name": "Reject", "kind": "reject_once"}, + {"optionId": "opt-allow-99", "name": "Allow once", "kind": "allow_once"}, + {"optionId": "opt-always-7", "name": "Always allow", "kind": "allow_always"} + ]"#, + ) + .unwrap(); + + let allow_once = options + .iter() + .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("allow_once")); + + assert!(allow_once.is_some(), "should find allow_once option"); + let opt = allow_once.unwrap(); + // Found by kind, not by hardcoded optionId + assert_eq!(opt["kind"].as_str(), Some("allow_once")); + assert_eq!(opt["optionId"].as_str(), Some("opt-allow-99")); + } + + #[test] + fn find_allow_once_returns_none_when_absent() { + let options: Vec = serde_json::from_str( + r#"[ + {"optionId": "reject-1", "name": "Reject", "kind": "reject_once"}, + {"optionId": "reject-always", "name": "Always reject", "kind": "reject_always"} + ]"#, + ) + .unwrap(); + + let allow_once = options + .iter() + .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("allow_once")); + + assert!(allow_once.is_none()); + } + + #[test] + fn find_reject_once_fallback_when_no_allow_once() { + let options: Vec = serde_json::from_str( + r#"[{"optionId": "rej-x", "name": "Reject", "kind": "reject_once"}]"#, + ) + .unwrap(); + + let allow_once = options + .iter() + .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("allow_once")); + assert!(allow_once.is_none()); + + let reject_once = options + .iter() + .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("reject_once")); + assert!(reject_once.is_some()); + assert_eq!(reject_once.unwrap()["optionId"].as_str(), Some("rej-x")); + } + + // ── JSON-RPC message construction ───────────────────────────────────── + + #[test] + fn request_has_id_field() { + let id: u64 = 42; + let msg = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "method": "initialize", + "params": {} + }); + assert!(msg.get("id").is_some(), "request must have id field"); + assert_eq!(msg["id"].as_u64(), Some(42)); + assert_eq!(msg["jsonrpc"].as_str(), Some("2.0")); + assert_eq!(msg["method"].as_str(), Some("initialize")); + } + + #[test] + fn notification_has_no_id_field() { + // session/cancel is a notification — must NOT have an id field. + let msg = serde_json::json!({ + "jsonrpc": "2.0", + "method": "session/cancel", + "params": { + "sessionId": "sess_abc123" + } + }); + assert!( + msg.get("id").is_none(), + "notification must NOT have id field" + ); + assert_eq!(msg["jsonrpc"].as_str(), Some("2.0")); + assert_eq!(msg["method"].as_str(), Some("session/cancel")); + } + + #[test] + fn initialize_request_format() { + let msg = serde_json::json!({ + "jsonrpc": "2.0", + "id": 0u64, + "method": "initialize", + "params": { + "protocolVersion": 1, + "clientCapabilities": {}, + "clientInfo": { + "name": "sprout-acp", + "version": "0.1.0" + } + } + }); + assert_eq!(msg["params"]["protocolVersion"].as_u64(), Some(1)); + assert_eq!( + msg["params"]["clientInfo"]["name"].as_str(), + Some("sprout-acp") + ); + assert!(msg["params"]["clientCapabilities"].is_object()); + } + + #[test] + fn session_new_mcp_server_has_required_fields() { + // Schema requires name, command, args, env — all present, args/env may be empty. + let server = McpServer { + name: "sprout-mcp".into(), + command: "/usr/local/bin/sprout-mcp-server".into(), + args: vec![], + env: vec![ + EnvVar { + name: "SPROUT_RELAY_URL".into(), + value: "ws://localhost:3000".into(), + }, + EnvVar { + name: "SPROUT_PRIVATE_KEY".into(), + value: "nsec1abc".into(), + }, + ], + }; + let serialized = serde_json::to_value(&server).unwrap(); + assert_eq!(serialized["name"].as_str(), Some("sprout-mcp")); + assert_eq!( + serialized["command"].as_str(), + Some("/usr/local/bin/sprout-mcp-server") + ); + assert!(serialized["args"].is_array()); + assert_eq!(serialized["args"].as_array().unwrap().len(), 0); + assert!(serialized["env"].is_array()); + assert_eq!(serialized["env"].as_array().unwrap().len(), 2); + assert_eq!( + serialized["env"][0]["name"].as_str(), + Some("SPROUT_RELAY_URL") + ); + } + + #[test] + fn session_prompt_request_format() { + let prompt_text = "[Sprout @mention]\nChannel: test\nFrom: npub1...\nMessage: hello"; + let msg = serde_json::json!({ + "jsonrpc": "2.0", + "id": 2u64, + "method": "session/prompt", + "params": { + "sessionId": "sess_abc123", + "prompt": [ + { "type": "text", "text": prompt_text } + ] + } + }); + assert_eq!(msg["method"].as_str(), Some("session/prompt")); + let prompt = msg["params"]["prompt"].as_array().unwrap(); + assert_eq!(prompt.len(), 1); + assert_eq!(prompt[0]["type"].as_str(), Some("text")); + assert_eq!(prompt[0]["text"].as_str(), Some(prompt_text)); + } + + #[test] + fn permission_response_selected_format() { + let id: u64 = 5; + let option_id = "opt-allow-99"; + let response = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "outcome": { + "outcome": "selected", + "optionId": option_id + } + } + }); + assert_eq!(response["id"].as_u64(), Some(5)); + assert_eq!( + response["result"]["outcome"]["outcome"].as_str(), + Some("selected") + ); + assert_eq!( + response["result"]["outcome"]["optionId"].as_str(), + Some("opt-allow-99") + ); + } + + #[test] + fn permission_response_cancelled_format() { + let id: u64 = 5; + let response = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "outcome": { + "outcome": "cancelled" + } + } + }); + assert_eq!( + response["result"]["outcome"]["outcome"].as_str(), + Some("cancelled") + ); + // cancelled outcome has no optionId + assert!(response["result"]["outcome"].get("optionId").is_none()); + } + + #[test] + fn session_cancel_notification_has_session_id_in_params() { + let session_id = "sess_xyz789"; + let msg = serde_json::json!({ + "jsonrpc": "2.0", + "method": "session/cancel", + "params": { + "sessionId": session_id + } + }); + // Must have no id (notification) + assert!(msg.get("id").is_none()); + // Must have sessionId in params + assert_eq!(msg["params"]["sessionId"].as_str(), Some("sess_xyz789")); + } + + // ── String ID handling (Fix 1) ──────────────────────────────────────── + + #[test] + fn permission_request_with_string_id() { + // Verify that permission response uses the same ID type as the request. + // JSON-RPC 2.0 permits string IDs from the agent. + let string_id = serde_json::json!("perm-req-001"); + let response = serde_json::json!({ + "jsonrpc": "2.0", + "id": string_id, + "result": { + "outcome": { "outcome": "selected", "optionId": "allow-once" } + } + }); + assert_eq!(response["id"], "perm-req-001"); + assert!(response["id"].is_string()); + } + + #[test] + fn id_comparison_works_for_numeric_and_string() { + // Verify json!(expected_id) comparison logic used in read_until_response. + let expected_id: u64 = 3; + let numeric_response_id = serde_json::json!(3u64); + let string_response_id = serde_json::json!("3"); + + // Numeric matches + assert_eq!(numeric_response_id, serde_json::json!(expected_id)); + // String does NOT match numeric (correct — different types) + assert_ne!(string_response_id, serde_json::json!(expected_id)); + } + + #[test] + fn permission_cancelled_response_preserves_id_type() { + // String ID from agent should be echoed back as string in cancelled response. + let string_id = serde_json::json!("req-abc"); + let cancelled = serde_json::json!({ + "jsonrpc": "2.0", + "id": string_id.clone(), + "result": { "outcome": { "outcome": "cancelled" } } + }); + assert_eq!(cancelled["id"], string_id); + assert!(cancelled["id"].is_string()); + + // Numeric ID from agent should be echoed back as numeric. + let numeric_id = serde_json::json!(42u64); + let cancelled_numeric = serde_json::json!({ + "jsonrpc": "2.0", + "id": numeric_id.clone(), + "result": { "outcome": { "outcome": "cancelled" } } + }); + assert_eq!(cancelled_numeric["id"], numeric_id); + assert!(cancelled_numeric["id"].is_number()); + } +} diff --git a/crates/sprout-acp/src/config.rs b/crates/sprout-acp/src/config.rs new file mode 100644 index 000000000..8933df061 --- /dev/null +++ b/crates/sprout-acp/src/config.rs @@ -0,0 +1,107 @@ +use nostr::Keys; +use thiserror::Error; + +/// Errors that can occur when loading configuration from environment variables. +#[derive(Debug, Error)] +pub enum ConfigError { + #[error("required environment variable {0} is not set")] + MissingVar(&'static str), + + #[error("failed to parse nostr keys from {var}: {source}")] + KeyParse { + var: &'static str, + #[source] + source: nostr::key::Error, + }, +} + +/// Configuration for the sprout-acp harness. +#[derive(Debug)] +pub struct Config { + /// Agent's nostr keypair — used for both relay auth and agent identity. + /// + /// Parsed from `SPROUT_PRIVATE_KEY` (preferred) or the legacy + /// `SPROUT_ACP_PRIVATE_KEY` + `SPROUT_AGENT_PRIVATE_KEY` pair. + pub keys: Keys, + /// API token, optional. Required if the relay enforces token auth. + pub api_token: Option, + /// Relay WebSocket URL (`SPROUT_RELAY_URL`). Default: `ws://localhost:3000`. + pub relay_url: String, + + // --- Agent binary --- + /// Agent command (`SPROUT_ACP_AGENT_COMMAND`). Default: `goose`. + pub agent_command: String, + /// Agent arguments (`SPROUT_ACP_AGENT_ARGS`, comma-separated). Default: `["acp"]`. + pub agent_args: Vec, + + // --- MCP server --- + /// MCP server binary path (`SPROUT_ACP_MCP_COMMAND`). Default: `sprout-mcp-server`. + pub mcp_command: String, + + // --- Tuning --- + /// Maximum turn duration in seconds (`SPROUT_ACP_TURN_TIMEOUT`). Default: 300. + pub turn_timeout_secs: u64, +} + +/// Parse a nostr `Keys` from the named environment variable. +fn parse_keys_var(var: &'static str) -> Result { + let nsec = std::env::var(var).map_err(|_| ConfigError::MissingVar(var))?; + Keys::parse(&nsec).map_err(|e| ConfigError::KeyParse { var, source: e }) +} + +impl Config { + /// Load configuration from environment variables. + /// + /// Key resolution order: + /// 1. `SPROUT_PRIVATE_KEY` — single key for everything (preferred) + /// 2. `SPROUT_ACP_PRIVATE_KEY` — legacy harness key (fallback) + pub fn from_env() -> Result { + let keys = parse_keys_var("SPROUT_PRIVATE_KEY") + .or_else(|_| parse_keys_var("SPROUT_ACP_PRIVATE_KEY"))?; + + let api_token = std::env::var("SPROUT_API_TOKEN") + .or_else(|_| std::env::var("SPROUT_ACP_API_TOKEN")) + .ok(); + + let relay_url = + std::env::var("SPROUT_RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()); + + let agent_command = + std::env::var("SPROUT_ACP_AGENT_COMMAND").unwrap_or_else(|_| "goose".to_string()); + + let agent_args = std::env::var("SPROUT_ACP_AGENT_ARGS") + .map(|s| s.split(',').map(|a| a.trim().to_string()).collect()) + .unwrap_or_else(|_| vec!["acp".to_string()]); + + let mcp_command = std::env::var("SPROUT_ACP_MCP_COMMAND") + .unwrap_or_else(|_| "sprout-mcp-server".to_string()); + + let turn_timeout_secs = std::env::var("SPROUT_ACP_TURN_TIMEOUT") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(300); + + Ok(Config { + keys, + api_token, + relay_url, + agent_command, + agent_args, + mcp_command, + turn_timeout_secs, + }) + } + + /// Return a human-readable summary (no secrets). + pub fn summary(&self) -> String { + format!( + "relay={} pubkey={} agent_cmd={} {} mcp_cmd={} turn_timeout={}s", + self.relay_url, + self.keys.public_key().to_hex(), + self.agent_command, + self.agent_args.join(" "), + self.mcp_command, + self.turn_timeout_secs, + ) + } +} diff --git a/crates/sprout-acp/src/main.rs b/crates/sprout-acp/src/main.rs new file mode 100644 index 000000000..d312868d1 --- /dev/null +++ b/crates/sprout-acp/src/main.rs @@ -0,0 +1,299 @@ +mod acp; +mod config; +mod queue; +mod relay; + +use std::collections::HashMap; +use std::time::Duration; + +use anyhow::Result; +use nostr::ToBech32; +use tokio::time::timeout; +use tracing_subscriber::EnvFilter; +use uuid::Uuid; + +use acp::{AcpClient, AcpError, EnvVar, McpServer, StopReason}; +use config::Config; +use queue::{EventQueue, QueuedEvent}; +use relay::HarnessRelay; + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("sprout_acp=info")), + ) + .compact() + .init(); + + let config = Config::from_env().map_err(|e| anyhow::anyhow!("configuration error: {e}"))?; + tracing::info!("sprout-acp starting: {}", config.summary()); + + // ── Step 1: Spawn ACP agent subprocess and initialize ───────────────────── + let mut acp = spawn_and_init(&config).await?; + + // ── Step 2: Connect to Sprout relay ────────────────────────────────────── + let pubkey_hex = config.keys.public_key().to_hex(); + let mut relay = HarnessRelay::connect( + &config.relay_url, + &config.keys, + config.api_token.as_deref(), + &pubkey_hex, + ) + .await + .map_err(|e| anyhow::anyhow!("relay connect error: {e}"))?; + + tracing::info!("connected to relay at {}", config.relay_url); + + // ── Step 3: Discover channels and subscribe ─────────────────────────────── + let channels = relay + .discover_channels() + .await + .map_err(|e| anyhow::anyhow!("channel discovery error: {e}"))?; + + tracing::info!("discovered {} channel(s)", channels.len()); + + for channel_id in &channels { + if let Err(e) = relay.subscribe_channel(*channel_id).await { + tracing::warn!("failed to subscribe to channel {channel_id}: {e}"); + } else { + tracing::info!("subscribed to channel {channel_id}"); + } + } + + // ── Step 4: Main orchestration loop ────────────────────────────────────── + let mut sessions: HashMap = HashMap::new(); + let mut queue = EventQueue::new(); + let mcp_servers = build_mcp_servers(&config); + let turn_timeout = Duration::from_secs(config.turn_timeout_secs); + + loop { + // Wait for the next relay event or shutdown signal. + let sprout_event = tokio::select! { + event = relay.next_event() => event, + _ = tokio::signal::ctrl_c() => { + tracing::info!("shutting down (SIGINT/SIGTERM)"); + break; + } + }; + + match sprout_event { + Some(sprout_event) => { + // Push event into the queue. + queue.push(QueuedEvent { + channel_id: sprout_event.channel_id, + event: sprout_event.event, + received_at: std::time::Instant::now(), + }); + + // Try to flush and process batches. + loop { + let batch = match queue.flush_next() { + Some(b) => b, + None => break, + }; + + let channel_id = batch.channel_id; + // Format prompt before potentially requeuing (borrows batch by ref). + let prompt_text = queue::format_prompt(&batch); + + // Get or create session for this channel. + let session_id = match get_or_create_session( + &mut sessions, + channel_id, + &mut acp, + &mcp_servers, + ) + .await + { + Ok(id) => id, + Err(e) => { + tracing::error!( + "failed to create session for channel {channel_id}: {e}" + ); + queue.requeue(batch); + queue.mark_complete(); + break; + } + }; + + tracing::info!( + "prompting agent for channel {channel_id} (session {session_id}, {} event(s))", + batch.events.len() + ); + + // Send prompt with turn timeout. + let prompt_result = + timeout(turn_timeout, acp.session_prompt(&session_id, &prompt_text)).await; + + match prompt_result { + Ok(Ok(stop_reason)) => { + log_stop_reason(channel_id, &stop_reason); + queue.mark_complete(); + } + Ok(Err(AcpError::AgentExited)) => { + tracing::error!("agent process exited — respawning"); + sessions.clear(); + queue.requeue(batch); + queue.mark_complete(); + acp = respawn_agent(&config).await?; + break; + } + Ok(Err(e)) => { + tracing::error!("session_prompt error for channel {channel_id}: {e}"); + queue.requeue(batch); + queue.mark_complete(); + sessions.remove(&channel_id); + break; + } + Err(_elapsed) => { + tracing::warn!( + "turn timeout ({}s) for channel {channel_id} — cancelling", + config.turn_timeout_secs + ); + match acp.cancel_with_cleanup(&session_id).await { + Ok(stop_reason) => { + log_stop_reason(channel_id, &stop_reason); + } + Err(AcpError::AgentExited) => { + tracing::error!("agent exited during cancel — respawning"); + sessions.clear(); + acp = respawn_agent(&config).await?; + } + Err(e) => { + tracing::error!("cancel_with_cleanup error: {e}"); + } + } + queue.mark_complete(); + break; + } + } + } + } + None => { + // Relay connection lost — reconnect. + tracing::warn!("relay connection lost — reconnecting"); + loop { + match relay.reconnect().await { + Ok(()) => { + tracing::info!("relay reconnected"); + break; + } + Err(e) => { + tracing::error!("relay reconnect failed: {e} — retrying in 5s"); + tokio::time::sleep(Duration::from_secs(5)).await; + } + } + } + } + } + } + + tracing::info!("sprout-acp stopped"); + Ok(()) +} + +// ── Helper: respawn agent after exit ───────────────────────────────────────── + +async fn respawn_agent(config: &Config) -> Result { + match spawn_and_init(config).await { + Ok(new_acp) => { + tracing::info!("agent respawned successfully"); + Ok(new_acp) + } + Err(e) => { + tracing::error!("failed to respawn agent: {e}"); + Err(e) + } + } +} + +// ── Helper: spawn agent and initialize ─────────────────────────────────────── + +async fn spawn_and_init(config: &Config) -> Result { + let mut acp = AcpClient::spawn(&config.agent_command, &config.agent_args) + .await + .map_err(|e| anyhow::anyhow!("failed to spawn agent: {e}"))?; + + let init_result = acp + .initialize() + .await + .map_err(|e| anyhow::anyhow!("agent initialize failed: {e}"))?; + + tracing::info!("agent initialized: {init_result}"); + Ok(acp) +} + +// ── Helper: get or create session for a channel ─────────────────────────────── + +async fn get_or_create_session( + sessions: &mut HashMap, + channel_id: Uuid, + acp: &mut AcpClient, + mcp_servers: &[McpServer], +) -> Result { + if let Some(session_id) = sessions.get(&channel_id) { + return Ok(session_id.clone()); + } + + let cwd = std::env::current_dir() + .unwrap_or_else(|_| std::path::PathBuf::from("/")) + .to_string_lossy() + .to_string(); + + let session_id = acp.session_new(&cwd, mcp_servers.to_vec()).await?; + tracing::info!("created session {session_id} for channel {channel_id}"); + sessions.insert(channel_id, session_id.clone()); + Ok(session_id) +} + +// ── Helper: build MCP server config from Config ─────────────────────────────── + +fn build_mcp_servers(config: &Config) -> Vec { + vec![McpServer { + name: "sprout-mcp".to_string(), + command: config.mcp_command.clone(), + args: vec![], + env: { + let mut env = vec![ + EnvVar { + name: "SPROUT_RELAY_URL".into(), + value: config.relay_url.clone(), + }, + EnvVar { + name: "SPROUT_PRIVATE_KEY".into(), + value: config.keys.secret_key().to_bech32().unwrap_or_default(), + }, + ]; + if let Some(ref token) = config.api_token { + env.push(EnvVar { + name: "SPROUT_API_TOKEN".into(), + value: token.clone(), + }); + } + env + }, + }] +} + +// ── Helper: log stop reason at appropriate level ────────────────────────────── + +fn log_stop_reason(channel_id: Uuid, stop_reason: &StopReason) { + match stop_reason { + StopReason::EndTurn => { + tracing::info!("turn complete for channel {channel_id}: end_turn"); + } + StopReason::Cancelled => { + tracing::warn!("turn cancelled for channel {channel_id}"); + } + StopReason::MaxTokens => { + tracing::warn!("turn hit max_tokens for channel {channel_id}"); + } + StopReason::MaxTurnRequests => { + tracing::warn!("turn hit max_turn_requests for channel {channel_id}"); + } + StopReason::Refusal => { + tracing::warn!("turn refused for channel {channel_id}"); + } + } +} diff --git a/crates/sprout-acp/src/queue.rs b/crates/sprout-acp/src/queue.rs new file mode 100644 index 000000000..c97e06ff8 --- /dev/null +++ b/crates/sprout-acp/src/queue.rs @@ -0,0 +1,502 @@ +//! Event queue state machine for sprout-acp. +//! +//! Manages per-channel event queues with a global one-in-flight constraint. +//! When the harness is ready to prompt the agent, it flushes the channel with +//! the oldest pending event, draining ALL events for that channel into a single +//! batch. Only one `session/prompt` is in flight at a time across all channels. + +use nostr::{Event, ToBech32}; +use std::collections::{HashMap, VecDeque}; +use std::time::Instant; +use uuid::Uuid; + +/// An event waiting in the queue. +#[derive(Debug, Clone)] +pub struct QueuedEvent { + pub channel_id: Uuid, + pub event: Event, + pub received_at: Instant, +} + +/// A batch of events to prompt the agent with. +#[derive(Debug)] +pub struct FlushBatch { + pub channel_id: Uuid, + pub events: Vec, +} + +/// Per-channel event queue with global one-in-flight enforcement. +/// +/// # State Machine +/// +/// ```text +/// State: +/// queues: Map> +/// prompt_in_flight: bool +/// +/// Transitions: +/// push(event): +/// queues[event.channel_id].push_back(event) +/// +/// flush_next() → Option: +/// if prompt_in_flight: return None +/// if all queues empty: return None +/// channel = pick channel with oldest head event (min received_at) +/// events = drain queues[channel] +/// remove queues[channel] if now empty +/// prompt_in_flight = true +/// return Some(FlushBatch { channel, events }) +/// +/// mark_complete(): +/// prompt_in_flight = false +/// ``` +pub struct EventQueue { + queues: HashMap>, + prompt_in_flight: bool, +} + +impl EventQueue { + /// Create a new empty event queue. + pub fn new() -> Self { + Self { + queues: HashMap::new(), + prompt_in_flight: false, + } + } + + /// Push an event into the queue for its channel. + pub fn push(&mut self, event: QueuedEvent) { + self.queues + .entry(event.channel_id) + .or_default() + .push_back(event); + } + + /// Try to flush the next batch. + /// + /// Returns `None` if a prompt is already in flight or if all queues are + /// empty. Otherwise picks the channel with the oldest pending event (FIFO + /// fairness across channels), drains ALL events for that channel into a + /// single batch, sets `prompt_in_flight = true`, and returns the batch. + pub fn flush_next(&mut self) -> Option { + if self.prompt_in_flight { + return None; + } + + // Find the channel whose head event has the oldest received_at. + let channel_id = self + .queues + .iter() + .filter(|(_, q)| !q.is_empty()) + .min_by_key(|(_, q)| q.front().unwrap().received_at) + .map(|(id, _)| *id)?; + + // Drain ALL events for that channel. + let queue = self.queues.remove(&channel_id)?; + let events: Vec = queue.into_iter().map(|qe| qe.event).collect(); + + self.prompt_in_flight = true; + + Some(FlushBatch { channel_id, events }) + } + + /// Mark the current prompt as complete. Clears `prompt_in_flight`. + pub fn mark_complete(&mut self) { + self.prompt_in_flight = false; + } + + /// Re-queue a batch of events that failed to process. + /// + /// Events are pushed back to the **front** of the channel's queue so they + /// are processed first on the next flush cycle. This prevents event loss + /// when session creation or `session/prompt` fails transiently. + /// + /// Note: `received_at` is reset to `Instant::now()` for re-queued events. + /// This means a re-queued channel competes fairly with other channels rather + /// than always winning due to stale timestamps. + pub fn requeue(&mut self, batch: FlushBatch) { + let queue = self.queues.entry(batch.channel_id).or_default(); + // Push to front in reverse order so original order is preserved. + for event in batch.events.into_iter().rev() { + queue.push_front(QueuedEvent { + channel_id: batch.channel_id, + event, + received_at: Instant::now(), + }); + } + } + + /// Whether a prompt is currently in flight. + #[allow(dead_code)] + pub fn is_in_flight(&self) -> bool { + self.prompt_in_flight + } + + /// Total number of pending events across all channels. + #[allow(dead_code)] + pub fn pending_count(&self) -> usize { + self.queues.values().map(|q| q.len()).sum() + } + + /// Number of channels with pending events. + #[allow(dead_code)] + pub fn pending_channels(&self) -> usize { + self.queues.len() + } +} + +impl Default for EventQueue { + fn default() -> Self { + Self::new() + } +} + +/// Format the Channel/From/Time/Message lines for a single event. +fn format_event_lines(channel_id: Uuid, event: &Event) -> String { + format!( + "Channel: {}\nFrom: {}\nTime: {}\nMessage: {}", + channel_id, + event + .pubkey + .to_bech32() + .unwrap_or_else(|_| event.pubkey.to_hex()), + chrono::DateTime::from_timestamp(event.created_at.as_u64() as i64, 0) + .map(|dt| dt.to_rfc3339()) + .unwrap_or_else(|| event.created_at.as_u64().to_string()), + event.content, + ) +} + +/// Format a batch of events into a prompt string for the agent. +pub fn format_prompt(batch: &FlushBatch) -> String { + if batch.events.len() == 1 { + format!( + "[Sprout @mention]\n{}", + format_event_lines(batch.channel_id, &batch.events[0]) + ) + } else { + let mut prompt = format!("[Sprout @mention — {} events]\n", batch.events.len()); + for (i, event) in batch.events.iter().enumerate() { + prompt.push_str(&format!( + "\n--- Event {} ---\n{}\n", + i + 1, + format_event_lines(batch.channel_id, event) + )); + } + prompt + } +} + +// ─── Unit Tests ────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use nostr::{EventBuilder, Keys, Kind}; + use std::time::Duration; + + /// Build a test event with the given content. + fn make_event(content: &str) -> Event { + let keys = Keys::generate(); + EventBuilder::new(Kind::Custom(40001), content, []) + .sign_with_keys(&keys) + .unwrap() + } + + /// Build a QueuedEvent for the given channel. + fn make_queued(channel_id: Uuid, content: &str) -> QueuedEvent { + QueuedEvent { + channel_id, + event: make_event(content), + received_at: Instant::now(), + } + } + + /// Build a QueuedEvent with a specific `received_at` offset from now. + fn make_queued_at(channel_id: Uuid, content: &str, age: Duration) -> QueuedEvent { + QueuedEvent { + channel_id, + event: make_event(content), + received_at: Instant::now() - age, + } + } + + // ── Test 1: push + flush_next basic ────────────────────────────────────── + + #[test] + fn test_push_flush_basic() { + let mut q = EventQueue::new(); + let ch = Uuid::new_v4(); + + q.push(make_queued(ch, "hello")); + + let batch = q.flush_next().expect("should return a batch"); + assert_eq!(batch.channel_id, ch); + assert_eq!(batch.events.len(), 1); + assert_eq!(batch.events[0].content, "hello"); + + // Queue should be empty now. + assert_eq!(q.pending_count(), 0); + assert_eq!(q.pending_channels(), 0); + } + + // ── Test 2: in_flight blocks flush ─────────────────────────────────────── + + #[test] + fn test_in_flight_blocks_flush() { + let mut q = EventQueue::new(); + let ch = Uuid::new_v4(); + + q.push(make_queued(ch, "first")); + let _batch = q.flush_next().expect("first flush should succeed"); + assert!(q.is_in_flight()); + + // Push another event while in-flight. + q.push(make_queued(ch, "second")); + + // flush_next must return None while in-flight. + assert!(q.flush_next().is_none()); + } + + // ── Test 3: mark_complete enables flush ────────────────────────────────── + + #[test] + fn test_mark_complete_enables_flush() { + let mut q = EventQueue::new(); + let ch = Uuid::new_v4(); + + q.push(make_queued(ch, "first")); + let _batch = q.flush_next().expect("first flush should succeed"); + + // Push while in-flight; flush blocked. + q.push(make_queued(ch, "second")); + assert!(q.flush_next().is_none()); + + // Complete the in-flight prompt. + q.mark_complete(); + assert!(!q.is_in_flight()); + + // Now flush should succeed. + let batch = q.flush_next().expect("should flush after mark_complete"); + assert_eq!(batch.channel_id, ch); + assert_eq!(batch.events.len(), 1); + assert_eq!(batch.events[0].content, "second"); + } + + // ── Test 4: batch drain ─────────────────────────────────────────────────── + + #[test] + fn test_batch_drain_all_events() { + let mut q = EventQueue::new(); + let ch = Uuid::new_v4(); + + q.push(make_queued(ch, "msg1")); + q.push(make_queued(ch, "msg2")); + q.push(make_queued(ch, "msg3")); + + assert_eq!(q.pending_count(), 3); + + let batch = q.flush_next().expect("should return batch"); + assert_eq!(batch.channel_id, ch); + assert_eq!(batch.events.len(), 3); + assert_eq!(batch.events[0].content, "msg1"); + assert_eq!(batch.events[1].content, "msg2"); + assert_eq!(batch.events[2].content, "msg3"); + + // All drained. + assert_eq!(q.pending_count(), 0); + assert_eq!(q.pending_channels(), 0); + } + + // ── Test 5: FIFO fairness ───────────────────────────────────────────────── + + #[test] + fn test_fifo_fairness_picks_oldest_channel() { + let mut q = EventQueue::new(); + let ch_a = Uuid::new_v4(); + let ch_b = Uuid::new_v4(); + + // Channel A has an older event (2 seconds ago), B has a newer one (1 second ago). + q.push(make_queued_at(ch_a, "from A", Duration::from_secs(2))); + q.push(make_queued_at(ch_b, "from B", Duration::from_secs(1))); + + let batch = q.flush_next().expect("should return batch"); + // A is older, so it should be picked first. + assert_eq!(batch.channel_id, ch_a); + assert_eq!(batch.events[0].content, "from A"); + } + + // ── Test 6: multi-channel interleave ───────────────────────────────────── + + #[test] + fn test_multi_channel_interleave() { + let mut q = EventQueue::new(); + let ch_a = Uuid::new_v4(); + let ch_b = Uuid::new_v4(); + + // A is older. + q.push(make_queued_at(ch_a, "A-event", Duration::from_secs(2))); + q.push(make_queued_at(ch_b, "B-event", Duration::from_secs(1))); + + // First flush picks A. + let batch_a = q.flush_next().expect("first flush"); + assert_eq!(batch_a.channel_id, ch_a); + assert!(q.is_in_flight()); + + // B still pending. + assert_eq!(q.pending_count(), 1); + assert_eq!(q.pending_channels(), 1); + + q.mark_complete(); + + // Second flush picks B. + let batch_b = q.flush_next().expect("second flush"); + assert_eq!(batch_b.channel_id, ch_b); + assert_eq!(batch_b.events[0].content, "B-event"); + + assert_eq!(q.pending_count(), 0); + } + + // ── Test 7: empty queue returns None ───────────────────────────────────── + + #[test] + fn test_empty_queue_returns_none() { + let mut q = EventQueue::new(); + assert!(q.flush_next().is_none()); + } + + // ── Test 8: pending_count ───────────────────────────────────────────────── + + #[test] + fn test_pending_count() { + let mut q = EventQueue::new(); + let ch_a = Uuid::new_v4(); + let ch_b = Uuid::new_v4(); + + assert_eq!(q.pending_count(), 0); + assert_eq!(q.pending_channels(), 0); + + q.push(make_queued(ch_a, "a1")); + q.push(make_queued(ch_a, "a2")); + q.push(make_queued(ch_b, "b1")); + + assert_eq!(q.pending_count(), 3); + assert_eq!(q.pending_channels(), 2); + + // Flush A (2 events drained). + let _ = q.flush_next(); + assert_eq!(q.pending_count(), 1); + assert_eq!(q.pending_channels(), 1); + + q.mark_complete(); + + // Flush B (1 event drained). + let _ = q.flush_next(); + assert_eq!(q.pending_count(), 0); + assert_eq!(q.pending_channels(), 0); + } + + // ── Test 9: format_prompt single event ─────────────────────────────────── + + #[test] + fn test_format_prompt_single() { + let ch = Uuid::new_v4(); + let event = make_event("Hello @agent"); + let npub = event + .pubkey + .to_bech32() + .unwrap_or_else(|_| event.pubkey.to_hex()); + + let batch = FlushBatch { + channel_id: ch, + events: vec![event], + }; + + let prompt = format_prompt(&batch); + + assert!(prompt.starts_with("[Sprout @mention]\n")); + assert!(prompt.contains(&format!("Channel: {}", ch))); + assert!(prompt.contains(&format!("From: {}", npub))); + assert!(prompt.contains("Message: Hello @agent")); + // Should NOT contain "--- Event 1 ---" (that's the multi-event format). + assert!(!prompt.contains("--- Event 1 ---")); + } + + // ── Test 9b: requeue preserves events ──────────────────────────────────── + + #[test] + fn test_requeue_preserves_events() { + let mut queue = EventQueue::new(); + let ch = Uuid::new_v4(); + queue.push(make_queued(ch, "msg1")); + queue.push(make_queued(ch, "msg2")); + + let batch = queue.flush_next().unwrap(); + assert_eq!(batch.events.len(), 2); + assert!(queue.is_in_flight()); + + // Simulate failure — requeue the batch. + queue.requeue(batch); + queue.mark_complete(); + + // Should be able to flush again and get the same events in order. + let batch2 = queue.flush_next().unwrap(); + assert_eq!(batch2.events.len(), 2); + assert_eq!(batch2.events[0].content, "msg1"); + assert_eq!(batch2.events[1].content, "msg2"); + } + + #[test] + fn test_requeue_interleaves_with_other_channels() { + let mut queue = EventQueue::new(); + let ch_a = Uuid::new_v4(); + let ch_b = Uuid::new_v4(); + + // ch_a has an older event. + queue.push(make_queued_at(ch_a, "A-old", Duration::from_secs(5))); + queue.push(make_queued_at(ch_b, "B-new", Duration::from_secs(1))); + + // Flush ch_a first (older). + let batch_a = queue.flush_next().unwrap(); + assert_eq!(batch_a.channel_id, ch_a); + + // Requeue ch_a (simulating failure) and complete. + queue.requeue(batch_a); + queue.mark_complete(); + + // After requeue, ch_a's received_at is reset to now, so ch_b (older) goes first. + let next_batch = queue.flush_next().unwrap(); + assert_eq!(next_batch.channel_id, ch_b); + } + + // ── Test 10: format_prompt batch ───────────────────────────────────────── + + #[test] + fn test_format_prompt_batch() { + let ch = Uuid::new_v4(); + let e1 = make_event("first message"); + let e2 = make_event("second message"); + let e3 = make_event("third message"); + + let batch = FlushBatch { + channel_id: ch, + events: vec![e1, e2, e3], + }; + + let prompt = format_prompt(&batch); + + assert!(prompt.starts_with("[Sprout @mention — 3 events]\n")); + assert!(prompt.contains("--- Event 1 ---")); + assert!(prompt.contains("--- Event 2 ---")); + assert!(prompt.contains("--- Event 3 ---")); + assert!(prompt.contains("Message: first message")); + assert!(prompt.contains("Message: second message")); + assert!(prompt.contains("Message: third message")); + // All events reference the same channel. + assert_eq!( + prompt.matches(&format!("Channel: {}", ch)).count(), + 3, + "each event block should include the channel id" + ); + } +} diff --git a/crates/sprout-acp/src/relay.rs b/crates/sprout-acp/src/relay.rs new file mode 100644 index 000000000..2a981fb25 --- /dev/null +++ b/crates/sprout-acp/src/relay.rs @@ -0,0 +1,1439 @@ +//! Harness-side Sprout relay client. +//! +//! Connects to the Sprout relay via NIP-01 WebSocket, authenticates via NIP-42, +//! discovers channels via REST API, and streams matching events back to the +//! harness main loop. +//! +//! This is a simplified receive-only client adapted from `sprout-mcp`'s +//! `relay_client.rs`. It does not publish events or perform queries — it only +//! subscribes and receives. +//! +//! ## Architecture +//! +//! A background tokio task owns the WebSocket stream. It: +//! - Responds to Ping frames with Pong (preventing relay disconnect on long turns) +//! - Forwards `SproutEvent`s through an `mpsc` channel +//! - Handles reconnection with `since` filters to avoid event loss +//! - Responds to mid-session AUTH challenges +//! +//! `HarnessRelay` communicates with the background task via a `RelayCommand` +//! channel. `next_event()` reads from the event receiver. + +use std::collections::{HashMap, HashSet, VecDeque}; +use std::time::Duration; + +// ─── Named constants ────────────────────────────────────────────────────────── + +/// Capacity of the event channel from background task to harness. +const EVENT_CHANNEL_CAPACITY: usize = 256; +/// Capacity of the command channel from harness to background task. +const CMD_CHANNEL_CAPACITY: usize = 64; +/// Maximum number of seen event IDs before the dedup set is cleared. +const SEEN_ID_LIMIT: usize = 12_000; +/// Seconds subtracted from `since` on resubscribe to tolerate clock skew. +const SINCE_SKEW_SECS: u64 = 5; +/// Timeout for the NIP-42 auth handshake steps. +const AUTH_TIMEOUT: Duration = Duration::from_secs(5); + +use futures_util::{SinkExt, StreamExt}; +use nostr::{Event, EventBuilder, Keys, Kind, Tag, Url as NostrUrl}; +use serde_json::{json, Value}; +use tokio::sync::mpsc; +use tokio::time::timeout; +use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream}; +use tracing::{debug, info, warn}; +use uuid::Uuid; + +use sprout_core::kind::{ + KIND_STREAM_MESSAGE, KIND_STREAM_REMINDER, KIND_WORKFLOW_APPROVAL_REQUESTED, +}; + +// ── Types ───────────────────────────────────────────────────────────────────── + +/// Events the harness cares about. +#[derive(Debug, Clone)] +pub struct SproutEvent { + /// Which channel this event belongs to. + pub channel_id: Uuid, + /// The underlying Nostr event. + pub event: Event, +} + +/// Errors from relay operations. +#[derive(Debug, thiserror::Error)] +pub enum RelayError { + #[error("WebSocket error: {0}")] + WebSocket(Box), + + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + + #[error("Auth failed: {0}")] + AuthFailed(String), + + #[error("No auth challenge received")] + NoAuthChallenge, + + #[error("Connection closed")] + ConnectionClosed, + + #[error("Timeout")] + Timeout, + + #[error("HTTP error: {0}")] + Http(String), + + #[error("Unexpected message: {0}")] + UnexpectedMessage(String), +} + +impl From for RelayError { + fn from(e: nostr::event::builder::Error) -> Self { + RelayError::AuthFailed(e.to_string()) + } +} + +// ── Internal relay message types ────────────────────────────────────────────── + +/// A parsed NIP-01 relay message. +#[derive(Debug, Clone)] +enum RelayMessage { + Event { + subscription_id: String, + event: Box, + }, + Ok { + event_id: String, + accepted: bool, + message: String, + }, + Eose { + subscription_id: String, + }, + Closed { + subscription_id: String, + message: String, + }, + Notice { + message: String, + }, + Auth { + challenge: String, + }, +} + +// ── Commands sent from HarnessRelay to the background task ─────────────────── + +/// Commands sent from `HarnessRelay` to the background WebSocket task. +enum RelayCommand { + /// Subscribe to a channel (sends a NIP-01 REQ). + Subscribe { channel_id: Uuid }, + /// Unsubscribe from a channel (sends a NIP-01 CLOSE). + #[allow(dead_code)] + Unsubscribe { channel_id: Uuid }, + /// Reconnect to the relay (re-authenticate and resubscribe). + Reconnect, + /// Shut down the background task. + Shutdown, +} + +// ── WebSocket stream type alias ─────────────────────────────────────────────── + +type WsStream = WebSocketStream>; + +// ── HarnessRelay ────────────────────────────────────────────────────────────── + +/// Harness-side relay client. +/// +/// Connects to the Sprout relay, authenticates via NIP-42, and streams +/// matching events for subscribed channels. +/// +/// A background tokio task owns the WebSocket connection and responds to +/// Ping frames, preventing disconnection during long agent turns. +pub struct HarnessRelay { + /// Receiver for events forwarded by the background task. + event_rx: mpsc::Receiver>, + /// Sender for commands to the background task. + cmd_tx: mpsc::Sender, + /// HTTP client for REST API calls. + http: reqwest::Client, + /// WebSocket URL of the relay. + relay_url: String, + /// Optional API token for Bearer auth. + api_token: Option, + /// Keys used for NIP-42 signing. + keys: Keys, + /// Agent public key (hex) used as the `#p` filter on subscriptions. + #[allow(dead_code)] + agent_pubkey_hex: String, + /// Handle to the background task (for clean shutdown). + bg_handle: tokio::task::JoinHandle<()>, +} + +impl HarnessRelay { + // ── Public API ──────────────────────────────────────────────────────────── + + /// Connect to relay and authenticate via NIP-42. + pub async fn connect( + relay_url: &str, + keys: &Keys, + api_token: Option<&str>, + agent_pubkey_hex: &str, + ) -> Result { + // Perform the initial connection and auth handshake. + let (ws, _buffer) = do_connect(relay_url, keys, api_token).await?; + + let (event_tx, event_rx) = mpsc::channel::>(EVENT_CHANNEL_CAPACITY); + let (cmd_tx, cmd_rx) = mpsc::channel::(CMD_CHANNEL_CAPACITY); + + let bg_keys = keys.clone(); + let bg_relay_url = relay_url.to_string(); + let bg_api_token = api_token.map(|t| t.to_string()); + let bg_agent_pubkey_hex = agent_pubkey_hex.to_string(); + + let bg_handle = tokio::spawn(async move { + run_background_task( + ws, + event_tx, + cmd_rx, + bg_keys, + bg_relay_url, + bg_api_token, + bg_agent_pubkey_hex, + ) + .await; + }); + + Ok(Self { + event_rx, + cmd_tx, + http: reqwest::Client::new(), + relay_url: relay_url.to_string(), + api_token: api_token.map(|t| t.to_string()), + keys: keys.clone(), + agent_pubkey_hex: agent_pubkey_hex.to_string(), + bg_handle, + }) + } + + /// Discover channels the harness is a member of via `GET /api/channels`. + pub async fn discover_channels(&self) -> Result, RelayError> { + let http_url = relay_ws_to_http(&self.relay_url); + let url = format!("{http_url}/api/channels"); + + let builder = self.http.get(&url); + let builder = apply_auth(builder, &self.api_token, &self.keys); + + let resp = builder + .send() + .await + .map_err(|e| RelayError::Http(e.to_string()))?; + + if !resp.status().is_success() { + return Err(RelayError::Http(format!( + "GET /api/channels returned HTTP {}", + resp.status() + ))); + } + + let body: Value = resp + .json() + .await + .map_err(|e| RelayError::Http(e.to_string()))?; + + let channels = body + .as_array() + .ok_or_else(|| RelayError::Http("expected JSON array from /api/channels".into()))?; + + let mut ids = Vec::with_capacity(channels.len()); + for ch in channels { + if let Some(id_str) = ch.get("id").and_then(|v| v.as_str()) { + match id_str.parse::() { + Ok(uuid) => ids.push(uuid), + Err(e) => { + warn!("skipping channel with unparseable id {id_str:?}: {e}"); + } + } + } + } + + debug!("discovered {} channel(s)", ids.len()); + Ok(ids) + } + + /// Subscribe to events in a channel filtered by agent pubkey. + /// + /// Sends a `Subscribe` command to the background task, which issues the + /// NIP-01 `REQ` for kinds 40001, 46010, 40007 tagged with the channel UUID + /// and agent pubkey. Subscription ID is `ch-`. + pub async fn subscribe_channel(&mut self, channel_id: Uuid) -> Result<(), RelayError> { + self.cmd_tx + .send(RelayCommand::Subscribe { channel_id }) + .await + .map_err(|_| RelayError::ConnectionClosed)?; + debug!("queued subscribe for channel {channel_id}"); + Ok(()) + } + + /// Unsubscribe from a channel. + #[allow(dead_code)] + pub async fn unsubscribe_channel(&mut self, channel_id: Uuid) -> Result<(), RelayError> { + self.cmd_tx + .send(RelayCommand::Unsubscribe { channel_id }) + .await + .map_err(|_| RelayError::ConnectionClosed)?; + debug!("queued unsubscribe for channel {channel_id}"); + Ok(()) + } + + /// Wait for the next event from any subscribed channel. + /// + /// Reads from the background task's event channel. Returns `None` on + /// connection loss — the caller should call [`reconnect`](Self::reconnect). + pub async fn next_event(&mut self) -> Option { + // The background task sends `None` to signal connection loss. + self.event_rx.recv().await.flatten() + } + + /// Reconnect after connection loss. Instructs the background task to + /// re-authenticate and resubscribe to all previously active channels. + pub async fn reconnect(&mut self) -> Result<(), RelayError> { + warn!("relay connection lost — reconnecting…"); + self.cmd_tx + .send(RelayCommand::Reconnect) + .await + .map_err(|_| RelayError::ConnectionClosed)?; + Ok(()) + } +} + +impl Drop for HarnessRelay { + fn drop(&mut self) { + // Best-effort shutdown signal; ignore errors (task may already be done). + let _ = self.cmd_tx.try_send(RelayCommand::Shutdown); + self.bg_handle.abort(); + } +} + +// ── Background task ─────────────────────────────────────────────────────────── + +/// State maintained by the background WebSocket task. +struct BgState { + /// Active subscriptions: channel_id → subscription_id string. + active_subscriptions: HashMap, + /// Most recent `created_at` timestamp seen per channel (for `since` filter). + last_seen: HashMap, + /// Set of event IDs seen, for deduplication. + seen_ids: HashSet, +} + +impl BgState { + fn new() -> Self { + Self { + active_subscriptions: HashMap::new(), + last_seen: HashMap::new(), + seen_ids: HashSet::new(), + } + } + + /// Record a received event for dedup and `since` tracking. + /// Returns `true` if the event is new (not a duplicate). + fn record_event(&mut self, channel_id: Uuid, event: &Event) -> bool { + let id_hex = event.id.to_hex(); + + // Deduplicate. + if !self.seen_ids.insert(id_hex) { + return false; + } + + // Bound seen_ids to prevent unbounded memory growth. + if self.seen_ids.len() > SEEN_ID_LIMIT { + // HashSet has no ordering, so we clear and re-insert the current + // event to avoid a false-negative dedup gap for this event. + let current_id = event.id.to_hex(); + self.seen_ids.clear(); + self.seen_ids.insert(current_id); + } + + // Update last_seen timestamp. + let ts = event.created_at.as_u64(); + self.last_seen + .entry(channel_id) + .and_modify(|t| *t = (*t).max(ts)) + .or_insert(ts); + + true + } +} + +/// The main background task loop. +/// +/// Owns the WebSocket stream, responds to Pings, forwards events, and handles +/// reconnection. +async fn run_background_task( + mut ws: WsStream, + event_tx: mpsc::Sender>, + mut cmd_rx: mpsc::Receiver, + keys: Keys, + relay_url: String, + api_token: Option, + agent_pubkey_hex: String, +) { + let mut state = BgState::new(); + + loop { + tokio::select! { + // ── Incoming WebSocket message ──────────────────────────────────── + raw = ws.next() => { + match raw { + Some(Ok(msg)) => { + if !handle_ws_message( + msg, + &mut ws, + &event_tx, + &mut state, + &keys, + &relay_url, + api_token.as_deref(), + ) + .await + { + // handle_ws_message returns false on connection loss. + // Signal the caller and wait for a Reconnect command. + let _ = event_tx.send(None).await; + wait_for_reconnect( + &mut ws, + &mut cmd_rx, + &mut state, + &keys, + &relay_url, + api_token.as_deref(), + &agent_pubkey_hex, + ) + .await; + } + } + Some(Err(e)) => { + warn!("WebSocket error in background task: {e}"); + let _ = event_tx.send(None).await; + wait_for_reconnect( + &mut ws, + &mut cmd_rx, + &mut state, + &keys, + &relay_url, + api_token.as_deref(), + &agent_pubkey_hex, + ) + .await; + } + None => { + debug!("WebSocket stream ended"); + let _ = event_tx.send(None).await; + wait_for_reconnect( + &mut ws, + &mut cmd_rx, + &mut state, + &keys, + &relay_url, + api_token.as_deref(), + &agent_pubkey_hex, + ) + .await; + } + } + } + + // ── Command from HarnessRelay ───────────────────────────────────── + cmd = cmd_rx.recv() => { + match cmd { + Some(RelayCommand::Subscribe { channel_id }) => { + send_subscribe(&mut ws, &state, channel_id, &agent_pubkey_hex, None).await; + state.active_subscriptions.insert(channel_id, channel_sub_id(channel_id)); + } + Some(RelayCommand::Unsubscribe { channel_id }) => { + if let Some(sub_id) = state.active_subscriptions.remove(&channel_id) { + let msg = json!(["CLOSE", sub_id]); + if let Ok(text) = serde_json::to_string(&msg) { + let _ = ws.send(Message::Text(text.into())).await; + } + debug!("unsubscribed from channel {channel_id}"); + } + } + Some(RelayCommand::Reconnect) => { + // Caller already got None from next_event(); drive reconnect now. + wait_for_reconnect( + &mut ws, + &mut cmd_rx, + &mut state, + &keys, + &relay_url, + api_token.as_deref(), + &agent_pubkey_hex, + ) + .await; + } + Some(RelayCommand::Shutdown) | None => { + debug!("background task shutting down"); + return; + } + } + } + } + } +} + +/// Handle a single WebSocket message in the background task. +/// +/// Returns `false` if the connection has been lost (Close frame or unrecoverable +/// error), `true` otherwise. +async fn handle_ws_message( + msg: Message, + ws: &mut WsStream, + event_tx: &mpsc::Sender>, + state: &mut BgState, + keys: &Keys, + relay_url: &str, + api_token: Option<&str>, +) -> bool { + match msg { + Message::Text(text) => { + let relay_msg = match parse_relay_message(&text) { + Ok(m) => m, + Err(e) => { + warn!("failed to parse relay message: {e} — raw: {text}"); + return true; + } + }; + + match relay_msg { + RelayMessage::Event { + subscription_id, + event, + } => { + if let Some(channel_id) = channel_id_from_sub_id(&subscription_id) { + if state.record_event(channel_id, &event) { + let sprout_event = SproutEvent { + channel_id, + event: *event, + }; + match event_tx.try_send(Some(sprout_event)) { + Ok(()) => {} + Err(mpsc::error::TrySendError::Full(_)) => { + warn!("event channel full — dropping event for channel {channel_id}"); + } + Err(mpsc::error::TrySendError::Closed(_)) => { + // Receiver dropped — shut down. + return false; + } + } + } else { + debug!("dropping duplicate event for channel {channel_id}"); + } + } else { + warn!("received EVENT for unknown subscription {subscription_id}"); + } + } + RelayMessage::Eose { subscription_id } => { + debug!("EOSE for subscription {subscription_id}"); + } + RelayMessage::Notice { message } => { + // Fix 4: NOTICE at warn level. + tracing::warn!("relay NOTICE: {message}"); + } + RelayMessage::Closed { + subscription_id, + message, + } => { + warn!("subscription {subscription_id} closed by relay: {message}"); + } + RelayMessage::Auth { challenge } => { + // Fix 5: Handle mid-session AUTH challenge by re-authenticating. + debug!("received mid-session AUTH challenge — re-authenticating"); + if let Err(e) = + send_auth_response(ws, &challenge, relay_url, keys, api_token).await + { + warn!("failed to respond to mid-session AUTH challenge: {e}"); + } + } + RelayMessage::Ok { + event_id, + accepted, + message, + } => { + debug!("OK for event {event_id}: accepted={accepted} message={message}"); + } + } + true + } + Message::Ping(data) => { + if let Err(e) = ws.send(Message::Pong(data)).await { + warn!("failed to send pong: {e}"); + return false; + } + true + } + Message::Close(_) => { + debug!("relay sent Close frame"); + false + } + // Binary, Pong, Frame — ignore + _ => true, + } +} + +/// Wait for a `Reconnect` command from the caller, then attempt reconnection +/// with exponential backoff. Resubscribes all active channels with `since` +/// filters on success. +async fn wait_for_reconnect( + ws: &mut WsStream, + cmd_rx: &mut mpsc::Receiver, + state: &mut BgState, + keys: &Keys, + relay_url: &str, + api_token: Option<&str>, + agent_pubkey_hex: &str, +) { + // Drain commands until we get Reconnect (or Shutdown). + loop { + match cmd_rx.recv().await { + Some(RelayCommand::Reconnect) => break, + Some(RelayCommand::Shutdown) | None => return, + // Ignore Subscribe/Unsubscribe while disconnected — they'll be + // resubmitted after reconnect via active_subscriptions. + _ => {} + } + } + + // Attempt reconnection with backoff. + let mut delay = Duration::from_secs(1); + loop { + info!("attempting relay reconnect to {relay_url}…"); + match do_connect(relay_url, keys, api_token).await { + Ok((new_ws, _buffer)) => { + *ws = new_ws; + info!("relay reconnected to {relay_url}"); + + // Resubscribe all active channels with `since` filter. + let channels: Vec = state.active_subscriptions.keys().copied().collect(); + if !channels.is_empty() { + info!("resubscribing to {} channel(s)", channels.len()); + for channel_id in channels { + let since = state.last_seen.get(&channel_id).copied(); + send_subscribe(ws, state, channel_id, agent_pubkey_hex, since).await; + } + } + + return; + } + Err(e) => { + warn!( + "relay reconnect failed: {e} — retrying in {}s", + delay.as_secs() + ); + tokio::time::sleep(delay).await; + delay = (delay * 2).min(Duration::from_secs(60)); + } + } + } +} + +/// Send a NIP-01 REQ for a channel, optionally with a `since` filter. +async fn send_subscribe( + ws: &mut WsStream, + _state: &BgState, + channel_id: Uuid, + agent_pubkey_hex: &str, + since: Option, +) { + let sub_id = channel_sub_id(channel_id); + + let mut filter = json!({ + "kinds": [ + KIND_STREAM_MESSAGE, + KIND_WORKFLOW_APPROVAL_REQUESTED, + KIND_STREAM_REMINDER, + ], + "#h": [channel_id.to_string()], + "#p": [agent_pubkey_hex], + }); + + // Fix 2: Add `since` filter on reconnect (with 5-second skew buffer). + if let Some(ts) = since { + filter["since"] = json!(ts.saturating_sub(SINCE_SKEW_SECS)); + } + + let req = json!(["REQ", sub_id, filter]); + + match serde_json::to_string(&req) { + Ok(text) => { + if let Err(e) = ws.send(Message::Text(text.into())).await { + warn!("failed to send REQ for channel {channel_id}: {e}"); + } else { + debug!( + "subscribed to channel {channel_id}{}", + if since.is_some() { + " (with since filter)" + } else { + "" + } + ); + } + } + Err(e) => { + warn!("failed to serialize REQ for channel {channel_id}: {e}"); + } + } +} + +/// Build and send a NIP-42 AUTH response event. +async fn send_auth_response( + ws: &mut WsStream, + challenge: &str, + relay_url: &str, + keys: &Keys, + api_token: Option<&str>, +) -> Result<(), RelayError> { + let relay_nostr_url: NostrUrl = relay_url + .parse() + .map_err(|e: url::ParseError| RelayError::Http(format!("invalid relay URL: {e}")))?; + + let auth_event = if let Some(token) = api_token { + let tags = vec![ + Tag::parse(&["relay", relay_url]).map_err(|e| RelayError::AuthFailed(e.to_string()))?, + Tag::parse(&["challenge", challenge]) + .map_err(|e| RelayError::AuthFailed(e.to_string()))?, + Tag::parse(&["auth_token", token]) + .map_err(|e| RelayError::AuthFailed(e.to_string()))?, + ]; + EventBuilder::new(Kind::Authentication, "", tags).sign_with_keys(keys)? + } else { + EventBuilder::auth(challenge, relay_nostr_url).sign_with_keys(keys)? + }; + + let auth_msg = serde_json::to_string(&json!(["AUTH", auth_event]))?; + ws.send(Message::Text(auth_msg.into())) + .await + .map_err(|e| RelayError::WebSocket(Box::new(e)))?; + debug!("sent AUTH response for challenge"); + Ok(()) +} + +// ── Free functions ──────────────────────────────────────────────────────────── + +/// Convert a WebSocket URL to its HTTP equivalent. +/// +/// `ws://host:port` → `http://host:port` +/// `wss://host:port` → `https://host:port` +/// Trailing slashes are stripped. +pub(crate) fn relay_ws_to_http(url: &str) -> String { + url.replace("wss://", "https://") + .replace("ws://", "http://") + .trim_end_matches('/') + .to_string() +} + +/// Build the subscription ID for a channel: `ch-`. +pub(crate) fn channel_sub_id(channel_id: Uuid) -> String { + format!("ch-{channel_id}") +} + +/// Extract a channel UUID from a subscription ID of the form `ch-`. +/// Returns `None` if the format doesn't match or the UUID is invalid. +fn channel_id_from_sub_id(sub_id: &str) -> Option { + sub_id + .strip_prefix("ch-") + .and_then(|s| s.parse::().ok()) +} + +/// Apply the appropriate auth header to a reqwest request builder. +fn apply_auth( + builder: reqwest::RequestBuilder, + api_token: &Option, + keys: &Keys, +) -> reqwest::RequestBuilder { + if let Some(ref token) = api_token { + builder.header("Authorization", format!("Bearer {token}")) + } else { + builder.header("X-Pubkey", keys.public_key().to_hex()) + } +} + +/// Parse a raw relay text frame into a typed [`RelayMessage`]. +#[allow(private_interfaces)] +pub(crate) fn parse_relay_message(text: &str) -> Result { + let arr: Vec = serde_json::from_str(text)?; + + let msg_type = arr + .first() + .and_then(|v| v.as_str()) + .ok_or_else(|| RelayError::UnexpectedMessage(text.to_string()))?; + + match msg_type { + "EVENT" => { + let sub_id = arr + .get(1) + .and_then(|v| v.as_str()) + .ok_or_else(|| RelayError::UnexpectedMessage(text.to_string()))? + .to_string(); + let event: Event = serde_json::from_value( + arr.get(2) + .cloned() + .ok_or_else(|| RelayError::UnexpectedMessage(text.to_string()))?, + )?; + Ok(RelayMessage::Event { + subscription_id: sub_id, + event: Box::new(event), + }) + } + "OK" => { + let event_id = arr + .get(1) + .and_then(|v| v.as_str()) + .ok_or_else(|| RelayError::UnexpectedMessage(text.to_string()))? + .to_string(); + let accepted = arr.get(2).and_then(|v| v.as_bool()).unwrap_or(false); + let message = arr + .get(3) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + Ok(RelayMessage::Ok { + event_id, + accepted, + message, + }) + } + "EOSE" => { + let sub_id = arr + .get(1) + .and_then(|v| v.as_str()) + .ok_or_else(|| RelayError::UnexpectedMessage(text.to_string()))? + .to_string(); + Ok(RelayMessage::Eose { + subscription_id: sub_id, + }) + } + "CLOSED" => { + let sub_id = arr + .get(1) + .and_then(|v| v.as_str()) + .ok_or_else(|| RelayError::UnexpectedMessage(text.to_string()))? + .to_string(); + let message = arr + .get(2) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + Ok(RelayMessage::Closed { + subscription_id: sub_id, + message, + }) + } + "NOTICE" => { + let message = arr + .get(1) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + Ok(RelayMessage::Notice { message }) + } + "AUTH" => { + let challenge = arr + .get(1) + .and_then(|v| v.as_str()) + .ok_or_else(|| RelayError::UnexpectedMessage(text.to_string()))? + .to_string(); + Ok(RelayMessage::Auth { challenge }) + } + other => Err(RelayError::UnexpectedMessage(format!( + "unknown message type: {other}" + ))), + } +} + +// ── Connection helpers ──────────────────────────────────────────────────────── + +/// Perform a single WebSocket connect + NIP-42 auth handshake. +/// +/// Returns `(ws, buffer)` on success. +async fn do_connect( + relay_url: &str, + keys: &Keys, + api_token: Option<&str>, +) -> Result<(WsStream, VecDeque), RelayError> { + let parsed = relay_url + .parse::() + .map_err(|e| RelayError::Http(format!("invalid relay URL: {e}")))?; + + let (ws, _response) = connect_async(parsed.as_str()) + .await + .map_err(|e| RelayError::WebSocket(Box::new(e)))?; + debug!("connected to relay at {relay_url}"); + + let mut ws = ws; + let mut buffer: VecDeque = VecDeque::new(); + + // ── Step 1: Wait for AUTH challenge ─────────────────────────────────── + let challenge = wait_for_auth_challenge(&mut ws, &mut buffer, AUTH_TIMEOUT).await?; + + // ── Step 2: Build and send kind:22242 auth event ────────────────────── + send_auth_response(&mut ws, &challenge, relay_url, keys, api_token).await?; + + // ── Step 3: Wait for OK ─────────────────────────────────────────────── + let event_id = { + // We need the event_id that was just sent. Re-derive it by signing again + // just to get the ID — but that's wasteful. Instead, parse the last sent + // message. Simpler: wait_for_ok accepts any OK (we just sent one event). + // The event_id in the OK will match whatever we sent. + // We'll accept the first OK we receive. + let ok = wait_for_any_ok(&mut ws, &mut buffer, AUTH_TIMEOUT).await?; + if !ok.accepted { + return Err(RelayError::AuthFailed(ok.message)); + } + ok.event_id + }; + + debug!("NIP-42 authentication successful (event {event_id})"); + Ok((ws, buffer)) +} + +/// Wait for an `AUTH` challenge from the relay, buffering any other messages. +async fn wait_for_auth_challenge( + ws: &mut WsStream, + buffer: &mut VecDeque, + timeout_dur: Duration, +) -> Result { + // Check if there's already one buffered. + if let Some(idx) = buffer + .iter() + .position(|m| matches!(m, RelayMessage::Auth { .. })) + { + if let Some(RelayMessage::Auth { challenge }) = buffer.remove(idx) { + return Ok(challenge); + } + } + + let deadline = tokio::time::Instant::now() + timeout_dur; + + loop { + let remaining = deadline + .checked_duration_since(tokio::time::Instant::now()) + .unwrap_or(Duration::ZERO); + + if remaining.is_zero() { + return Err(RelayError::NoAuthChallenge); + } + + let raw = timeout(remaining, ws.next()) + .await + .map_err(|_| RelayError::NoAuthChallenge)? + .ok_or(RelayError::ConnectionClosed)? + .map_err(|e| RelayError::WebSocket(Box::new(e)))?; + + match raw { + Message::Text(text) => { + let msg = parse_relay_message(&text)?; + match msg { + RelayMessage::Auth { challenge } => return Ok(challenge), + other => buffer.push_back(other), + } + } + Message::Ping(data) => { + ws.send(Message::Pong(data)) + .await + .map_err(|e| RelayError::WebSocket(Box::new(e)))?; + } + Message::Close(_) => return Err(RelayError::ConnectionClosed), + _ => {} + } + } +} + +/// Response from an `OK` relay message. +struct OkResponse { + event_id: String, + accepted: bool, + message: String, +} + +/// Wait for the first `OK` message from the relay (used after sending AUTH). +async fn wait_for_any_ok( + ws: &mut WsStream, + buffer: &mut VecDeque, + timeout_dur: Duration, +) -> Result { + // Check if there's already one buffered. + if let Some(idx) = buffer + .iter() + .position(|m| matches!(m, RelayMessage::Ok { .. })) + { + if let Some(RelayMessage::Ok { + event_id, + accepted, + message, + }) = buffer.remove(idx) + { + return Ok(OkResponse { + event_id, + accepted, + message, + }); + } + } + + let deadline = tokio::time::Instant::now() + timeout_dur; + + loop { + let remaining = deadline + .checked_duration_since(tokio::time::Instant::now()) + .unwrap_or(Duration::ZERO); + + if remaining.is_zero() { + return Err(RelayError::Timeout); + } + + let raw = timeout(remaining, ws.next()) + .await + .map_err(|_| RelayError::Timeout)? + .ok_or(RelayError::ConnectionClosed)? + .map_err(|e| RelayError::WebSocket(Box::new(e)))?; + + match raw { + Message::Text(text) => { + let msg = parse_relay_message(&text)?; + match msg { + RelayMessage::Ok { + event_id, + accepted, + message, + } => { + return Ok(OkResponse { + event_id, + accepted, + message, + }); + } + other => buffer.push_back(other), + } + } + Message::Ping(data) => { + ws.send(Message::Pong(data)) + .await + .map_err(|e| RelayError::WebSocket(Box::new(e)))?; + } + Message::Close(_) => return Err(RelayError::ConnectionClosed), + _ => {} + } + } +} + +// ── Unit tests ──────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + // ── relay_ws_to_http ────────────────────────────────────────────────────── + + #[test] + fn relay_ws_to_http_plain() { + assert_eq!( + relay_ws_to_http("ws://localhost:3000"), + "http://localhost:3000" + ); + } + + #[test] + fn relay_ws_to_http_secure() { + assert_eq!( + relay_ws_to_http("wss://relay.example.com"), + "https://relay.example.com" + ); + } + + #[test] + fn relay_ws_to_http_strips_trailing_slash() { + assert_eq!( + relay_ws_to_http("ws://localhost:3000/"), + "http://localhost:3000" + ); + } + + #[test] + fn relay_ws_to_http_with_path() { + assert_eq!( + relay_ws_to_http("wss://relay.example.com/nostr"), + "https://relay.example.com/nostr" + ); + } + + #[test] + fn relay_ws_to_http_with_port_and_path() { + assert_eq!( + relay_ws_to_http("wss://relay.example.com:4000/ws"), + "https://relay.example.com:4000/ws" + ); + } + + // ── channel_sub_id ──────────────────────────────────────────────────────── + + #[test] + fn channel_sub_id_format() { + let uuid = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); + assert_eq!( + channel_sub_id(uuid), + "ch-550e8400-e29b-41d4-a716-446655440000" + ); + } + + #[test] + fn channel_id_from_sub_id_roundtrip() { + let uuid = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); + let sub_id = channel_sub_id(uuid); + let recovered = channel_id_from_sub_id(&sub_id).unwrap(); + assert_eq!(recovered, uuid); + } + + #[test] + fn channel_id_from_sub_id_invalid_prefix() { + assert!(channel_id_from_sub_id("sub-550e8400-e29b-41d4-a716-446655440000").is_none()); + } + + #[test] + fn channel_id_from_sub_id_invalid_uuid() { + assert!(channel_id_from_sub_id("ch-not-a-uuid").is_none()); + } + + #[test] + fn channel_id_from_sub_id_empty() { + assert!(channel_id_from_sub_id("").is_none()); + } + + // ── parse_relay_message ─────────────────────────────────────────────────── + + #[test] + fn parse_ok_accepted() { + let text = r#"["OK","abc123",true,""]"#; + let msg = parse_relay_message(text).unwrap(); + match msg { + RelayMessage::Ok { + event_id, + accepted, + message, + } => { + assert_eq!(event_id, "abc123"); + assert!(accepted); + assert_eq!(message, ""); + } + _ => panic!("expected Ok"), + } + } + + #[test] + fn parse_ok_rejected() { + let text = r#"["OK","abc123",false,"blocked: spam"]"#; + let msg = parse_relay_message(text).unwrap(); + match msg { + RelayMessage::Ok { + event_id, + accepted, + message, + } => { + assert_eq!(event_id, "abc123"); + assert!(!accepted); + assert_eq!(message, "blocked: spam"); + } + _ => panic!("expected Ok"), + } + } + + #[test] + fn parse_eose() { + let text = r#"["EOSE","sub-1"]"#; + let msg = parse_relay_message(text).unwrap(); + match msg { + RelayMessage::Eose { subscription_id } => { + assert_eq!(subscription_id, "sub-1"); + } + _ => panic!("expected Eose"), + } + } + + #[test] + fn parse_notice() { + let text = r#"["NOTICE","hello from relay"]"#; + let msg = parse_relay_message(text).unwrap(); + match msg { + RelayMessage::Notice { message } => { + assert_eq!(message, "hello from relay"); + } + _ => panic!("expected Notice"), + } + } + + #[test] + fn parse_notice_empty() { + let text = r#"["NOTICE"]"#; + let msg = parse_relay_message(text).unwrap(); + match msg { + RelayMessage::Notice { message } => { + assert_eq!(message, ""); + } + _ => panic!("expected Notice"), + } + } + + #[test] + fn parse_auth() { + let text = r#"["AUTH","some-challenge-string"]"#; + let msg = parse_relay_message(text).unwrap(); + match msg { + RelayMessage::Auth { challenge } => { + assert_eq!(challenge, "some-challenge-string"); + } + _ => panic!("expected Auth"), + } + } + + #[test] + fn parse_closed() { + let text = r#"["CLOSED","sub-2","error: rate-limited"]"#; + let msg = parse_relay_message(text).unwrap(); + match msg { + RelayMessage::Closed { + subscription_id, + message, + } => { + assert_eq!(subscription_id, "sub-2"); + assert_eq!(message, "error: rate-limited"); + } + _ => panic!("expected Closed"), + } + } + + #[test] + fn parse_closed_no_message() { + let text = r#"["CLOSED","sub-3"]"#; + let msg = parse_relay_message(text).unwrap(); + match msg { + RelayMessage::Closed { + subscription_id, + message, + } => { + assert_eq!(subscription_id, "sub-3"); + assert_eq!(message, ""); + } + _ => panic!("expected Closed"), + } + } + + #[test] + fn parse_unknown_type_returns_error() { + let text = r#"["UNKNOWN","data"]"#; + let result = parse_relay_message(text); + assert!(result.is_err()); + match result.unwrap_err() { + RelayError::UnexpectedMessage(msg) => { + assert!(msg.contains("unknown message type")); + } + e => panic!("expected UnexpectedMessage, got {e:?}"), + } + } + + #[test] + fn parse_invalid_json_returns_error() { + let text = "not json at all"; + let result = parse_relay_message(text); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), RelayError::Json(_))); + } + + #[test] + fn parse_empty_array_returns_error() { + let text = "[]"; + let result = parse_relay_message(text); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + RelayError::UnexpectedMessage(_) + )); + } + + #[test] + fn parse_auth_missing_challenge_returns_error() { + let text = r#"["AUTH"]"#; + let result = parse_relay_message(text); + assert!(result.is_err()); + } + + #[test] + fn parse_eose_missing_sub_id_returns_error() { + let text = r#"["EOSE"]"#; + let result = parse_relay_message(text); + assert!(result.is_err()); + } + + // ── channel_sub_id subscription format ─────────────────────────────────── + + #[test] + fn subscription_id_starts_with_ch_prefix() { + let uuid = Uuid::new_v4(); + let sub_id = channel_sub_id(uuid); + assert!(sub_id.starts_with("ch-")); + } + + #[test] + fn subscription_id_contains_full_uuid() { + let uuid = Uuid::parse_str("12345678-1234-5678-1234-567812345678").unwrap(); + let sub_id = channel_sub_id(uuid); + assert_eq!(sub_id, "ch-12345678-1234-5678-1234-567812345678"); + } + + // ── BgState: seen_ids deduplication ────────────────────────────────────── + + /// Build a real signed Nostr event for testing BgState. + /// + /// Uses `custom_created_at` so tests can control the timestamp. + /// The event ID is determined by the nostr signing process — we don't + /// control it, but we return it so callers can use it for dedup tests. + fn make_test_event(keys: &nostr::Keys, created_at_secs: u64) -> Event { + let ts = nostr::Timestamp::from(created_at_secs); + EventBuilder::new(nostr::Kind::TextNote, "test", []) + .custom_created_at(ts) + .sign_with_keys(keys) + .expect("signing should succeed") + } + + #[test] + fn bg_state_dedup_first_event_accepted() { + let mut state = BgState::new(); + let channel_id = Uuid::new_v4(); + let keys = nostr::Keys::generate(); + let event = make_test_event(&keys, 1_000_000); + assert!( + state.record_event(channel_id, &event), + "first event should be accepted" + ); + } + + #[test] + fn bg_state_dedup_duplicate_rejected() { + let mut state = BgState::new(); + let channel_id = Uuid::new_v4(); + let keys = nostr::Keys::generate(); + let event = make_test_event(&keys, 1_000_000); + assert!( + state.record_event(channel_id, &event), + "first should be accepted" + ); + assert!( + !state.record_event(channel_id, &event), + "duplicate should be rejected" + ); + } + + #[test] + fn bg_state_dedup_different_ids_both_accepted() { + let mut state = BgState::new(); + let channel_id = Uuid::new_v4(); + // Two different keys → two different event IDs. + let keys1 = nostr::Keys::generate(); + let keys2 = nostr::Keys::generate(); + let event1 = make_test_event(&keys1, 1_000_000); + let event2 = make_test_event(&keys2, 1_000_001); + assert!(state.record_event(channel_id, &event1)); + assert!(state.record_event(channel_id, &event2)); + } + + // ── BgState: last_seen tracking ─────────────────────────────────────────── + + #[test] + fn bg_state_last_seen_set_on_first_event() { + let mut state = BgState::new(); + let channel_id = Uuid::new_v4(); + let keys = nostr::Keys::generate(); + let event = make_test_event(&keys, 1_700_000); + state.record_event(channel_id, &event); + assert_eq!(state.last_seen.get(&channel_id).copied(), Some(1_700_000)); + } + + #[test] + fn bg_state_last_seen_advances_on_newer_event() { + let mut state = BgState::new(); + let channel_id = Uuid::new_v4(); + let keys1 = nostr::Keys::generate(); + let keys2 = nostr::Keys::generate(); + let event1 = make_test_event(&keys1, 1_700_000); + let event2 = make_test_event(&keys2, 1_800_000); + state.record_event(channel_id, &event1); + state.record_event(channel_id, &event2); + assert_eq!(state.last_seen.get(&channel_id).copied(), Some(1_800_000)); + } + + #[test] + fn bg_state_last_seen_does_not_regress_on_older_event() { + let mut state = BgState::new(); + let channel_id = Uuid::new_v4(); + let keys1 = nostr::Keys::generate(); + let keys2 = nostr::Keys::generate(); + let event_new = make_test_event(&keys1, 1_800_000); + let event_old = make_test_event(&keys2, 1_700_000); + state.record_event(channel_id, &event_new); + state.record_event(channel_id, &event_old); + // last_seen should remain at the higher timestamp + assert_eq!(state.last_seen.get(&channel_id).copied(), Some(1_800_000)); + } + + #[test] + fn bg_state_last_seen_independent_per_channel() { + let mut state = BgState::new(); + let ch1 = Uuid::new_v4(); + let ch2 = Uuid::new_v4(); + let keys1 = nostr::Keys::generate(); + let keys2 = nostr::Keys::generate(); + let event1 = make_test_event(&keys1, 1_000_000); + let event2 = make_test_event(&keys2, 2_000_000); + state.record_event(ch1, &event1); + state.record_event(ch2, &event2); + assert_eq!(state.last_seen.get(&ch1).copied(), Some(1_000_000)); + assert_eq!(state.last_seen.get(&ch2).copied(), Some(2_000_000)); + } + + #[test] + fn bg_state_seen_ids_cleared_at_limit() { + let mut state = BgState::new(); + let channel_id = Uuid::new_v4(); + + // Pre-populate seen_ids to just below the threshold (12_000 - 1). + // Use synthetic hex strings — we're testing the clear logic, not signing. + for i in 0u64..11_999 { + state.seen_ids.insert(format!("{:0>64x}", i)); + } + assert_eq!(state.seen_ids.len(), 11_999); + + // Now insert two real events. The first will bring us to 12_000 (no + // clear yet), the second will push us to 12_001 and trigger the clear. + let keys = nostr::Keys::generate(); + let event1 = make_test_event(&keys, 1_000_000); + let keys2 = nostr::Keys::generate(); + let event2 = make_test_event(&keys2, 1_000_001); + + // First insert: 12_000 entries — no clear triggered yet. + state.record_event(channel_id, &event1); + assert_eq!( + state.seen_ids.len(), + 12_000, + "should be at 12_000 before clear" + ); + + // Second insert: 12_001 entries — triggers clear, then re-inserts. + state.record_event(channel_id, &event2); + // After clear + re-insert of event2, seen_ids should be very small. + assert!( + state.seen_ids.len() < 12_000, + "seen_ids should have been cleared, got {}", + state.seen_ids.len() + ); + } +} diff --git a/crates/sprout-mcp/src/lib.rs b/crates/sprout-mcp/src/lib.rs index 3740cdc97..d6eaeb43d 100644 --- a/crates/sprout-mcp/src/lib.rs +++ b/crates/sprout-mcp/src/lib.rs @@ -51,7 +51,7 @@ //! //! ### Channels //! - **`list_channels`** — List channels accessible to this agent, optionally filtered by -//! visibility (`public` / `private`). +//! visibility (`open` / `private`). //! - **`create_channel`** — Create a new channel with a given name, type, and visibility. //! //! ### Canvas diff --git a/crates/sprout-mcp/src/relay_client.rs b/crates/sprout-mcp/src/relay_client.rs index 6d3b55383..3ab3e4665 100644 --- a/crates/sprout-mcp/src/relay_client.rs +++ b/crates/sprout-mcp/src/relay_client.rs @@ -543,6 +543,19 @@ impl RelayClient { Ok(resp.text().await?) } + /// Get the canvas content for a channel via REST. + pub async fn get_canvas(&self, channel_id: &str) -> anyhow::Result { + self.get(&format!("/api/channels/{}/canvas", channel_id)) + .await + } + + /// Set the canvas content for a channel via REST. + pub async fn set_canvas(&self, channel_id: &str, content: &str) -> anyhow::Result { + let body = serde_json::json!({ "content": content }); + self.put(&format!("/api/channels/{}/canvas", channel_id), &body) + .await + } + /// Authenticated GET to a full URL (for feed tools that build the URL themselves). pub async fn get_api(&self, url: &str) -> anyhow::Result { let resp = self.apply_auth(self.http.get(url)).send().await?; diff --git a/crates/sprout-mcp/src/server.rs b/crates/sprout-mcp/src/server.rs index d24ca7153..3479616a0 100644 --- a/crates/sprout-mcp/src/server.rs +++ b/crates/sprout-mcp/src/server.rs @@ -1,5 +1,3 @@ -use sprout_core::kind::KIND_CANVAS; - use rmcp::{ handler::server::{router::tool::ToolRouter, wrapper::Parameters}, model::{ServerCapabilities, ServerInfo}, @@ -79,7 +77,7 @@ pub struct GetChannelHistoryParams { /// Parameters for the `list_channels` tool. #[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] pub struct ListChannelsParams { - /// Optional visibility filter: `"public"` or `"private"`. + /// Optional visibility filter: `"open"` or `"private"`. #[serde(default)] pub visibility: Option, } @@ -89,9 +87,9 @@ pub struct ListChannelsParams { pub struct CreateChannelParams { /// Display name for the new channel. pub name: String, - /// Channel type identifier (e.g. `"text"`, `"voice"`). + /// Channel type: `"stream"` (real-time chat) or `"forum"` (threaded discussions). pub channel_type: String, - /// Visibility of the channel: `"public"` or `"private"`. + /// Channel visibility: `"open"` (anyone can join) or `"private"` (invite-only). pub visibility: String, /// Optional human-readable description of the channel's purpose. #[serde(default)] @@ -128,7 +126,19 @@ pub struct ListWorkflowsParams { pub struct CreateWorkflowParams { /// UUID of the channel to own this workflow. pub channel_id: String, - /// Full workflow definition in YAML format. + /// Full workflow definition in YAML format. Required fields: name (string), trigger (object with + /// 'on' field: 'message_posted', 'reaction_added', or 'webhook'), steps (array). + /// Each step needs: id (alphanumeric/underscore), action (e.g. 'send_message'), and action-specific + /// fields as direct properties (NOT nested under 'params'). Example: + /// ```yaml + /// name: My Workflow + /// trigger: + /// on: message_posted + /// steps: + /// - id: notify + /// action: send_message + /// text: Hello from workflow! + /// ``` pub yaml_definition: String, } @@ -522,7 +532,10 @@ impl SproutMcpServer { } /// Create a new Sprout channel. - #[tool(name = "create_channel", description = "Create a new Sprout channel")] + #[tool( + name = "create_channel", + description = "Create a new Sprout channel. channel_type must be 'stream' or 'forum'. visibility must be 'open' or 'private'." + )] pub async fn create_channel(&self, Parameters(p): Parameters) -> String { let body = serde_json::json!({ "name": p.name, @@ -545,28 +558,19 @@ impl SproutMcpServer { if let Err(e) = validate_uuid(&p.channel_id) { return format!("Error: {e}"); } - - let filter = nostr::Filter::new() - .kind(nostr::Kind::Custom(KIND_CANVAS as u16)) - .custom_tag( - nostr::SingleLetterTag::lowercase(nostr::Alphabet::H), - [p.channel_id.as_str()], - ) - .limit(50); - - let sub_id = format!("canvas-{}", uuid::Uuid::new_v4()); - let events = match self.client.subscribe(&sub_id, vec![filter]).await { - Ok(e) => e, - Err(e) => return format!("Error: {e}"), - }; - let _ = self.client.close_subscription(&sub_id).await; - - let canvas_event = events.iter().max_by_key(|event| event.created_at.as_u64()); - - if let Some(event) = canvas_event { - event.content.clone() - } else { - "No canvas set for this channel.".to_string() + match self.client.get_canvas(&p.channel_id).await { + Ok(body) => { + // Parse REST JSON and return just the content string. + if let Ok(v) = serde_json::from_str::(&body) { + match v.get("content").and_then(|c| c.as_str()) { + Some(content) => content.to_string(), + None => "No canvas set for this channel.".to_string(), + } + } else { + body + } + } + Err(e) => format!("Error: {e}"), } } @@ -579,29 +583,20 @@ impl SproutMcpServer { if let Err(e) = validate_uuid(&p.channel_id) { return format!("Error: {e}"); } - - let keys = self.client.keys().clone(); - - let channel_tag = match nostr::Tag::parse(&["h", &p.channel_id]) { - Ok(t) => t, - Err(e) => return format!("Error building tag: {e}"), - }; - - let event = match nostr::EventBuilder::new( - nostr::Kind::Custom(KIND_CANVAS as u16), - &p.content, - [channel_tag], - ) - .sign_with_keys(&keys) - { - Ok(e) => e, - Err(e) => return format!("Error signing event: {e}"), - }; - - match self.client.send_event(event).await { - Ok(ok) if ok.accepted => "Canvas updated.".to_string(), - Ok(ok) => format!("Canvas update rejected: {}", ok.message), - Err(e) => format!("Relay error: {e}"), + match self.client.set_canvas(&p.channel_id, &p.content).await { + Ok(body) => { + // Parse REST JSON; return a clean confirmation string. + if let Ok(v) = serde_json::from_str::(&body) { + if v.get("ok").and_then(|o| o.as_bool()) == Some(true) { + return "Canvas updated.".to_string(); + } + if let Some(err) = v.get("error").and_then(|e| e.as_str()) { + return format!("Error: {err}"); + } + } + body + } + Err(e) => format!("Error: {e}"), } } @@ -629,7 +624,7 @@ impl SproutMcpServer { /// Create a new workflow in a channel from a YAML definition. #[tool( name = "create_workflow", - description = "Create a new workflow in a channel from a YAML definition" + description = "Create a new workflow from a YAML definition. Steps need 'id' (not 'name'), and action fields are direct properties (not nested under 'params'). Triggers: message_posted, reaction_added, webhook." )] pub async fn create_workflow(&self, Parameters(p): Parameters) -> String { if uuid::Uuid::parse_str(&p.channel_id).is_err() { diff --git a/crates/sprout-relay/src/api/canvas.rs b/crates/sprout-relay/src/api/canvas.rs new file mode 100644 index 000000000..617546056 --- /dev/null +++ b/crates/sprout-relay/src/api/canvas.rs @@ -0,0 +1,160 @@ +//! Canvas REST API. +//! +//! Endpoints: +//! GET /api/channels/:channel_id/canvas — fetch the most recent canvas for a channel +//! PUT /api/channels/:channel_id/canvas — set/update the canvas for a channel +//! +//! Canvas events are Nostr events of kind KIND_CANVAS (40100) with an "h" tag +//! scoped to the channel UUID. The relay signs these events on behalf of the +//! authenticated user (same pattern as `messages.rs`). + +use std::sync::Arc; + +use axum::{ + extract::{Path, State}, + http::{HeaderMap, StatusCode}, + response::Json, +}; +use nostr::util::hex as nostr_hex; +use nostr::{EventBuilder, Kind, Tag}; +use serde::Deserialize; +use sprout_core::kind::KIND_CANVAS; +use sprout_db::event::EventQuery; + +use crate::handlers::event::dispatch_persistent_event; +use crate::state::AppState; + +use super::{api_error, check_channel_access, extract_auth_pubkey, internal_error}; + +// ── GET /api/channels/:channel_id/canvas ───────────────────────────────────── + +/// Fetch the most recent canvas for a channel. +/// +/// Returns the canvas content, author pubkey (hex), and updated_at timestamp +/// (Unix seconds) if a canvas exists, or `{"content": null}` if none has been set. +pub async fn get_canvas( + State(state): State>, + headers: HeaderMap, + Path(channel_id_str): Path, +) -> Result, (StatusCode, Json)> { + let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?; + + let channel_id = uuid::Uuid::parse_str(&channel_id_str) + .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel UUID"))?; + + check_channel_access(&state, channel_id, &pubkey_bytes).await?; + + // Query the events table for the most recent KIND_CANVAS event scoped to + // this channel. `channel_id` is stored as a column on the events row, so + // we can filter directly without scanning tags. + let q = EventQuery { + channel_id: Some(channel_id), + kinds: Some(vec![KIND_CANVAS as i32]), + pubkey: None, + since: None, + until: None, + limit: Some(1), + offset: None, + }; + + let events = state + .db + .query_events(&q) + .await + .map_err(|e| internal_error(&format!("db error: {e}")))?; + + match events.into_iter().next() { + Some(stored) => { + // Canvas events are relay-signed; the real author is in the first `p` tag. + let author_hex = stored + .event + .tags + .find(nostr::TagKind::SingleLetter( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::P), + )) + .and_then(|t| t.content().map(|s| s.to_string())) + .unwrap_or_else(|| nostr_hex::encode(stored.event.pubkey.serialize())); + let updated_at = stored.event.created_at.as_u64() as i64; + Ok(Json(serde_json::json!({ + "content": stored.event.content, + "updated_at": updated_at, + "author": author_hex, + }))) + } + None => Ok(Json(serde_json::json!({ "content": null }))), + } +} + +// ── PUT /api/channels/:channel_id/canvas ───────────────────────────────────── + +/// Request body for setting the canvas. +#[derive(Debug, Deserialize)] +pub struct SetCanvasBody { + /// New canvas content (Markdown or plain text). + pub content: String, +} + +/// Set or update the canvas for a channel. +/// +/// Creates a Nostr event of kind KIND_CANVAS signed by the relay keypair, +/// attributed to the authenticated user via a `p` tag. Fans out to WebSocket +/// subscribers so live clients receive the update immediately. +pub async fn set_canvas( + State(state): State>, + headers: HeaderMap, + Path(channel_id_str): Path, + Json(body): Json, +) -> Result, (StatusCode, Json)> { + let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?; + + let channel_id = uuid::Uuid::parse_str(&channel_id_str) + .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel UUID"))?; + + check_channel_access(&state, channel_id, &pubkey_bytes).await?; + + // Reject writes to archived channels (consistent with messages, metadata, etc.). + let channel = state + .db + .get_channel(channel_id) + .await + .map_err(|e| internal_error(&format!("db error: {e}")))?; + if channel.archived_at.is_some() { + return Err(api_error(StatusCode::FORBIDDEN, "channel is archived")); + } + + // Attribution: real author carried in a `p` tag; event is relay-signed. + let user_pubkey_hex = nostr_hex::encode(&pubkey_bytes); + + let tags: Vec = vec![ + // Real sender attribution (mirrors messages.rs pattern). + Tag::parse(&["p", &user_pubkey_hex]) + .map_err(|e| internal_error(&format!("tag build error: {e}")))?, + // NIP-29 channel scope tag. + Tag::parse(&["h", &channel_id.to_string()]) + .map_err(|e| internal_error(&format!("tag build error: {e}")))?, + ]; + + let kind = Kind::from(KIND_CANVAS as u16); + + let event = EventBuilder::new(kind, &body.content, tags) + .sign_with_keys(&state.relay_keypair) + .map_err(|e| internal_error(&format!("event signing error: {e}")))?; + + let event_id_hex = event.id.to_hex(); + + let (stored_event, was_inserted) = state + .db + .insert_event(&event, Some(channel_id)) + .await + .map_err(|e| internal_error(&format!("db error: {e}")))?; + + if was_inserted { + let _ = + dispatch_persistent_event(&state, &stored_event, KIND_CANVAS, &user_pubkey_hex).await; + } + + Ok(Json(serde_json::json!({ + "ok": true, + "event_id": event_id_hex, + }))) +} diff --git a/crates/sprout-relay/src/api/mod.rs b/crates/sprout-relay/src/api/mod.rs index 4b2e75a8f..794d72bd1 100644 --- a/crates/sprout-relay/src/api/mod.rs +++ b/crates/sprout-relay/src/api/mod.rs @@ -14,6 +14,8 @@ pub mod agents; /// Workflow approval grant/deny endpoints. pub mod approvals; +/// Canvas (shared document) endpoints. +pub mod canvas; /// Channel CRUD and membership endpoints. pub mod channels; /// Channel metadata endpoints (get, update, topic, purpose, archive). @@ -44,6 +46,7 @@ pub mod workflows; // Re-export all public handlers so router.rs can use `api::*_handler` unchanged. pub use agents::agents_handler; pub use approvals::{deny_approval, grant_approval}; +pub use canvas::{get_canvas, set_canvas}; pub use channels::{channels_handler, create_channel}; pub use channels_metadata::{ archive_channel_handler, delete_channel_handler, get_channel_handler, set_purpose_handler, diff --git a/crates/sprout-relay/src/router.rs b/crates/sprout-relay/src/router.rs index d64dca7ed..ef366d4cd 100644 --- a/crates/sprout-relay/src/router.rs +++ b/crates/sprout-relay/src/router.rs @@ -83,6 +83,11 @@ pub fn build_router(state: Arc) -> Router { "/api/channels/{channel_id}/unarchive", post(api::unarchive_channel_handler), ) + // Canvas routes + .route( + "/api/channels/{channel_id}/canvas", + get(api::get_canvas).put(api::set_canvas), + ) // Message + thread routes .route( "/api/channels/{channel_id}/messages", diff --git a/crates/sprout-test-client/Cargo.toml b/crates/sprout-test-client/Cargo.toml index 70be79cb1..6c1ecc627 100644 --- a/crates/sprout-test-client/Cargo.toml +++ b/crates/sprout-test-client/Cargo.toml @@ -8,6 +8,7 @@ repository.workspace = true description = "Integration test client and E2E test suite for Sprout" [dependencies] +anyhow = { workspace = true } sprout-core = { workspace = true } sprout-mcp = { workspace = true } nostr = { workspace = true } diff --git a/crates/sprout-test-client/src/bin/mention.rs b/crates/sprout-test-client/src/bin/mention.rs new file mode 100644 index 000000000..38732f686 --- /dev/null +++ b/crates/sprout-test-client/src/bin/mention.rs @@ -0,0 +1,36 @@ +//! Send an @mention event to a Sprout channel targeting a specific pubkey. +//! Usage: mention + +use nostr::{EventBuilder, Keys, Kind, Tag}; +use sprout_test_client::SproutTestClient; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let args: Vec = std::env::args().collect(); + if args.len() < 4 { + eprintln!("Usage: mention "); + std::process::exit(1); + } + let channel_id = &args[1]; + let target_pubkey = &args[2]; + let message = args[3..].join(" "); + + let url = std::env::var("SPROUT_RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".into()); + let keys = Keys::generate(); + println!("Sender pubkey: {}", keys.public_key().to_hex()); + + let mut client = SproutTestClient::connect(&url, &keys).await?; + + let h_tag = Tag::parse(&["h", channel_id])?; + let p_tag = Tag::parse(&["p", target_pubkey])?; + let event = + EventBuilder::new(Kind::Custom(40001), message, [h_tag, p_tag]).sign_with_keys(&keys)?; + + let ok = client.send_event(event).await?; + if ok.accepted { + println!("✅ @mention sent: {}", ok.event_id); + } else { + eprintln!("❌ Rejected: {}", ok.message); + } + Ok(()) +} diff --git a/crates/sprout-test-client/tests/e2e_mcp.rs b/crates/sprout-test-client/tests/e2e_mcp.rs index 72a224b7b..9f6aefc53 100644 --- a/crates/sprout-test-client/tests/e2e_mcp.rs +++ b/crates/sprout-test-client/tests/e2e_mcp.rs @@ -15,25 +15,18 @@ //! //! # Auth //! -//! The MCP server generates an ephemeral keypair on startup (no `SPROUT_PRIVATE_KEY` -//! needed). In dev mode (`require_auth_token=false`) the relay accepts any -//! authenticated NIP-42 client. -//! -//! # Channel setup -//! -//! Tests use the pre-seeded open channels that are stable across relay restarts. +//! Each test generates a known keypair, creates a fresh channel via the REST API +//! (so the keypair is the channel owner and member), then passes the private key +//! as `SPROUT_PRIVATE_KEY` to the MCP server subprocess. This ensures the MCP +//! server uses a stable identity that has access to the channels under test. use std::io::{BufRead, BufReader, Write}; use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; use std::time::Duration; +use nostr::{Keys, ToBech32}; use serde_json::{json, Value}; -// ── Seeded channel IDs (UUID5-derived, stable across relay restarts) ────────── - -const CHANNEL_GENERAL: &str = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; -const CHANNEL_ENGINEERING: &str = "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9"; - // ── Helpers ─────────────────────────────────────────────────────────────────── /// WebSocket relay URL (e.g. `ws://localhost:3001`). @@ -41,11 +34,63 @@ fn relay_ws_url() -> String { std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3001".to_string()) } +/// HTTP relay URL derived from the WebSocket URL. +fn relay_http_url() -> String { + relay_ws_url() + .replace("wss://", "https://") + .replace("ws://", "http://") + .trim_end_matches('/') + .to_string() +} + +/// Generate a fresh Nostr keypair for a test run. +fn generate_test_keys() -> Keys { + Keys::generate() +} + +/// Encode the secret key as an `nsec1…` bech32 string. +fn nsec_from_keys(keys: &Keys) -> String { + keys.secret_key().to_bech32().expect("bech32 encode nsec") +} + +/// Create a fresh channel via the REST API using the given keypair as the owner. +/// +/// Returns the new channel's UUID string. The creating pubkey is automatically +/// added as a member, so the MCP server (using the same keypair) will have +/// access to it. +async fn create_channel_for_test(keys: &Keys, name: &str) -> String { + let client = reqwest::Client::new(); + let url = format!("{}/api/channels", relay_http_url()); + let resp = client + .post(&url) + .header("X-Pubkey", keys.public_key().to_hex()) + .json(&serde_json::json!({ + "name": name, + "channel_type": "stream", + "visibility": "open", + })) + .send() + .await + .expect("create channel request failed"); + assert_eq!( + resp.status(), + 201, + "channel creation failed: {}", + resp.text().await.unwrap_or_default() + ); + let body: serde_json::Value = resp.json().await.expect("parse channel response"); + body["id"] + .as_str() + .expect("channel id in response") + .to_string() +} + /// Spawn the MCP server as a subprocess with stdin/stdout piped. /// -/// The server connects to the relay and performs NIP-42 auth on startup. -/// We give it a few seconds to complete the handshake before sending requests. -fn spawn_mcp_server() -> Child { +/// The server connects to the relay and performs NIP-42 auth on startup using +/// the provided keypair (passed via `SPROUT_PRIVATE_KEY`). +fn spawn_mcp_server(keys: &Keys) -> Child { + let nsec = nsec_from_keys(keys); Command::new("cargo") .args([ "run", @@ -56,6 +101,7 @@ fn spawn_mcp_server() -> Child { "--", ]) .env("SPROUT_RELAY_URL", relay_ws_url()) + .env("SPROUT_PRIVATE_KEY", &nsec) // Suppress verbose startup logs so they don't pollute stderr output. .env("RUST_LOG", "error") .stdin(Stdio::piped()) @@ -74,9 +120,9 @@ struct McpSession { } impl McpSession { - /// Spawn the MCP server and wait for it to connect to the relay. - async fn start() -> Self { - let mut child = spawn_mcp_server(); + /// Spawn the MCP server with the given keypair and wait for it to connect. + async fn start(keys: &Keys) -> Self { + let mut child = spawn_mcp_server(keys); let stdin = child.stdin.take().expect("stdin not piped"); let stdout = child.stdout.take().expect("stdout not piped"); let reader = BufReader::new(stdout); @@ -200,11 +246,12 @@ impl McpSession { // ── Tests ───────────────────────────────────────────────────────────────────── /// Spawn the MCP server, complete the initialize handshake, and verify that -/// all 16 expected tools are listed by `tools/list`. +/// all 36 expected tools are listed by `tools/list`. #[tokio::test] #[ignore] async fn test_mcp_initialize_and_list_tools() { - let mut session = McpSession::start().await; + let keys = generate_test_keys(); + let mut session = McpSession::start(&keys).await; // ── initialize ────────────────────────────────────────────────────────── let init_resp = session.initialize(); @@ -248,8 +295,8 @@ async fn test_mcp_initialize_and_list_tools() { assert_eq!( tools.len(), - 16, - "expected exactly 16 tools, got {}. Tools: {:?}", + 36, + "expected exactly 36 tools, got {}. Tools: {:?}", tools.len(), tools .iter() @@ -277,6 +324,26 @@ async fn test_mcp_initialize_and_list_tools() { "get_feed", "get_feed_mentions", "get_feed_actions", + "add_channel_member", + "remove_channel_member", + "list_channel_members", + "join_channel", + "leave_channel", + "get_channel", + "update_channel", + "set_channel_topic", + "set_channel_purpose", + "archive_channel", + "unarchive_channel", + "send_reply", + "get_thread", + "open_dm", + "add_dm_member", + "list_dms", + "add_reaction", + "remove_reaction", + "get_reactions", + "set_profile", ]; for expected in &expected_tools { @@ -302,11 +369,19 @@ async fn test_mcp_initialize_and_list_tools() { session.stop(); } -/// Call `list_channels` via MCP and verify the response contains the seeded channels. +/// Call `list_channels` via MCP and verify the response contains the channel +/// we created for this test run. #[tokio::test] #[ignore] async fn test_mcp_list_channels() { - let mut session = McpSession::start().await; + let keys = generate_test_keys(); + let channel_id = create_channel_for_test( + &keys, + &format!("mcp-e2e-list-{}", uuid::Uuid::new_v4().simple()), + ) + .await; + + let mut session = McpSession::start(&keys).await; session.initialize(); let resp = session.call_tool("list_channels", json!({})); @@ -335,12 +410,12 @@ async fn test_mcp_list_channels() { "list_channels returned an empty channel list" ); - // Verify the seeded general channel is present. + // Verify the channel we just created is present. let ids: Vec<&str> = channels.iter().filter_map(|ch| ch["id"].as_str()).collect(); assert!( - ids.contains(&CHANNEL_GENERAL), - "expected seeded 'general' channel (id={CHANNEL_GENERAL}) in list, got: {ids:?}" + ids.contains(&channel_id.as_str()), + "expected created channel (id={channel_id}) in list, got: {ids:?}" ); // Each channel must have the required fields. @@ -361,7 +436,14 @@ async fn test_mcp_list_channels() { #[tokio::test] #[ignore] async fn test_mcp_send_and_read_message() { - let mut session = McpSession::start().await; + let keys = generate_test_keys(); + let channel_id = create_channel_for_test( + &keys, + &format!("mcp-e2e-msg-{}", uuid::Uuid::new_v4().simple()), + ) + .await; + + let mut session = McpSession::start(&keys).await; session.initialize(); // Generate a unique message content so we can identify it in history. @@ -372,7 +454,7 @@ async fn test_mcp_send_and_read_message() { let send_resp = session.call_tool( "send_message", json!({ - "channel_id": CHANNEL_GENERAL, + "channel_id": channel_id, "content": content, }), ); @@ -384,8 +466,8 @@ async fn test_mcp_send_and_read_message() { let send_text = McpSession::tool_text(&send_resp); assert!( - send_text.contains("Message sent"), - "expected 'Message sent' in send_message response, got: {send_text}" + send_text.contains("event_id"), + "expected 'event_id' in send_message response, got: {send_text}" ); assert!( !send_text.starts_with("Error"), @@ -399,7 +481,7 @@ async fn test_mcp_send_and_read_message() { let history_resp = session.call_tool( "get_channel_history", json!({ - "channel_id": CHANNEL_GENERAL, + "channel_id": channel_id, "limit": 20, }), ); @@ -415,9 +497,13 @@ async fn test_mcp_send_and_read_message() { "get_channel_history returned an error: {history_text}" ); - let events: Vec = serde_json::from_str(&history_text).unwrap_or_else(|e| { - panic!("get_channel_history response is not valid JSON array: {e}\n{history_text}") + let history_json: Value = serde_json::from_str(&history_text).unwrap_or_else(|e| { + panic!("get_channel_history response is not valid JSON: {e}\n{history_text}") }); + let events = history_json + .get("messages") + .and_then(|m| m.as_array()) + .unwrap_or_else(|| panic!("expected 'messages' array in response: {history_text}")); let found = events .iter() @@ -438,7 +524,14 @@ async fn test_mcp_send_and_read_message() { #[tokio::test] #[ignore] async fn test_mcp_search() { - let mut session = McpSession::start().await; + let keys = generate_test_keys(); + let channel_id = create_channel_for_test( + &keys, + &format!("mcp-e2e-search-{}", uuid::Uuid::new_v4().simple()), + ) + .await; + + let mut session = McpSession::start(&keys).await; session.initialize(); // Generate a unique token that will appear in the search index. @@ -449,7 +542,7 @@ async fn test_mcp_search() { let send_resp = session.call_tool( "send_message", json!({ - "channel_id": CHANNEL_GENERAL, + "channel_id": channel_id, "content": content, }), ); @@ -461,8 +554,8 @@ async fn test_mcp_search() { let send_text = McpSession::tool_text(&send_resp); assert!( - send_text.contains("Message sent"), - "expected 'Message sent', got: {send_text}" + send_text.contains("event_id"), + "expected 'event_id' in send_message response, got: {send_text}" ); // Wait for the search index to catch up. @@ -485,7 +578,7 @@ async fn test_mcp_search() { let history_resp = session.call_tool( "get_channel_history", json!({ - "channel_id": CHANNEL_GENERAL, + "channel_id": channel_id, "limit": 50, }), ); @@ -501,9 +594,13 @@ async fn test_mcp_search() { "get_channel_history returned an error: {history_text}" ); - let events: Vec = serde_json::from_str(&history_text).unwrap_or_else(|e| { + let history_json: Value = serde_json::from_str(&history_text).unwrap_or_else(|e| { panic!("get_channel_history response is not valid JSON: {e}\n{history_text}") }); + let events = history_json + .get("messages") + .and_then(|m| m.as_array()) + .unwrap_or_else(|| panic!("expected 'messages' array in response: {history_text}")); let found = events .iter() @@ -524,7 +621,14 @@ async fn test_mcp_search() { #[tokio::test] #[ignore] async fn test_mcp_create_and_trigger_workflow() { - let mut session = McpSession::start().await; + let keys = generate_test_keys(); + let channel_id = create_channel_for_test( + &keys, + &format!("mcp-e2e-wf-{}", uuid::Uuid::new_v4().simple()), + ) + .await; + + let mut session = McpSession::start(&keys).await; session.initialize(); // A minimal webhook-triggered workflow (no external side effects). @@ -543,7 +647,7 @@ async fn test_mcp_create_and_trigger_workflow() { let create_resp = session.call_tool( "create_workflow", json!({ - "channel_id": CHANNEL_ENGINEERING, + "channel_id": channel_id, "yaml_definition": yaml_definition, }), ); @@ -555,9 +659,9 @@ async fn test_mcp_create_and_trigger_workflow() { let create_text = McpSession::tool_text(&create_resp); if create_text.starts_with("Error") { - // The MCP server uses an ephemeral keypair that may not exist in the - // users table (FK constraint on workflows.owner_pubkey). This is a - // test-environment limitation, not a bug. Skip gracefully. + // The MCP server uses a keypair that may not exist in the users table + // (FK constraint on workflows.owner_pubkey). This is a test-environment + // limitation, not a bug. Skip gracefully. eprintln!("Skipping workflow test — MCP keypair not in users table: {create_text}"); session.stop(); return; @@ -583,7 +687,7 @@ async fn test_mcp_create_and_trigger_workflow() { let list_resp = session.call_tool( "list_workflows", json!({ - "channel_id": CHANNEL_ENGINEERING, + "channel_id": channel_id, }), ); @@ -701,7 +805,8 @@ async fn test_mcp_create_and_trigger_workflow() { #[tokio::test] #[ignore] async fn test_mcp_feed_tools() { - let mut session = McpSession::start().await; + let keys = generate_test_keys(); + let mut session = McpSession::start(&keys).await; session.initialize(); // ── get_feed ──────────────────────────────────────────────────────────── @@ -776,7 +881,14 @@ async fn test_mcp_feed_tools() { #[tokio::test] #[ignore] async fn test_mcp_canvas_set_and_get() { - let mut session = McpSession::start().await; + let keys = generate_test_keys(); + let channel_id = create_channel_for_test( + &keys, + &format!("mcp-e2e-canvas-{}", uuid::Uuid::new_v4().simple()), + ) + .await; + + let mut session = McpSession::start(&keys).await; session.initialize(); let unique_content = format!("MCP E2E canvas test: {}", uuid::Uuid::new_v4().simple()); @@ -785,7 +897,7 @@ async fn test_mcp_canvas_set_and_get() { let set_resp = session.call_tool( "set_canvas", json!({ - "channel_id": CHANNEL_GENERAL, + "channel_id": channel_id, "content": unique_content, }), ); @@ -796,9 +908,9 @@ async fn test_mcp_canvas_set_and_get() { ); let set_text = McpSession::tool_text(&set_resp); - assert!( - set_text.contains("Canvas updated"), - "expected 'Canvas updated' in set_canvas response, got: {set_text}" + assert_eq!( + set_text, "Canvas updated.", + "expected 'Canvas updated.' from set_canvas, got: {set_text}" ); // Small delay for the event to propagate. @@ -808,7 +920,7 @@ async fn test_mcp_canvas_set_and_get() { let get_resp = session.call_tool( "get_canvas", json!({ - "channel_id": CHANNEL_GENERAL, + "channel_id": channel_id, }), ); @@ -818,9 +930,9 @@ async fn test_mcp_canvas_set_and_get() { ); let get_text = McpSession::tool_text(&get_resp); - assert!( - get_text.contains(&unique_content), - "expected canvas content '{unique_content}' in get_canvas response, got: {get_text}" + assert_eq!( + get_text, unique_content, + "expected exact canvas content '{unique_content}' from get_canvas, got: {get_text}" ); session.stop(); diff --git a/crates/sprout-test-client/tests/e2e_relay.rs b/crates/sprout-test-client/tests/e2e_relay.rs index ef6e90d33..ef8c9ed11 100644 --- a/crates/sprout-test-client/tests/e2e_relay.rs +++ b/crates/sprout-test-client/tests/e2e_relay.rs @@ -31,8 +31,33 @@ fn sub_id(name: &str) -> String { format!("e2e-{name}-{}", uuid::Uuid::new_v4()) } -fn channel_id(name: &str) -> String { - format!("test-channel-{name}-{}", uuid::Uuid::new_v4()) +fn relay_http_url() -> String { + relay_url() + .replace("wss://", "https://") + .replace("ws://", "http://") + .trim_end_matches('/') + .to_string() +} + +/// Create a real channel in the DB via REST so the relay accepts events for it. +async fn create_test_channel(keys: &Keys) -> String { + let client = reqwest::Client::new(); + let url = format!("{}/api/channels", relay_http_url()); + let pubkey_hex = keys.public_key().to_hex(); + let resp = client + .post(&url) + .header("X-Pubkey", &pubkey_hex) + .json(&serde_json::json!({ + "name": format!("relay-e2e-{}", uuid::Uuid::new_v4()), + "channel_type": "stream", + "visibility": "open", + })) + .send() + .await + .expect("create channel request"); + assert_eq!(resp.status(), 201, "channel creation failed"); + let body: serde_json::Value = resp.json().await.expect("parse channel response"); + body["id"].as_str().expect("channel id").to_string() } #[tokio::test] @@ -52,11 +77,11 @@ async fn test_connect_and_authenticate() { #[ignore] async fn test_send_event_and_receive_via_subscription() { let url = relay_url(); - let channel = channel_id("send-recv"); let kind: u16 = 40001; let keys_a = Keys::generate(); let keys_b = Keys::generate(); + let channel = create_test_channel(&keys_a).await; let mut client_a = SproutTestClient::connect(&url, &keys_a) .await @@ -111,11 +136,11 @@ async fn test_send_event_and_receive_via_subscription() { #[ignore] async fn test_subscription_filters_by_kind() { let url = relay_url(); - let channel = channel_id("filter-kind"); let target_kind: u16 = 40001; let other_kind: u16 = 40002; let keys = Keys::generate(); + let channel = create_test_channel(&keys).await; let mut client = SproutTestClient::connect(&url, &keys) .await @@ -183,10 +208,10 @@ async fn test_subscription_filters_by_kind() { #[ignore] async fn test_close_subscription_stops_delivery() { let url = relay_url(); - let channel = channel_id("close-sub"); let kind: u16 = 40001; let keys = Keys::generate(); + let channel = create_test_channel(&keys).await; let mut client = SproutTestClient::connect(&url, &keys) .await .expect("connect"); @@ -275,10 +300,10 @@ async fn test_unauthenticated_rejected() { #[ignore] async fn test_multiple_concurrent_clients() { let url = relay_url(); - let channel = channel_id("multi-client"); let kind: u16 = 40001; let keys: Vec = (0..3).map(|_| Keys::generate()).collect(); + let channel = create_test_channel(&keys[0]).await; let mut clients: Vec = futures_util::future::try_join_all(keys.iter().map(|k| SproutTestClient::connect(&url, k))) @@ -332,10 +357,10 @@ async fn test_multiple_concurrent_clients() { #[ignore] async fn test_stored_events_returned_before_eose() { let url = relay_url(); - let channel = channel_id("stored-events"); let kind: u16 = 40001; let keys = Keys::generate(); + let channel = create_test_channel(&keys).await; let mut client = SproutTestClient::connect(&url, &keys) .await .expect("connect"); @@ -376,10 +401,10 @@ async fn test_stored_events_returned_before_eose() { #[ignore] async fn test_ephemeral_event_not_stored() { let url = relay_url(); - let channel = channel_id("ephemeral"); let ephemeral_kind: u16 = 20001; let keys = Keys::generate(); + let channel = create_test_channel(&keys).await; let mut client = SproutTestClient::connect(&url, &keys) .await .expect("connect"); @@ -566,10 +591,10 @@ async fn test_nip11_relay_info() { #[ignore] async fn test_pubkey_mismatch_rejected() { let url = relay_url(); - let channel = channel_id("pubkey-mismatch"); let keys_a = Keys::generate(); let keys_b = Keys::generate(); + let channel = create_test_channel(&keys_a).await; let mut client = SproutTestClient::connect(&url, &keys_a) .await @@ -592,10 +617,10 @@ async fn test_pubkey_mismatch_rejected() { #[ignore] async fn test_eose_sent_for_empty_subscription() { let url = relay_url(); - let channel = channel_id("empty-eose"); let kind: u16 = 40001; let keys = Keys::generate(); + let channel = create_test_channel(&keys).await; let mut client = SproutTestClient::connect(&url, &keys) .await .expect("connect"); diff --git a/crates/sprout-test-client/tests/e2e_rest_api.rs b/crates/sprout-test-client/tests/e2e_rest_api.rs index 8d99815f0..54c10a5d7 100644 --- a/crates/sprout-test-client/tests/e2e_rest_api.rs +++ b/crates/sprout-test-client/tests/e2e_rest_api.rs @@ -20,10 +20,10 @@ //! //! # Channel setup //! -//! The relay exposes REST endpoints to list and create channels. Tests use the -//! pre-seeded open channels (`general`, `agents`, `engineering`, etc.) for read -//! operations and create temporary channels for write coverage when needed. -//! Some tests also send messages via WebSocket to set up search / feed data. +//! Each test creates its own channels dynamically via `POST /api/channels`. +//! No pre-seeded data is required — tests are fully self-contained and work +//! against a fresh database. Some tests also send messages via WebSocket to +//! set up search / feed data. use std::time::Duration; @@ -79,13 +79,6 @@ async fn authed_post_json( .unwrap_or_else(|e| panic!("HTTP POST {url} failed: {e}")) } -/// Known open channel IDs seeded in the dev database. -/// -/// These are UUID5-derived from the channel name and are stable across relay -/// restarts as long as the seed data uses the same namespace + name inputs. -const CHANNEL_GENERAL: &str = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; -const CHANNEL_ENGINEERING: &str = "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9"; - // ── Channel tests ───────────────────────────────────────────────────────────── /// GET /api/channels returns a non-empty list with the expected fields. @@ -97,6 +90,26 @@ async fn test_list_channels_returns_expected_fields() { let pubkey_hex = keys.public_key().to_hex(); let url = format!("{}/api/channels", relay_http_url()); + + // Ensure at least one channel exists (fresh DB may be empty). + let seed_resp = authed_post_json( + &client, + &url, + &pubkey_hex, + serde_json::json!({ + "name": format!("list-test-{}", uuid::Uuid::new_v4()), + "channel_type": "stream", + "visibility": "open", + "description": "Seed channel for list test" + }), + ) + .await; + assert_eq!( + seed_resp.status(), + 201, + "bootstrap channel creation must succeed" + ); + let resp = authed_get(&client, &url, &pubkey_hex).await; assert_eq!(resp.status(), 200, "expected 200 OK from /api/channels"); @@ -190,6 +203,30 @@ async fn test_channel_visibility_open_channels_visible_to_all() { let url = format!("{}/api/channels", relay_http_url()); + // Create an open channel as keys_a so there is at least one open channel to verify. + let open_channel_name = format!("e2e-open-{}", uuid::Uuid::new_v4().simple()); + let create_resp = authed_post_json( + &client, + &url, + &keys_a.public_key().to_hex(), + serde_json::json!({ + "name": open_channel_name, + "channel_type": "stream", + "visibility": "open" + }), + ) + .await; + assert_eq!( + create_resp.status(), + 201, + "failed to create open channel for visibility test" + ); + let created_channel: serde_json::Value = create_resp.json().await.expect("JSON"); + let open_channel_id = created_channel["id"] + .as_str() + .expect("created channel must have id") + .to_string(); + let resp_a = authed_get(&client, &url, &keys_a.public_key().to_hex()).await; let resp_b = authed_get(&client, &url, &keys_b.public_key().to_hex()).await; @@ -199,7 +236,7 @@ async fn test_channel_visibility_open_channels_visible_to_all() { let channels_a: Vec = resp_a.json().await.expect("JSON"); let channels_b: Vec = resp_b.json().await.expect("JSON"); - // Both users should see the same set of open channels. + // Both users should see the open channel we just created. let ids_a: std::collections::HashSet = channels_a .iter() .filter_map(|c| c["id"].as_str().map(|s| s.to_string())) @@ -209,19 +246,13 @@ async fn test_channel_visibility_open_channels_visible_to_all() { .filter_map(|c| c["id"].as_str().map(|s| s.to_string())) .collect(); - assert_eq!( - ids_a, ids_b, - "two fresh users should see the same set of open channels" - ); - - // The well-known seeded channels must be present. assert!( - ids_a.contains(CHANNEL_GENERAL), - "expected seeded 'general' channel (id={CHANNEL_GENERAL})" + ids_a.contains(&open_channel_id), + "keys_a should see the open channel we created (id={open_channel_id})" ); assert!( - ids_a.contains(CHANNEL_ENGINEERING), - "expected seeded 'engineering' channel (id={CHANNEL_ENGINEERING})" + ids_b.contains(&open_channel_id), + "keys_b (unrelated user) should also see the open channel (id={open_channel_id})" ); } @@ -235,14 +266,36 @@ async fn test_rest_send_message_reaches_websocket_channel_subscriptions() { let poster_keys = Keys::generate(); let ws_url = relay_ws_url(); + // Create a fresh open channel for this test. + let channels_url = format!("{}/api/channels", relay_http_url()); + let channel_name = format!("e2e-rest-live-{}", uuid::Uuid::new_v4().simple()); + let create_resp = authed_post_json( + &client, + &channels_url, + &poster_keys.public_key().to_hex(), + serde_json::json!({ + "name": channel_name, + "channel_type": "stream", + "visibility": "open" + }), + ) + .await; + assert_eq!(create_resp.status(), 201, "failed to create test channel"); + let created: serde_json::Value = create_resp.json().await.expect("JSON"); + let channel_id = created["id"] + .as_str() + .expect("channel must have id") + .to_string(); + let mut subscriber = SproutTestClient::connect(&ws_url, &subscriber_keys) .await .expect("WebSocket connect failed"); let sid = format!("rest-live-{}", uuid::Uuid::new_v4().simple()); - let filter = Filter::new() - .kind(Kind::Custom(40001)) - .custom_tag(SingleLetterTag::lowercase(Alphabet::H), [CHANNEL_GENERAL]); + let filter = Filter::new().kind(Kind::Custom(40001)).custom_tag( + SingleLetterTag::lowercase(Alphabet::H), + [channel_id.as_str()], + ); subscriber .subscribe(&sid, vec![filter]) @@ -254,11 +307,7 @@ async fn test_rest_send_message_reaches_websocket_channel_subscriptions() { .expect("EOSE failed"); let content = format!("E2E REST live message: {}", uuid::Uuid::new_v4().simple()); - let url = format!( - "{}/api/channels/{}/messages", - relay_http_url(), - CHANNEL_GENERAL - ); + let url = format!("{}/api/channels/{}/messages", relay_http_url(), channel_id); let resp = authed_post_json( &client, &url, @@ -286,7 +335,7 @@ async fn test_rest_send_message_reaches_websocket_channel_subscriptions() { assert!( tags.iter() - .any(|tag| tag.len() >= 2 && tag[0] == "h" && tag[1] == CHANNEL_GENERAL), + .any(|tag| tag.len() >= 2 && tag[0] == "h" && tag[1] == channel_id), "REST-created message is missing the channel h tag: {tags:?}" ); assert!( @@ -363,6 +412,27 @@ async fn test_search_returns_indexed_event() { let pubkey_hex = keys.public_key().to_hex(); let ws_url = relay_ws_url(); + // Create a channel for this test so the event is accepted by the relay. + let channels_url = format!("{}/api/channels", relay_http_url()); + let channel_name = format!("e2e-search-{}", uuid::Uuid::new_v4().simple()); + let create_resp = authed_post_json( + &client, + &channels_url, + &pubkey_hex, + serde_json::json!({ + "name": channel_name, + "channel_type": "stream", + "visibility": "open" + }), + ) + .await; + assert_eq!(create_resp.status(), 201, "failed to create test channel"); + let created: serde_json::Value = create_resp.json().await.expect("JSON"); + let channel_id = created["id"] + .as_str() + .expect("channel must have id") + .to_string(); + let unique_token = format!("e2e-search-{}", uuid::Uuid::new_v4().simple()); let content = format!("E2E REST search test marker: {unique_token}"); @@ -370,8 +440,8 @@ async fn test_search_returns_indexed_event() { .await .expect("WebSocket connect failed"); - let e_tag = Tag::parse(&["e", CHANNEL_GENERAL]).expect("tag parse failed"); - let event = nostr::EventBuilder::new(Kind::Custom(40001), &content, [e_tag]) + let h_tag = Tag::parse(&["h", &channel_id]).expect("tag parse failed"); + let event = nostr::EventBuilder::new(Kind::Custom(40001), &content, [h_tag]) .sign_with_keys(&keys) .expect("event sign failed"); @@ -673,7 +743,28 @@ async fn test_feed_returns_activity() { return; } - // Send a message to an open channel so there is activity to return. + // Create a channel for this test so the event is accepted by the relay. + let channels_url = format!("{}/api/channels", relay_http_url()); + let channel_name = format!("e2e-feed-{}", uuid::Uuid::new_v4().simple()); + let create_resp = authed_post_json( + &client, + &channels_url, + &pubkey_hex, + serde_json::json!({ + "name": channel_name, + "channel_type": "stream", + "visibility": "open" + }), + ) + .await; + assert_eq!(create_resp.status(), 201, "failed to create test channel"); + let created_channel: serde_json::Value = create_resp.json().await.expect("JSON"); + let channel_id = created_channel["id"] + .as_str() + .expect("channel must have id") + .to_string(); + + // Send a message to the open channel so there is activity to return. let unique_token = format!("e2e-feed-{}", uuid::Uuid::new_v4().simple()); let content = format!("E2E feed test: {unique_token}"); @@ -681,8 +772,8 @@ async fn test_feed_returns_activity() { .await .expect("WebSocket connect failed"); - let e_tag = Tag::parse(&["e", CHANNEL_GENERAL]).expect("tag parse failed"); - let event = nostr::EventBuilder::new(Kind::Custom(40001), &content, [e_tag]) + let h_tag = Tag::parse(&["h", &channel_id]).expect("tag parse failed"); + let event = nostr::EventBuilder::new(Kind::Custom(40001), &content, [h_tag]) .sign_with_keys(&keys) .expect("event sign failed");