Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,102 @@ docker compose down # stop services, keep data

---

## Testing Projects

Projects group channels and provide shared context to agents. The feature spans
`sprout-core`, `sprout-db`, `sprout-sdk`, `sprout-relay`, and `sprout-mcp`.

### Automated Tests

```bash
# SDK builder tests (no infrastructure needed)
cargo test -p sprout-sdk -- project

# Relay ingest classification + d-tag extraction tests
cargo test -p sprout-relay -- project
cargo test -p sprout-relay -- extract_d_tag_uuid

# DB CRUD tests (require running Postgres)
cargo test -p sprout-db -- project --ignored
```

**SDK tests** (9 tests) verify event construction:
- `create_project_happy_path` / `_all_fields` / `_no_repos` — kind 50001, d-tag, name tag, optional fields
- `update_project_partial` / `_clear_repos` / `_no_fields_rejected` — kind 50002, partial updates, sentinel tag for repo clearing
- `delete_project_happy_path` — kind 50003, d-tag only
- `create_channel_with_project` / `_without_project` — `["project", uuid]` tag presence

**Relay tests** (6 tests) verify ingest pipeline classification:
- `project_kinds_require_channels_write` — 50001-50003 require `ChannelsWrite` scope
- `project_kinds_are_global_only` — projects are not channel-scoped
- `project_kinds_not_channel_scoped` — no h-tag required
- `extract_d_tag_uuid_valid` / `_missing` / `_invalid` — d-tag UUID extraction

**DB tests** (6 tests, `#[ignore]` — require Postgres):
- `create_and_get_project` — round-trip create + fetch
- `list_projects_by_creator` — filtering by pubkey
- `update_project_partial` — partial field updates
- `update_project_repos` — set and clear JSONB repo_urls
- `soft_delete_project` — soft delete, verify not found
- `archive_and_unarchive_project` — archive round-trip

### Manual Testing with MCP Tools

With the relay running, use the MCP tools via `sprout-mcp-server`:

```bash
# Create a project
# MCP tool: create_project
# name: "my-project"
# environment: "local"
# description: "Test project"
# repo_urls: ["https://github.com/example/repo"]

# List all projects
# MCP tool: list_projects

# Get a specific project
# MCP tool: get_project
# project_id: "<uuid from create>"

# Update a project
# MCP tool: update_project
# project_id: "<uuid>"
# name: "renamed-project"
# prompt: "You are a helpful coding assistant"

# Delete a project
# MCP tool: delete_project
# project_id: "<uuid>"
```

### Channel-Project Association

Create a channel linked to a project:

```bash
# MCP tool: create_channel
# name: "project-channel"
# channel_type: "stream"
# visibility: "open"
# project_id: "<project-uuid>"

# Verify via REST API:
curl -s -H "X-Pubkey: $USER_PK" \
http://localhost:3000/api/projects/<project-uuid>/channels
```

### Validation Checks

- **Environment validation**: only `"local"` and `"blox"` are accepted. Other values
are rejected at ingest time (kind 50001) and update time (kind 50002).
- **Empty update guard**: `update_project` with no fields returns an error from the SDK.
- **Repo clearing**: to clear repos, pass an empty `repo_urls: []`. The SDK emits a
sentinel `["repo", ""]` tag so the relay knows to set the field to `[]` rather than
ignoring it.

---

## Troubleshooting

| Symptom | Cause | Fix |
Expand Down
8 changes: 8 additions & 0 deletions VISION.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,14 @@ Beyond chat: channels are workspaces.

---

## Projects

A lightweight grouping that gives channels shared context. A project references repos (by git remote URL), carries shared instructions for agents (the project prompt), and specifies the compute environment (local dev laptop or remote Blox instance). Channels optionally belong to a project. When they do, agents automatically receive the project's context — eliminating manual setup per channel.

Projects are relay-stored Nostr events (kinds 50001-50003), accessible from any client or agent runtime via REST API and MCP tools. They support all three compute topologies: local agent + local code, local/remote agent + Blox code, and fully remote (mobile-initiated).

---

## Agent CLI

`sprout-cli` is a 48-command agent-first CLI covering the full MCP surface. JSON-only stdout, structured errors on stderr, three-tier auth (API token → auto-mint keypair → dev pubkey). Agents can script the entire platform without a GUI.
Expand Down
1 change: 1 addition & 0 deletions crates/sprout-acp/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -770,6 +770,7 @@ async fn tokio_main() -> Result<()> {
context_message_limit: config.context_message_limit,
max_turns_per_session: config.max_turns_per_session,
permission_mode: config.permission_mode,
project_cache: tokio::sync::RwLock::new(HashMap::new()),
});

// ── Step 6: Heartbeat timer ───────────────────────────────────────────────
Expand Down
80 changes: 78 additions & 2 deletions crates/sprout-acp/src/pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ use crate::queue::{
ContextMessage, ConversationContext, FlushBatch, PromptChannelInfo, PromptProfile,
PromptProfileLookup,
};
use crate::relay::{ChannelInfo, RestClient};
use crate::relay::{ChannelInfo, ProjectInfo, RestClient};

// ── FlushBatch Clone note ─────────────────────────────────────────────────────
// FlushBatch and BatchEvent derive Clone (added in queue.rs) so we can store
Expand Down Expand Up @@ -188,6 +188,8 @@ pub struct PromptContext {
pub max_turns_per_session: u32,
/// Permission mode to apply after session creation. `Default` = skip.
pub permission_mode: PermissionMode,
/// Lazily-populated project metadata cache. Keyed by project UUID.
pub project_cache: tokio::sync::RwLock<HashMap<Uuid, ProjectInfo>>,
}

// ── AgentPool impl ────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -812,6 +814,7 @@ pub async fn run_prompt_task(
Some(ci) => Some(PromptChannelInfo {
name: ci.name.clone(),
channel_type: ci.channel_type.clone(),
project_id: ci.project_id,
}),
None => fetch_channel_info(b.channel_id, &ctx.rest_client).await,
};
Expand All @@ -822,6 +825,16 @@ pub async fn run_prompt_task(
None
};

let project_info = if let Some(ref ci) = channel_info {
if let Some(pid) = ci.project_id {
fetch_or_cache_project(pid, &ctx).await
} else {
None
}
} else {
None
};

let profile_lookup =
fetch_prompt_profile_lookup(b, conversation_context.as_ref(), &ctx.rest_client).await;

Expand All @@ -831,6 +844,7 @@ pub async fn run_prompt_task(
channel_info.as_ref(),
conversation_context.as_ref(),
profile_lookup.as_ref(),
project_info.as_ref(),
)
} else {
// Should not happen — batch is None only for heartbeats which have prompt_text.
Expand Down Expand Up @@ -1140,7 +1154,15 @@ async fn fetch_channel_info(channel_id: Uuid, rest: &RestClient) -> Option<Promp
.and_then(|v| v.as_str())
.unwrap_or("stream")
.to_string();
Some(PromptChannelInfo { name, channel_type })
let project_id = json
.get("project_id")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<uuid::Uuid>().ok());
Some(PromptChannelInfo {
name,
channel_type,
project_id,
})
}
Ok(Err(e)) => {
tracing::debug!(
Expand All @@ -1161,6 +1183,60 @@ async fn fetch_channel_info(channel_id: Uuid, rest: &RestClient) -> Option<Promp
.await
}

/// Fetch project info from cache or relay REST API.
async fn fetch_or_cache_project(project_id: Uuid, ctx: &PromptContext) -> Option<ProjectInfo> {
// Check cache first (read lock).
{
let cache = ctx.project_cache.read().await;
if let Some(info) = cache.get(&project_id) {
return Some(info.clone());
}
}

// Fetch from relay.
let path = format!("/api/projects/{}", project_id);
let json = match timeout(CONTEXT_FETCH_TIMEOUT, ctx.rest_client.get_json(&path)).await {
Ok(Ok(json)) => json,
Ok(Err(e)) => {
tracing::debug!(project_id = %project_id, "project info fetch failed: {e}");
return None;
}
Err(_) => {
tracing::debug!(project_id = %project_id, "project info fetch timed out");
return None;
}
};

let info = ProjectInfo {
name: json.get("name").and_then(|v| v.as_str())?.to_string(),
prompt: json
.get("prompt")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
description: json
.get("description")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
repo_urls: json
.get("repo_urls")
.cloned()
.unwrap_or(serde_json::json!([])),
environment: json
.get("environment")
.and_then(|v| v.as_str())
.unwrap_or("local")
.to_string(),
};

// Cache it (write lock).
{
let mut cache = ctx.project_cache.write().await;
cache.insert(project_id, info.clone());
}

Some(info)
}

/// Fetch conversation context (thread or DM) for a batch before prompting.
///
/// Returns `None` if:
Expand Down
Loading