diff --git a/.story_kit/project.toml b/.story_kit/project.toml index 31440a9..ffaae72 100644 --- a/.story_kit/project.toml +++ b/.story_kit/project.toml @@ -10,7 +10,31 @@ path = "." setup = ["cargo check"] teardown = [] -[agent] -command = "claude" -args = [] -prompt = "Read .story_kit/README.md, then pick up story {{story_id}}" +[[agent]] +name = "supervisor" +role = "Coordinates work, reviews PRs, decomposes stories." +model = "opus" +max_turns = 50 +max_budget_usd = 10.00 +system_prompt = "You are a senior engineering lead. Coordinate the work, review code, and ensure quality." + +[[agent]] +name = "coder-1" +role = "Full-stack engineer. Implements features across all components." +model = "sonnet" +max_turns = 30 +max_budget_usd = 5.00 + +[[agent]] +name = "coder-2" +role = "Full-stack engineer. Implements features across all components." +model = "sonnet" +max_turns = 30 +max_budget_usd = 5.00 + +[[agent]] +name = "reviewer" +role = "Reviews code changes, runs tests, checks quality gates." +model = "sonnet" +max_turns = 20 +max_budget_usd = 3.00 diff --git a/.story_kit/stories/upcoming/34_agent_configuration_and_roles.md b/.story_kit/stories/archived/34_agent_configuration_and_roles.md similarity index 100% rename from .story_kit/stories/upcoming/34_agent_configuration_and_roles.md rename to .story_kit/stories/archived/34_agent_configuration_and_roles.md diff --git a/frontend/src/api/agents.ts b/frontend/src/api/agents.ts index 2c55327..7106e5b 100644 --- a/frontend/src/api/agents.ts +++ b/frontend/src/api/agents.ts @@ -2,6 +2,7 @@ export type AgentStatusValue = "pending" | "running" | "completed" | "failed"; export interface AgentInfo { story_id: string; + agent_name: string; status: AgentStatusValue; session_id: string | null; worktree_path: string | null; @@ -10,6 +11,7 @@ export interface AgentInfo { export interface AgentEvent { type: "status" | "output" | "agent_json" | "done" | "error" | "warning"; story_id?: string; + agent_name?: string; status?: string; text?: string; data?: unknown; @@ -17,6 +19,15 @@ export interface AgentEvent { message?: string; } +export interface AgentConfigInfo { + name: string; + role: string; + model: string | null; + allowed_tools: string[] | null; + max_turns: number | null; + max_budget_usd: number | null; +} + const DEFAULT_API_BASE = "/api"; function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string { @@ -45,23 +56,29 @@ async function requestJson( } export const agentsApi = { - startAgent(storyId: string, baseUrl?: string) { + startAgent(storyId: string, agentName?: string, baseUrl?: string) { return requestJson( "/agents/start", { method: "POST", - body: JSON.stringify({ story_id: storyId }), + body: JSON.stringify({ + story_id: storyId, + agent_name: agentName, + }), }, baseUrl, ); }, - stopAgent(storyId: string, baseUrl?: string) { + stopAgent(storyId: string, agentName: string, baseUrl?: string) { return requestJson( "/agents/stop", { method: "POST", - body: JSON.stringify({ story_id: storyId }), + body: JSON.stringify({ + story_id: storyId, + agent_name: agentName, + }), }, baseUrl, ); @@ -70,6 +87,18 @@ export const agentsApi = { listAgents(baseUrl?: string) { return requestJson("/agents", {}, baseUrl); }, + + getAgentConfig(baseUrl?: string) { + return requestJson("/agents/config", {}, baseUrl); + }, + + reloadConfig(baseUrl?: string) { + return requestJson( + "/agents/config/reload", + { method: "POST" }, + baseUrl, + ); + }, }; /** @@ -78,11 +107,12 @@ export const agentsApi = { */ export function subscribeAgentStream( storyId: string, + agentName: string, onEvent: (event: AgentEvent) => void, onError?: (error: Event) => void, ): () => void { const host = import.meta.env.DEV ? "http://127.0.0.1:3001" : ""; - const url = `${host}/agents/${encodeURIComponent(storyId)}/stream`; + const url = `${host}/agents/${encodeURIComponent(storyId)}/${encodeURIComponent(agentName)}/stream`; const eventSource = new EventSource(url); diff --git a/frontend/src/components/AgentPanel.tsx b/frontend/src/components/AgentPanel.tsx index 9d4ccce..e71d10c 100644 --- a/frontend/src/components/AgentPanel.tsx +++ b/frontend/src/components/AgentPanel.tsx @@ -1,5 +1,10 @@ import * as React from "react"; -import type { AgentEvent, AgentInfo, AgentStatusValue } from "../api/agents"; +import type { + AgentConfigInfo, + AgentEvent, + AgentInfo, + AgentStatusValue, +} from "../api/agents"; import { agentsApi, subscribeAgentStream } from "../api/agents"; import type { UpcomingStory } from "../api/workflow"; @@ -10,6 +15,7 @@ interface AgentPanelProps { } interface AgentState { + agentName: string; status: AgentStatusValue; log: string[]; sessionId: string | null; @@ -71,30 +77,65 @@ function StatusBadge({ status }: { status: AgentStatusValue }) { ); } +function RosterBadge({ agent }: { agent: AgentConfigInfo }) { + return ( + + {agent.name} + {agent.model && {agent.model}} + + ); +} + +/** Build a composite key for tracking agent state. */ +function agentKey(storyId: string, agentName: string): string { + return `${storyId}:${agentName}`; +} + export function AgentPanel({ stories }: AgentPanelProps) { const [agents, setAgents] = useState>({}); - const [expandedStory, setExpandedStory] = useState(null); + const [roster, setRoster] = useState([]); + const [expandedKey, setExpandedKey] = useState(null); const [actionError, setActionError] = useState(null); const [lastRefresh, setLastRefresh] = useState(null); + const [selectorStory, setSelectorStory] = useState(null); const cleanupRefs = useRef void>>({}); const logEndRefs = useRef>({}); - // Load existing agents on mount + // Load roster and existing agents on mount useEffect(() => { + agentsApi + .getAgentConfig() + .then(setRoster) + .catch((err) => console.error("Failed to load agent config:", err)); + agentsApi .listAgents() .then((agentList) => { const agentMap: Record = {}; for (const a of agentList) { - agentMap[a.story_id] = { + const key = agentKey(a.story_id, a.agent_name); + agentMap[key] = { + agentName: a.agent_name, status: a.status, log: [], sessionId: a.session_id, worktreePath: a.worktree_path, }; - // Re-subscribe to running agents if (a.status === "running" || a.status === "pending") { - subscribeToAgent(a.story_id); + subscribeToAgent(a.story_id, a.agent_name); } } setAgents(agentMap); @@ -110,15 +151,17 @@ export function AgentPanel({ stories }: AgentPanelProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const subscribeToAgent = useCallback((storyId: string) => { - // Clean up existing subscription - cleanupRefs.current[storyId]?.(); + const subscribeToAgent = useCallback((storyId: string, agentName: string) => { + const key = agentKey(storyId, agentName); + cleanupRefs.current[key]?.(); const cleanup = subscribeAgentStream( storyId, + agentName, (event: AgentEvent) => { setAgents((prev) => { - const current = prev[storyId] ?? { + const current = prev[key] ?? { + agentName, status: "pending" as AgentStatusValue, log: [], sessionId: null, @@ -129,7 +172,7 @@ export function AgentPanel({ stories }: AgentPanelProps) { case "status": return { ...prev, - [storyId]: { + [key]: { ...current, status: (event.status as AgentStatusValue) ?? current.status, }, @@ -137,7 +180,7 @@ export function AgentPanel({ stories }: AgentPanelProps) { case "output": return { ...prev, - [storyId]: { + [key]: { ...current, log: [...current.log, event.text ?? ""], }, @@ -145,7 +188,7 @@ export function AgentPanel({ stories }: AgentPanelProps) { case "done": return { ...prev, - [storyId]: { + [key]: { ...current, status: "completed", sessionId: event.session_id ?? current.sessionId, @@ -154,7 +197,7 @@ export function AgentPanel({ stories }: AgentPanelProps) { case "error": return { ...prev, - [storyId]: { + [key]: { ...current, status: "failed", log: [ @@ -173,47 +216,51 @@ export function AgentPanel({ stories }: AgentPanelProps) { }, ); - cleanupRefs.current[storyId] = cleanup; + cleanupRefs.current[key] = cleanup; }, []); // Auto-scroll log when expanded useEffect(() => { - if (expandedStory) { - const el = logEndRefs.current[expandedStory]; + if (expandedKey) { + const el = logEndRefs.current[expandedKey]; el?.scrollIntoView({ behavior: "smooth" }); } - }, [expandedStory, agents]); + }, [expandedKey, agents]); - const handleStart = async (storyId: string) => { + const handleStart = async (storyId: string, agentName?: string) => { setActionError(null); + setSelectorStory(null); try { - const info: AgentInfo = await agentsApi.startAgent(storyId); + const info: AgentInfo = await agentsApi.startAgent(storyId, agentName); + const key = agentKey(info.story_id, info.agent_name); setAgents((prev) => ({ ...prev, - [storyId]: { + [key]: { + agentName: info.agent_name, status: info.status, log: [], sessionId: info.session_id, worktreePath: info.worktree_path, }, })); - setExpandedStory(storyId); - subscribeToAgent(storyId); + setExpandedKey(key); + subscribeToAgent(info.story_id, info.agent_name); } catch (err) { const message = err instanceof Error ? err.message : String(err); setActionError(`Failed to start agent for ${storyId}: ${message}`); } }; - const handleStop = async (storyId: string) => { + const handleStop = async (storyId: string, agentName: string) => { setActionError(null); + const key = agentKey(storyId, agentName); try { - await agentsApi.stopAgent(storyId); - cleanupRefs.current[storyId]?.(); - delete cleanupRefs.current[storyId]; + await agentsApi.stopAgent(storyId, agentName); + cleanupRefs.current[key]?.(); + delete cleanupRefs.current[key]; setAgents((prev) => { const next = { ...prev }; - delete next[storyId]; + delete next[key]; return next; }); } catch (err) { @@ -222,9 +269,23 @@ export function AgentPanel({ stories }: AgentPanelProps) { } }; - const isAgentActive = (storyId: string): boolean => { - const agent = agents[storyId]; - return agent?.status === "running" || agent?.status === "pending"; + const handleRunClick = (storyId: string) => { + if (roster.length <= 1) { + handleStart(storyId); + } else { + setSelectorStory(selectorStory === storyId ? null : storyId); + } + }; + + /** Get all active agent keys for a story. */ + const getActiveKeysForStory = (storyId: string): string[] => { + return Object.keys(agents).filter((key) => { + const a = agents[key]; + return ( + key.startsWith(`${storyId}:`) && + (a.status === "running" || a.status === "pending") + ); + }); }; return ( @@ -273,6 +334,21 @@ export function AgentPanel({ stories }: AgentPanelProps) { )} + {/* Roster badges */} + {roster.length > 0 && ( +
+ {roster.map((a) => ( + + ))} +
+ )} + {actionError && (
{stories.map((story) => { - const agent = agents[story.story_id]; - const isExpanded = expandedStory === story.story_id; + const activeKeys = getActiveKeysForStory(story.story_id); + const hasActive = activeKeys.length > 0; + + // Gather all agent states for this story + const storyAgentEntries = Object.entries(agents).filter(([key]) => + key.startsWith(`${story.story_id}:`), + ); return (
- setExpandedStory(isExpanded ? null : story.story_id) + setExpandedKey( + expandedKey?.startsWith(`${story.story_id}:`) + ? null + : (storyAgentEntries[0]?.[0] ?? story.story_id), + ) } onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { - setExpandedStory(isExpanded ? null : story.story_id); + setExpandedKey( + expandedKey?.startsWith(`${story.story_id}:`) + ? null + : (storyAgentEntries[0]?.[0] ?? story.story_id), + ); } }} style={{ @@ -338,7 +427,9 @@ export function AgentPanel({ stories }: AgentPanelProps) { cursor: "pointer", fontSize: "0.8em", padding: "0 4px", - transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)", + transform: expandedKey?.startsWith(`${story.story_id}:`) + ? "rotate(90deg)" + : "rotate(0deg)", transition: "transform 0.15s", }} > @@ -358,12 +449,38 @@ export function AgentPanel({ stories }: AgentPanelProps) { {story.name ?? story.story_id}
- {agent && } + {storyAgentEntries.map(([key, a]) => ( + + + {a.agentName} + + + + ))} - {isAgentActive(story.story_id) ? ( + {hasActive ? ( ) : ( - +
+ + {selectorStory === story.story_id && + roster.length > 1 && ( +
+ {roster.map((r) => ( + + ))} +
+ )} +
)}
- {isExpanded && agent && ( -
- {agent.worktreePath && ( + {/* Expanded detail per agent */} + {storyAgentEntries.map(([key, a]) => { + if (expandedKey !== key) return null; + return ( +
- Worktree: {agent.worktreePath} + {a.agentName}
- )} -
- {agent.log.length === 0 ? ( - - {agent.status === "pending" || - agent.status === "running" - ? "Waiting for output..." - : "No output captured."} - - ) : ( - agent.log.map((line, i) => ( -
- {line} -
- )) + {a.worktreePath && ( +
+ Worktree: {a.worktreePath} +
)}
{ - logEndRefs.current[story.story_id] = el; + style={{ + maxHeight: "300px", + overflowY: "auto", + background: "#111", + borderRadius: "6px", + padding: "8px", + fontFamily: "monospace", + fontSize: "0.8em", + lineHeight: "1.5", + color: "#ccc", + whiteSpace: "pre-wrap", + wordBreak: "break-word", }} - /> + > + {a.log.length === 0 ? ( + + {a.status === "pending" || a.status === "running" + ? "Waiting for output..." + : "No output captured."} + + ) : ( + a.log.map((line, i) => ( +
+ {line} +
+ )) + )} +
{ + logEndRefs.current[key] = el; + }} + /> +
-
- )} + ); + })}
); })} diff --git a/frontend/src/components/Chat.test.tsx b/frontend/src/components/Chat.test.tsx index 501f460..523ce0a 100644 --- a/frontend/src/components/Chat.test.tsx +++ b/frontend/src/components/Chat.test.tsx @@ -475,7 +475,11 @@ describe("Chat review panel", () => { it("fetches upcoming stories on mount and renders panel", async () => { mockedWorkflow.getUpcomingStories.mockResolvedValueOnce({ stories: [ - { story_id: "31_view_upcoming", name: "View Upcoming Stories", error: null }, + { + story_id: "31_view_upcoming", + name: "View Upcoming Stories", + error: null, + }, { story_id: "32_worktree", name: null, error: null }, ], }); diff --git a/frontend/src/components/UpcomingPanel.test.tsx b/frontend/src/components/UpcomingPanel.test.tsx index da37874..79fd13f 100644 --- a/frontend/src/components/UpcomingPanel.test.tsx +++ b/frontend/src/components/UpcomingPanel.test.tsx @@ -43,7 +43,11 @@ describe("UpcomingPanel", () => { it("renders story list with names", () => { const stories: UpcomingStory[] = [ - { story_id: "31_view_upcoming", name: "View Upcoming Stories", error: null }, + { + story_id: "31_view_upcoming", + name: "View Upcoming Stories", + error: null, + }, { story_id: "32_worktree", name: "Worktree Orchestration", error: null }, ]; render(); diff --git a/server/src/agents.rs b/server/src/agents.rs index 4b34656..1a3312a 100644 --- a/server/src/agents.rs +++ b/server/src/agents.rs @@ -8,23 +8,45 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use tokio::sync::broadcast; +/// Build the composite key used to track agents in the pool. +fn composite_key(story_id: &str, agent_name: &str) -> String { + format!("{story_id}:{agent_name}") +} + /// Events streamed from a running agent to SSE clients. #[derive(Debug, Clone, Serialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum AgentEvent { /// Agent status changed. - Status { story_id: String, status: String }, + Status { + story_id: String, + agent_name: String, + status: String, + }, /// Raw text output from the agent process. - Output { story_id: String, text: String }, + Output { + story_id: String, + agent_name: String, + text: String, + }, /// Agent produced a JSON event from `--output-format stream-json`. - AgentJson { story_id: String, data: serde_json::Value }, + AgentJson { + story_id: String, + agent_name: String, + data: serde_json::Value, + }, /// Agent finished. Done { story_id: String, + agent_name: String, session_id: Option, }, /// Agent errored. - Error { story_id: String, message: String }, + Error { + story_id: String, + agent_name: String, + message: String, + }, } #[derive(Debug, Clone, Serialize, PartialEq)] @@ -50,12 +72,14 @@ impl std::fmt::Display for AgentStatus { #[derive(Serialize, Clone)] pub struct AgentInfo { pub story_id: String, + pub agent_name: String, pub status: AgentStatus, pub session_id: Option, pub worktree_path: Option, } struct StoryAgent { + agent_name: String, status: AgentStatus, worktree_info: Option, config: ProjectConfig, @@ -77,32 +101,54 @@ impl AgentPool { } /// Start an agent for a story: load config, create worktree, spawn agent. + /// If `agent_name` is None, defaults to the first configured agent. pub async fn start_agent( &self, project_root: &Path, story_id: &str, + agent_name: Option<&str>, ) -> Result { + let config = ProjectConfig::load(project_root)?; + + // Resolve agent name from config + let resolved_name = match agent_name { + Some(name) => { + config + .find_agent(name) + .ok_or_else(|| format!("No agent named '{name}' in config"))?; + name.to_string() + } + None => config + .default_agent() + .ok_or_else(|| "No agents configured".to_string())? + .name + .clone(), + }; + + let key = composite_key(story_id, &resolved_name); + // Check not already running { let agents = self.agents.lock().map_err(|e| e.to_string())?; - if let Some(agent) = agents.get(story_id) - && (agent.status == AgentStatus::Running || agent.status == AgentStatus::Pending) { - return Err(format!( - "Agent for story '{story_id}' is already {}", - agent.status - )); - } + if let Some(agent) = agents.get(&key) + && (agent.status == AgentStatus::Running || agent.status == AgentStatus::Pending) + { + return Err(format!( + "Agent '{resolved_name}' for story '{story_id}' is already {}", + agent.status + )); + } } - let config = ProjectConfig::load(project_root)?; let (tx, _) = broadcast::channel::(256); // Register as pending { let mut agents = self.agents.lock().map_err(|e| e.to_string())?; agents.insert( - story_id.to_string(), + key.clone(), StoryAgent { + agent_name: resolved_name.clone(), status: AgentStatus::Pending, worktree_info: None, config: config.clone(), @@ -115,6 +161,7 @@ impl AgentPool { let _ = tx.send(AgentEvent::Status { story_id: story_id.to_string(), + agent_name: resolved_name.clone(), status: "pending".to_string(), }); @@ -124,51 +171,55 @@ impl AgentPool { // Update with worktree info { let mut agents = self.agents.lock().map_err(|e| e.to_string())?; - if let Some(agent) = agents.get_mut(story_id) { + if let Some(agent) = agents.get_mut(&key) { agent.worktree_info = Some(wt_info.clone()); } } // Spawn the agent process let wt_path_str = wt_info.path.to_string_lossy().to_string(); - let rendered = config.render_agent_args(&wt_path_str, story_id); - - let (command, args, prompt) = rendered.ok_or_else(|| { - "No [agent] section in config — cannot spawn agent".to_string() - })?; + let (command, args, prompt) = + config.render_agent_args(&wt_path_str, story_id, Some(&resolved_name))?; let sid = story_id.to_string(); + let aname = resolved_name.clone(); let tx_clone = tx.clone(); let agents_ref = self.agents.clone(); let cwd = wt_path_str.clone(); + let key_clone = key.clone(); let handle = tokio::spawn(async move { let _ = tx_clone.send(AgentEvent::Status { story_id: sid.clone(), + agent_name: aname.clone(), status: "running".to_string(), }); - match run_agent_pty_streaming(&sid, &command, &args, &prompt, &cwd, &tx_clone).await { + match run_agent_pty_streaming(&sid, &aname, &command, &args, &prompt, &cwd, &tx_clone) + .await + { Ok(session_id) => { - // Mark completed in the pool if let Ok(mut agents) = agents_ref.lock() - && let Some(agent) = agents.get_mut(&sid) { - agent.status = AgentStatus::Completed; - agent.session_id = session_id.clone(); - } + && let Some(agent) = agents.get_mut(&key_clone) + { + agent.status = AgentStatus::Completed; + agent.session_id = session_id.clone(); + } let _ = tx_clone.send(AgentEvent::Done { story_id: sid.clone(), + agent_name: aname.clone(), session_id, }); } Err(e) => { - // Mark failed in the pool if let Ok(mut agents) = agents_ref.lock() - && let Some(agent) = agents.get_mut(&sid) { - agent.status = AgentStatus::Failed; - } + && let Some(agent) = agents.get_mut(&key_clone) + { + agent.status = AgentStatus::Failed; + } let _ = tx_clone.send(AgentEvent::Error { story_id: sid.clone(), + agent_name: aname.clone(), message: e, }); } @@ -178,7 +229,7 @@ impl AgentPool { // Update status to running with task handle { let mut agents = self.agents.lock().map_err(|e| e.to_string())?; - if let Some(agent) = agents.get_mut(story_id) { + if let Some(agent) = agents.get_mut(&key) { agent.status = AgentStatus::Running; agent.task_handle = Some(handle); } @@ -186,6 +237,7 @@ impl AgentPool { Ok(AgentInfo { story_id: story_id.to_string(), + agent_name: resolved_name, status: AgentStatus::Running, session_id: None, worktree_path: Some(wt_path_str), @@ -193,12 +245,19 @@ impl AgentPool { } /// Stop a running agent and clean up its worktree. - pub async fn stop_agent(&self, project_root: &Path, story_id: &str) -> Result<(), String> { + pub async fn stop_agent( + &self, + project_root: &Path, + story_id: &str, + agent_name: &str, + ) -> Result<(), String> { + let key = composite_key(story_id, agent_name); + let (worktree_info, config, task_handle, tx) = { let mut agents = self.agents.lock().map_err(|e| e.to_string())?; let agent = agents - .get_mut(story_id) - .ok_or_else(|| format!("No agent for story '{story_id}'"))?; + .get_mut(&key) + .ok_or_else(|| format!("No agent '{agent_name}' for story '{story_id}'"))?; let wt = agent.worktree_info.clone(); let cfg = agent.config.clone(); @@ -216,19 +275,21 @@ impl AgentPool { // Remove worktree if let Some(ref wt) = worktree_info - && let Err(e) = worktree::remove_worktree(project_root, wt, &config).await { - eprintln!("[agents] Worktree cleanup warning for {story_id}: {e}"); - } + && let Err(e) = worktree::remove_worktree(project_root, wt, &config).await + { + eprintln!("[agents] Worktree cleanup warning for {story_id}:{agent_name}: {e}"); + } let _ = tx.send(AgentEvent::Status { story_id: story_id.to_string(), + agent_name: agent_name.to_string(), status: "stopped".to_string(), }); // Remove from map { let mut agents = self.agents.lock().map_err(|e| e.to_string())?; - agents.remove(story_id); + agents.remove(&key); } Ok(()) @@ -239,24 +300,37 @@ impl AgentPool { let agents = self.agents.lock().map_err(|e| e.to_string())?; Ok(agents .iter() - .map(|(story_id, agent)| AgentInfo { - story_id: story_id.clone(), - status: agent.status.clone(), - session_id: agent.session_id.clone(), - worktree_path: agent - .worktree_info - .as_ref() - .map(|wt| wt.path.to_string_lossy().to_string()), + .map(|(key, agent)| { + // Extract story_id from composite key "story_id:agent_name" + let story_id = key + .rsplit_once(':') + .map(|(sid, _)| sid.to_string()) + .unwrap_or_else(|| key.clone()); + AgentInfo { + story_id, + agent_name: agent.agent_name.clone(), + status: agent.status.clone(), + session_id: agent.session_id.clone(), + worktree_path: agent + .worktree_info + .as_ref() + .map(|wt| wt.path.to_string_lossy().to_string()), + } }) .collect()) } /// Subscribe to events for a story agent. - pub fn subscribe(&self, story_id: &str) -> Result, String> { + pub fn subscribe( + &self, + story_id: &str, + agent_name: &str, + ) -> Result, String> { + let key = composite_key(story_id, agent_name); let agents = self.agents.lock().map_err(|e| e.to_string())?; let agent = agents - .get(story_id) - .ok_or_else(|| format!("No agent for story '{story_id}'"))?; + .get(&key) + .ok_or_else(|| format!("No agent '{agent_name}' for story '{story_id}'"))?; Ok(agent.tx.subscribe()) } @@ -272,6 +346,7 @@ impl AgentPool { /// Spawn claude agent in a PTY and stream events through the broadcast channel. async fn run_agent_pty_streaming( story_id: &str, + agent_name: &str, command: &str, args: &[String], prompt: &str, @@ -279,6 +354,7 @@ async fn run_agent_pty_streaming( tx: &broadcast::Sender, ) -> Result, String> { let sid = story_id.to_string(); + let aname = agent_name.to_string(); let cmd = command.to_string(); let args = args.to_vec(); let prompt = prompt.to_string(); @@ -286,7 +362,7 @@ async fn run_agent_pty_streaming( let tx = tx.clone(); tokio::task::spawn_blocking(move || { - run_agent_pty_blocking(&sid, &cmd, &args, &prompt, &cwd, &tx) + run_agent_pty_blocking(&sid, &aname, &cmd, &args, &prompt, &cwd, &tx) }) .await .map_err(|e| format!("Agent task panicked: {e}"))? @@ -294,6 +370,7 @@ async fn run_agent_pty_streaming( fn run_agent_pty_blocking( story_id: &str, + agent_name: &str, command: &str, args: &[String], prompt: &str, @@ -317,7 +394,7 @@ fn run_agent_pty_blocking( cmd.arg("-p"); cmd.arg(prompt); - // Add configured args (e.g., --directory /path/to/worktree) + // Add configured args (e.g., --directory /path/to/worktree, --model, etc.) for arg in args { cmd.arg(arg); } @@ -333,12 +410,12 @@ fn run_agent_pty_blocking( cmd.cwd(cwd); cmd.env("NO_COLOR", "1"); - eprintln!("[agent:{story_id}] Spawning {command} in {cwd} with args: {args:?}"); + eprintln!("[agent:{story_id}:{agent_name}] Spawning {command} in {cwd} with args: {args:?}"); let mut child = pair .slave .spawn_command(cmd) - .map_err(|e| format!("Failed to spawn agent for {story_id}: {e}"))?; + .map_err(|e| format!("Failed to spawn agent for {story_id}:{agent_name}: {e}"))?; drop(pair.slave); @@ -370,6 +447,7 @@ fn run_agent_pty_blocking( // Non-JSON output (terminal escapes etc.) — send as raw output let _ = tx.send(AgentEvent::Output { story_id: story_id.to_string(), + agent_name: agent_name.to_string(), text: trimmed.to_string(), }); continue; @@ -387,16 +465,18 @@ fn run_agent_pty_blocking( } "assistant" => { if let Some(message) = json.get("message") - && let Some(content) = message.get("content").and_then(|c| c.as_array()) { - for block in content { - if let Some(text) = block.get("text").and_then(|t| t.as_str()) { - let _ = tx.send(AgentEvent::Output { - story_id: story_id.to_string(), - text: text.to_string(), - }); - } + && let Some(content) = message.get("content").and_then(|c| c.as_array()) + { + for block in content { + if let Some(text) = block.get("text").and_then(|t| t.as_str()) { + let _ = tx.send(AgentEvent::Output { + story_id: story_id.to_string(), + agent_name: agent_name.to_string(), + text: text.to_string(), + }); } } + } } _ => {} } @@ -404,6 +484,7 @@ fn run_agent_pty_blocking( // Forward all JSON events let _ = tx.send(AgentEvent::AgentJson { story_id: story_id.to_string(), + agent_name: agent_name.to_string(), data: json, }); } @@ -411,7 +492,7 @@ fn run_agent_pty_blocking( let _ = child.kill(); eprintln!( - "[agent:{story_id}] Done. Session: {:?}", + "[agent:{story_id}:{agent_name}] Done. Session: {:?}", session_id ); diff --git a/server/src/config.rs b/server/src/config.rs index 55b5582..554d4f1 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -1,11 +1,13 @@ use serde::Deserialize; +use std::collections::HashSet; use std::path::Path; #[derive(Debug, Clone, Deserialize)] pub struct ProjectConfig { #[serde(default)] pub component: Vec, - pub agent: Option, + #[serde(default)] + pub agent: Vec, } #[derive(Debug, Clone, Deserialize)] @@ -21,18 +23,36 @@ pub struct ComponentConfig { #[derive(Debug, Clone, Deserialize)] pub struct AgentConfig { + #[serde(default = "default_agent_name")] + pub name: String, + #[serde(default)] + pub role: String, #[serde(default = "default_agent_command")] pub command: String, #[serde(default)] pub args: Vec, #[serde(default = "default_agent_prompt")] pub prompt: String, + #[serde(default)] + pub model: Option, + #[serde(default)] + pub allowed_tools: Option>, + #[serde(default)] + pub max_turns: Option, + #[serde(default)] + pub max_budget_usd: Option, + #[serde(default)] + pub system_prompt: Option, } fn default_path() -> String { ".".to_string() } +fn default_agent_name() -> String { + "default".to_string() +} + fn default_agent_command() -> String { "claude".to_string() } @@ -41,15 +61,30 @@ fn default_agent_prompt() -> String { "Read .story_kit/README.md, then pick up story {{story_id}}".to_string() } +/// Legacy config format with `agent` as an optional single table (`[agent]`). +#[derive(Debug, Deserialize)] +struct LegacyProjectConfig { + #[serde(default)] + component: Vec, + agent: Option, +} + impl Default for ProjectConfig { fn default() -> Self { Self { component: Vec::new(), - agent: Some(AgentConfig { + agent: vec![AgentConfig { + name: default_agent_name(), + role: String::new(), command: default_agent_command(), args: vec![], prompt: default_agent_prompt(), - }), + model: None, + allowed_tools: None, + max_turns: None, + max_budget_usd: None, + system_prompt: None, + }], } } } @@ -57,6 +92,9 @@ impl Default for ProjectConfig { impl ProjectConfig { /// Load from `.story_kit/project.toml` relative to the given root. /// Falls back to sensible defaults if the file doesn't exist. + /// + /// Supports both the new `[[agent]]` array format and the legacy + /// `[agent]` single-table format (with a deprecation warning). pub fn load(project_root: &Path) -> Result { let config_path = project_root.join(".story_kit/project.toml"); if !config_path.exists() { @@ -64,27 +102,152 @@ impl ProjectConfig { } let content = std::fs::read_to_string(&config_path).map_err(|e| format!("Read config: {e}"))?; - toml::from_str(&content).map_err(|e| format!("Parse config: {e}")) + Self::parse(&content) } - /// Render template variables in agent args and prompt. + /// Parse config from a TOML string, supporting both new and legacy formats. + pub fn parse(content: &str) -> Result { + // Try new format first (agent as array of tables) + match toml::from_str::(content) { + Ok(config) if !config.agent.is_empty() => { + validate_agents(&config.agent)?; + Ok(config) + } + Ok(config) => { + // Parsed successfully but no agents — could be legacy or no agent section. + // Try legacy format. + if let Ok(legacy) = toml::from_str::(content) + && let Some(agent) = legacy.agent { + eprintln!( + "[config] Warning: [agent] table is deprecated. \ + Use [[agent]] array format instead." + ); + let config = ProjectConfig { + component: legacy.component, + agent: vec![agent], + }; + validate_agents(&config.agent)?; + return Ok(config); + } + // No agent section at all + Ok(config) + } + Err(_) => { + // New format failed — try legacy + let legacy: LegacyProjectConfig = + toml::from_str(content).map_err(|e| format!("Parse config: {e}"))?; + if let Some(agent) = legacy.agent { + eprintln!( + "[config] Warning: [agent] table is deprecated. \ + Use [[agent]] array format instead." + ); + let config = ProjectConfig { + component: legacy.component, + agent: vec![agent], + }; + validate_agents(&config.agent)?; + Ok(config) + } else { + Ok(ProjectConfig { + component: legacy.component, + agent: Vec::new(), + }) + } + } + } + } + + /// Look up an agent config by name. + pub fn find_agent(&self, name: &str) -> Option<&AgentConfig> { + self.agent.iter().find(|a| a.name == name) + } + + /// Get the default (first) agent config. + pub fn default_agent(&self) -> Option<&AgentConfig> { + self.agent.first() + } + + /// Render template variables in agent args and prompt for the given agent. + /// If `agent_name` is None, uses the first (default) agent. pub fn render_agent_args( &self, worktree_path: &str, story_id: &str, - ) -> Option<(String, Vec, String)> { - let agent = self.agent.as_ref()?; + agent_name: Option<&str>, + ) -> Result<(String, Vec, String), String> { + let agent = match agent_name { + Some(name) => self + .find_agent(name) + .ok_or_else(|| format!("No agent named '{name}' in config"))?, + None => self + .default_agent() + .ok_or_else(|| "No agents configured".to_string())?, + }; + let render = |s: &str| { s.replace("{{worktree_path}}", worktree_path) .replace("{{story_id}}", story_id) }; + let command = render(&agent.command); - let args: Vec = agent.args.iter().map(|a| render(a)).collect(); + let mut args: Vec = agent.args.iter().map(|a| render(a)).collect(); let prompt = render(&agent.prompt); - Some((command, args, prompt)) + + // Append structured CLI flags + if let Some(ref model) = agent.model { + args.push("--model".to_string()); + args.push(model.clone()); + } + if let Some(ref tools) = agent.allowed_tools + && !tools.is_empty() { + args.push("--allowedTools".to_string()); + args.push(tools.join(",")); + } + if let Some(turns) = agent.max_turns { + args.push("--max-turns".to_string()); + args.push(turns.to_string()); + } + if let Some(budget) = agent.max_budget_usd { + args.push("--max-budget-usd".to_string()); + args.push(budget.to_string()); + } + if let Some(ref sp) = agent.system_prompt { + args.push("--append-system-prompt".to_string()); + args.push(render(sp)); + } + + Ok((command, args, prompt)) } } +/// Validate agent configs: no duplicate names, no empty names, positive budgets/turns. +fn validate_agents(agents: &[AgentConfig]) -> Result<(), String> { + let mut names = HashSet::new(); + for agent in agents { + if agent.name.trim().is_empty() { + return Err("Agent name must not be empty".to_string()); + } + if !names.insert(&agent.name) { + return Err(format!("Duplicate agent name: '{}'", agent.name)); + } + if let Some(budget) = agent.max_budget_usd + && budget <= 0.0 { + return Err(format!( + "Agent '{}': max_budget_usd must be positive, got {budget}", + agent.name + )); + } + if let Some(turns) = agent.max_turns + && turns == 0 { + return Err(format!( + "Agent '{}': max_turns must be positive, got 0", + agent.name + )); + } + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -94,12 +257,204 @@ mod tests { fn default_config_when_missing() { let tmp = tempfile::tempdir().unwrap(); let config = ProjectConfig::load(tmp.path()).unwrap(); - assert!(config.agent.is_some()); + assert_eq!(config.agent.len(), 1); + assert_eq!(config.agent[0].name, "default"); assert!(config.component.is_empty()); } #[test] - fn parse_project_toml() { + fn parse_multi_agent_toml() { + let toml_str = r#" +[[component]] +name = "server" +path = "." +setup = ["cargo check"] + +[[agent]] +name = "supervisor" +role = "Coordinates work" +model = "opus" +max_turns = 50 +max_budget_usd = 10.00 +system_prompt = "You are a senior engineer" + +[[agent]] +name = "coder-1" +role = "Full-stack engineer" +model = "sonnet" +max_turns = 30 +max_budget_usd = 5.00 +"#; + + let config = ProjectConfig::parse(toml_str).unwrap(); + assert_eq!(config.agent.len(), 2); + assert_eq!(config.agent[0].name, "supervisor"); + assert_eq!(config.agent[0].role, "Coordinates work"); + assert_eq!(config.agent[0].model, Some("opus".to_string())); + assert_eq!(config.agent[0].max_turns, Some(50)); + assert_eq!(config.agent[0].max_budget_usd, Some(10.0)); + assert_eq!( + config.agent[0].system_prompt, + Some("You are a senior engineer".to_string()) + ); + assert_eq!(config.agent[1].name, "coder-1"); + assert_eq!(config.agent[1].model, Some("sonnet".to_string())); + assert_eq!(config.component.len(), 1); + } + + #[test] + fn parse_legacy_single_agent() { + let toml_str = r#" +[[component]] +name = "server" +path = "." + +[agent] +command = "claude" +args = ["--print", "--directory", "{{worktree_path}}"] +prompt = "Pick up story {{story_id}}" +"#; + + let config = ProjectConfig::parse(toml_str).unwrap(); + assert_eq!(config.agent.len(), 1); + assert_eq!(config.agent[0].name, "default"); + assert_eq!(config.agent[0].command, "claude"); + } + + #[test] + fn validate_duplicate_names() { + let toml_str = r#" +[[agent]] +name = "coder" +role = "Engineer" + +[[agent]] +name = "coder" +role = "Another engineer" +"#; + + let err = ProjectConfig::parse(toml_str).unwrap_err(); + assert!(err.contains("Duplicate agent name: 'coder'")); + } + + #[test] + fn validate_empty_name() { + let toml_str = r#" +[[agent]] +name = "" +role = "Engineer" +"#; + + let err = ProjectConfig::parse(toml_str).unwrap_err(); + assert!(err.contains("Agent name must not be empty")); + } + + #[test] + fn validate_non_positive_budget() { + let toml_str = r#" +[[agent]] +name = "coder" +max_budget_usd = -1.0 +"#; + + let err = ProjectConfig::parse(toml_str).unwrap_err(); + assert!(err.contains("must be positive")); + } + + #[test] + fn validate_zero_max_turns() { + let toml_str = r#" +[[agent]] +name = "coder" +max_turns = 0 +"#; + + let err = ProjectConfig::parse(toml_str).unwrap_err(); + assert!(err.contains("max_turns must be positive")); + } + + #[test] + fn render_agent_args_default() { + let config = ProjectConfig::default(); + let (cmd, args, prompt) = config + .render_agent_args("/tmp/wt", "42_foo", None) + .unwrap(); + assert_eq!(cmd, "claude"); + assert!(args.is_empty()); + assert!(prompt.contains("42_foo")); + } + + #[test] + fn render_agent_args_by_name() { + let toml_str = r#" +[[agent]] +name = "supervisor" +model = "opus" +max_turns = 50 +max_budget_usd = 10.00 +system_prompt = "You lead story {{story_id}}" +allowed_tools = ["Read", "Write", "Bash"] + +[[agent]] +name = "coder" +model = "sonnet" +max_turns = 30 +"#; + + let config = ProjectConfig::parse(toml_str).unwrap(); + let (cmd, args, prompt) = config + .render_agent_args("/tmp/wt", "42_foo", Some("supervisor")) + .unwrap(); + assert_eq!(cmd, "claude"); + assert!(args.contains(&"--model".to_string())); + assert!(args.contains(&"opus".to_string())); + assert!(args.contains(&"--max-turns".to_string())); + assert!(args.contains(&"50".to_string())); + assert!(args.contains(&"--max-budget-usd".to_string())); + assert!(args.contains(&"10".to_string())); + assert!(args.contains(&"--allowedTools".to_string())); + assert!(args.contains(&"Read,Write,Bash".to_string())); + assert!(args.contains(&"--append-system-prompt".to_string())); + // System prompt should have template rendered + assert!(args.contains(&"You lead story 42_foo".to_string())); + assert!(prompt.contains("42_foo")); + + // Render for coder + let (_, coder_args, _) = config + .render_agent_args("/tmp/wt", "42_foo", Some("coder")) + .unwrap(); + assert!(coder_args.contains(&"sonnet".to_string())); + assert!(coder_args.contains(&"30".to_string())); + assert!(!coder_args.contains(&"--max-budget-usd".to_string())); + assert!(!coder_args.contains(&"--append-system-prompt".to_string())); + } + + #[test] + fn render_agent_args_not_found() { + let config = ProjectConfig::default(); + let result = config.render_agent_args("/tmp/wt", "42_foo", Some("nonexistent")); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("No agent named 'nonexistent'")); + } + + #[test] + fn find_agent_and_default() { + let toml_str = r#" +[[agent]] +name = "first" + +[[agent]] +name = "second" +"#; + + let config = ProjectConfig::parse(toml_str).unwrap(); + assert_eq!(config.default_agent().unwrap().name, "first"); + assert_eq!(config.find_agent("second").unwrap().name, "second"); + assert!(config.find_agent("missing").is_none()); + } + + #[test] + fn parse_project_toml_from_file() { let tmp = tempfile::tempdir().unwrap(); let sk = tmp.path().join(".story_kit"); fs::create_dir_all(&sk).unwrap(); @@ -117,10 +472,12 @@ name = "frontend" path = "frontend" setup = ["pnpm install"] -[agent] +[[agent]] +name = "main" command = "claude" args = ["--print", "--directory", "{{worktree_path}}"] prompt = "Pick up story {{story_id}}" +model = "sonnet" "#, ) .unwrap(); @@ -129,17 +486,8 @@ prompt = "Pick up story {{story_id}}" assert_eq!(config.component.len(), 2); assert_eq!(config.component[0].name, "server"); assert_eq!(config.component[1].setup, vec!["pnpm install"]); - - let agent = config.agent.unwrap(); - assert_eq!(agent.command, "claude"); - } - - #[test] - fn render_template_vars() { - let config = ProjectConfig::default(); - let (cmd, args, prompt) = config.render_agent_args("/tmp/wt", "42_foo").unwrap(); - assert_eq!(cmd, "claude"); - assert!(args.is_empty()); - assert!(prompt.contains("42_foo")); + assert_eq!(config.agent.len(), 1); + assert_eq!(config.agent[0].name, "main"); + assert_eq!(config.agent[0].model, Some("sonnet".to_string())); } } diff --git a/server/src/http/agents.rs b/server/src/http/agents.rs index e7e8942..be5dd9c 100644 --- a/server/src/http/agents.rs +++ b/server/src/http/agents.rs @@ -1,3 +1,4 @@ +use crate::config::ProjectConfig; use crate::http::context::{AppContext, OpenApiResult, bad_request}; use poem_openapi::{Object, OpenApi, Tags, payload::Json}; use serde::Serialize; @@ -9,18 +10,36 @@ enum AgentsTags { } #[derive(Object)] -struct StoryIdPayload { +struct StartAgentPayload { story_id: String, + agent_name: Option, +} + +#[derive(Object)] +struct StopAgentPayload { + story_id: String, + agent_name: String, } #[derive(Object, Serialize)] struct AgentInfoResponse { story_id: String, + agent_name: String, status: String, session_id: Option, worktree_path: Option, } +#[derive(Object, Serialize)] +struct AgentConfigInfoResponse { + name: String, + role: String, + model: Option, + allowed_tools: Option>, + max_turns: Option, + max_budget_usd: Option, +} + pub struct AgentsApi { pub ctx: Arc, } @@ -28,10 +47,11 @@ pub struct AgentsApi { #[OpenApi(tag = "AgentsTags::Agents")] impl AgentsApi { /// Start an agent for a given story (creates worktree, runs setup, spawns agent). + /// If agent_name is omitted, the first configured agent is used. #[oai(path = "/agents/start", method = "post")] async fn start_agent( &self, - payload: Json, + payload: Json, ) -> OpenApiResult> { let project_root = self .ctx @@ -42,12 +62,17 @@ impl AgentsApi { let info = self .ctx .agents - .start_agent(&project_root, &payload.0.story_id) + .start_agent( + &project_root, + &payload.0.story_id, + payload.0.agent_name.as_deref(), + ) .await .map_err(bad_request)?; Ok(Json(AgentInfoResponse { story_id: info.story_id, + agent_name: info.agent_name, status: info.status.to_string(), session_id: info.session_id, worktree_path: info.worktree_path, @@ -56,7 +81,7 @@ impl AgentsApi { /// Stop a running agent and clean up its worktree. #[oai(path = "/agents/stop", method = "post")] - async fn stop_agent(&self, payload: Json) -> OpenApiResult> { + async fn stop_agent(&self, payload: Json) -> OpenApiResult> { let project_root = self .ctx .agents @@ -65,7 +90,11 @@ impl AgentsApi { self.ctx .agents - .stop_agent(&project_root, &payload.0.story_id) + .stop_agent( + &project_root, + &payload.0.story_id, + &payload.0.agent_name, + ) .await .map_err(bad_request)?; @@ -82,6 +111,7 @@ impl AgentsApi { .into_iter() .map(|info| AgentInfoResponse { story_id: info.story_id, + agent_name: info.agent_name, status: info.status.to_string(), session_id: info.session_id, worktree_path: info.worktree_path, @@ -89,4 +119,62 @@ impl AgentsApi { .collect(), )) } + + /// Get the configured agent roster from project.toml. + #[oai(path = "/agents/config", method = "get")] + async fn get_agent_config( + &self, + ) -> OpenApiResult>> { + let project_root = self + .ctx + .agents + .get_project_root(&self.ctx.state) + .map_err(bad_request)?; + + let config = ProjectConfig::load(&project_root).map_err(bad_request)?; + + Ok(Json( + config + .agent + .iter() + .map(|a| AgentConfigInfoResponse { + name: a.name.clone(), + role: a.role.clone(), + model: a.model.clone(), + allowed_tools: a.allowed_tools.clone(), + max_turns: a.max_turns, + max_budget_usd: a.max_budget_usd, + }) + .collect(), + )) + } + + /// Reload project config and return the updated agent roster. + #[oai(path = "/agents/config/reload", method = "post")] + async fn reload_config( + &self, + ) -> OpenApiResult>> { + let project_root = self + .ctx + .agents + .get_project_root(&self.ctx.state) + .map_err(bad_request)?; + + let config = ProjectConfig::load(&project_root).map_err(bad_request)?; + + Ok(Json( + config + .agent + .iter() + .map(|a| AgentConfigInfoResponse { + name: a.name.clone(), + role: a.role.clone(), + model: a.model.clone(), + allowed_tools: a.allowed_tools.clone(), + max_turns: a.max_turns, + max_budget_usd: a.max_budget_usd, + }) + .collect(), + )) + } } diff --git a/server/src/http/agents_sse.rs b/server/src/http/agents_sse.rs index 7f3cb69..edc2484 100644 --- a/server/src/http/agents_sse.rs +++ b/server/src/http/agents_sse.rs @@ -5,16 +5,16 @@ use poem::web::{Data, Path}; use poem::{Body, IntoResponse, Response}; use std::sync::Arc; -/// SSE endpoint: `GET /agents/:story_id/stream` +/// SSE endpoint: `GET /agents/:story_id/:agent_name/stream` /// /// Streams `AgentEvent`s as Server-Sent Events. Each event is JSON-encoded /// with `data:` prefix and double newline terminator per the SSE spec. #[handler] pub async fn agent_stream( - Path(story_id): Path, + Path((story_id, agent_name)): Path<(String, String)>, ctx: Data<&Arc>, ) -> impl IntoResponse { - let mut rx = match ctx.agents.subscribe(&story_id) { + let mut rx = match ctx.agents.subscribe(&story_id, &agent_name) { Ok(rx) => rx, Err(e) => { return Response::builder() diff --git a/server/src/http/mod.rs b/server/src/http/mod.rs index af8f0c7..46aeef0 100644 --- a/server/src/http/mod.rs +++ b/server/src/http/mod.rs @@ -35,7 +35,7 @@ pub fn build_routes(ctx: AppContext) -> impl poem::Endpoint { .nest("/docs", docs_service.swagger_ui()) .at("/ws", get(ws::ws_handler)) .at( - "/agents/:story_id/stream", + "/agents/:story_id/:agent_name/stream", get(agents_sse::agent_stream), ) .at("/health", get(health::health)) diff --git a/server/src/main.rs b/server/src/main.rs index 6e5a61e..f622916 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -44,6 +44,8 @@ fn remove_port_file(path: &Path) { #[tokio::main] async fn main() -> Result<(), std::io::Error> { let app_state = Arc::new(SessionState::default()); + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + *app_state.project_root.lock().unwrap() = Some(cwd.clone()); let store = Arc::new( JsonFileStore::from_path(PathBuf::from("store.json")).map_err(std::io::Error::other)?, ); @@ -69,7 +71,10 @@ async fn main() -> Result<(), std::io::Error> { println!("\x1b[96;1mFrontend:\x1b[0m \x1b[94mhttp://{addr}\x1b[0m"); println!("\x1b[92;1mOpenAPI Docs:\x1b[0m \x1b[94mhttp://{addr}/docs\x1b[0m"); - let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + // Validate agent config at startup — panic on invalid project.toml. + config::ProjectConfig::load(&cwd) + .unwrap_or_else(|e| panic!("Invalid project.toml: {e}")); + let port_file = write_port_file(&cwd, port); let result = Server::new(TcpListener::bind(&addr)).run(app).await; @@ -100,6 +105,28 @@ mod tests { assert_eq!(parse_port(Some("not_a_number".to_string())), 3001); } + #[test] + #[should_panic(expected = "Invalid project.toml: Duplicate agent name")] + fn panics_on_duplicate_agent_names() { + let tmp = tempfile::tempdir().unwrap(); + let sk = tmp.path().join(".story_kit"); + std::fs::create_dir_all(&sk).unwrap(); + std::fs::write( + sk.join("project.toml"), + r#" +[[agent]] +name = "coder" + +[[agent]] +name = "coder" +"#, + ) + .unwrap(); + + config::ProjectConfig::load(tmp.path()) + .unwrap_or_else(|e| panic!("Invalid project.toml: {e}")); + } + #[test] fn write_and_remove_port_file() { let tmp = tempfile::tempdir().unwrap();