2026-02-19 17:58:53 +00:00
|
|
|
use crate::http::context::AppContext;
|
|
|
|
|
use poem::handler;
|
|
|
|
|
use poem::http::StatusCode;
|
|
|
|
|
use poem::web::{Data, Path};
|
|
|
|
|
use poem::{Body, IntoResponse, Response};
|
|
|
|
|
use std::sync::Arc;
|
|
|
|
|
|
Accept story 34: Per-Project Agent Configuration and Role Definitions
Replace single [agent] config with multi-agent [[agent]] roster system.
Each agent has name, role, model, allowed_tools, max_turns, max_budget_usd,
and system_prompt fields that map to Claude CLI flags at spawn time.
- AgentConfig expanded with structured fields, validated at startup (panics
on duplicate names, empty names, non-positive budgets/turns)
- Backwards-compatible: legacy [agent] format auto-wraps with deprecation warning
- AgentPool uses composite "story_id:agent_name" keys for concurrent agents
- agent_name added to AgentEvent variants, AgentInfo, start/stop/subscribe APIs
- GET /agents/config returns roster, POST /agents/config/reload hot-reloads
- POST /agents/start accepts optional agent_name, /agents/stop requires it
- SSE route updated to /agents/:story_id/:agent_name/stream
- Frontend: roster badges, agent selector dropdown, composite-key state
- Project root initialized to cwd at startup so config endpoints work immediately
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 18:46:14 +00:00
|
|
|
/// SSE endpoint: `GET /agents/:story_id/:agent_name/stream`
|
2026-02-19 17:58:53 +00:00
|
|
|
///
|
|
|
|
|
/// Streams `AgentEvent`s as Server-Sent Events. Each event is JSON-encoded
|
|
|
|
|
/// with `data:` prefix and double newline terminator per the SSE spec.
|
2026-03-13 18:34:16 +00:00
|
|
|
///
|
|
|
|
|
/// `AgentEvent::Thinking` events are intentionally excluded — thinking traces
|
|
|
|
|
/// are internal model state and must never be displayed in the UI.
|
2026-02-19 17:58:53 +00:00
|
|
|
#[handler]
|
|
|
|
|
pub async fn agent_stream(
|
Accept story 34: Per-Project Agent Configuration and Role Definitions
Replace single [agent] config with multi-agent [[agent]] roster system.
Each agent has name, role, model, allowed_tools, max_turns, max_budget_usd,
and system_prompt fields that map to Claude CLI flags at spawn time.
- AgentConfig expanded with structured fields, validated at startup (panics
on duplicate names, empty names, non-positive budgets/turns)
- Backwards-compatible: legacy [agent] format auto-wraps with deprecation warning
- AgentPool uses composite "story_id:agent_name" keys for concurrent agents
- agent_name added to AgentEvent variants, AgentInfo, start/stop/subscribe APIs
- GET /agents/config returns roster, POST /agents/config/reload hot-reloads
- POST /agents/start accepts optional agent_name, /agents/stop requires it
- SSE route updated to /agents/:story_id/:agent_name/stream
- Frontend: roster badges, agent selector dropdown, composite-key state
- Project root initialized to cwd at startup so config endpoints work immediately
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 18:46:14 +00:00
|
|
|
Path((story_id, agent_name)): Path<(String, String)>,
|
2026-02-19 17:58:53 +00:00
|
|
|
ctx: Data<&Arc<AppContext>>,
|
|
|
|
|
) -> impl IntoResponse {
|
Accept story 34: Per-Project Agent Configuration and Role Definitions
Replace single [agent] config with multi-agent [[agent]] roster system.
Each agent has name, role, model, allowed_tools, max_turns, max_budget_usd,
and system_prompt fields that map to Claude CLI flags at spawn time.
- AgentConfig expanded with structured fields, validated at startup (panics
on duplicate names, empty names, non-positive budgets/turns)
- Backwards-compatible: legacy [agent] format auto-wraps with deprecation warning
- AgentPool uses composite "story_id:agent_name" keys for concurrent agents
- agent_name added to AgentEvent variants, AgentInfo, start/stop/subscribe APIs
- GET /agents/config returns roster, POST /agents/config/reload hot-reloads
- POST /agents/start accepts optional agent_name, /agents/stop requires it
- SSE route updated to /agents/:story_id/:agent_name/stream
- Frontend: roster badges, agent selector dropdown, composite-key state
- Project root initialized to cwd at startup so config endpoints work immediately
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 18:46:14 +00:00
|
|
|
let mut rx = match ctx.agents.subscribe(&story_id, &agent_name) {
|
2026-02-19 17:58:53 +00:00
|
|
|
Ok(rx) => rx,
|
|
|
|
|
Err(e) => {
|
|
|
|
|
return Response::builder()
|
|
|
|
|
.status(StatusCode::NOT_FOUND)
|
|
|
|
|
.body(Body::from_string(e));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let stream = async_stream::stream! {
|
|
|
|
|
loop {
|
|
|
|
|
match rx.recv().await {
|
|
|
|
|
Ok(event) => {
|
2026-03-13 18:34:16 +00:00
|
|
|
// Never forward thinking traces to the UI — they are
|
|
|
|
|
// internal model state and must not be displayed.
|
|
|
|
|
if matches!(event, crate::agents::AgentEvent::Thinking { .. }) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-02-19 17:58:53 +00:00
|
|
|
if let Ok(json) = serde_json::to_string(&event) {
|
|
|
|
|
yield Ok::<_, std::io::Error>(format!("data: {json}\n\n"));
|
|
|
|
|
}
|
|
|
|
|
// Check for terminal events
|
|
|
|
|
match &event {
|
|
|
|
|
crate::agents::AgentEvent::Done { .. }
|
|
|
|
|
| crate::agents::AgentEvent::Error { .. } => break,
|
|
|
|
|
crate::agents::AgentEvent::Status { status, .. }
|
|
|
|
|
if status == "stopped" => break,
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
|
|
|
|
let msg = format!("{{\"type\":\"warning\",\"message\":\"Skipped {n} events\"}}");
|
|
|
|
|
yield Ok::<_, std::io::Error>(format!("data: {msg}\n\n"));
|
|
|
|
|
}
|
|
|
|
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
Response::builder()
|
|
|
|
|
.header("Content-Type", "text/event-stream")
|
|
|
|
|
.header("Cache-Control", "no-cache")
|
|
|
|
|
.header("Connection", "keep-alive")
|
|
|
|
|
.body(Body::from_bytes_stream(
|
|
|
|
|
futures::StreamExt::map(stream, |r| r.map(bytes::Bytes::from)),
|
|
|
|
|
))
|
|
|
|
|
}
|
2026-03-13 18:34:16 +00:00
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
use crate::agents::{AgentEvent, AgentStatus};
|
|
|
|
|
use crate::http::context::AppContext;
|
|
|
|
|
use poem::{EndpointExt, Route, get};
|
|
|
|
|
use std::sync::Arc;
|
|
|
|
|
use tempfile::tempdir;
|
|
|
|
|
|
|
|
|
|
fn test_app(ctx: Arc<AppContext>) -> impl poem::Endpoint {
|
|
|
|
|
Route::new()
|
|
|
|
|
.at(
|
|
|
|
|
"/agents/:story_id/:agent_name/stream",
|
|
|
|
|
get(agent_stream),
|
|
|
|
|
)
|
|
|
|
|
.data(ctx)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn thinking_events_are_not_forwarded_via_sse() {
|
|
|
|
|
let tmp = tempdir().unwrap();
|
|
|
|
|
let ctx = Arc::new(AppContext::new_test(tmp.path().to_path_buf()));
|
|
|
|
|
|
|
|
|
|
// Inject a running agent and get its broadcast sender.
|
|
|
|
|
let tx = ctx
|
|
|
|
|
.agents
|
|
|
|
|
.inject_test_agent("1_story", "coder-1", AgentStatus::Running);
|
|
|
|
|
|
|
|
|
|
// Spawn a task that sends events after the SSE connection is established.
|
|
|
|
|
let tx_clone = tx.clone();
|
|
|
|
|
tokio::spawn(async move {
|
|
|
|
|
// Brief pause so the SSE handler has subscribed before we emit.
|
|
|
|
|
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
|
|
|
|
|
|
|
|
|
// Thinking event — must be filtered out.
|
|
|
|
|
let _ = tx_clone.send(AgentEvent::Thinking {
|
|
|
|
|
story_id: "1_story".to_string(),
|
|
|
|
|
agent_name: "coder-1".to_string(),
|
|
|
|
|
text: "secret thinking text".to_string(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Output event — must be forwarded.
|
|
|
|
|
let _ = tx_clone.send(AgentEvent::Output {
|
|
|
|
|
story_id: "1_story".to_string(),
|
|
|
|
|
agent_name: "coder-1".to_string(),
|
|
|
|
|
text: "visible output".to_string(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Done event — closes the stream.
|
|
|
|
|
let _ = tx_clone.send(AgentEvent::Done {
|
|
|
|
|
story_id: "1_story".to_string(),
|
|
|
|
|
agent_name: "coder-1".to_string(),
|
|
|
|
|
session_id: None,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let cli = poem::test::TestClient::new(test_app(ctx));
|
|
|
|
|
let resp = cli
|
|
|
|
|
.get("/agents/1_story/coder-1/stream")
|
|
|
|
|
.send()
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
let body = resp.0.into_body().into_string().await.unwrap();
|
|
|
|
|
|
|
|
|
|
// Thinking content must not appear anywhere in the SSE output.
|
|
|
|
|
assert!(
|
|
|
|
|
!body.contains("secret thinking text"),
|
|
|
|
|
"Thinking text must not be forwarded via SSE: {body}"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
!body.contains("\"type\":\"thinking\""),
|
|
|
|
|
"Thinking event type must not appear in SSE output: {body}"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Output event must be present.
|
|
|
|
|
assert!(
|
|
|
|
|
body.contains("visible output"),
|
|
|
|
|
"Output event must be forwarded via SSE: {body}"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
body.contains("\"type\":\"output\""),
|
|
|
|
|
"Output event type must appear in SSE output: {body}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn output_and_done_events_are_forwarded_via_sse() {
|
|
|
|
|
let tmp = tempdir().unwrap();
|
|
|
|
|
let ctx = Arc::new(AppContext::new_test(tmp.path().to_path_buf()));
|
|
|
|
|
|
|
|
|
|
let tx = ctx
|
|
|
|
|
.agents
|
|
|
|
|
.inject_test_agent("2_story", "coder-1", AgentStatus::Running);
|
|
|
|
|
|
|
|
|
|
let tx_clone = tx.clone();
|
|
|
|
|
tokio::spawn(async move {
|
|
|
|
|
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
|
|
|
|
|
|
|
|
|
let _ = tx_clone.send(AgentEvent::Output {
|
|
|
|
|
story_id: "2_story".to_string(),
|
|
|
|
|
agent_name: "coder-1".to_string(),
|
|
|
|
|
text: "step 1 output".to_string(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let _ = tx_clone.send(AgentEvent::Done {
|
|
|
|
|
story_id: "2_story".to_string(),
|
|
|
|
|
agent_name: "coder-1".to_string(),
|
|
|
|
|
session_id: Some("sess-abc".to_string()),
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let cli = poem::test::TestClient::new(test_app(ctx));
|
|
|
|
|
let resp = cli
|
|
|
|
|
.get("/agents/2_story/coder-1/stream")
|
|
|
|
|
.send()
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
let body = resp.0.into_body().into_string().await.unwrap();
|
|
|
|
|
|
|
|
|
|
assert!(body.contains("step 1 output"), "Output must be forwarded: {body}");
|
|
|
|
|
assert!(body.contains("\"type\":\"done\""), "Done event must be forwarded: {body}");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn unknown_agent_returns_404() {
|
|
|
|
|
let tmp = tempdir().unwrap();
|
|
|
|
|
let ctx = Arc::new(AppContext::new_test(tmp.path().to_path_buf()));
|
|
|
|
|
|
|
|
|
|
let cli = poem::test::TestClient::new(test_app(ctx));
|
|
|
|
|
let resp = cli
|
|
|
|
|
.get("/agents/nonexistent/coder-1/stream")
|
|
|
|
|
.send()
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
resp.0.status(),
|
|
|
|
|
poem::http::StatusCode::NOT_FOUND,
|
|
|
|
|
"Unknown agent must return 404"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|